diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index d058a896d1..b17bc614a8 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -346,6 +346,10 @@ - id: ui-model name: '@deepseek-ai/dsh-client-ui-model' +# Plan control: the composer plan seat over the plan projection + /plan channel. +- id: ui-plan + name: '@deepseek-ai/dsh-client-ui-plan' + - id: ui-question name: '@deepseek-ai/dsh-client-ui-question' diff --git a/apps/cli/package.json b/apps/cli/package.json index f6d2b45dfd..e31e7378a0 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -32,6 +32,7 @@ "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-model": "workspace:^", "@deepseek-ai/dsh-client-ui-models": "workspace:^", + "@deepseek-ai/dsh-client-ui-plan": "workspace:^", "@deepseek-ai/dsh-client-ui-question": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^", diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 05947889b2..b54a2a8bbb 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -62,6 +62,9 @@ { "path": "../../packages/client/ui-conversation" }, + { + "path": "../../packages/client/ui-plan" + }, { "path": "../../packages/client/ui-trajectory" }, diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8866696162..5b9ece6019 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -846,7 +846,7 @@ export interface PlanModeConfig { } ``` -Source: [`packages/plan/plan-mode/src/index.ts:58`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:68`](../packages/plan/plan-mode/src/index.ts) ## `@deepseek-ai/dsh-pty-local` @@ -2176,6 +2176,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) - `@deepseek-ai/dsh-client-ui-model` ([`packages/client/ui-model/src/index.ts`](../packages/client/ui-model/src/index.ts)) - `@deepseek-ai/dsh-client-ui-models` ([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-plan` ([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) - `@deepseek-ai/dsh-client-ui-question` — requires `tools` · `userInteraction` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index d472aeac24..6f32d656af 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -866,18 +866,27 @@ Source: [`packages/ui/permission/src/index.ts:97`](../../packages/ui/permission/ get(agent: Agent): { active: boolean; pending?: boolean } /** - * Select whether plan mode should be active from the next request boundary. - * Repeated selection of the current or already-pending state is a no-op. + * Select whether plan mode should be active. Between turns the change + * commits immediately — no request boundary would arrive until the next + * prompt, so a queued intent would hang (the open-turn fold is the idle + * signal: agent status stays `running` through post-turn checkpointing, + * where a boundary equally never comes). During an open turn the + * selection is held as pending intent for the next in-turn request + * boundary. Repeated selection of the current or already-pending state is + * a no-op. * * @param agent The agent to switch. * @param active Whether plan mode should be active. + * @returns what happened: `committed` (logged now), `queued` (awaiting the + * next boundary), `cancelled` (an opposite pending selection was cleared; + * the logged state already matches), or `noop` (already in that state). */ -set(agent: Agent, active: boolean): void +set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop' ``` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/plan/plan-mode/src/index.ts:142`](../../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:179`](../../packages/plan/plan-mode/src/index.ts) ## `ctx.pty` — `PtyService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 33703d8d2c..9d66b52bfb 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -33,7 +33,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | diff --git a/docs/module-graph.md b/docs/module-graph.md index 0a4c712e7a..b0f1111826 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -148,6 +148,7 @@ flowchart TD pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_model["client-ui-model"] pkg_client_ui_models["client-ui-models"] + pkg_client_ui_plan["client-ui-plan"] pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_question["client-ui-question"] pkg_client_ui_settings["client-ui-settings"] @@ -682,6 +683,7 @@ flowchart TD pkg_plan_mode --> pkg_commands pkg_plan_mode --> pkg_invariants pkg_plan_mode --> pkg_session + pkg_plan_mode --> pkg_session_projection pkg_plan_mode --> pkg_system_prompt pkg_plan_mode --> pkg_tools pkg_plan_mode --> pkg_user_interaction @@ -825,6 +827,12 @@ flowchart TD pkg_tui --> pkg_token_meter pkg_tui --> pkg_tools pkg_tui --> pkg_user_interaction + pkg_client_ui_plan --> pkg_client_connection + pkg_client_ui_plan --> pkg_client_runtime + pkg_client_ui_plan --> pkg_client_ui_conversation + pkg_client_ui_plan --> pkg_client_ui_slots + pkg_client_ui_plan --> pkg_invariants + pkg_client_ui_plan --> pkg_plan_mode pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_loop pkg_agent_spine_demo --> pkg_goal @@ -1055,7 +1063,7 @@ flowchart TD | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`tools`](../packages/core/tools) | -| [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | `session-persistence` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | @@ -1078,6 +1086,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 2b8d936a9c..7c027afd68 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -361,7 +361,7 @@ Source: [`packages/ui/permission/src/index.ts:36`](../packages/ui/permission/src 'plan/mode': { active: boolean } ``` -Source: [`packages/plan/plan-mode/src/index.ts:41`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:51`](../packages/plan/plan-mode/src/index.ts) ### `request/*` diff --git a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt index e4fa63468a..09f3cb1fb4 100644 --- a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt @@ -15,8 +15,8 @@ buffer style 0-2 fg=bright-blue bold underline 5| "Reply with exactly the word: ONE. No tools. " 6| -7| "Entering plan mode (applies from the next step). Use /plan off to leave. " - style 0-71 fg=bright-black +7| "Plan mode on. Use /plan off to leave. " + style 0-36 fg=bright-black 8| 9| "Assistant " style 0-8 fg=bright-magenta bold underline @@ -28,17 +28,17 @@ buffer 13| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim 14| -15| "Leaving plan mode (applies from the next step). " - style 0-46 fg=bright-black -16| -17| "You " - style 0-2 fg=bright-blue bold underline -18| "Reply with exactly the word: TWO. No tools. " -19| -20| "Context · plan-mode " +15| "Context · plan-mode " style 0-18 dim -21| "The user switched this session back to the default mode. " +16| "The user switched this session back to the default mode. " style 0-55 fg=bright-black +17| +18| "Plan mode off. " + style 0-13 fg=bright-black +19| +20| "You " + style 0-2 fg=bright-blue bold underline +21| "Reply with exactly the word: TWO. No tools. " 22| 23| "Assistant " style 0-8 fg=bright-magenta bold underline diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 3908670e27..7ae98167de 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -149,14 +149,14 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { actions: [ { waitFor: 'main-session-', send: '/plan' }, { waitFor: '[off|message] — Enter or leave plan mode', send: '\r' }, - { waitFor: 'Entering plan mode (applies from the next step). Use /plan off to leave.', send: '/exit\r' }, + { waitFor: 'Plan mode on. Use /plan off to leave.', send: '/exit\r' }, ], }) expect(output).toContain('DEEPSEEK') expect(output).toContain('HARNESS') expect(output).toContain('main-session-') expect(output).toContain('[off|message] — Enter or leave plan mode') - expect(output).toContain('Entering plan mode (applies from the next step). Use /plan off to leave.') + expect(output).toContain('Plan mode on. Use /plan off to leave.') // Borderless: no box-drawing frame around the banner. expect(output).not.toContain('╭') expect(output).not.toContain('╮') @@ -183,15 +183,15 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { // Gating /status on it keeps the assertion race-free; the diagnostics // card is then exercised through the same real Loader/PTY composition. { waitFor: 'scripted session title — DeepSeek Harness', send: '/plan off\r' }, - { waitFor: 'Leaving plan mode (applies from the next step).', send: 'Confirm the scripted run left plan mode.\r' }, + { waitFor: 'Plan mode off.', send: 'Confirm the scripted run left plan mode.\r' }, { waitFor: 'Default mode confirmed.', send: '/status\r' }, { waitFor: 'Session status', send: '/exit\r' }, ], }) expect(output).toContain('I need one decision before I continue.') expect(output).toContain('Reasoning effort: Max.') - expect(output).toContain('Entering plan mode (applies from the next step). Use /plan off to leave.') - expect(output).toContain('Leaving plan mode (applies from the next step).') + expect(output).toContain('Plan mode on. Use /plan off to leave.') + expect(output).toContain('Plan mode off.') expect(output).toContain('Default mode confirmed.') expect(output).toContain(String.raw`\x1b]2;MODEL_CONTROLLED\x07`) expect(output).toContain(String.raw`\x1b[999CMODEL_CURSOR`) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index ea2e569047..bcb88ac0f7 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -302,6 +302,34 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi return undefined } +/** + * Fixture parallel of the plan unit's double-event fold: `command/run` + * records named `plan` set the wanted target (`off` → false, else true); + * `plan/mode` commits and clears it. `wanted` is exposed for the prompt + * boundary (the fixture's agent/step parallel). + */ +function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boolean; wanted: boolean | null } { + let active = false + let wanted: boolean | null = null + for (const event of log) { + const item = event as unknown as { type: string; data?: Record } + if (item.type === 'command/run' && item.data?.['name'] === 'plan') { + const args = item.data['args'] + wanted = (typeof args === 'string' ? args : '').trim() !== 'off' + } else if (item.type === 'plan/mode') { + active = item.data?.['active'] === true + wanted = null + } + } + return { active, pending: wanted !== null && wanted !== active, wanted } +} + +/** The plan projection's wire view over the full log. */ +function planViewOf(log: readonly SessionEvent[]): { active: boolean; pending: boolean } { + const plan = foldPlan(log) + return { active: plan.active, pending: plan.pending } +} + /** Fixture parallel of the host's projection units: whole current values per key over the full log. */ function projectionValuesOf(log: readonly SessionEvent[]): Record { const values: Record = {} @@ -311,6 +339,8 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record' } }, + { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } }, ], }) }, @@ -1073,14 +1121,29 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { append(id, { type: 'command/done', data: { commandId, kind: 'success', text } }) return ok(request, { matched: true as const, commandId }) } + // Host parallel: /plan on an idle fixture session commits plan/mode + // immediately (the boundary flush covers only a running turn), so the + // outcome copy matches the immediate branch of the host handler. + const running = summaryOf(id)?.running === true const outcomes: Record = { compact: 'fixture:已压缩(假动作)', echo: args.trim(), + plan: args.trim() === 'off' + ? (running ? 'Leaving plan mode (applies from the next step).' : 'Plan mode off.') + : (running + ? 'Entering plan mode (applies from the next step). Use /plan off to leave.' + : 'Plan mode on. Use /plan off to leave.'), } const text = name === undefined ? undefined : outcomes[name] if (name === undefined || text === undefined) return ok(request, { matched: false as const }) const commandId = `fx-cmd-${logOf(id).length}` as CommandId append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }) + if (name === 'plan' && !running) { + const plan = foldPlan(logOf(id)) + if (plan.wanted !== null && plan.wanted !== plan.active) { + append(id, { type: 'plan/mode', data: { active: plan.wanted } }) + } + } append(id, { type: 'command/done', data: { commandId, kind: 'success', ...text === '' ? {} : { text } } }) return ok(request, { matched: true as const, commandId }) }, diff --git a/packages/client/connection/tests/fixture-commands.spec.ts b/packages/client/connection/tests/fixture-commands.spec.ts index d64e6705cb..f589024a24 100644 --- a/packages/client/connection/tests/fixture-commands.spec.ts +++ b/packages/client/connection/tests/fixture-commands.spec.ts @@ -23,7 +23,7 @@ describe('createFixtureApi commands/skills', () => { expect(response.rpcId).toBe(request.rpcId) if (!response.result.ok) throw new Error('list failed') const commands = response.result.value.commands - expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal']) + expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'plan']) // input hint rides only the commands declaring it. const echo = commands.find(c => c.name === 'echo') expect(echo?.input?.hint).toBeTruthy() diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index ae50006a21..8f51861283 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -69,9 +69,11 @@ describe('createFixtureApi', () => { // tail block still rides it — empty-log cut at -1, the host convention. const empty = await api.sessions.history(req({ sessionId: sid('no-such'), maxMessages: 10 })) if (!empty.result.ok) throw new Error('empty failed') - // Fixture composes the todos unit (host parallel when tool-todo is mounted): null before any write. + // Fixture composes the todos + plan units (host parallel when tool-todo + // and plan-mode are mounted): the empty-log values. expect(empty.result.value).toEqual({ - events: [], hasMore: false, projections: { asOfSeq: -1, values: { goal: null, todos: null } }, + events: [], hasMore: false, + projections: { asOfSeq: -1, values: { goal: null, todos: null, plan: { active: false, pending: false } } }, }) }) @@ -206,7 +208,7 @@ describe('createFixtureApi', () => { const envelopes: RpcRequest[] = [] for await (const envelope of api.events.mux(req({}), abort.signal)) { envelopes.push(envelope) - if (envelopes.length >= 4) abort.abort() + if (envelopes.length >= 7) abort.abort() } return envelopes } @@ -214,14 +216,15 @@ describe('createFixtureApi', () => { const second = await openOnce() expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' }) expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0) - // Projection baseline frames follow the subscribed frame (title + todos + goal units). + // Projection baseline frames follow the subscribed frame (title + todos + plan + goal units). expect(first[1]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'title', value: 'Fixture 历史会话' }) expect(first[2]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'todos' }) - expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null }) - expect(first[4]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) - expect(second[4]?.rpcId).toBe(first[4]?.rpcId) // stable rpcId across replays (host replay semantics) - expect(first[5]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' }) - expect(second[5]?.rpcId).toBe(first[5]?.rpcId) + expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'plan', value: { active: false, pending: false } }) + expect(first[4]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null }) + expect(first[5]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) + expect(second[5]?.rpcId).toBe(first[5]?.rpcId) // stable rpcId across replays (host replay semantics) + expect(first[6]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' }) + expect(second[6]?.rpcId).toBe(first[6]?.rpcId) }) it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => { diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 8947e97f7f..77af4601ab 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -218,12 +218,14 @@ export class Session implements SessionFace { this.notifier.markDirty() return result } - // Blank flips on ACCEPTANCE, not attempt: an accepted prompt has logged - // its user/message on the host (events.length > 0 is fact, not - // optimism), while a rejected first prompt must keep the session blank - // — the client-side blank mirror only ever lowers, so flipping early on - // a failure would surface the session forever and strip its - // connectWorkspace reuse eligibility against the host's authority. + // Blank flips on ACCEPTANCE, not attempt: an accepted prompt starts the + // conversation's first turn on the host (the host criterion — a logged + // turn/start — is fact, not optimism; standalone command and projection + // events never flip it), while a rejected first prompt must keep the + // session blank — the client-side blank mirror only ever lowers, so + // flipping early on a failure would surface the session forever and + // strip its connectWorkspace reuse eligibility against the host's + // authority. if (this.blankBit) { this.blankBit = false this.options.onEngaged?.(this) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 4d5a458d7d..49e43861f3 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 51ddecf93240c2196483d3fb2bcfaca4104da31a -README.zh.md: d98cbcc69b875d2f426d9bdd9f2fa81874ec614a +README.md: 85cf040a48cf43b6ee6a8978ad7110ecdffb4051 +README.zh.md: 305258e2861fb17966050e295a5b980067a59a2d diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 51ddecf932..85cf040a48 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -16,7 +16,7 @@ The todo surfaces are two registrations over that shape, both plain registrant p Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. -The composer bar declares session-scoped single seats for `'conversation.input.plan'` and `'conversation.input.model'`, plus list slots for overlay, dock, left, and right input extensions. InputBar renders the model seat immediately before its pending indicator and send/stop button. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats. +The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats. `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index d98cbcc69b..305258e286 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -16,7 +16,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 -输入栏为 `'conversation.input.plan'` 和 `'conversation.input.model'` 声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。InputBar 将模型 seat 渲染在 pending 指示器与发送/停止按钮之前。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。 +输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。 `src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 0b184dcbdf..2e37415bdc 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -49,6 +49,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 46c15d1d05..44c4834ec6 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -130,8 +130,9 @@ export function apply(ctx: Context): void { // verbs ride this inject (package-internal — hub and bar are one plugin). slots.register({ name: 'conversation.composer.bar', - // The two named control seats in the bar's tool row (plan left, model - // right); empty until their owning plugins register (B ruling). + // The two named control seats in the bar's tool row (plan beside the + // access control, model right); empty until their owning plugins + // register (B ruling). children: { 'conversation.input.plan': { kind: 'single', scope: 'session' }, 'conversation.input.model': { kind: 'single', scope: 'session' }, diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 5273c75357..e9503b5677 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -375,7 +375,9 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio ))} )} - {pending.map(item => )} + {pending.map(item => item.kind === 'approval' + ? + : null)} {/* Turn-level loading signal: rides the whole running turn (first-token wait, tool execution, streaming) so it never flickers per step. */} {running && } diff --git a/packages/client/ui-conversation/src/client/chat/PendingCard.tsx b/packages/client/ui-conversation/src/client/chat/PendingCard.tsx index b6825aed9a..ee72252717 100644 --- a/packages/client/ui-conversation/src/client/chat/PendingCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/PendingCard.tsx @@ -1,30 +1,19 @@ -// PendingCard: approval/question placeholder card (visible, not answerable — -// the composer-takeover approval panel is a P-II item; wire pending semantics -// already exist so the flow must show them). +// PendingCard: display-only approval placeholder. Questions render exclusively +// through the composer takeover so the same pending wait is never shown twice. import { memo } from 'react' -import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client' -import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' import css from './PendingCard.module.css' export interface PendingCardProps { - item: PendingInteraction + item: PendingWait<'approval'> } export const PendingCard = memo(function PendingCard({ item }: PendingCardProps) { return (
- {item.kind === 'approval' ? ( - <> -
等待审批:{item.payload.toolName}
- {item.payload.reason !== undefined &&
{item.payload.reason}
} - - ) : ( - <> -
等待回答({item.payload.questions.length} 题)
- - - )} +
等待审批:{item.payload.toolName}
+ {item.payload.reason !== undefined &&
{item.payload.reason}
}
请在原客户端处理(web 端作答后续里程碑提供)
) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index d390ffc007..3e7e665e66 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -83,9 +83,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { */ 'conversation.composer.bar': { kind: 'single'; scope: 'session'; owner: ComposerBarOwnerProps } /** - * The Plan-mode control seat in the composer tool row (left group). - * Declared by the composer-bar entry; empty until a plan plugin - * registers (B ruling: no placeholder fallback). + * The Plan-mode status seat in the composer tool row (left group, + * right of the access-mode control). Declared by the composer-bar + * entry; empty until a plan plugin registers (B ruling: no placeholder + * fallback). */ 'conversation.input.plan': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps } /** diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 26a65def6a..c9476bd653 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -10,6 +10,9 @@ import { useEffect, useRef, useState } from 'react' import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +// Type-only: the `plan` projection key merge (the TodoDock posture — the +// composer reads a host-computed value; the domain owns the key). +import type {} from '@deepseek-ai/dsh-plan-mode/client' import type { ComposerBarProps } from '../contract/slots.ts' import { deriveDecorations } from '../input/decorations.ts' import css from './InputBar.module.css' @@ -28,7 +31,7 @@ const READONLY_OPTIONS: readonly { id: string; label: string }[] = [ ] export function InputBar({ - useSession, useInput, inputActions, keyboard, stop, renderSlot, useNotices, useLexicon, + useSession, useInput, inputActions, keyboard, stop, renderSlot, useNotices, useLexicon, useProjection, variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment', }: InputBarProps) { const input = useInput(s => s) @@ -37,6 +40,9 @@ export function InputBar({ const promptError = useSession(s => s.promptError) const running = useSession(s => s.running) const disabled = useSession(s => s.removed) + // Plan mode swaps the textarea placeholder (the projection is the folded + // host value; owner-prop placeholders — hero, session-unavailable — win). + const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active)) // Prompt failures are ordinary failures (no create/attach transaction // exists anymore): the strip renders promptError, the draft stays in the // machine, and the user resubmits. @@ -334,7 +340,9 @@ export function InputBar({ disabled={locked} readOnly={machineBusy} data-phase={input.phase} - placeholder={placeholder ?? (disabled ? 'Session unavailable' : 'Message the agent')} + placeholder={placeholder ?? (disabled + ? 'Session unavailable' + : planActive ? 'describe your task to generate plan' : 'Message the agent')} rows={2} onChange={onChange} onKeyDown={onKeyDown} @@ -361,15 +369,15 @@ export function InputBar({
- {renderSlot('conversation.input.plan', { locked })} {accessSelect} + {renderSlot('conversation.input.plan', { locked })}
{leftItems}
{rightItems} {renderSlot('conversation.input.model', { locked })} - {machineBusy && } + {/* {machineBusy && } */} + {error !== null && 退出 plan mode 失败} + + ) +} diff --git a/packages/client/ui-plan/src/client/index.ts b/packages/client/ui-plan/src/client/index.ts new file mode 100644 index 0000000000..2780d66a09 --- /dev/null +++ b/packages/client/ui-plan/src/client/index.ts @@ -0,0 +1,50 @@ +/** + * Plan control plugin, browser half: occupies the composer's named + * `conversation.input.plan` seat with a read-only status chip. Plan mode is + * entered through the /plan command only; while the projection's effective + * target is plan mode the chip renders (hover × executes /plan off through + * `command.execute`), otherwise the seat stays empty. Reads ride the generic + * projection pair through the standard-kit `useProjection` (an absent key is + * capability absence); zero client-side plan state. + */ +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: pulls the ui-conversation SlotMap merge (the input.plan seat). +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +// Type-only: pulls the `plan` SessionProjectionMap merge for useProjection. +import type {} from '@deepseek-ai/dsh-plan-mode/client' +import { PlanChip } from './PlanModeControl.tsx' + +/** Injected business face of the composer plan seat. */ +export interface PlanChipInjected { + /** + * Leave plan mode by executing /plan off. + * @returns null on admitted execution; a user-visible failure line otherwise. + */ + exitPlanMode: () => Promise +} + +/** + * Required services: the seat's slot registry, the transport, and the + * conversation service whose presence guarantees the seat is declared. + */ +export const inject = ['slots', 'connection', 'conversation'] + +/** + * Client plugin body: register the plan chip over the command channel. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.slots.register({ + name: 'conversation.input.plan', + inject: (sessionId: SessionId): PlanChipInjected => ({ + exitPlanMode: async () => { + const connection = ctx.get('connection') as ConnectionHandle + const { result } = await connection.api.commands.execute({ sessionId, line: '/plan off' }) + if (!result.ok) return `${result.error.message}(${result.error.code})` + if (!result.value.matched) return '未知命令:/plan off' + return null + }, + }), + }, PlanChip), 'ui-plan: composer plan chip registration') +} diff --git a/packages/client/ui-plan/src/css-modules.d.ts b/packages/client/ui-plan/src/css-modules.d.ts new file mode 100644 index 0000000000..8811db1264 --- /dev/null +++ b/packages/client/ui-plan/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} diff --git a/packages/client/ui-plan/src/index.ts b/packages/client/ui-plan/src/index.ts new file mode 100644 index 0000000000..8d63cb2102 --- /dev/null +++ b/packages/client/ui-plan/src/index.ts @@ -0,0 +1,11 @@ +/** + * Plan control plugin, node half. Pure UI plugin: the empty apply exists so + * the plugin appears in the host cordis.yml / Loader; the browser half ships + * via exports["./client"], discovered through the package.json dshClient + * declaration. Plan behavior itself (the /plan command, the plan projection + * unit, the policy section) is owned by `@deepseek-ai/dsh-plan-mode`, + * composed independently on the host roster. + */ + +/** Host plugin body — no host-side behavior for this surface plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-plan/src/invariant.ts b/packages/client/ui-plan/src/invariant.ts new file mode 100644 index 0000000000..82c9fc9376 --- /dev/null +++ b/packages/client/ui-plan/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-plan`. + * @module @deepseek-ai/dsh-client-ui-plan/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-plan' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-plan-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: plan state and boundary ownership are + * audited by dsh-plan-mode, while the control is a slot effect whose + * declaration, registration, and teardown are exercised by this package. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns The installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-plan/tests/browser-plugin.spec.ts b/packages/client/ui-plan/tests/browser-plugin.spec.ts new file mode 100644 index 0000000000..865ff67042 --- /dev/null +++ b/packages/client/ui-plan/tests/browser-plugin.spec.ts @@ -0,0 +1,78 @@ +/** + * ui-plan browser half on a real SlotsService: the plugin occupies the + * conversation-declared `conversation.input.plan` single seat with the plan + * status chip; the injected face executes /plan off and folds admission + * outcomes into null (admitted) or a user-visible failure line; teardown + * empties the seat (HMR safety). + */ +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { PlanChip } from '../src/client/PlanModeControl.tsx' +import type { PlanChipInjected } from '../src/client/index.ts' +import { apply, inject } from '../src/client/index.ts' +import { apply as nodeApply } from '../src/index.ts' + +const SID = 's-plan' as SessionId + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + const slots = ctx.get('slots') as SlotsService + slots.register({ + name: 'root', + children: { 'conversation.input.plan': { kind: 'single', scope: 'session' } }, + } as never, () => null) + const execute = vi.fn((_payload: { sessionId: SessionId; line: string }) => + Promise.resolve({ result: { ok: true as const, value: { matched: true as const, commandId: 'c1' } } })) + ctx.provide('connection', { api: { commands: { execute } } }) + ctx.provide('conversation', {}) + return { ctx, slots, execute } +} + +describe('ui-plan browser apply', () => { + it('declares every service it binds', () => { + expect(inject).toEqual(['slots', 'connection', 'conversation']) + }) + + it('node-half apply is an intentional no-op', () => { + expect(() => { nodeApply() }).not.toThrow() + }) + + it('fails loud when conversation did not declare the plan seat', async () => { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + ctx.provide('connection', {}) + ctx.provide('conversation', {}) + await expect(ctx.plugin({ inject: [...inject], apply })) + .rejects.toThrow(/slot "conversation.input.plan" is not declared/) + }) + + it('registers the chip, executes /plan off, and unregisters on teardown', async () => { + const b = await bench() + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + const entry = b.slots.entries('conversation.input.plan')[0]! + expect(entry.component).toBe(PlanChip) + const injected = (entry.inject as unknown as (id: SessionId) => PlanChipInjected)(SID) + + await expect(injected.exitPlanMode()).resolves.toBeNull() + expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan off' }) + + // Business failure folds to the composer-visible line. + b.execute.mockResolvedValueOnce({ + result: { ok: false as const, error: { code: 'session-not-found', message: 'gone', details: {} } }, + } as never) + await expect(injected.exitPlanMode()).resolves.toBe('gone(session-not-found)') + + // Unmatched admission (plan-mode not composed host-side) is also a failure line. + b.execute.mockResolvedValueOnce({ + result: { ok: true as const, value: { matched: false as const } }, + } as never) + await expect(injected.exitPlanMode()).resolves.toBe('未知命令:/plan off') + + await fiber.dispose() + expect(b.slots.entries('conversation.input.plan')).toHaveLength(0) + }) +}) diff --git a/packages/client/ui-plan/tests/plan-mode-control.spec.tsx b/packages/client/ui-plan/tests/plan-mode-control.spec.tsx new file mode 100644 index 0000000000..44c17e5837 --- /dev/null +++ b/packages/client/ui-plan/tests/plan-mode-control.spec.tsx @@ -0,0 +1,111 @@ +// @vitest-environment jsdom +/** + * PlanChip over the `plan` projection: nothing renders while the capability + * is absent or the effective target is the default mode; the chip renders + * while the target is plan mode (pending follows the target — /plan shows it + * immediately, /plan off hides it immediately); the chip button executes + * /plan off and surfaces failures without hiding until the projection says so. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import type { PlanProjection } from '@deepseek-ai/dsh-plan-mode/client' +import { PlanChip, type PlanChipProps } from '../src/client/PlanModeControl.tsx' + +afterEach(cleanup) + +function setup( + plan: PlanProjection | undefined, + exitPlanMode = vi.fn(() => Promise.resolve(null)), + locked = false, +) { + const store = createSnapshotStore<{ value: PlanProjection | undefined }>({ value: plan }) + const useProjection = (_key: string, selector?: (v: unknown) => unknown) => + bindSnapshotSelector(store)(s => (selector ?? (v => v))(s.value)) + const props = { useProjection, locked, exitPlanMode } as unknown as PlanChipProps + const view = render() + return { store, exitPlanMode, view } +} + +const chip = () => screen.getByRole('button', { name: 'Plan mode on, press to turn off' }) + +describe('PlanChip', () => { + it('renders nothing for absent capability or the default mode', () => { + const absent = setup(undefined) + expect(absent.view.container.innerHTML).toBe('') + cleanup() + const inactive = setup({ active: false, pending: false }) + expect(inactive.view.container.innerHTML).toBe('') + cleanup() + // Active with a pending exit: the target is default — chip already gone. + const leaving = setup({ active: true, pending: true }) + expect(leaving.view.container.innerHTML).toBe('') + }) + + it('renders while the effective target is plan mode, including the pending entry window', () => { + setup({ active: true, pending: false }) + expect(chip()).toBeTruthy() + cleanup() + // /plan just ran (command/run folded, plan/mode not yet): target is plan. + setup({ active: false, pending: true }) + expect(chip()).toBeTruthy() + }) + + it('the chip executes /plan off once and follows the projection down', async () => { + let resolve!: (value: string | null) => void + const exitPlanMode = vi.fn(() => new Promise((done) => { resolve = done })) + const { store } = setup({ active: true, pending: false }, exitPlanMode) + fireEvent.click(chip()) + expect(exitPlanMode).toHaveBeenCalledTimes(1) + // Busy while its own call is in flight. + fireEvent.click(chip()) + expect(exitPlanMode).toHaveBeenCalledTimes(1) + resolve(null) + // The off command's run record folds: target flips, the chip unmounts. + store.set({ value: { active: true, pending: true } }) + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'Plan mode on, press to turn off' })).toBeNull() + }) + }) + + it('disables under the locked owner prop', () => { + setup({ active: true, pending: false }, vi.fn(), true) + expect((chip() as HTMLButtonElement).disabled).toBe(true) + }) + + it('surfaces admission and transport failures while staying visible', async () => { + const exitPlanMode = vi.fn() + .mockResolvedValueOnce('host said no') + .mockRejectedValueOnce(new Error('network down')) + .mockRejectedValueOnce('socket closed') + setup({ active: true, pending: false }, exitPlanMode) + fireEvent.click(chip()) + expect((await screen.findByText('退出 plan mode 失败')).getAttribute('title')).toBe('host said no') + expect(chip()).toBeTruthy() + + fireEvent.click(chip()) + expect(await screen.findByTitle('network down')).toBeTruthy() + + fireEvent.click(chip()) + expect(await screen.findByTitle('socket closed')).toBeTruthy() + }) + + it('ignores in-flight fulfillment and rejection after unmount', () => { + let resolve!: (value: string | null) => void + const successful = setup( + { active: true, pending: false }, + vi.fn(() => new Promise((done) => { resolve = done })), + ) + fireEvent.click(chip()) + successful.view.unmount() + expect(() => { resolve(null) }).not.toThrow() + + let reject!: (reason: unknown) => void + const exitPlanMode = vi.fn(() => new Promise((_done, fail) => { reject = fail })) + const { view } = setup({ active: true, pending: false }, exitPlanMode) + fireEvent.click(chip()) + view.unmount() + expect(() => { reject(new Error('late')) }).not.toThrow() + }) +}) diff --git a/packages/client/ui-plan/tsconfig.json b/packages/client/ui-plan/tsconfig.json new file mode 100644 index 0000000000..4ab13662f2 --- /dev/null +++ b/packages/client/ui-plan/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../runtime" + }, + { + "path": "../connection" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-slots" + }, + { + "path": "../web-react" + }, + { + "path": "../../plan/plan-mode" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-plan/tsdown.config.ts b/packages/client/ui-plan/tsdown.config.ts new file mode 100644 index 0000000000..7f6a91d3f6 --- /dev/null +++ b/packages/client/ui-plan/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-plan', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-question/README.i18n.yaml b/packages/client/ui-question/README.i18n.yaml index 476bd3f0b6..a58cd055a7 100644 --- a/packages/client/ui-question/README.i18n.yaml +++ b/packages/client/ui-question/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 28132d1d0643f5e8f658ab77de9017467da2d172 -README.zh.md: c70e77fc90eb5b226ceabd8a1e7cc7ca6c011c40 +# pnpm run verify-translation-pairing --write packages/client/ui-question/README.md +README.md: 3a3cd639fc2834685230aca7c8087583e0a48c71 +README.zh.md: 1330578577da7ed7d0890595f675fd272fd5ebc7 diff --git a/packages/client/ui-question/README.md b/packages/client/ui-question/README.md index 28132d1d06..3a3cd639fc 100644 --- a/packages/client/ui-question/README.md +++ b/packages/client/ui-question/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user` only when the Web feature is selected; its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot. -The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`. +The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`. Selection state is local to a component keyed by the request rpcId. A replay with the same id preserves a still-mounted draft, while `question/resolved` from the host removes the composer. The host remains authoritative: successful HTTP delivery does not remove pending state locally. diff --git a/packages/client/ui-question/README.zh.md b/packages/client/ui-question/README.zh.md index c70e77fc90..1330578577 100644 --- a/packages/client/ui-question/README.zh.md +++ b/packages/client/ui-question/README.zh.md @@ -4,7 +4,7 @@ Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧才会挂载 `dsh-tool-ask-user`;浏览器侧会把 `question` 配置项注册到会话拥有的 `conversation.composer` 键控 slot 中。 -组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。 +组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信内容策略。封顶卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。 选择状态只存在于以请求 rpcId 为 key 的组件本地。使用相同 id 回放时,只要组件仍挂载,就会保留草稿;主机发出的 `question/resolved` 则会移除编辑器。主机仍具有最终决定权:HTTP 交付成功不会在本地移除待处理状态。 diff --git a/packages/client/ui-question/src/client/QuestionComposer.module.css b/packages/client/ui-question/src/client/QuestionComposer.module.css index be4fd5791b..16dc982e36 100644 --- a/packages/client/ui-question/src/client/QuestionComposer.module.css +++ b/packages/client/ui-question/src/client/QuestionComposer.module.css @@ -74,11 +74,7 @@ } .detail { - margin: 2px 0 0; - color: var(--dsw-alias-label-tertiary); - font-size: 13px; - line-height: 20px; - font-weight: 400; + margin: 0 2px 8px; } .headerActions, @@ -120,13 +116,19 @@ cursor: default; } +.body { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; +} + .options { display: flex; flex-direction: column; gap: 4px; - /* The scrollable region of the capped card (ChatView list pattern). */ - min-height: 0; - overflow-y: auto; } .option { diff --git a/packages/client/ui-question/src/client/QuestionComposer.tsx b/packages/client/ui-question/src/client/QuestionComposer.tsx index 4380ae8db0..542a24b935 100644 --- a/packages/client/ui-question/src/client/QuestionComposer.tsx +++ b/packages/client/ui-question/src/client/QuestionComposer.tsx @@ -2,7 +2,7 @@ import { useMemo, useState, type KeyboardEvent } from 'react' import clsx from 'clsx' import { Button, IconCheckOutline16, IconChevronLeftOutline14, IconChevronRightOutline14, - IconCloseOutline16, IconEditOutline16, + IconCloseOutline16, IconEditOutline16, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' import { PendingQuestion, type QuestionAnswer, type QuestionComposerProps } from './contract/slots.ts' import css from './QuestionComposer.module.css' @@ -176,7 +176,6 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) { : question.question} {question.multiSelect === true && 可多选} - {question.detail !== undefined &&

{question.detail}

}
{index + 1} / {questions.length} @@ -204,79 +203,84 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
-
- {(question.options ?? []).map((option, optionIndex) => { - const selected = draft.selected.includes(option.label) - const display = parseRecommendedLabel(option.label) - return ( - - ) - })} - -
- {hasOptions && ( - - )} - {draft.customOpen && ( -