From 2c075711eb2e762cdd5ae1201b0a17d79b5e1ab8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 20 Jul 2026 22:44:01 +0800 Subject: [PATCH] feat(tui): add session controls and compact views --- ...cated-full-screen-tui-front-door.i18n.yaml | 4 +- ...17-dedicated-full-screen-tui-front-door.md | 8 +- ...dedicated-full-screen-tui-front-door.zh.md | 8 +- docs/config-catalog.md | 20 +- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 4 +- docs/module-graph.md | 3 +- .../tests/snapshots/bash-spill/session.jsonl | 2 +- .../escalation-approved/session.jsonl | 4 +- .../escalation-rejected/session.jsonl | 4 +- .../fs-escalation-approved/session.jsonl | 4 +- .../hook-cc-pretool-ask/session.jsonl | 4 +- examples/cordis-agent/composition.md | 3 + examples/cordis-agent/cordis.yml | 3 + examples/tui-agent/README.md | 2 +- examples/tui-agent/code-mode.cordis.yml | 2 +- examples/tui-agent/cordis.yml | 2 +- .../tests/fixtures/tui-scripted-llm.ts | 14 +- .../tests/fixtures/tui-scripted.cordis.yml | 4 + .../bash-terminal-card/terminal.expected.txt | 6 +- .../snapshots/code-mode/terminal.expected.txt | 6 +- .../terminal.expected.txt | 6 +- .../dynamic-workflow/terminal.expected.txt | 6 +- .../terminal.expected.txt | 6 +- .../parallel-file-reads/terminal.expected.txt | 6 +- .../snapshots/todo-plan/terminal.expected.txt | 6 +- .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 6 +- examples/tui-agent/tests/tui.snapshot.ts | 2 + packages/core/agent/README.md | 2 +- packages/core/agent/src/index.ts | 1 + packages/core/agent/src/llm-target.ts | 66 +++ packages/core/agent/tests/llm-target.spec.ts | 45 ++ packages/ui/acp/src/index.ts | 49 +- packages/ui/tui/README.md | 39 +- packages/ui/tui/package.json | 3 + packages/ui/tui/src/index.ts | 431 +++++++++++++++--- packages/ui/tui/tests/harness.ts | 41 +- packages/ui/tui/tests/plugin-shape.spec.ts | 2 +- .../advanced-cards-collapsed.expected.txt | 19 +- .../advanced-cards-expanded.expected.txt | 4 +- .../snapshots/code-mode-pending.expected.txt | 4 +- .../conversation-streaming.expected.txt | 12 +- .../cordis-tools-pending.expected.txt | 4 +- .../snapshots/disposed-terminal.expected.txt | 10 +- .../dynamic-workflow-pending.expected.txt | 7 +- .../snapshots/errors-and-help.expected.txt | 10 +- .../snapshots/model-selector.expected.txt | 52 +++ .../snapshots/model-switching.expected.txt | 35 ++ .../question-dialog-validation.expected.txt | 76 ++- .../snapshots/question-dialog.expected.txt | 68 +-- ...rface-after-compaction-narrow.expected.txt | 5 +- ...surface-after-compaction-wide.expected.txt | 4 +- .../surface-before-compaction.expected.txt | 9 +- .../snapshots/untrusted-controls.expected.txt | 63 +-- packages/ui/tui/tests/tui.snapshot.ts | 49 +- packages/ui/tui/tests/tui.spec.ts | 222 ++++++++- packages/ui/tui/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 58 files changed, 1115 insertions(+), 370 deletions(-) create mode 100644 packages/core/agent/src/llm-target.ts create mode 100644 packages/core/agent/tests/llm-target.spec.ts create mode 100644 packages/ui/tui/tests/snapshots/model-selector.expected.txt create mode 100644 packages/ui/tui/tests/snapshots/model-switching.expected.txt diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml index bc77edbd0a..810f8863e5 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.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 -2026-07-17-dedicated-full-screen-tui-front-door.md: 8fbc5dddc029190b346075a65c9e7857187f3d2b -2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 6ddc3523b7a7173013efe2ef15c5ca0e940929fb +2026-07-17-dedicated-full-screen-tui-front-door.md: 3e2b1e751001020eccc9193438daff23dd42518a +2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 5f3632f43702e16ca9dec07c0bb8e42bf50499b7 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md index 8fbc5dddc0..3e2b1e7510 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md @@ -20,9 +20,11 @@ The selected front door receives the exact generated or resumed `SessionId` used ### Session projection and interaction -The TUI rebuilds the transcript from the active `session.surface` and reprojects it whenever an event carries a `surfaceOp`, so resumed and compacted history matches the model-visible conversation. It renders Markdown text and reasoning, token totals, the latest `todo/write` plan, and tool cards produced through each tool definition's `presentCall` and `presentResult` methods. Pending chunks and tool calls update the same components that completed events settle. +The TUI rebuilds the transcript from the active `session.surface` and reprojects it whenever an event carries a `surfaceOp`, so resumed and compacted history matches the model-visible conversation. It renders Markdown text and reasoning, token totals, the latest `todo/write` plan, and tool cards produced through each tool definition's `presentCall` and `presentResult` methods. Long card bodies retain a configurable head/tail preview with the hidden-line count; one terminal control expands or collapses every card. Pending chunks and tool calls update the same components that completed events settle. -Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. The plugin registers the shared `userInteraction` provider and presents questions as queued keyboard overlays; agent behavior and answer logging remain owned by their existing services. +Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. The idle footer derives context occupancy from `tokenMeter` and pairs the selected model with its reasoning state; during a run, elapsed activity and the Escape interrupt hint replace that summary. The plugin registers the shared `userInteraction` provider and presents queued questions in a wide bottom-left keyboard panel with batch progress, numbered options, and aligned descriptions; agent behavior and answer logging remain owned by their existing services. + +The `/model` command presents the advisory `ctx.llm` catalog as a keyboard selector and changes only this TUI session's target; argument forms remain available for direct selection. Agent-scoped prompt-assembly and request waterfalls snapshot one provider/model pair per step, so `{{provider}}` / `{{model}}` interpolation and request routing cannot split when a command arrives during assembly. The latest logged request header restores a used target; a selection that never reaches a request remains process-local. ### Terminal ownership @@ -39,6 +41,7 @@ The implemented [TUI terminal-state snapshot Agent Note](../testing/2026-07-18-t - **Keep readline and full-screen modes inside `@deepseek-ai/dsh-stdio`** — rejected because line-oriented output and differential TTY rendering have different dependencies, input rules, logging ownership, and teardown obligations. Separate packages keep the pipe-safe contract small and explicit. - **Let the TUI plugin silently downgrade when either stream is not a TTY** — rejected because a fallback hides deployment mistakes and changes interaction semantics. The app bundle may select a front door with `auto`; an explicitly mounted TUI fails loud. - **Keep TUI wiring and tests under the readline `repl-agent` leaf** — rejected because one leaf would represent two distinct front doors and break symmetry with `acp-agent`. A dedicated `tui-agent` leaf owns TUI overlays and tests while reusing the repl-agent backend composition. +- **Mutate `agent.options` when `/model` runs** — rejected because creation options do not provide an atomic boundary between asynchronous prompt assembly and request routing. Agent-scoped waterfalls preserve immutable creation input and snapshot the selected pair for each step. ## Consequences @@ -46,3 +49,4 @@ The implemented [TUI terminal-state snapshot Agent Note](../testing/2026-07-18-t - The TUI carries a pi-tui dependency and a strict TTY requirement; non-TTY deployments use the Headless app or a structured protocol. - Session projection makes resume and compaction consistent with the durable conversation, but one configured session owns the transcript and editor. - Tool packages extend terminal cards through their existing presentation methods without adding tool-specific branches to the TUI. +- Model selection uses adapter-advertised metadata without turning catalog membership into request validation; unused selections are not durable state. diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md index 6ddc3523b7..5f3632f437 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md @@ -20,9 +20,11 @@ DeepSeek Harness 将 [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README ### 会话投影与交互 -TUI 从活跃的 `session.surface` 重建 transcript(文本记录),并在事件携带 `surfaceOp` 时重新投影,因此恢复或压缩后的历史与模型可见会话保持一致。TUI 渲染 Markdown 文本与推理、token 用量、最新 `todo/write` 计划,以及各工具定义通过 `presentCall` 和 `presentResult` 方法生成的工具卡片。进行中的分片与工具调用会更新同一组组件,随后由完成事件收束状态。 +TUI 从活跃的 `session.surface` 重建 transcript(文本记录),并在事件携带 `surfaceOp` 时重新投影,因此恢复或压缩后的历史与模型可见会话保持一致。TUI 渲染 Markdown 文本与推理、token 用量、最新 `todo/write` 计划,以及各工具定义通过 `presentCall` 和 `presentResult` 方法生成的工具卡片。较长的工具卡片正文会保留可配置的头尾预览,并显示隐藏行数;一个终端控制可以展开或收起全部卡片。进行中的分片与工具调用会更新同一组组件,随后由完成事件收束状态。 -agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。插件注册共享的 `userInteraction` 提供方,以排队的键盘浮层呈现问题;agent 行为和答案日志仍由既有服务负责。 +agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。空闲态页脚根据 `tokenMeter` 得出上下文占用率,并将选中模型及其推理状态组合显示;agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述;agent 行为和答案日志仍由既有服务负责。 + +`/model` 命令将建议性的 `ctx.llm` 目录呈现为键盘选择器,并且只更改当前 TUI 会话的目标;带参数的形式仍可直接选择目标。agent 作用域内的 prompt 组装和请求两条 waterfall(瀑布式事件)会为每个 step 快照一次同一个提供方/模型字段组合,因此即使命令在组装期间到达,`{{provider}}` / `{{model}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。 ### 终端所有权 @@ -39,6 +41,7 @@ agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调 - **把 readline 与全屏模式都保留在 `@deepseek-ai/dsh-stdio` 中**:不予采纳,因为逐行输出和差分 TTY 渲染具有不同的依赖、输入规则、日志所有权和资源清理义务。拆分为独立包可以让管道安全契约保持精简、明确。 - **当任一进程流不是 TTY 时,让 TUI 插件静默降级**:不予采纳,因为回退会掩盖部署错误并改变交互语义。应用包可以通过 `auto` 选择入口;明确挂载的 TUI 会快速失败。 - **把 TUI 接线与测试保留在 readline `repl-agent` 叶节点下**:不予采纳,因为一个叶节点会代表两个不同入口,也会破坏它与 `acp-agent` 的对称性。独立的 `tui-agent` 叶节点负责 TUI 浮层和测试,同时复用 repl-agent 的后端组合。 +- **在 `/model` 运行时修改 `agent.options`**:不予采纳,因为创建选项无法在异步 prompt 组装与请求路由之间提供原子边界。agent 作用域内的 waterfall 会在保持创建输入不可变的同时,为每个 step 快照一次选中的字段组合。 ## 后果 @@ -46,3 +49,4 @@ agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调 - TUI 会引入 pi-tui 依赖并严格要求 TTY;非 TTY 部署使用 Headless app 或结构化协议。 - 会话投影使恢复和压缩与持久会话保持一致,但只有一个已配置会话拥有 transcript 和编辑器。 - 工具包通过既有呈现方法扩展终端卡片,无需在 TUI 中增加工具专用分支。 +- 模型选择使用适配器提供的目录元数据,但不会把目录成员关系变成请求校验;未使用的选择不属于持久化状态。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 198c9fbbaa..d8fe905de2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -27,7 +27,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:206`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:211`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-demo` @@ -1217,7 +1217,7 @@ Source: [`packages/core/tools/src/index.ts:382`](../packages/core/tools/src/inde ## `@deepseek-ai/dsh-tui` -Requires: `agents` · `userInteraction` · `tools` +Requires: `agents` · `userInteraction` · `tools` · `llm` · `systemPrompt` · `tokenMeter` ```ts config-catalog /** Serializable plugin configuration. */ @@ -1232,14 +1232,20 @@ export interface Config extends TuiConfig { export interface TuiConfig { /** Render model reasoning blocks. */ showReasoning?: boolean - /** Maximum tool-output lines shown before the card is collapsed. */ + /** Maximum tool-card body lines retained in its collapsed head/tail preview. */ maxToolOutputLines?: number - /** Maximum options visible at once in a user-question dialog. */ + /** Maximum options visible at once in a user-question panel. */ maxQuestionOptions?: number - /** User-question dialog width in terminal columns. */ + /** Maximum models visible at once in the model selector. */ + maxModelOptions?: number + /** User-question panel width in terminal columns, clamped to the terminal. */ questionDialogWidth?: number - /** User-question dialog maximum height in terminal rows. */ + /** User-question panel maximum height in terminal rows. */ questionDialogMaxHeight?: number + /** Model-selector width in terminal columns. */ + modelDialogWidth?: number + /** Model-selector maximum height in terminal rows. */ + modelDialogMaxHeight?: number /** Show the terminal's hardware cursor at the pi editor's IME marker. */ showHardwareCursor?: boolean /** Apply the built-in ANSI color palette. */ @@ -1249,7 +1255,7 @@ export interface TuiConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:101`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:124`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-tui-demo` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index bad8b6a302..4d2c9e1de0 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -216,7 +216,7 @@ roots(): Agent[] Types: [Agent](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:223`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:224`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 52d85d2fb9..394b1da60e 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -15,7 +15,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:207`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | | `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:217`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | | `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:178`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:229`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:229`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:281`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | | `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:244`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:191`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | @@ -36,7 +36,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:119`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:130`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | -| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`acp`](../packages/ui/acp) | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:33`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:116`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:89`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 84068531fe..2a26e2d4fb 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -398,6 +398,7 @@ flowchart TD pkg_tui --> pkg_agent_loop pkg_tui --> pkg_llm pkg_tui --> pkg_session + pkg_tui --> pkg_system_prompt pkg_tui --> pkg_tools pkg_tui --> pkg_user_interaction pkg_agent_spine_demo --> pkg_agent @@ -543,7 +544,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`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), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | diff --git a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl index 65f92c4916..7db76466bc 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl @@ -10,7 +10,7 @@ {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} {"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}} -{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snapshot-spill/session-2ef0a5f14624/b5e2b8c5e6a6-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snapshot-spill/session-766ddb6deb99/b7b4bdfcf7aa-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} {"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index 354a014a63..8ae92a7be9 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -131,8 +131,8 @@ {"type":"assistant/chunk","seq":129,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129],"surfaceOp":"append"} {"type":"tool/call","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"e2d45b4a-ff48-488d-aeb6-8edc9dc5c3de","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"e2d45b4a-ff48-488d-aeb6-8edc9dc5c3de","outcome":"allowed-once"}} +{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"69daf6e5-d7a8-442c-a624-9172517cba58","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"69daf6e5-d7a8-442c-a624-9172517cba58","outcome":"allowed-once"}} {"type":"tool/result","seq":134,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[131],"surfaceOp":"append"} {"type":"step/end","seq":135,"time":1783962245400,"data":{"turn":1,"step":1}} {"type":"step/start","seq":136,"time":1783962245400,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index ff6d1187b3..2a612ed289 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -155,8 +155,8 @@ {"type":"assistant/chunk","seq":153,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153],"surfaceOp":"append"} {"type":"tool/call","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"46c8dba4-52c9-4a6a-b6a6-5f34c95c28df","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"46c8dba4-52c9-4a6a-b6a6-5f34c95c28df","outcome":"rejected"}} +{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"78869d2f-1a83-4672-8a66-2a75d1ffe49f","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"78869d2f-1a83-4672-8a66-2a75d1ffe49f","outcome":"rejected"}} {"type":"tool/result","seq":158,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[155],"surfaceOp":"append"} {"type":"step/end","seq":159,"time":1783962246276,"data":{"turn":1,"step":1}} {"type":"step/start","seq":160,"time":1783962246276,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl index 54601f5354..6c8261e7cb 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl @@ -89,8 +89,8 @@ {"type":"assistant/chunk","seq":87,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":88,"time":1784045703780,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."},{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87],"surfaceOp":"append"} {"type":"tool/call","seq":89,"time":1784045703780,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}} -{"type":"approval/asked","seq":90,"time":1784045703782,"data":{"id":"c37500b3-c252-4a9b-ad0d-9c4349419b30","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} -{"type":"approval/decided","seq":91,"time":1784045703786,"data":{"id":"c37500b3-c252-4a9b-ad0d-9c4349419b30","outcome":"allowed-once"}} +{"type":"approval/asked","seq":90,"time":1784045703782,"data":{"id":"50a67945-0145-4850-93bf-3552f2550b47","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} +{"type":"approval/decided","seq":91,"time":1784045703786,"data":{"id":"50a67945-0145-4850-93bf-3552f2550b47","outcome":"allowed-once"}} {"type":"tool/result","seq":92,"time":1784045703798,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false},"sourceEventSeqs":[89],"surfaceOp":"append"} {"type":"step/end","seq":93,"time":1784045703798,"data":{"turn":1,"step":1}} {"type":"step/start","seq":94,"time":1784045703799,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index 247e13a075..1517df75e9 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -55,8 +55,8 @@ {"type":"tool/call","seq":53,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} {"type":"hook/invoked","seq":54,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":55,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} -{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"68f1e09d-f3e5-4e39-8a51-da082ba3ba99","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} -{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"68f1e09d-f3e5-4e39-8a51-da082ba3ba99","outcome":"rejected"}} +{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"0ff46a23-8e38-46cd-9b1a-99bf363032a4","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} +{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"0ff46a23-8e38-46cd-9b1a-99bf363032a4","outcome":"rejected"}} {"type":"tool/result","seq":58,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":59,"time":1783962235814,"data":{"turn":1,"step":1}} {"type":"step/start","seq":60,"time":1783962235814,"data":{"turn":1,"step":2}} diff --git a/examples/cordis-agent/composition.md b/examples/cordis-agent/composition.md index a812f71445..3cbcb0e571 100644 --- a/examples/cordis-agent/composition.md +++ b/examples/cordis-agent/composition.md @@ -20,6 +20,8 @@ flowchart LR cfg --> plugin_cordis_web plugin_cordis_web_fetch_local["web-fetch-local
@deepseek-ai/dsh-web-fetch-local"] cfg --> plugin_cordis_web_fetch_local + plugin_cordis_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] + cfg --> plugin_cordis_token_meter plugin_cordis_tui_agent["tui-agent
@deepseek-ai/dsh-tui-demo"] cfg --> plugin_cordis_tui_agent plugin_cordis_tui_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] @@ -41,6 +43,7 @@ flowchart LR | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `web` | `@deepseek-ai/dsh-web` | | `web-fetch-local` | `@deepseek-ai/dsh-web-fetch-local` | +| `token-meter` | `@deepseek-ai/dsh-token-meter` | | `tui-agent` | `@deepseek-ai/dsh-tui-demo` | | `tool-cordis` | `@deepseek-ai/dsh-tool-cordis` | diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml index 39b6cd3b48..01dcfc50be 100644 --- a/examples/cordis-agent/cordis.yml +++ b/examples/cordis-agent/cordis.yml @@ -44,6 +44,9 @@ - id: web-fetch-local name: '@deepseek-ai/dsh-web-fetch-local' +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + # The app bundle pre-creates the self-referential demo's `main` agent. - id: tui-agent name: '@deepseek-ai/dsh-tui-demo' diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index 4af7be198f..0e83c8bf3f 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -10,7 +10,7 @@ pnpm run demo:tui The command needs `DEEPSEEK_API_KEY` in the environment or gitignored repository-root `.env`. Set `RESUME_SESSION_ID` to reopen a persisted conversation under `./.sessions`. -The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and the latest todo list. Enter submits or steers while the agent runs; Ctrl+O expands cards, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `ask_user_question` opens a keyboard-driven overlay. +The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and the latest todo list. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/model` opens a keyboard selector for the current provider catalog; use Up/Down and Enter, or `/model ` and `/model /` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options. Run `pnpm run demo:code-mode tui` for the Code Mode overlay. diff --git a/examples/tui-agent/code-mode.cordis.yml b/examples/tui-agent/code-mode.cordis.yml index 45f5a7af04..a11e86a068 100644 --- a/examples/tui-agent/code-mode.cordis.yml +++ b/examples/tui-agent/code-mode.cordis.yml @@ -19,7 +19,7 @@ welcome: 'TUI Code Mode ready. Give it a multi-tool task.' ui: showReasoning: true - maxToolOutputLines: 12 + maxToolOutputLines: 6 persona: | You are a coding agent powered by the {{model}} model. diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 73b42b284a..4ea97ea0e8 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -31,7 +31,7 @@ welcome: 'TUI agent ready. Give it a coding task.' ui: showReasoning: true - maxToolOutputLines: 12 + maxToolOutputLines: 6 persona: | You are a coding agent powered by the {{model}} model. diff --git a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts index c55b3d355c..020394ebe3 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts +++ b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts @@ -1,5 +1,5 @@ import type { Context } from 'cordis' -import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' const CONTROL_PROBE = '\u001b]2;MODEL_CONTROLLED\u0007\u001b[999CMODEL_CURSOR\u009b31mMODEL_C1' @@ -18,7 +18,17 @@ function textChunks(text: string): StreamChunk[] { /** Keyless two-step adapter for the real-PTY TUI conversation test. */ class ScriptedTuiAdapter extends LlmAdapter { - async * stream(options: GenerateOptions): AsyncIterable { + override listModels(provider: string): Promise { + return Promise.resolve([ + { provider, id: 'tui-scripted-model', name: 'Scripted Base' }, + { provider, id: 'tui-scripted-model-pro', name: 'Scripted Pro' }, + ]) + } + + override async * stream(options: GenerateOptions): AsyncIterable { + if (options.model !== 'tui-scripted-model-pro' || !options.system?.includes('tui-scripted-model-pro')) { + throw new Error('the scripted TUI request did not apply the selected model to routing and prompt variables') + } const hasToolResult = options.messages.at(-1)?.content.some(block => block.type === 'tool-result') ?? false if (hasToolResult) { for (const chunk of textChunks(FINAL_TEXT)) yield chunk diff --git a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml index f8de00a1a6..dbe22cb53b 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml +++ b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml @@ -12,6 +12,9 @@ config: cwd: !!js process.cwd() +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + - id: tui-agent name: '@deepseek-ai/dsh-tui-demo' config: @@ -21,5 +24,6 @@ workspaceContext: maxBytes: 65536 welcome: 'scripted TUI ready.' + persona: 'Scripted model {{model}}.' ui: showReasoning: true diff --git a/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt b/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt index 29ea17fd66..8aadb202ff 100644 --- a/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt @@ -67,7 +67,7 @@ buffer style 1-1 inverse 28| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -29| "/workspace/project ↑3.0k ↓115 idle reasoning:on tools:compact" - style 0-58 dim - style 67-99 dim +29| "/tmp/dsh-tui-snapshot-bash-te ↑3.0k ↓115 3% context tools:compact deepseek-v4-flash(reasoning:on)" + style 0-28 dim + style 42-99 dim 30-35| diff --git a/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt b/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt index 2850e0c7d0..ec3b873efd 100644 --- a/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt @@ -73,7 +73,7 @@ buffer style 1-1 inverse 30| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -31| "/workspace/project ↑3.1k ↓158 idle reasoning:on tools:compact" - style 0-49 dim - style 67-99 dim +31| "/tmp/dsh-tui-snapshot-code-mo ↑3.1k ↓158 3% context tools:compact deepseek-v4-flash(reasoning:on)" + style 0-28 dim + style 42-99 dim 32-35| diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt index e4e6d0a040..02fbde0fe0 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt @@ -111,6 +111,6 @@ buffer style 1-1 inverse 48| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -49| "/workspace/project ↑18 ↓18 idle reasoning:on tools:compact" - style 0-61 dim - style 67-99 dim +49| "/tmp/dsh-tui-snapshot-cordis-dyn ↑18 ↓18 7% context tools:compact deepseek-v4-flash(reasoning:on)" + style 0-31 dim + style 42-99 dim diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt b/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt index 459fddd90c..465ceee4e1 100644 --- a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt @@ -101,6 +101,6 @@ buffer style 1-1 inverse 45| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -46| "/workspace/project ↑3.5k ↓227 idle reasoning:on tools:compact" - style 0-56 dim - style 67-99 dim +46| "/tmp/dsh-tui-snapshot-dynamic ↑3.5k ↓227 3% context tools:compact deepseek-v4-flash(reasoning:on)" + style 0-28 dim + style 42-99 dim 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 d56db72ccb..47eb6889fd 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 @@ -64,7 +64,7 @@ buffer style 1-1 inverse 29| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -30| "/workspace/project ↑2.9k ↓41 idle reasoning:on tools:compact" - style 0-62 dim - style 67-99 dim +30| "/tmp/dsh-tui-snapshot-multi-tu ↑2.9k ↓41 3% context tools:compact deepseek-v4-flash(reasoning:on)" + style 0-29 dim + style 42-99 dim 31-35| diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt b/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt index f4c3d83b3d..3af0096d1f 100644 --- a/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt @@ -86,6 +86,6 @@ buffer style 1-1 inverse 37| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -38| "/workspace/project ↑20 ↓6 idle reasoning:on tools:compact" - style 0-55 dim - style 67-99 dim +38| "/tmp/dsh-tui-snapshot-parallel-fi ↑20 ↓6 3% context tools:compact deepseek-v4-flash(reasoning:on)" + style 0-32 dim + style 42-99 dim diff --git a/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt b/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt index 527e6e4016..9ba20c4a28 100644 --- a/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt @@ -76,6 +76,6 @@ buffer style 1-1 inverse 34| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -35| "/workspace/project ↑3.1k ↓145 idle reasoning:on tools:compact" - style 0-49 dim - style 67-99 dim +35| "/tmp/dsh-tui-snapshot-todo-pl ↑3.1k ↓145 3% context tools:compact deepseek-v4-flash(reasoning:on)" + style 0-28 dim + style 42-99 dim diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 21be35eef5..19a366b675 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -25,7 +25,7 @@ describe.skipIf(process.platform === 'win32')('tui-agent keyless smoke (real Loa expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('streams a response, answers a user-question dialog, completes the tool round-trip, and exits cleanly', async () => { + it('switches models, streams a response, answers a user-question dialog, and exits cleanly', async () => { const output = await runTuiPtySmoke({ label: 'tui-agent conversation', tempDirPrefix: 'tui-agent-conversation-', @@ -33,7 +33,9 @@ describe.skipIf(process.platform === 'win32')('tui-agent keyless smoke (real Loa configPath: scriptedConfigPath, tsconfigPath, actions: [ - { waitFor: 'scripted TUI ready.', send: 'exercise the TUI\r' }, + { waitFor: 'scripted TUI ready.', send: '/model\r' }, + { waitFor: 'Select model', send: '\x1b[B\r' }, + { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: 'exercise the TUI\r' }, { waitFor: 'How should the scripted run proceed?', send: '\r' }, { waitFor: 'Decision received. Scripted TUI run complete.', send: '/exit\r' }, ], diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 534207c875..3361b67059 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -14,6 +14,7 @@ import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay' +import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { SessionId } from '@deepseek-ai/dsh-session' import SubagentService from '@deepseek-ai/dsh-subagent' @@ -168,6 +169,7 @@ async function mountScenarioContext( tools: { mode: scenario.composition === 'code' ? 'code' : scenario.composition === 'advanced' ? 'both' : 'native' }, skills: { local: { agentsHome: join(cwd, '.agents') } }, }) + await ctx.plugin(TokenMeterService) await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(FsPolicy) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 23a7bfddf0..76b74945a7 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -8,7 +8,7 @@ Tracks live agents and carries the initiating Agent through asynchronous driver ### Public API -The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. +The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model selection during prompt assembly and applies that pair to both prompt variables and request routing for one step. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. - `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber. - Advanced ordered lifecycle: `enter(agent, owner): () => void` enforces `agent.id === agent.session.id`, performs the authoritative ID collision check, and inserts without announcing; `owner` explicitly records the live creator-agent relation (or `undefined` for a root), independently of durable session lineage. `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`. diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 061ddae53f..66dce92464 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -15,6 +15,7 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type { Agent, AgentOptions } from './types.ts' export * from './types.ts' +export * from './llm-target.ts' export { agentEvents, assembleContextFor } from './dispatch.ts' export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts' diff --git a/packages/core/agent/src/llm-target.ts b/packages/core/agent/src/llm-target.ts new file mode 100644 index 0000000000..98f1e19d7d --- /dev/null +++ b/packages/core/agent/src/llm-target.ts @@ -0,0 +1,66 @@ +/** + * Agent-scoped provider/model target snapshot shared by interactive front doors. + * @module @deepseek-ai/dsh-agent/llm-target + */ + +import type { Context } from 'cordis' +import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' + +/** Complete provider/model route selected for one live agent. */ +export interface AgentLlmTarget { + /** Registered provider route. */ + provider: string + /** Provider-owned model id. */ + model: string +} + +/** Mutable selection plus the target captured for the current step. */ +export interface AgentLlmTargetRef { + /** Target selected for the next step that enters prompt assembly. */ + current: AgentLlmTarget | undefined + /** Target captured when the current step entered prompt assembly. */ + assembled: AgentLlmTarget | undefined +} + +/** + * Couple one mutable target to agent-scoped prompt assembly and request routing. + * Prompt assembly snapshots the selected pair before delegating, then applies + * both prompt variables and request config to that snapshot so a concurrent + * switch takes effect on a later step instead of splitting the two surfaces. + * + * @param agentCtx - The target agent's scoped context. + * @param target - Mutable selection owned by the calling front door. + * @returns Disposer for both scoped waterfall listeners. + */ +export function installAgentLlmTarget(agentCtx: Context, target: AgentLlmTargetRef): () => void { + const disposeAssembly = agentCtx.on('system-prompt/assemble', async (_assembly, _context, next) => { + const selected = target.current + const assembled = await next() + target.assembled = selected + if (selected === undefined) return assembled + return { + ...assembled, + variables: { + ...assembled.variables, + provider: selected.provider, + model: selected.model, + }, + } + }) + const disposeRequest = agentCtx.on( + 'agent/request', + async (_agent, _turn, _step, _config, next): Promise => { + const resolved = await next() + const selected = target.assembled + return selected === undefined ? resolved : { + ...resolved, + provider: selected.provider, + model: selected.model, + } + }, + ) + return () => { + disposeAssembly() + disposeRequest() + } +} diff --git a/packages/core/agent/tests/llm-target.spec.ts b/packages/core/agent/tests/llm-target.spec.ts new file mode 100644 index 0000000000..0c9ccb56ec --- /dev/null +++ b/packages/core/agent/tests/llm-target.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import { + agentEvents, + installAgentLlmTarget, + type Agent, + type AgentLlmTargetRef, +} from '../src/index.ts' +import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' + +describe('installAgentLlmTarget()', () => { + it('snapshots prompt variables and request routing together, then disposes both listeners', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + const target: AgentLlmTargetRef = { current: undefined, assembled: undefined } + const dispose = installAgentLlmTarget(ctx, target) + const agent = {} as Agent + const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } + + expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) + await expect(agentEvents(ctx, agent).waterfall( + 'agent/request', 1, 0, seed, () => Promise.resolve(seed), + )).resolves.toBe(seed) + + target.current = { provider: 'alpha', model: 'a1' } + expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'alpha', model: 'a1' }) + target.current = { provider: 'beta', model: 'b1' } + await expect(agentEvents(ctx, agent).waterfall( + 'agent/request', 1, 0, seed, () => Promise.resolve(seed), + )).resolves.toEqual({ provider: 'alpha', model: 'a1', temperature: 0.2 }) + + expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'beta', model: 'b1' }) + await expect(agentEvents(ctx, agent).waterfall( + 'agent/request', 1, 1, seed, () => Promise.resolve(seed), + )).resolves.toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 }) + + dispose() + expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) + await expect(agentEvents(ctx, agent).waterfall( + 'agent/request', 2, 0, seed, () => Promise.resolve(seed), + )).resolves.toBe(seed) + await ctx.fiber.dispose() + }) +}) diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index b190aeb47d..3c133aab6e 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -42,9 +42,14 @@ import { type Stream, type StopReason, } from '@agentclientprotocol/sdk' -import type { ContentBlock, LlmCallConfig, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' import { assertNever, CallId } from '@deepseek-ai/dsh-llm' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { + installAgentLlmTarget, + type Agent, + type AgentLlmTarget as LlmTarget, + type AgentLlmTargetRef as LlmTargetRef, +} from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' // Side-effect type import: resolves `ctx.get('permission')` to the service. import type {} from '@deepseek-ai/dsh-permission' @@ -217,19 +222,6 @@ export const Config: Schema = Schema.object({ model: Schema.string(), }) -/** Provider/model pair selected for one ACP session. */ -interface LlmTarget { - provider: string - model: string -} - -/** Mutable target shared by one agent's scoped assembly and request listeners. */ -interface LlmTargetRef { - current: LlmTarget | undefined - /** Step snapshot captured by prompt assembly so target switches cannot split prompt and request. */ - assembled: LlmTarget | undefined -} - /** One resolved ACP model selector plus its opaque value lookup. */ interface ModelDirectory { option: Extract | undefined @@ -294,32 +286,7 @@ export function apply(ctx: Context, config: AcpConfig): void { const logged = agent.session.requestHeader()?.config if (logged !== undefined) target.current = { provider: logged.provider, model: logged.model } - // Capture once at assembly entry and apply the same pair after downstream - // prompt listeners. A selector change during async assembly therefore takes - // effect on the following step instead of splitting {{model}} from routing. - agentCtx.on('system-prompt/assemble', async (_assembly, _context, next) => { - const selected = target.current - const assembled = await next() - target.assembled = selected - if (selected === undefined) return assembled - return { - ...assembled, - variables: { - ...assembled.variables, - provider: selected.provider, - model: selected.model, - }, - } - }) - agentCtx.on('agent/request', async (_agent, _turn, _step, _callConfig, next): Promise => { - const resolved = await next() - const selected = target.assembled - return selected === undefined ? resolved : { - ...resolved, - provider: selected.provider, - model: selected.model, - } - }) + installAgentLlmTarget(agentCtx, target) } /** Opaque ACP value preserving both routing dimensions. */ diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index f44644c430..d569fe39c6 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -4,13 +4,15 @@ The interactive terminal front door for DeepSeek Harness agents, built on [`@ear The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) owns the front-door decision; the [terminal-state snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns its verification strategy. -This package owns interactive terminal presentation and input only. It injects `agents`, `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries. +This package owns interactive terminal presentation and input only. It injects `agents`, `llm`, `systemPrompt`, `tokenMeter`, `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries. -The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions as keyboard-driven overlays. Surface replacement events rebuild the transcript so compacted history does not reappear. +The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The idle footer shows token-meter context occupancy, tool-card mode, and the current model with reasoning state; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear. Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling. -While the agent is running, editor submissions call `agent.steer()`; otherwise they call `agent.send()`. Ctrl+C or Escape cancels a running turn. Ctrl+O expands tool cards, Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/help`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` provide the same actions without key chords. +While the agent is running, editor submissions call `agent.steer()`; otherwise they call `agent.send()`. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/help`, `/model`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` provide terminal-only controls without entering command text into the session. + +`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Enter selects, and Escape closes it. `/model ` still selects an unambiguous model id directly, while `/model /` selects an exact target. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same pair through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. ## Config @@ -19,10 +21,13 @@ While the agent is running, editor submissions call `agent.steer()`; otherwise t | `welcome` | `ready.` | Header subtitle | | `sessionId` | `main` | Exact shared agent/session identity driven by the terminal | | `showReasoning` | `true` | Render reasoning blocks | -| `maxToolOutputLines` | `12` | Collapsed tool-card output limit | -| `maxQuestionOptions` | `8` | Visible options in a question overlay | -| `questionDialogWidth` | `72` | Question-overlay width in columns | -| `questionDialogMaxHeight` | `20` | Question-overlay maximum rows | +| `maxToolOutputLines` | `6` | Output lines retained across a collapsed tool card's head/tail preview | +| `maxQuestionOptions` | `8` | Visible options in a question panel | +| `maxModelOptions` | `8` | Visible models in the model selector | +| `questionDialogWidth` | `200` | Question-panel width in columns, clamped to the terminal | +| `questionDialogMaxHeight` | `20` | Question-panel maximum rows | +| `modelDialogWidth` | `72` | Model-selector width in columns | +| `modelDialogMaxHeight` | `20` | Model-selector maximum rows | | `showHardwareCursor` | `false` | Show the hardware cursor at pi-tui's IME marker | | `color` | `true` | Apply the built-in ANSI palette (see [Color](#color)) | | `title` | `DeepSeek Harness` | Terminal window title | @@ -34,14 +39,14 @@ While the agent is running, editor submissions call `agent.steer()`; otherwise t welcome: 'Coding agent ready.' sessionId: main-session-123 showReasoning: true - maxToolOutputLines: 12 + maxToolOutputLines: 6 ``` Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR. ## Color -The palette uses the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so it stays readable on light and dark backgrounds alike. Body text keeps the terminal's default foreground rather than a fixed shade. Grouped regions (user prompts, tool cards) use a colored left-gutter bar instead of a filled background block, and the question overlay's active row uses reverse video; both are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling. +The palette uses the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so it stays readable on light and dark backgrounds alike. Body text keeps the terminal's default foreground rather than a fixed shade. Grouped regions (user prompts, tool cards) use a colored left-gutter bar instead of a filled background block; the question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling. ## Model Experience @@ -49,7 +54,7 @@ The palette uses the standard 16-color ANSI foregrounds and SGR attributes, whic #### What the model sees -Each non-empty editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. Slash commands and keybindings are TUI-only. +Each non-empty non-command editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. Slash command text and keybindings are TUI-only. #### Token effect @@ -59,6 +64,20 @@ Submitted text is retained under the agent loop's normal session-history and com Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. +### Session model selection + +#### What the model sees + +The `/model` command text and keyboard-selector input are not logged or sent. New steps receive the selected provider/model pair in both prompt variables and request routing. + +#### Token effect + +The selector adds no messages. A target change may alter interpolated system-prompt text and sends subsequent requests to the selected model. + +#### KV Cache effect + +Changing provider or model enters that target's cache domain; no cache reuse across distinct targets is assumed. + ### Interactive user-question answers #### What the model sees diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index fd4f187e35..7481d78e58 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -26,6 +26,8 @@ "@deepseek-ai/dsh-agent-loop": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-token-meter": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -41,6 +43,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 35721669fb..dd3e58c6bc 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -13,12 +13,12 @@ import { Editor, Input, Key, - Loader, Markdown, Spacer, Text, TUI, ProcessTerminal, + SelectList, matchesKey, truncateToWidth, visibleWidth, @@ -33,10 +33,21 @@ import { } from '@earendil-works/pi-tui' import type { Context } from 'cordis' import z from 'schemastery' -import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' +import { + installAgentLlmTarget, + type Agent, + type AgentLlmTarget, + type AgentLlmTargetRef, + type AgentStatus, +} from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-loop' +import type {} from '@deepseek-ai/dsh-token-meter' import { errorChain } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { + ContentBlock, + LlmModelInfo, + StreamChunk, +} from '@deepseek-ai/dsh-llm' import { SessionId, type Session, type SessionEvent, type TodoItem } from '@deepseek-ai/dsh-session' import type { FileDiff, @@ -54,20 +65,26 @@ import { } from '@deepseek-ai/dsh-user-interaction' export const name = 'ui-tui' -export const inject = ['agents', 'userInteraction', 'tools'] +export const inject = ['agents', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter'] /** Presentation settings for the pi-tui terminal mode. */ export interface TuiConfig { /** Render model reasoning blocks. */ showReasoning?: boolean - /** Maximum tool-output lines shown before the card is collapsed. */ + /** Maximum tool-card body lines retained in its collapsed head/tail preview. */ maxToolOutputLines?: number - /** Maximum options visible at once in a user-question dialog. */ + /** Maximum options visible at once in a user-question panel. */ maxQuestionOptions?: number - /** User-question dialog width in terminal columns. */ + /** Maximum models visible at once in the model selector. */ + maxModelOptions?: number + /** User-question panel width in terminal columns, clamped to the terminal. */ questionDialogWidth?: number - /** User-question dialog maximum height in terminal rows. */ + /** User-question panel maximum height in terminal rows. */ questionDialogMaxHeight?: number + /** Model-selector width in terminal columns. */ + modelDialogWidth?: number + /** Model-selector maximum height in terminal rows. */ + modelDialogMaxHeight?: number /** Show the terminal's hardware cursor at the pi editor's IME marker. */ showHardwareCursor?: boolean /** Apply the built-in ANSI color palette. */ @@ -77,10 +94,13 @@ export interface TuiConfig { } const showReasoningSchema = z.boolean().default(true) -const maxToolOutputLinesSchema = z.number().step(1).min(1).default(12) +const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6) const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8) -const questionDialogWidthSchema = z.number().step(1).min(20).default(72) +const maxModelOptionsSchema = z.number().step(1).min(1).default(8) +const questionDialogWidthSchema = z.number().step(1).min(20).default(200) const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20) +const modelDialogWidthSchema = z.number().step(1).min(20).default(72) +const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20) const showHardwareCursorSchema = z.boolean().default(false) const colorSchema = z.boolean().default(true) const titleSchema = z.string().default('DeepSeek Harness') @@ -90,8 +110,11 @@ export const TuiConfigSchema: z = z.object({ showReasoning: showReasoningSchema, maxToolOutputLines: maxToolOutputLinesSchema, maxQuestionOptions: maxQuestionOptionsSchema, + maxModelOptions: maxModelOptionsSchema, questionDialogWidth: questionDialogWidthSchema, questionDialogMaxHeight: questionDialogMaxHeightSchema, + modelDialogWidth: modelDialogWidthSchema, + modelDialogMaxHeight: modelDialogMaxHeightSchema, showHardwareCursor: showHardwareCursorSchema, color: colorSchema, title: titleSchema, @@ -111,8 +134,11 @@ export const Config: z = z.object({ showReasoning: showReasoningSchema, maxToolOutputLines: maxToolOutputLinesSchema, maxQuestionOptions: maxQuestionOptionsSchema, + maxModelOptions: maxModelOptionsSchema, questionDialogWidth: questionDialogWidthSchema, questionDialogMaxHeight: questionDialogMaxHeightSchema, + modelDialogWidth: modelDialogWidthSchema, + modelDialogMaxHeight: modelDialogMaxHeightSchema, showHardwareCursor: showHardwareCursorSchema, color: colorSchema, title: titleSchema, @@ -123,8 +149,11 @@ export interface ResolvedTuiConfig { showReasoning: boolean maxToolOutputLines: number maxQuestionOptions: number + maxModelOptions: number questionDialogWidth: number questionDialogMaxHeight: number + modelDialogWidth: number + modelDialogMaxHeight: number showHardwareCursor: boolean color: boolean title: string @@ -136,6 +165,8 @@ export interface TuiRuntime { terminal: Terminal /** Exit hook used by terminal shutdown or a target-agent startup failure. */ exit(code: number): void + /** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */ + now?(): number } /** @@ -147,10 +178,13 @@ export interface TuiRuntime { export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConfig { return { showReasoning: config?.showReasoning ?? true, - maxToolOutputLines: config?.maxToolOutputLines ?? 12, + maxToolOutputLines: config?.maxToolOutputLines ?? 6, maxQuestionOptions: config?.maxQuestionOptions ?? 8, - questionDialogWidth: config?.questionDialogWidth ?? 72, + maxModelOptions: config?.maxModelOptions ?? 8, + questionDialogWidth: config?.questionDialogWidth ?? 200, questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20, + modelDialogWidth: config?.modelDialogWidth ?? 72, + modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20, showHardwareCursor: config?.showHardwareCursor ?? false, color: config?.color ?? true, title: config?.title ?? 'DeepSeek Harness', @@ -251,6 +285,13 @@ function selectTheme(palette: Palette): SelectListTheme { } } +function dialogSelectTheme(palette: Palette): SelectListTheme { + return { + ...selectTheme(palette), + selectedText: text => palette.selected(palette.accent(text)), + } +} + function contentText(content: readonly ContentBlock[]): string { const parts: string[] = [] for (const block of content) { @@ -282,11 +323,52 @@ function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning' .join('\n\n') } +interface ModelChoice extends AgentLlmTarget { + modelName: string + description?: string +} + +function targetLabel(target: AgentLlmTarget): string { + return `${target.provider}/${target.model}` +} + +function initialTarget(agent: Agent): AgentLlmTarget | undefined { + const logged = agent.session.requestHeader()?.config + if (logged !== undefined) return { provider: logged.provider, model: logged.model } + if (agent.options.provider === undefined || agent.options.model === undefined) return undefined + return { provider: agent.options.provider, model: agent.options.model } +} + +async function readModelChoices( + ctx: Context, + current: AgentLlmTarget | undefined, +): Promise { + const providers = ctx.llm.listProviders() + const groups = await Promise.all(providers.map(async (provider) => { + const advertised = await ctx.llm.listModels(provider.id) + const models: LlmModelInfo[] = [...advertised] + if ( + current?.provider === provider.id + && !models.some(model => model.id === current.model) + ) { + models.push({ provider: provider.id, id: current.model, name: current.model }) + } + return models.map((model): ModelChoice => ({ + provider: provider.id, + model: model.id, + modelName: model.name, + ...model.description === undefined ? {} : { description: model.description }, + })) + })) + return groups.flat() +} + class HeaderComponent implements Component { constructor( private readonly agent: Agent, private readonly welcome: string, private readonly palette: Palette, + private readonly currentModel: () => string | undefined, ) {} invalidate(): void {} @@ -294,7 +376,7 @@ class HeaderComponent implements Component { render(width: number): string[] { const usable = Math.max(1, width - 4) const title = `${this.palette.bold(this.palette.accent('DEEPSEEK'))} ${this.palette.bold('HARNESS')}` - const model = displayText(this.agent.options.model ?? 'model unset') + const model = displayText(this.currentModel() ?? 'model unset') const detail = `${model} • ${displayText(this.agent.session.id)}` const top = this.palette.accent(`╭${'─'.repeat(Math.max(0, width - 2))}╮`) const bottom = this.palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`) @@ -506,9 +588,15 @@ class ToolCardComponent implements Component { const glyph = this.result === undefined ? this.palette.warning('◌') : isError ? this.palette.error('✕') : this.palette.success('✓') const body = this.renderBody() const title = truncateToWidth(`${glyph} ${displayText(this.title())}`, Math.max(1, width - 4), '') + const headLines = Math.ceil(this.maxOutputLines / 2) + const tailLines = this.maxOutputLines - headLines const visibleBody = this.expanded || body.length <= this.maxOutputLines ? body - : [...body.slice(0, this.maxOutputLines), this.palette.dim(`… ${body.length - this.maxOutputLines} more lines (Ctrl+O to expand)`)] + : [ + ...body.slice(0, headLines), + this.palette.dim(`… +${body.length - this.maxOutputLines} lines (Ctrl+O to expand)`), + ...body.slice(body.length - tailLines), + ] const barFn = this.result === undefined ? this.palette.warning : isError ? this.palette.error : this.palette.success @@ -622,19 +710,40 @@ class FooterComponent implements Component { private readonly toolsExpanded: () => boolean, private readonly showReasoning: () => boolean, private readonly tokens: () => { input: number; output: number }, + private readonly currentModel: () => string | undefined, + private readonly contextPercent: () => number, + private readonly runningSeconds: () => number, ) {} invalidate(): void {} render(width: number): string[] { + if (this.agent.status === 'running') { + const interrupt = this.palette.dim('esc interrupt') + const activityAvailable = Math.max(0, width - visibleWidth(interrupt) - 1) + const activity = truncateToWidth(this.palette.accent(`◒ Working · ${this.runningSeconds()}s`), activityAvailable, '') + const gap = ' '.repeat(Math.max(0, width - visibleWidth(activity) - visibleWidth(interrupt))) + return [`${activity}${gap}${interrupt}`] + } const { input, output } = this.tokens() - const left = `${formatCwd(this.agent.session.header.cwd)} ↑${formatTokens(input)} ↓${formatTokens(output)}` - const right = `${this.agent.status} reasoning:${this.showReasoning() ? 'on' : 'off'} tools:${this.toolsExpanded() ? 'expanded' : 'compact'}` - const leftStyled = this.palette.dim(left) - const available = Math.max(0, width - visibleWidth(left) - 2) - const rightClipped = truncateToWidth(right, available, '') - const gap = ' '.repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(rightClipped))) - return [truncateToWidth(`${leftStyled}${gap}${this.palette.dim(rightClipped)}`, width, '')] + const counters = `↑${formatTokens(input)} ↓${formatTokens(output)}` + const model = displayText(this.currentModel() ?? 'model unset') + const modelState = `${model}(reasoning:${this.showReasoning() ? 'on' : 'off'})` + const context = `${this.contextPercent()}% context` + const fullRight = `${context} tools:${this.toolsExpanded() ? 'expanded' : 'compact'} ${modelState}` + const compactRight = `${context} ${modelState}` + if (visibleWidth(counters) + visibleWidth(compactRight) + 1 > width) { + const compact = truncateToWidth(compactRight, width, '') + return [`${' '.repeat(Math.max(0, width - visibleWidth(compact)))}${this.palette.dim(compact)}`] + } + const rightAvailable = width - visibleWidth(counters) - 1 + const right = visibleWidth(fullRight) <= rightAvailable ? fullRight : compactRight + const rightClipped = truncateToWidth(right, rightAvailable, '') + const cwdAvailable = Math.max(0, width - visibleWidth(counters) - visibleWidth(rightClipped) - 3) + const cwd = truncateToWidth(formatCwd(this.agent.session.header.cwd), cwdAvailable, '') + const left = [cwd, counters].filter(Boolean).join(' ') + const gap = ' '.repeat(Math.max(0, width - visibleWidth(left) - visibleWidth(rightClipped))) + return [`${this.palette.dim(left)}${gap}${this.palette.dim(rightClipped)}`] } } @@ -643,6 +752,76 @@ interface QuestionSelection { custom?: string } +function renderDialog( + title: string, + body: readonly string[], + width: number, + palette: Palette, +): string[] { + const innerWidth = Math.max(1, width - 4) + const topLabel = ` ${displayText(title)} ` + const top = `╭${topLabel}${'─'.repeat(Math.max(0, width - visibleWidth(topLabel) - 2))}╮` + const lines: string[] = [palette.accent(top)] + for (const line of body) { + const clipped = truncateToWidth(line, innerWidth, '') + lines.push(`${palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${palette.accent('│')}`) + } + lines.push(palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`)) + return lines +} + +class ModelDialog implements Component { + private readonly list: SelectList + + constructor( + choices: readonly ModelChoice[], + current: AgentLlmTarget | undefined, + maxVisible: number, + private readonly palette: Palette, + done: (choice: ModelChoice) => void, + cancel: () => void, + ) { + this.list = new SelectList(choices.map(choice => ({ + value: targetLabel(choice), + label: displayText(targetLabel(choice)), + description: [ + displayText(choice.modelName), + ...choice.description === undefined ? [] : [displayText(choice.description)], + ...current?.provider === choice.provider && current.model === choice.model ? ['current'] : [], + ].join(' — '), + })), maxVisible, dialogSelectTheme(palette)) + const currentIndex = current === undefined + ? 0 + : choices.findIndex(choice => choice.provider === current.provider && choice.model === current.model) + this.list.setSelectedIndex(currentIndex) + this.list.onSelect = (item) => { + const selected = choices.find(choice => targetLabel(choice) === item.value) + /* v8 ignore next -- SelectList only returns values built from `choices`. */ + if (selected === undefined) return + done(selected) + } + this.list.onCancel = cancel + } + + invalidate(): void { + this.list.invalidate() + } + + handleInput(data: string): void { + this.list.handleInput(data) + this.invalidate() + } + + render(width: number): string[] { + const innerWidth = Math.max(1, width - 4) + return renderDialog('Select model', [ + ...this.list.render(innerWidth), + '', + this.palette.dim('↑/↓ navigate • Enter select • Esc cancel'), + ], width, this.palette) + } +} + class QuestionDialog implements Component, Focusable { private selectedIndex = 0 private selected = new Set() @@ -654,6 +833,9 @@ class QuestionDialog implements Component, Focusable { constructor( private readonly question: AskUserQuestionItem, + private readonly position: number, + private readonly total: number, + private readonly unanswered: number, private readonly maxVisible: number, private readonly palette: Palette, private readonly done: (selection: QuestionSelection) => void, @@ -694,11 +876,11 @@ class QuestionDialog implements Component, Focusable { } else if (matchesKey(data, Key.enter)) { const indices = this.question.multiSelect ? [...this.selected].sort((a, b) => a - b) : [this.selectedIndex] if (indices.length === 0) { - this.error = 'Select at least one option, or press C for a custom answer.' + this.error = 'Select at least one option, or press Tab for a custom answer.' return } this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) }) - } else if (data.toLowerCase() === 'c') { + } else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') { this.mode = 'custom' this.error = '' } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { @@ -718,16 +900,13 @@ class QuestionDialog implements Component, Focusable { render(width: number): string[] { this.input.focused = this.focused const innerWidth = Math.max(1, width - 4) - const title = displayText(this.question.header ?? 'Question') - const topLabel = ` ${title} ` - const top = `╭${topLabel}${'─'.repeat(Math.max(0, width - visibleWidth(topLabel) - 2))}╮` - const lines: string[] = [this.palette.accent(top)] - const push = (line: string): void => { - const clipped = truncateToWidth(line, innerWidth, '') - lines.push(`${this.palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${this.palette.accent('│')}`) - } - for (const line of wrapTextWithAnsi(this.palette.bold(displayText(this.question.question)), innerWidth)) push(line) - push('') + const header = `Question ${this.position}/${this.total} (${this.unanswered} unanswered)${this.question.header === undefined ? '' : ` · ${displayText(this.question.header)}`}` + const lines = [ + this.palette.muted(header), + ...wrapTextWithAnsi(this.palette.text(displayText(this.question.question)), innerWidth), + '', + ] + const push = (line: string): void => { lines.push(line) } if (this.mode === 'custom') { for (const line of this.input.render(innerWidth)) push(line) push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel')) @@ -738,27 +917,45 @@ class QuestionDialog implements Component, Focusable { options.length - this.maxVisible, )) const end = Math.min(options.length, start + this.maxVisible) + const optionRows = options.slice(start, end).map((option, offset) => { + const index = start + offset + const mark = this.question.multiSelect + ? this.selected.has(index) ? '[x] ' : '[ ] ' + : '' + return `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}` + }) + const descriptionColumn = Math.min( + Math.max(...optionRows.map(row => visibleWidth(row))) + 2, + Math.max(1, Math.floor(innerWidth * 0.55)), + ) for (let index = start; index < end; index += 1) { // `index < end <= options.length`; the options array is borrowed immutably for this dialog. const option = options[index] as NonNullable[number] - const cursor = index === this.selectedIndex ? this.palette.accent('›') : ' ' const mark = this.question.multiSelect - ? this.selected.has(index) ? this.palette.success('[x]') : '[ ]' - : index === this.selectedIndex ? this.palette.accent('●') : this.palette.dim('○') - const description = option.description - ? this.palette.muted(` — ${displayText(option.description)}`) + ? this.selected.has(index) ? '[x] ' : '[ ] ' : '' - const line = `${cursor} ${mark} ${displayText(option.label)}${description}` - push(index === this.selectedIndex ? this.palette.selected(line) : line) + const left = `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}` + const leftStyled = index === this.selectedIndex + ? this.palette.bold(this.palette.accent(left)) + : left + const description = option.description === undefined + ? '' + : `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.muted(displayText(option.description))}` + push(`${leftStyled}${description}`) } if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`)) - push(this.palette.dim(this.question.multiSelect - ? '↑↓ navigate • Space toggle • Enter submit • C custom • Esc cancel' - : '↑↓ navigate • Enter select • C custom • Esc cancel')) + const hint = this.palette.dim(this.question.multiSelect + ? 'Tab custom answer • ↑/↓ navigate • Space toggle • Enter submit • Esc interrupt' + : 'Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt') + for (const line of wrapTextWithAnsi(hint, innerWidth)) push(line) } - if (this.error) push(this.palette.error(this.error)) - lines.push(this.palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`)) - return lines + if (this.error) { + for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) push(line) + } + return ['', ...lines, ''].map((line) => { + const clipped = truncateToWidth(line, innerWidth, '') + return ` ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ` + }) } } @@ -814,7 +1011,6 @@ export function createTuiChat( const ui = new TUI(runtime.terminal, resolved.showHardwareCursor) const chat = new Container() const todoContainer = new Container() - const statusContainer = new Container() const editor = new Editor(ui, { borderColor: palette.dim, selectList: selectTheme(palette), @@ -823,7 +1019,8 @@ export function createTuiChat( let showReasoning = resolved.showReasoning let toolsExpanded = false let streaming: StreamingAssistantComponent | undefined - let statusLoader: Loader | undefined + let runningStartedAt: number | undefined + let statusTicker: ReturnType | undefined let disposed = false let shuttingDown: Promise | undefined const tokens = sessionTokens(agent.session) @@ -832,13 +1029,25 @@ export function createTuiChat( const liveErrors = new Set() const questionQueue: PendingQuestion[] = [] let activeQuestion: PendingQuestion | undefined + let modelOverlay: OverlayHandle | undefined + const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined } + let modelCommands = Promise.resolve() + const now = (): number => runtime.now?.() ?? Date.now() const welcome = config.welcome ?? 'ready.' - const header = new HeaderComponent(agent, welcome, palette) - const footer = new FooterComponent(agent, palette, () => toolsExpanded, () => showReasoning, () => tokens) + const header = new HeaderComponent(agent, welcome, palette, () => target.current?.model) + const footer = new FooterComponent( + agent, + palette, + () => toolsExpanded, + () => showReasoning, + () => tokens, + () => target.current?.model, + () => Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / ctx.tokenMeter.contextWindow * 100)), + () => runningStartedAt === undefined ? 0 : Math.max(0, Math.floor((now() - runningStartedAt) / 1_000)), + ) ui.addChild(header) ui.addChild(chat) - ui.addChild(statusContainer) todoContainer.addChild(todo) ui.addChild(todoContainer) ui.addChild(editor) @@ -858,10 +1067,98 @@ export function createTuiChat( requestRender() } + const disposeTargetListeners = installAgentLlmTarget(agent.ctx, target) + + const selectModel = (selected: ModelChoice): void => { + if (target.current?.provider === selected.provider && target.current.model === selected.model) { + appendNotice(`Model is already ${targetLabel(selected)}.`) + return + } + target.current = { provider: selected.provider, model: selected.model } + appendNotice(`Model selected: ${targetLabel(selected)}. New steps will use it.`) + } + + const showModelSelector = (choices: readonly ModelChoice[]): void => { + const current = target.current === undefined ? 'unset' : targetLabel(target.current) + if (choices.length === 0) { + appendNotice(`Current model: ${current}\nNo models are advertised by registered providers.`, 'warning') + return + } + modelOverlay?.hide() + modelOverlay = undefined + const close = (): void => { + modelOverlay?.hide() + modelOverlay = undefined + requestRender() + } + const dialog = new ModelDialog( + choices, + target.current, + resolved.maxModelOptions, + palette, + (selected) => { + close() + selectModel(selected) + }, + close, + ) + modelOverlay = ui.showOverlay(dialog, { + width: resolved.modelDialogWidth, + maxHeight: resolved.modelDialogMaxHeight, + anchor: 'center', + margin: 1, + }) + requestRender() + } + + const handleModelCommand = async (raw: string): Promise => { + const choices = await readModelChoices(ctx, target.current) + if (disposed) return + const argument = raw.trim() + if (argument === '') { + showModelSelector(choices) + return + } + const parts = argument.split(/\s+/u) + if (parts.length > 2) { + appendNotice('Usage: /model [provider/]model', 'warning') + return + } + + let matches: ModelChoice[] + if (parts.length === 2) { + matches = choices.filter(choice => choice.provider === parts[0] && choice.model === parts[1]) + } else { + const value = argument + const qualified = choices.filter(choice => targetLabel(choice) === value) + matches = qualified.length > 0 ? qualified : choices.filter(choice => choice.model === value) + } + if (matches.length === 0) { + appendNotice(`Unknown model: ${argument}. Run /model to list available models.`, 'warning') + return + } + if (matches.length > 1) { + appendNotice(`Model "${argument}" is advertised by multiple providers; use /model /.`, 'warning') + return + } + const selected = matches[0] + /* v8 ignore next -- a non-empty matches array always has index zero. */ + if (selected === undefined) return + selectModel(selected) + } + + const queueModelCommand = (raw: string): void => { + modelCommands = modelCommands.then(async () => { + await handleModelCommand(raw) + }).catch((error: unknown) => { + if (!disposed) appendNotice(`Could not read the model catalog: ${errorChain(error)}`, 'error') + }) + } + const clearStatus = (): void => { - statusLoader?.stop() - statusLoader = undefined - statusContainer.clear() + if (statusTicker !== undefined) clearInterval(statusTicker) + statusTicker = undefined + runningStartedAt = undefined runtime.terminal.setProgress(false) } @@ -869,8 +1166,9 @@ export function createTuiChat( clearStatus() editor.borderColor = status === 'running' ? text => palette.accent(text) : text => palette.dim(text) if (status === 'running') { - statusLoader = new Loader(ui, text => palette.accent(text), text => palette.muted(text), 'Working — Enter sends steering, Esc cancels') - statusContainer.addChild(statusLoader) + runningStartedAt = now() + statusTicker = setInterval(requestRender, 1_000) + statusTicker.unref() runtime.terminal.setProgress(true) } requestRender() @@ -1030,6 +1328,9 @@ export function createTuiChat( } const dialog = new QuestionDialog( question, + pending.index + 1, + pending.request.questions.length, + pending.request.questions.length - pending.answers.length, resolved.maxQuestionOptions, palette, (selection) => { @@ -1048,8 +1349,8 @@ export function createTuiChat( pending.overlay = ui.showOverlay(dialog, { width: resolved.questionDialogWidth, maxHeight: resolved.questionDialogMaxHeight, - anchor: 'center', - margin: 1, + anchor: 'bottom-left', + margin: { bottom: 1 }, }) requestRender() } @@ -1089,6 +1390,8 @@ export function createTuiChat( shuttingDown ??= (async () => { disposed = true clearStatus() + modelOverlay?.hide() + modelOverlay = undefined if (activeQuestion !== undefined) { const pending = activeQuestion activeQuestion = undefined @@ -1115,6 +1418,7 @@ export function createTuiChat( editor.setAutocompleteProvider(new CombinedAutocompleteProvider([ { name: 'help', description: 'Show keyboard shortcuts and commands' }, + { name: 'model', description: 'Show or switch this session\'s model' }, { name: 'clear', description: 'Clear the transcript view (session history is unchanged)' }, { name: 'cancel', description: 'Cancel the active turn' }, { name: 'reasoning', description: 'Toggle reasoning blocks' }, @@ -1146,9 +1450,9 @@ export function createTuiChat( chat.addChild(new Text(palette.bold(palette.accent('Keyboard shortcuts')), 1, 0)) chat.addChild(new Text([ 'Enter send • Shift/Alt+Enter newline • Up/Down prompt history', - 'Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning', + 'Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning', 'Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit', - '/help /clear /cancel /reasoning /tools /redraw /exit', + '/help /model /clear /cancel /reasoning /tools /redraw /exit', ].map(line => palette.muted(line)).join('\n'), 1, 0)) requestRender() } @@ -1158,6 +1462,10 @@ export function createTuiChat( if (text === '') return editor.addToHistory(text) editor.setText('') + if (/^\/model(?:\s|$)/u.test(text)) { + queueModelCommand(text.slice('/model'.length)) + return + } switch (text) { case '/help': showHelp() @@ -1199,7 +1507,7 @@ export function createTuiChat( } const removeInputListener = ui.addInputListener((data) => { - if (activeQuestion !== undefined) return undefined + if (activeQuestion !== undefined || modelOverlay !== undefined) return undefined if (matchesKey(data, Key.ctrl('o'))) { toggleTools() return { consume: true } @@ -1271,6 +1579,7 @@ export function createTuiChat( disposeStatus() disposeError() disposeAgent() + disposeTargetListeners() } rebuildTranscript(true) diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 9994833308..28f93f223c 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -1,8 +1,9 @@ import { Context } from 'cordis' import type { Terminal } from '@earendil-works/pi-tui' -import AgentRegistry, { type Agent, type AgentStatus } from '@deepseek-ai/dsh-agent' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import AgentRegistry, { type Agent, type AgentOptions, type AgentStatus } from '@deepseek-ai/dsh-agent' +import type { ContentBlock, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import { createTuiChat, type Config } from '../src/index.ts' @@ -21,6 +22,15 @@ export interface TuiHarnessOptions { configureContext?: (ctx: Context) => Promise beforeMount?: (session: Session) => void cwd?: string | null + agentOptions?: AgentOptions + contextWindow?: number + contextTokens?: number + now?: () => number + catalog?: { + providers: LlmProviderInfo[] + models: LlmModelInfo[] + listModels?: (provider: string) => Promise + } } export interface TuiHarness void> { @@ -48,6 +58,28 @@ export async function createTuiTestHarness ({ ...provider })) + }, + listModels(provider: string) { + return catalog.listModels?.(provider) + ?? Promise.resolve(catalog.models.filter(model => model.provider === provider).map(model => ({ ...model }))) + }, + } as never) + ctx.provide('tokenMeter', { + contextWindow: options.contextWindow ?? 128_000, + measure() { + return { totalTokens: options.contextTokens ?? 0 } + }, + } as never) if (options.configureContext === undefined) { const tools = options.tools ?? {} ctx.provide('tools', { @@ -58,6 +90,7 @@ export async function createTuiTestHarness 0) }) return { ctx, session, agent, terminal, exit, controller } } diff --git a/packages/ui/tui/tests/plugin-shape.spec.ts b/packages/ui/tui/tests/plugin-shape.spec.ts index d1e4b92f3d..f46371ae17 100644 --- a/packages/ui/tui/tests/plugin-shape.spec.ts +++ b/packages/ui/tui/tests/plugin-shape.spec.ts @@ -12,7 +12,7 @@ describe('dsh-tui plugin export shape', () => { const unwrapped = loader.unwrapExports(tui) as Record expect(unwrapped).toBe(tui) expect(unwrapped.name).toBe('ui-tui') - expect(unwrapped.inject).toEqual(['agents', 'userInteraction', 'tools']) + expect(unwrapped.inject).toEqual(['agents', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter']) expect(unwrapped.Config).toBeDefined() expect(typeof unwrapped.apply).toBe('function') }) diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt index dd563c0614..b2c5547b3f 100644 --- a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt +++ b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt @@ -33,11 +33,12 @@ buffer 9| "▌ /workspace/project " style 0-0 fg=green style 2-19 dim -10| "▌ packages/ui/tui 100% " +10| "▌ … +4 lines (Ctrl+O to expand) " style 0-0 fg=green -11| "▌ … 4 more lines (Ctrl+O to expand) " + style 2-30 dim +11| "▌ [exit 0] " style 0-0 fg=green - style 2-34 dim + style 2-9 dim 12| "▌ " style 0-0 fg=green 13| @@ -53,12 +54,12 @@ buffer 17| "▌ - old line " style 0-0 fg=green style 2-11 fg=red -18| "▌ - keep " +18| "▌ … +5 lines (Ctrl+O to expand) " style 0-0 fg=green - style 2-7 fg=red -19| "▌ … 5 more lines (Ctrl+O to expand) " + style 2-30 dim +19| "▌ + expect(screen).toMatchSnapshot() " style 0-0 fg=green - style 2-34 dim + style 2-35 fg=green 20| "▌ " style 0-0 fg=green 21| @@ -102,6 +103,6 @@ buffer style 1-1 inverse 39| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -40| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +40| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 67-99 dim + style 42-99 dim diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt index 147d7fcdb1..0f93629223 100644 --- a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt +++ b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt @@ -122,6 +122,6 @@ buffer style 1-1 inverse 48| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -49| "/workspace/project ↑0 ↓0 idle reasoning:on tools:expanded" +49| "/workspace/project ↑0 ↓0 0% context tools:expanded deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 66-99 dim + style 41-99 dim diff --git a/packages/ui/tui/tests/snapshots/code-mode-pending.expected.txt b/packages/ui/tui/tests/snapshots/code-mode-pending.expected.txt index 30deac56c6..f52c197d51 100644 --- a/packages/ui/tui/tests/snapshots/code-mode-pending.expected.txt +++ b/packages/ui/tui/tests/snapshots/code-mode-pending.expected.txt @@ -46,7 +46,7 @@ buffer style 1-1 inverse 16| "────────────────────────────────────────────────────────────────────────────────────────────────" style 0-95 dim -17| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +17| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 63-95 dim + style 38-95 dim 18-35| diff --git a/packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt b/packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt index 4b6ccdee48..3b84636ca8 100644 --- a/packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt +++ b/packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt @@ -1,5 +1,5 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 -lifecycle started=1 stopped=0 progress=inactive +lifecycle started=1 stopped=0 progress=active title "DSH snapshot" cursor hidden column=1 viewportRow=17 bufferRow=17 viewport @@ -41,12 +41,12 @@ viewport 15| " Streaming visible state… " style 11-23 bold 16| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim + style 0-95 fg=bright-blue 17| " " style 1-1 inverse 18| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -19| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" - style 0-24 dim - style 63-95 dim + style 0-95 fg=bright-blue +19| "◒ Working · 0s esc interrupt" + style 0-13 fg=bright-blue + style 83-95 dim 20-35| diff --git a/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt b/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt index 81d59edfec..01022fee8c 100644 --- a/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt +++ b/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt @@ -53,7 +53,7 @@ buffer style 1-1 inverse 19| "────────────────────────────────────────────────────────────────────────────────────────────────" style 0-95 dim -20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +20| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 63-95 dim + style 38-95 dim 21-35| diff --git a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt index 05809dea0c..6259981036 100644 --- a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt +++ b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt @@ -25,12 +25,12 @@ buffer style 1-18 fg=bright-blue bold 7| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history " style 1-61 fg=bright-black -8| " Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning " +8| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning " style 1-75 fg=bright-black 9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " style 1-73 fg=bright-black -10| " /help /clear /cancel /reasoning /tools /redraw /exit " - style 1-52 fg=bright-black +10| " /help /model /clear /cancel /reasoning /tools /redraw /exit " + style 1-59 fg=bright-black 11| 12| " Unknown command: /unknown-advanced-command " style 1-42 fg=yellow @@ -46,7 +46,7 @@ buffer style 1-1 inverse 19| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +20| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 59-91 dim + style 34-91 dim 21-31| diff --git a/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.expected.txt b/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.expected.txt index 02395a1ff0..31af6ce523 100644 --- a/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.expected.txt +++ b/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.expected.txt @@ -33,8 +33,9 @@ buffer style 0-0 fg=yellow 10| "▌ () => agent('Audit layout', { label: 'layout', phase: 'Inspect' }), " style 0-0 fg=yellow -11| "▌ () => agent('Audit lifecycle', { label: 'lifecycle', phase: 'Inspect' }), " +11| "▌ … +1 lines (Ctrl+O to expand) " style 0-0 fg=yellow + style 2-30 dim 12| "▌ ]) " style 0-0 fg=yellow 13| "▌ phase('Verify') " @@ -49,7 +50,7 @@ buffer style 1-1 inverse 18| "────────────────────────────────────────────────────────────────────────────────────────────────" style 0-95 dim -19| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +19| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 63-95 dim + style 38-95 dim 20-35| diff --git a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt index fccfca604b..9837b28710 100644 --- a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt +++ b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt @@ -25,12 +25,12 @@ buffer style 1-18 fg=bright-blue bold 7| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history " style 1-61 fg=bright-black -8| " Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning " +8| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning " style 1-75 fg=bright-black 9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " style 1-73 fg=bright-black -10| " /help /clear /cancel /reasoning /tools /redraw /exit " - style 1-52 fg=bright-black +10| " /help /model /clear /cancel /reasoning /tools /redraw /exit " + style 1-59 fg=bright-black 11| 12| " Unknown command: /unknown-advanced-command " style 1-42 fg=yellow @@ -46,7 +46,7 @@ buffer style 1-1 inverse 19| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +20| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 59-91 dim + style 34-91 dim 21-31| diff --git a/packages/ui/tui/tests/snapshots/model-selector.expected.txt b/packages/ui/tui/tests/snapshots/model-selector.expected.txt new file mode 100644 index 0000000000..4bd9dcbbbc --- /dev/null +++ b/packages/ui/tui/tests/snapshots/model-selector.expected.txt @@ -0,0 +1,52 @@ +terminal 92x32 buffer=normal length=32 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=0 viewportRow=31 bufferRow=31 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-91 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 91-91 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 91-91 fg=bright-blue +3| "│ deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-35 dim + style 91-91 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-91 fg=bright-blue +5| "────────────────────────────────────────────────────────────────────────────────────────────" + style 0-91 dim +6| " " + style 1-1 inverse +7| "────────────────────────────────────────────────────────────────────────────────────────────" + style 0-91 dim +8| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" + style 0-24 dim + style 34-91 dim +9-12| +13| " ╭ Select model ────────────────────────────────────────────────────────╮ " + style 10-81 fg=bright-blue +14| " │ → deepseek/deepseek-v4-flash DeepSeek V4 Flash — current │ " + style 10-10 fg=bright-blue + style 12-72 fg=bright-blue inverse + style 81-81 fg=bright-blue +15| " │ deepseek/deepseek-v4-pro DeepSeek V4 Pro │ " + style 10-10 fg=bright-blue + style 38-60 fg=bright-black + style 81-81 fg=bright-blue +16| " │ │ " + style 10-10 fg=bright-blue + style 81-81 fg=bright-blue +17| " │ ↑/↓ navigate • Enter select • Esc cancel │ " + style 10-10 fg=bright-blue + style 12-51 dim + style 81-81 fg=bright-blue +18| " ╰──────────────────────────────────────────────────────────────────────╯ " + style 10-81 fg=bright-blue +19-31| diff --git a/packages/ui/tui/tests/snapshots/model-switching.expected.txt b/packages/ui/tui/tests/snapshots/model-switching.expected.txt new file mode 100644 index 0000000000..7027c435db --- /dev/null +++ b/packages/ui/tui/tests/snapshots/model-switching.expected.txt @@ -0,0 +1,35 @@ +terminal 92x32 buffer=normal length=32 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=1 viewportRow=8 bufferRow=8 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-91 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 91-91 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 91-91 fg=bright-blue +3| "│ deepseek-v4-pro • main-session │" + style 0-0 fg=bright-blue + style 2-33 dim + style 91-91 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-91 fg=bright-blue +5| +6| " Model selected: deepseek/deepseek-v4-pro. New steps will use it. " + style 1-64 fg=bright-black +7| "────────────────────────────────────────────────────────────────────────────────────────────" + style 0-91 dim +8| " " + style 1-1 inverse +9| "────────────────────────────────────────────────────────────────────────────────────────────" + style 0-91 dim +10| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-pro(reasoning:on)" + style 0-24 dim + style 36-91 dim +11-31| diff --git a/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt index a2dc6676e2..44bbecdd2a 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt @@ -1,7 +1,7 @@ terminal 56x20 buffer=normal length=20 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=56 viewportRow=13 bufferRow=13 +cursor hidden column=56 viewportRow=17 bufferRow=17 viewport 0| "╭──────────────────────────────────────────────────────╮" style 0-55 fg=bright-blue @@ -18,52 +18,30 @@ viewport style 0-0 fg=bright-blue style 2-35 dim style 55-55 fg=bright-blue -4| "╰───╭ Coverage ────────────────────────────────────╮───╯" +4| "╰──────────────────────────────────────────────────────╯" style 0-55 fg=bright-blue -5| "────│ Which advanced TUI states belong in the │────" - style 0-3 dim - style 4-4 fg=bright-blue - style 6-50 bold - style 51-51 fg=bright-blue bold - style 52-55 dim -6| " │ required matrix? │ " - style 1-1 inverse - style 4-4 fg=bright-blue - style 6-21 bold - style 51-51 fg=bright-blue -7| "────│ │────" - style 0-3 dim - style 4-4 fg=bright-blue - style 51-51 fg=bright-blue - style 52-55 dim -8| "/wor│ › [ ] Code Mode — run_code programs and capt │:com" - style 0-3 dim - style 4-4 fg=bright-blue - style 6-6 fg=bright-blue inverse - style 7-20 inverse - style 21-49 fg=bright-black inverse - style 51-51 fg=bright-blue - style 52-55 dim -9| " │ [ ] Workflows — phases and parallel agents │ " - style 4-4 fg=bright-blue - style 21-49 fg=bright-black - style 51-51 fg=bright-blue -10| " │ [ ] Cordis tools — inspect, mount, and unm │ " - style 4-4 fg=bright-blue - style 24-49 fg=bright-black - style 51-51 fg=bright-blue -11| " │ 1/4 │ " - style 4-4 fg=bright-blue - style 6-8 dim - style 51-51 fg=bright-blue -12| " │ ↑↓ navigate • Space toggle • Enter submit • │ " - style 4-4 fg=bright-blue - style 6-49 dim - style 51-51 fg=bright-blue -13| " │ Select at least one option, or press C for a │ " - style 4-4 fg=bright-blue - style 6-49 fg=red - style 51-51 fg=bright-blue -14| " ╰──────────────────────────────────────────────╯ " - style 4-51 fg=bright-blue -15-19| +5| " " +6| " Question 1/3 (3 unanswered) · Coverage " + style 2-39 fg=bright-black +7| " Which advanced TUI states belong in the required " +8| " matrix? " +9| " " +10| " › 1. [ ] Code Mode run_code programs and capture " + style 2-19 fg=bright-blue bold + style 25-53 fg=bright-black +11| " 2. [ ] Workflows phases and parallel agents " + style 25-50 fg=bright-black +12| " 3. [ ] Cordis tools inspect, mount, and unmount " + style 25-51 fg=bright-black +13| " 1/4 " + style 2-4 dim +14| " Tab custom answer • ↑/↓ navigate • Space toggle • " + style 2-55 dim +15| " Enter submit • Esc interrupt " + style 2-29 dim +16| " Select at least one option, or press Tab for a " + style 2-55 fg=red +17| " custom answer. " + style 2-15 fg=red +18| " " +19| diff --git a/packages/ui/tui/tests/snapshots/question-dialog.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog.expected.txt index 95dc4f2496..a761c2a3ab 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog.expected.txt @@ -20,48 +20,28 @@ viewport style 55-55 fg=bright-blue 4| "╰──────────────────────────────────────────────────────╯" style 0-55 fg=bright-blue -5| "────╭ Coverage ────────────────────────────────────╮────" - style 0-3 dim - style 4-51 fg=bright-blue - style 52-55 dim -6| " │ Which advanced TUI states belong in the │ " +5| "────────────────────────────────────────────────────────" + style 0-55 dim +6| " " style 1-1 inverse - style 4-4 fg=bright-blue - style 6-50 bold - style 51-51 fg=bright-blue bold -7| "────│ required matrix? │────" - style 0-3 dim - style 4-4 fg=bright-blue - style 6-21 bold - style 51-51 fg=bright-blue - style 52-55 dim -8| "/wor│ │:com" - style 0-3 dim - style 4-4 fg=bright-blue - style 51-51 fg=bright-blue - style 52-55 dim -9| " │ › [ ] Code Mode — run_code programs and capt │ " - style 4-4 fg=bright-blue - style 6-6 fg=bright-blue inverse - style 7-20 inverse - style 21-49 fg=bright-black inverse - style 51-51 fg=bright-blue -10| " │ [ ] Workflows — phases and parallel agents │ " - style 4-4 fg=bright-blue - style 21-49 fg=bright-black - style 51-51 fg=bright-blue -11| " │ [ ] Cordis tools — inspect, mount, and unm │ " - style 4-4 fg=bright-blue - style 24-49 fg=bright-black - style 51-51 fg=bright-blue -12| " │ 1/4 │ " - style 4-4 fg=bright-blue - style 6-8 dim - style 51-51 fg=bright-blue -13| " │ ↑↓ navigate • Space toggle • Enter submit • │ " - style 4-4 fg=bright-blue - style 6-49 dim - style 51-51 fg=bright-blue -14| " ╰──────────────────────────────────────────────╯ " - style 4-51 fg=bright-blue -15-19| +7| " " +8| " Question 1/3 (3 unanswered) · Coverage " + style 2-39 fg=bright-black +9| " Which advanced TUI states belong in the required " +10| " matrix? " +11| " " +12| " › 1. [ ] Code Mode run_code programs and capture " + style 2-19 fg=bright-blue bold + style 25-53 fg=bright-black +13| " 2. [ ] Workflows phases and parallel agents " + style 25-50 fg=bright-black +14| " 3. [ ] Cordis tools inspect, mount, and unmount " + style 25-51 fg=bright-black +15| " 1/4 " + style 2-4 dim +16| " Tab custom answer • ↑/↓ navigate • Space toggle • " + style 2-55 dim +17| " Enter submit • Esc interrupt " + style 2-29 dim +18| " " +19| diff --git a/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt b/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt index 7c63f3491e..28794f6d7f 100644 --- a/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt +++ b/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt @@ -35,7 +35,6 @@ buffer style 1-1 inverse 12| "────────────────────────────────────────────" style 0-43 dim -13| "/workspace/project ↑0 ↓0 idle reasoning:o" - style 0-24 dim - style 27-43 dim +13| " 0% context deepseek-v4-flash(reasoning:on)" + style 1-43 dim 14-17| diff --git a/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt b/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt index c5448befc8..d2e86239f2 100644 --- a/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt +++ b/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt @@ -31,7 +31,7 @@ buffer style 1-1 inverse 10| "────────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-103 dim -11| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +11| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 71-103 dim + style 46-103 dim 12-29| diff --git a/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt b/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt index 5c2bbacb4e..bebdb78357 100644 --- a/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt +++ b/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt @@ -45,8 +45,9 @@ buffer style 2-19 dim 15| "▌ packages/ui/tui 100% " style 0-0 fg=green -16| "▌ 4016 tests passed " +16| "▌ … +1 lines (Ctrl+O to expand) " style 0-0 fg=green + style 2-30 dim 17| "▌ 1 test skipped " style 0-0 fg=green 18| "▌ coverage complete " @@ -62,6 +63,6 @@ buffer style 1-1 inverse 23| "────────────────────────────────────────────────────────────────────────────────" style 0-79 dim -24| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" - style 0-24 dim - style 47-79 dim +24| "/workspace/pro ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" + style 0-13 dim + style 22-79 dim diff --git a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt index 1d82ec3c45..4d541b4687 100644 --- a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt +++ b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt @@ -49,36 +49,19 @@ buffer 19| "▌ Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-0 fg=green style 2-65 fg=bright-black -20| "▌ /unsafe/\\x1b╭ Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m ─────────╮ " +20| "▌ /unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-0 fg=green - style 2-13 dim - style 14-85 fg=bright-blue -21| "▌ Unsafe outpu│ Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m │ " + style 2-54 dim +21| "▌ Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-0 fg=green - style 14-14 fg=bright-blue - style 16-76 bold - style 85-85 fg=bright-blue -22| "▌ [signal SIG\\│ │ " +22| "▌ [signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] " style 0-0 fg=green - style 2-13 fg=red - style 14-14 fg=bright-blue - style 85-85 fg=bright-blue -23| "▌ │ › ● Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m — Un │ " + style 2-58 fg=red +23| "▌ " style 0-0 fg=green - style 14-14 fg=bright-blue - style 16-16 fg=bright-blue inverse - style 17-17 inverse - style 18-18 fg=bright-blue inverse - style 19-78 inverse - style 79-83 fg=bright-black inverse - style 85-85 fg=bright-blue -24| " │ ↑↓ navigate • Enter select • C custom • Esc cancel │ " - style 14-14 fg=bright-blue - style 16-65 dim - style 85-85 fg=bright-blue -25| " Context · uns╰──────────────────────────────────────────────────────────────────────╯ " - style 1-13 dim - style 14-85 fg=bright-blue +24| +25| " Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 1-62 dim 26| " Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 1-60 fg=bright-black 27| @@ -88,19 +71,17 @@ buffer 30| " Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 1-63 fg=red 31| -32| " Unsafe live error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 1-63 fg=red -33| -34| "Plan" - style 0-3 fg=bright-blue bold -35| " ● Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" - style 2-2 fg=yellow -36| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -37| " " - style 1-1 inverse -38| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -39| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +32| " " +33| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 2-90 fg=bright-black +34| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +35| " " +36| " › 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m Unsafe detail \\x1b]2;snapshot-c " + style 2-65 fg=bright-blue bold + style 67-97 fg=bright-black +37| " Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt " + style 2-64 dim +38| " " +39| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 67-99 dim + style 42-99 dim diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 609574e758..2d582d89cf 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -35,6 +35,8 @@ const CHECKPOINTS = [ 'surface-before-compaction', 'surface-after-compaction-narrow', 'surface-after-compaction-wide', + 'model-selector', + 'model-switching', 'errors-and-help', 'disposed-terminal', ] as const @@ -196,6 +198,8 @@ describe('TUI terminal-state snapshots', () => { it('pins an in-flight reasoning and Markdown stream', async () => { const harness = await setupSnapshot() await renderAfter(harness, () => { + harness.agent.status = 'running' + harness.ctx.emit('agent/status', harness.agent, 'running') appendUser(harness.session, 'Show the live update.') harness.session.append('assistant/chunk', { turn: 2, @@ -382,25 +386,29 @@ describe('TUI terminal-state snapshots', () => { const harness = await setupSnapshot({ config: { maxQuestionOptions: 3, - questionDialogWidth: 48, + questionDialogWidth: 200, questionDialogMaxHeight: 16, }, }, { columns: 56, rows: 20 }) const controller = new AbortController() const beforeQuestion = harness.terminal.frames const answer = harness.ctx.userInteraction.ask({ - questions: [{ - id: 'coverage', - header: 'Coverage', - question: 'Which advanced TUI states belong in the required matrix?', - multiSelect: true, - options: [ - { label: 'Code Mode', description: 'run_code programs and captured output' }, - { label: 'Workflows', description: 'phases and parallel agents' }, - { label: 'Cordis tools', description: 'inspect, mount, and unmount' }, - { label: 'Compaction', description: 'surface replacement and reflow' }, - ], - }], + questions: [ + { + id: 'coverage', + header: 'Coverage', + question: 'Which advanced TUI states belong in the required matrix?', + multiSelect: true, + options: [ + { label: 'Code Mode', description: 'run_code programs and captured output' }, + { label: 'Workflows', description: 'phases and parallel agents' }, + { label: 'Cordis tools', description: 'inspect, mount, and unmount' }, + { label: 'Compaction', description: 'surface replacement and reflow' }, + ], + }, + { id: 'priority', question: 'Which state should be implemented first?' }, + { id: 'notes', question: 'Any additional constraints?' }, + ], signal: controller.signal, }) const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) @@ -488,6 +496,21 @@ describe('TUI terminal-state snapshots', () => { await harness.ctx.fiber.dispose() await harness.terminal.dispose() }) + + it('pins the model selector and selection notice', async () => { + const harness = await setupSnapshot({}, { columns: 92, rows: 32 }) + await renderAfter(harness, () => { + harness.terminal.send('/model') + harness.terminal.send('\r') + }) + await checkpoint('model-selector', harness.terminal, { includeScrollback: true }) + await renderAfter(harness, () => { + harness.terminal.send('\x1b[B') + harness.terminal.send('\r') + }) + await checkpoint('model-switching', harness.terminal, { includeScrollback: true }) + await disposeSnapshot(harness) + }) }) afterAll(async () => { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 8e0df2cc75..c1f412a52c 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -3,7 +3,8 @@ import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { Terminal } from '@earendil-works/pi-tui' -import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' +import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -110,14 +111,26 @@ async function dispose(setupResult: Awaited>): Promise< await disposeTuiTestHarness(setupResult) } +function provideTokenMeter(ctx: Context): void { + ctx.provide('tokenMeter', { + contextWindow: 128_000, + measure() { + return { totalTokens: 0 } + }, + } as never) +} + describe('TUI config', () => { it('defaults every direct-call TUI option', () => { expect(resolveTuiConfig(undefined)).toEqual({ showReasoning: true, - maxToolOutputLines: 12, + maxToolOutputLines: 6, maxQuestionOptions: 8, - questionDialogWidth: 72, + maxModelOptions: 8, + questionDialogWidth: 200, questionDialogMaxHeight: 20, + modelDialogWidth: 72, + modelDialogMaxHeight: 20, showHardwareCursor: false, color: true, title: 'DeepSeek Harness', @@ -126,8 +139,11 @@ describe('TUI config', () => { showReasoning: false, maxToolOutputLines: 2, maxQuestionOptions: 3, + maxModelOptions: 4, questionDialogWidth: 60, questionDialogMaxHeight: 14, + modelDialogWidth: 64, + modelDialogMaxHeight: 16, showHardwareCursor: true, color: false, title: 'DSH', @@ -135,8 +151,11 @@ describe('TUI config', () => { showReasoning: false, maxToolOutputLines: 2, maxQuestionOptions: 3, + maxModelOptions: 4, questionDialogWidth: 60, questionDialogMaxHeight: 14, + modelDialogWidth: 64, + modelDialogMaxHeight: 16, showHardwareCursor: true, color: false, title: 'DSH', @@ -146,7 +165,11 @@ describe('TUI config', () => { describe('pi-tui chat lifecycle and transcript', () => { it('renders its header, footer, replay, streaming answer, todos, and status', async () => { + let now = 0 const result = await setup({ + contextWindow: 100, + contextTokens: 42, + now: () => now, beforeMount(session) { appendUser(session, 'restored prompt') appendAssistant(session, [ @@ -172,9 +195,19 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('restored answer') expect(result.terminal.output).toContain('write tests') expect(result.terminal.output).toContain('↑1.3k ↓42') + expect(result.terminal.output).toContain('42% context tools:compact deepseek-v4-flash(reasoning:on)') + result.terminal.resize(52) + await tick() + expect(result.terminal.output).toContain('42% context deepseek-v4-flash(reasoning:on)') + result.terminal.resize(65) + await tick() + expect(result.terminal.output).toContain('↑1.3k ↓42 42% context deepseek-v4-flash(reasoning:on)') + result.terminal.resize(88) + await tick() result.agent.status = 'running' result.ctx.emit('agent/status', result.agent, 'running') + now = 8_000 result.session.append('user/message', { content: [{ type: 'text', text: ' ' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: 'steering note' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) @@ -247,13 +280,13 @@ describe('pi-tui chat lifecycle and transcript', () => { appendAssistant(result.session, [{ type: 'text', text: 'final live answer' }], { inputTokens: 500, outputTokens: 8 }) await tick() - expect(result.terminal.output).toContain('Working') + expect(result.terminal.output).toContain('◒ Working · 8s') + expect(result.terminal.output).toContain('esc interrupt') expect(result.terminal.output).toContain('Steering') expect(result.terminal.output).toContain('user context') expect(result.terminal.output).toContain('Prompt blocked') expect(result.terminal.output).toContain('Turn cancelled') expect(result.terminal.output).toContain('final live answer') - expect(result.terminal.output).toContain('↑1.8k ↓50') expect(result.terminal.progress).toContain(true) result.session.append('assistant/chunk', { @@ -270,6 +303,8 @@ describe('pi-tui chat lifecycle and transcript', () => { result.agent.status = 'idle' result.ctx.emit('agent/status', result.agent, 'idle') await tick() + expect(result.terminal.output).toContain('↑1.8k ↓50') + expect(result.terminal.output).toContain('deepseek-v4-flash(reasoning:off)') expect(result.terminal.progress.at(-1)).toBe(false) await dispose(result) expect(result.terminal.stopped).toBe(1) @@ -377,6 +412,7 @@ describe('pi-tui chat lifecycle and transcript', () => { result.terminal.send('\r') result.agent.status = 'running' + result.ctx.emit('agent/status', result.agent, 'running') result.terminal.send('steer it') result.terminal.send('\r') expect(result.agent.steered).toEqual([[{ type: 'text', text: 'steer it' }]]) @@ -431,6 +467,162 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(disposedAgent) }) + it('opens a keyboard selector and switches the session model without sending slash text to the agent', async () => { + const result = await setup({ + agentOptions: { provider: 'alpha', model: 'a1' }, + catalog: { + providers: [{ id: 'alpha', name: 'Alpha' }, { id: 'beta', name: 'Beta' }], + models: [ + { provider: 'alpha', id: 'a1', name: 'Alpha One', description: 'Fast' }, + { provider: 'alpha', id: 'shared', name: 'Alpha Shared' }, + { provider: 'beta', id: 'b1', name: 'Beta One' }, + { provider: 'beta', id: 'shared', name: 'Beta Shared' }, + ], + }, + }) + + for (const command of ['/model too many model arguments', '/model missing', '/model shared', '/model alpha/a1', '/model alpha a1']) { + result.terminal.send(command) + result.terminal.send('\r') + await tick() + } + expect(result.terminal.output).toContain('Usage: /model') + expect(result.terminal.output).toContain('Unknown model: missing') + expect(result.terminal.output).toContain('advertised by multiple providers') + expect(result.terminal.output).toContain('already alpha/a1') + + result.agent.status = 'running' + result.terminal.send('/model') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Select model') + expect(result.terminal.output).toContain('alpha/a1') + expect(result.terminal.output).toContain('Alpha One — Fast — current') + result.terminal.send('\x1b[B') + result.terminal.send('\x1b[B') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Model selected: beta/b1') + expect(result.agent.sent).toEqual([]) + expect(result.agent.steered).toEqual([]) + + result.terminal.send('/model') + result.terminal.send('\r') + await tick() + result.terminal.send('\x1b') + await tick() + expect(result.agent.cancelled).not.toContain('cancelled from terminal') + result.agent.status = 'idle' + result.ctx.emit('agent/status', result.agent, 'idle') + await tick() + expect(result.terminal.output).toContain('tools:compact b1(reasoning:on)') + + const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent)) + expect(assembly.variables).toMatchObject({ provider: 'beta', model: 'b1' }) + const seed: LlmCallConfig = { provider: 'alpha', model: 'a1', temperature: 0.2 } + const request = await agentEvents(result.ctx, result.agent).waterfall( + 'agent/request', 1, 0, seed, () => Promise.resolve(seed), + ) + expect(request).toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 }) + await dispose(result) + }) + + it('restores the logged model, keeps an unlisted current model visible, and reports catalog failures', async () => { + const resumed = await setup({ + agentOptions: { provider: 'alpha', model: 'configured' }, + catalog: { providers: [{ id: 'beta', name: 'Beta' }], models: [] }, + beforeMount(session) { + session.append('request/header', { + header: { config: { provider: 'beta', model: 'private' } }, + reason: 'initial', + }) + }, + }) + resumed.terminal.send('/model') + resumed.terminal.send('\r') + await tick() + expect(resumed.terminal.output).toContain('Select model') + expect(resumed.terminal.output).toContain('beta/private') + expect(resumed.terminal.output).toContain('private — current') + await dispose(resumed) + + const unset = await setup({ + agentOptions: {}, + catalog: { + providers: [{ id: 'alpha', name: 'Alpha' }], + models: [{ provider: 'alpha', id: 'a1', name: 'Alpha One' }], + }, + }) + unset.terminal.send('/model') + unset.terminal.send('\r') + await tick() + unset.terminal.send('\r') + await tick() + expect(unset.terminal.output).toContain('Model selected: alpha/a1') + await dispose(unset) + + const empty = await setup({ agentOptions: {}, catalog: { providers: [], models: [] } }) + empty.terminal.send('/model') + empty.terminal.send('\r') + await tick() + expect(empty.terminal.output).toContain('Current model: unset') + expect(empty.terminal.output).toContain('No models are advertised') + const assembly = await empty.ctx.systemPrompt.assemble(assembleContextFor(empty.agent)) + expect(assembly.variables).toEqual({}) + const seed: LlmCallConfig = { provider: 'fallback', model: 'fallback' } + await expect(agentEvents(empty.ctx, empty.agent).waterfall( + 'agent/request', 1, 0, seed, () => Promise.resolve(seed), + )).resolves.toBe(seed) + await dispose(empty) + + const failed = await setup({ + catalog: { + providers: [{ id: 'deepseek', name: 'DeepSeek' }], + models: [], + listModels: () => Promise.reject(new Error('catalog offline')), + }, + }) + failed.terminal.send('/model') + failed.terminal.send('\r') + await tick() + expect(failed.terminal.output).toContain('Could not read the model catalog: catalog offline') + await dispose(failed) + }) + + it('does not render a model catalog that resolves after TUI disposal', async () => { + const deferred = Promise.withResolvers() + const result = await setup({ + catalog: { + providers: [{ id: 'deepseek', name: 'DeepSeek' }], + models: [], + listModels: () => deferred.promise, + }, + }) + result.terminal.send('/model') + result.terminal.send('\r') + await result.controller.dispose() + deferred.resolve([]) + await tick() + expect(result.terminal.output).not.toContain('Available models') + await result.ctx.fiber.dispose() + + const rejected = Promise.withResolvers() + const rejectedResult = await setup({ + catalog: { + providers: [{ id: 'deepseek', name: 'DeepSeek' }], + models: [], + listModels: () => rejected.promise, + }, + }) + rejectedResult.terminal.send('/model') + rejectedResult.terminal.send('\r') + await rejectedResult.controller.dispose() + rejected.reject(new Error('late catalog failure')) + await tick() + expect(rejectedResult.terminal.output).not.toContain('late catalog failure') + await rejectedResult.ctx.fiber.dispose() + }) + it('cancels before /exit while running and handles agent errors/disposal', async () => { const result = await setup({ status: 'running' }) result.terminal.send('/exit') @@ -525,7 +717,7 @@ describe('tool cards and surface replay', () => { } it('uses terminal, diff, generic, fallback, and collapsed tool presentations', async () => { - const result = await setup({ tools, config: { maxToolOutputLines: 1 } }) + const result = await setup({ tools, config: { maxToolOutputLines: 4 } }) const calls = [ ['c1', 'bash', '{"command":"printf hello"}'], ['c2', 'signal', '{}'], @@ -594,7 +786,7 @@ describe('tool cards and surface replay', () => { const output = result.terminal.output expect(output).toContain('Run command') expect(output).toContain('printf hello') - expect(output).toContain('more lines') + expect(output).toContain('lines (Ctrl+O to expand)') expect(output).toContain('SIGTERM') expect(output).toContain('Edit files') expect(output).toContain('Inspected') @@ -611,6 +803,11 @@ describe('tool cards and surface replay', () => { result.terminal.send('/redraw') result.terminal.send('\r') await tick() + const collapsed = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(collapsed).toContain('Run command') + expect(collapsed).toContain('[exit 0]') + expect(collapsed).not.toContain('▌ hello') + expect(collapsed).not.toContain('world') result.terminal.send('\x0f') await tick() expect(result.terminal.output).toContain('world') @@ -664,6 +861,7 @@ describe('TUI user-interaction dialogs', () => { }) await tick() expect(result.terminal.output).toContain('Choose a mode') + expect(result.terminal.output).toContain('Question 1/1 (1 unanswered) · Mode') expect(result.terminal.output).toContain('1/2') result.terminal.send('\x1b[B') result.terminal.send('\r') @@ -683,7 +881,7 @@ describe('TUI user-interaction dialogs', () => { questions: [{ id: 'other', question: 'Choose or type', options: [{ label: 'Default' }] }], }) await tick() - result.terminal.send('c') + result.terminal.send('\t') result.terminal.send('my choice') result.terminal.send('\r') await expect(custom).resolves.toEqual({ answers: [{ id: 'other', selected: [], custom: 'my choice' }] }) @@ -757,9 +955,11 @@ describe('TUI user-interaction dialogs', () => { ], }) await tick() + expect(result.terminal.output).toContain('Question 1/2 (2 unanswered)') result.terminal.send('\r') await tick() expect(result.terminal.output).toContain('Second?') + expect(result.terminal.output).toContain('Question 2/2 (1 unanswered)') result.terminal.send('done') result.terminal.send('\r') await expect(batch).resolves.toEqual({ answers: [ @@ -806,6 +1006,7 @@ describe('TUI user-interaction dialogs', () => { describe('terminal mounting', () => { it('starts immediately when the configured agent already exists', async () => { const ctx = new Context() + provideTokenMeter(ctx) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) @@ -824,6 +1025,7 @@ describe('terminal mounting', () => { it('waits for its configured agent before starting the TUI', async () => { const ctx = new Context() + provideTokenMeter(ctx) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) @@ -852,6 +1054,7 @@ describe('terminal mounting', () => { it('prints a matching live startup failure and exits instead of waiting forever', async () => { const ctx = new Context() + provideTokenMeter(ctx) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) @@ -879,6 +1082,7 @@ describe('terminal mounting', () => { it('renders an uncoercible startup failure without escaping the display boundary', async () => { const ctx = new Context() + provideTokenMeter(ctx) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) @@ -899,6 +1103,7 @@ describe('terminal mounting', () => { it('rolls back providers, listeners, and terminal state when startup fails', async () => { const ctx = new Context() + provideTokenMeter(ctx) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) @@ -929,6 +1134,7 @@ describe('terminal mounting', () => { it('throws when createTuiChat is called without the configured agent', async () => { const ctx = new Context() + provideTokenMeter(ctx) await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) ctx.provide('tools', { get: () => undefined } as never) diff --git a/packages/ui/tui/tsconfig.json b/packages/ui/tui/tsconfig.json index 3a09f80ad8..8e84c47555 100644 --- a/packages/ui/tui/tsconfig.json +++ b/packages/ui/tui/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../llm/token-meter" + }, { "path": "../../core/tools" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1c3c84521..857cb20197 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2192,6 +2192,9 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt + '@deepseek-ai/dsh-token-meter': + specifier: workspace:^ + version: link:../../llm/token-meter '@deepseek-ai/dsh-tool-cordis': specifier: workspace:^ version: link:../../cordis/tool-cordis