diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index 8256009552..4b5c54db3f 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -389,6 +389,10 @@ - id: ui-permission name: '@deepseek-ai/dsh-client-ui-permission' +# 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 a2e9a8d3e4..d2fecf4fbc 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -33,6 +33,7 @@ "@deepseek-ai/dsh-client-ui-model": "workspace:^", "@deepseek-ai/dsh-client-ui-models": "workspace:^", "@deepseek-ai/dsh-client-ui-permission": "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 46316430b8..ce43c0f31c 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -65,6 +65,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 5f0daa5c63..7461666dd6 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` @@ -2177,6 +2177,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@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-permission` ([`packages/client/ui-permission/src/index.ts`](../packages/client/ui-permission/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 3a3fd23529..9de75b266c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -874,18 +874,27 @@ Source: [`packages/ui/permission/src/index.ts:144`](../../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 8730558b05..71238305e5 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 0b0756b316..b0f1111826 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -148,7 +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_permission["client-ui-permission"] + 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"] @@ -576,12 +576,10 @@ flowchart TD pkg_acp --> pkg_session pkg_acp --> pkg_user_approval pkg_permission --> pkg_bash - pkg_permission --> pkg_commands pkg_permission --> pkg_invariants pkg_permission --> pkg_sandbox pkg_permission --> pkg_sandbox_policy pkg_permission --> pkg_session - pkg_permission --> pkg_session_projection pkg_permission --> pkg_user_approval pkg_client_ui_goal --> pkg_client_connection pkg_client_ui_goal --> pkg_client_runtime @@ -685,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 @@ -735,11 +734,6 @@ flowchart TD pkg_tool_ask_user --> pkg_invariants pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_interaction - pkg_client_ui_permission --> pkg_client_runtime - pkg_client_ui_permission --> pkg_client_ui_command - pkg_client_ui_permission --> pkg_client_ui_slash - pkg_client_ui_permission --> pkg_invariants - pkg_client_ui_permission --> pkg_permission pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -833,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 @@ -1047,7 +1047,7 @@ flowchart TD | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | | [`session-title-llm`](../packages/session-title/session-title-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`timeout`](../packages/util/timeout) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | -| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) | +| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | @@ -1063,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) | @@ -1073,7 +1073,6 @@ flowchart TD | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | -| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-slash`](../packages/client/ui-slash), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | @@ -1087,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 736094ca60..a1535f36dc 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -361,7 +361,7 @@ Source: [`packages/ui/permission/src/index.ts:49`](../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 eb538fbe34..c42be5a672 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. */ /** Fixture preset table (the host PermissionService defaults). */ const PERMISSION_PRESETS: Record = { @@ -351,6 +379,8 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record' } }, { name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '' } }, + { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } }, ], }) }, @@ -1147,14 +1195,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 c3b3162677..70ee127b3d 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', 'permission']) + expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'permission', '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 0511ca52e4..f7d6cbc3f0 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -69,7 +69,8 @@ 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: { todos: null, @@ -81,6 +82,7 @@ describe('createFixtureApi', () => { ], currentValue: 'workspace-write', }, + plan: { active: false, pending: false }, goal: null, } }, }) @@ -217,7 +219,7 @@ describe('createFixtureApi', () => { const envelopes: RpcRequest[] = [] for await (const envelope of api.events.mux(req({}), abort.signal)) { envelopes.push(envelope) - if (envelopes.length >= 7) abort.abort() + if (envelopes.length >= 8) abort.abort() } return envelopes } @@ -225,15 +227,16 @@ 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 + permissions + goal units). + // Projection baseline frames follow the subscribed frame (title + todos + permissions + 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: 'permissions' }) - 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) + expect(first[4]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'plan', value: { active: false, pending: false } }) + expect(first[5]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null }) + expect(first[6]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) + expect(second[6]?.rpcId).toBe(first[6]?.rpcId) // stable rpcId across replays (host replay semantics) + expect(first[7]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' }) + expect(second[7]?.rpcId).toBe(first[7]?.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 8d519683b4..3549422f90 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 b5761466eb..045488f917 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: 9f96883ad34d52e75211b9bea16f7273ded370ae -README.zh.md: ee766a23378a7ff21ceae4015addc19900c7c2b9 +README.md: b68b2ee0b816d0a4ffb440f05592a21772392b77 +README.zh.md: 06a324830e1900b02765853f4c31c53b44657dca diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 9f96883ad3..b68b2ee0b8 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -8,7 +8,7 @@ The resident conversation shell survives no-session and session transitions. Wit The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. -Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Question placeholders remain in the message flow as display-only PendingCards while ui-question owns the answering takeover. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); a pick submits the `/permission ` command line through the bar's injected `command` callback. +Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); a pick submits the `/permission ` command line through the bar's injected `command` callback. Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. @@ -18,7 +18,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 ee766a2337..06a324830e 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -12,13 +12,13 @@ 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 -审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。问题占位符仍以只读 PendingCard 形式留在消息流中,应答接管归 ui-question 所有。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);选中会经由输入栏注入的 `command` 回调提交 `/permission ` 命令行。 +审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);选中会经由输入栏注入的 `command` 回调提交 `/permission ` 命令行。 todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 逐 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 fdf8386b0a..42812dce24 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -49,6 +49,9 @@ }, "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:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index a8d460ca2f..2b845e6c83 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -137,8 +137,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 abd248693f..1cbb00a4ce 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -30,7 +30,6 @@ import { AssistantMarkdown } from './AssistantMarkdown.tsx' import { GenericCommandCard } from './GenericCommandCard.tsx' import { GenericToolCard } from './GenericToolCard.tsx' import { MessageItem } from './MessageItem.tsx' -import { PendingCard } from './PendingCard.tsx' import { StatsLine } from './StatsLine.tsx' import css from './ChatView.module.css' @@ -229,7 +228,6 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio const running = useSession(s => s.running) const runningCalls = useSession(s => s.runningCalls) const codeDispatches = useSession(s => s.codeDispatches) - const pending = useSession(s => s.pending) const openState = useSession(s => s.openState) const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`) const hasMore = useSession(s => s.hasMore) @@ -375,9 +373,9 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio ))} )} - {/* Approvals take over the composer (ApprovalPanel); only question - placeholders remain in the flow. */} - {pending.filter(item => item.kind === 'question').map(item => )} + {/* No pending placeholders: questions (ui-question) and approvals + (ApprovalPanel) both take over the composer, so a flow card would + double-render the same wait. */} {/* 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.module.css b/packages/client/ui-conversation/src/client/chat/PendingCard.module.css deleted file mode 100644 index cb17b4387c..0000000000 --- a/packages/client/ui-conversation/src/client/chat/PendingCard.module.css +++ /dev/null @@ -1,15 +0,0 @@ -/* Amber pending strip (question waiting = warn semantic, figma state colors). */ - -.card { - margin: 6px 0; - padding: 8px 12px; - border: 1px solid var(--dsw-alias-state-warn-secondary); - border-radius: 10px; - background: var(--dsw-alias-state-warn-tertiary); -} - -.title { - font-size: 12px; - font-weight: 500; - color: var(--dsw-alias-label-primary); -} diff --git a/packages/client/ui-conversation/src/client/chat/PendingCard.tsx b/packages/client/ui-conversation/src/client/chat/PendingCard.tsx deleted file mode 100644 index c5a5ead161..0000000000 --- a/packages/client/ui-conversation/src/client/chat/PendingCard.tsx +++ /dev/null @@ -1,22 +0,0 @@ -// PendingCard: question pending placeholder in the message flow (visible -// while the question composer owns the takeover slot elsewhere). Approvals -// do not render here: they take over the composer (skeleton ApprovalPanel) -// per the designer draft. - -import { memo } from 'react' -import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client' -import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives' -import css from './PendingCard.module.css' - -export interface PendingCardProps { - item: Extract -} - -export const PendingCard = memo(function PendingCard({ item }: PendingCardProps) { - return ( -
-
等待回答({item.payload.questions.length} 题)
- -
- ) -}) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 379c33a8c8..9f3f3edf02 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 821e309d89..a98ee09114 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 } 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 { PermissionSelect } from './PermissionSelect.tsx' @@ -33,6 +36,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. @@ -321,7 +327,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} @@ -348,15 +356,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 && ( -