Merge pull request #446 from deepseek-harness/worktree/tui-session-controls

feat(tui): add session model controls and compact interaction views
This commit is contained in:
Tianyi Cui
2026-07-21 18:16:53 +08:00
committed by GitHub
62 changed files with 1163 additions and 400 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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 中增加工具专用分支。
- 模型选择使用适配器提供的目录元数据,但不会把目录成员关系变成请求校验;未使用的选择不属于持久化状态。

View File

@@ -27,7 +27,7 @@ export interface AcpConfig {
Depends on: `Stream` (`@agentclientprotocol/sdk`)
Source: [`packages/ui/acp/src/index.ts:247`](../packages/ui/acp/src/index.ts)
Source: [`packages/ui/acp/src/index.ts:252`](../packages/ui/acp/src/index.ts)
## `@deepseek-ai/dsh-acp-demo`
@@ -1368,7 +1368,7 @@ Source: [`packages/core/tools/src/index.ts:382`](../packages/core/tools/src/inde
## `@deepseek-ai/dsh-tui`
Requires: `agents` · `commands` · `userInteraction` · `tools`
Requires: `agents` · `commands` · `userInteraction` · `tools` · `llm` · `systemPrompt` · `tokenMeter`
```ts config-catalog
/** Serializable plugin configuration. */
@@ -1383,14 +1383,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. */
@@ -1400,7 +1406,7 @@ export interface TuiConfig {
}
```
Source: [`packages/ui/tui/src/index.ts:103`](../packages/ui/tui/src/index.ts)
Source: [`packages/ui/tui/src/index.ts:127`](../packages/ui/tui/src/index.ts)
## `@deepseek-ai/dsh-tui-demo`

View File

@@ -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`

View File

@@ -16,7 +16,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:220`](../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:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`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:181`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:242`](../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:242`](../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:295`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:257`](../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:204`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
@@ -39,7 +39,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) |

View File

@@ -452,6 +452,8 @@ flowchart TD
pkg_tui --> pkg_llm
pkg_tui --> pkg_llm_retry
pkg_tui --> pkg_session
pkg_tui --> pkg_system_prompt
pkg_tui --> pkg_token_meter
pkg_tui --> pkg_tools
pkg_tui --> pkg_user_interaction
pkg_agent_spine_demo --> pkg_agent
@@ -620,7 +622,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), [`commands`](../packages/ui/commands), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`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), [`commands`](../packages/ui/commands), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`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), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`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-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`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) |

View File

@@ -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"}}}

View File

@@ -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}}

View File

@@ -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}}

View File

@@ -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":"<path>/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md</path>\n<type>file</type>\n<content>\nCreated file\n</content>"}],"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}}

View File

@@ -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}}

View File

@@ -20,6 +20,8 @@ flowchart LR
cfg --> plugin_cordis_web
plugin_cordis_web_fetch_local["web-fetch-local<br/>@deepseek-ai/dsh-web-fetch-local"]
cfg --> plugin_cordis_web_fetch_local
plugin_cordis_token_meter["token-meter<br/>@deepseek-ai/dsh-token-meter"]
cfg --> plugin_cordis_token_meter
plugin_cordis_tui_agent["tui-agent<br/>@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` |

View File

@@ -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'

View File

@@ -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 <model>` and `/model <provider>/<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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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<StreamChunk> {
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
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<StreamChunk> {
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

View File

@@ -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

View File

@@ -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| <blank>

View File

@@ -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| <blank>

View File

@@ -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

View File

@@ -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

View File

@@ -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| <blank>

View File

@@ -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

View File

@@ -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

View File

@@ -24,7 +24,7 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
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-',
@@ -32,7 +32,9 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
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' },
],

View File

@@ -15,6 +15,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'
@@ -170,6 +171,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)

View File

@@ -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()`.

View File

@@ -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'

View File

@@ -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<LlmCallConfig> => {
const resolved = await next()
const selected = target.assembled
return selected === undefined ? resolved : {
...resolved,
provider: selected.provider,
model: selected.model,
}
},
)
return () => {
disposeAssembly()
disposeRequest()
}
}

View File

@@ -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()
})
})

View File

@@ -44,10 +44,15 @@ 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 {} from '@deepseek-ai/dsh-llm-retry'
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 type {} from '@deepseek-ai/dsh-commands'
import { SessionId } from '@deepseek-ai/dsh-session'
// Side-effect type import: resolves `ctx.get('permission')` to the service.
@@ -258,19 +263,6 @@ export const Config: Schema<AcpConfig> = 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<SessionConfigOption, { type: 'select' }> | undefined
@@ -338,32 +330,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<LlmCallConfig> => {
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. */

View File

@@ -6,13 +6,15 @@ The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feat
Interactive terminals on macOS, Linux, and Windows are supported. Windows uses pi-tui's native console VT-input handling, and the [Windows support Agent Note](../../../.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md) owns the platform decision and ConPTY process verification.
This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `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`, [`commands`](../commands/README.md), `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. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. 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. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view 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, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path reaches the model. The TUI registers `/help`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically. 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.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path reaches the model. The TUI registers `/help`, `/model`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically. 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.
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Enter selects, and Escape closes it. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<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
@@ -21,10 +23,13 @@ While the agent is running, ordinary editor submissions call `agent.steer()`; ot
| `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 |
@@ -36,14 +41,14 @@ While the agent is running, ordinary editor submissions call `agent.steer()`; ot
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 aborts running commands, removes the TUI definitions, 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
@@ -61,6 +66,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

View File

@@ -28,6 +28,8 @@
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^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"
@@ -45,6 +47,7 @@
"@deepseek-ai/dsh-llm-retry": "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:^",

View File

@@ -13,12 +13,12 @@ import {
Editor,
Input,
Key,
Loader,
Markdown,
Spacer,
Text,
TUI,
ProcessTerminal,
SelectList,
matchesKey,
truncateToWidth,
visibleWidth,
@@ -33,11 +33,23 @@ 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 type {} from '@deepseek-ai/dsh-commands'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
import type {
ContentBlock,
LlmModelInfo,
StreamChunk,
TokenUsage,
} from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import { SessionId, type Session, type SessionEvent, type TodoItem } from '@deepseek-ai/dsh-session'
import type {
@@ -56,20 +68,26 @@ import {
} from '@deepseek-ai/dsh-user-interaction'
export const name = 'ui-tui'
export const inject = ['agents', 'commands', 'userInteraction', 'tools']
export const inject = ['agents', 'commands', '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. */
@@ -79,10 +97,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')
@@ -92,8 +113,11 @@ export const TuiConfigSchema: z<TuiConfig> = z.object({
showReasoning: showReasoningSchema,
maxToolOutputLines: maxToolOutputLinesSchema,
maxQuestionOptions: maxQuestionOptionsSchema,
maxModelOptions: maxModelOptionsSchema,
questionDialogWidth: questionDialogWidthSchema,
questionDialogMaxHeight: questionDialogMaxHeightSchema,
modelDialogWidth: modelDialogWidthSchema,
modelDialogMaxHeight: modelDialogMaxHeightSchema,
showHardwareCursor: showHardwareCursorSchema,
color: colorSchema,
title: titleSchema,
@@ -113,8 +137,11 @@ export const Config: z<Config> = z.object({
showReasoning: showReasoningSchema,
maxToolOutputLines: maxToolOutputLinesSchema,
maxQuestionOptions: maxQuestionOptionsSchema,
maxModelOptions: maxModelOptionsSchema,
questionDialogWidth: questionDialogWidthSchema,
questionDialogMaxHeight: questionDialogMaxHeightSchema,
modelDialogWidth: modelDialogWidthSchema,
modelDialogMaxHeight: modelDialogMaxHeightSchema,
showHardwareCursor: showHardwareCursorSchema,
color: colorSchema,
title: titleSchema,
@@ -125,8 +152,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
@@ -138,6 +168,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
}
/**
@@ -149,10 +181,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',
@@ -253,6 +288,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) {
@@ -284,11 +326,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<ModelChoice[]> {
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 {}
@@ -296,7 +379,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))}`)
@@ -508,9 +591,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
@@ -647,19 +736,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)}`]
}
}
@@ -668,6 +778,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<number>()
@@ -679,6 +859,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,
@@ -719,11 +902,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'))) {
@@ -743,16 +926,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'))
@@ -763,27 +943,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<AskUserQuestionItem['options']>[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)))} `
})
}
}
@@ -839,7 +1037,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),
@@ -848,7 +1045,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<typeof setInterval> | undefined
let disposed = false
let shuttingDown: Promise<void> | undefined
const tokens = sessionTokens(agent.session)
@@ -858,13 +1056,25 @@ export function createTuiChat(
const questionQueue: PendingQuestion[] = []
const commandControllers = new Set<AbortController>()
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)
@@ -884,10 +1094,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<void> => {
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 <provider>/<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)
}
@@ -895,8 +1193,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()
@@ -1072,6 +1371,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) => {
@@ -1090,8 +1392,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()
}
@@ -1131,6 +1433,8 @@ export function createTuiChat(
shuttingDown ??= (async () => {
disposed = true
clearStatus()
modelOverlay?.hide()
modelOverlay = undefined
for (const controller of commandControllers) controller.abort(new Error('TUI disposed'))
commandControllers.clear()
if (activeQuestion !== undefined) {
@@ -1184,7 +1488,7 @@ 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',
'',
...commandLines,
@@ -1213,6 +1517,15 @@ export function createTuiChat(
description: 'Show keyboard shortcuts and commands',
handler: () => { showHelp(); return { kind: 'success' } },
})
commandCtx.commands.register({
name: 'model',
description: 'Show or switch this session\'s model',
input: { hint: '[[provider/]model]' },
handler: ({ rawInput }) => {
queueModelCommand(rawInput)
return { kind: 'success' }
},
})
commandCtx.commands.register({
name: 'clear',
description: 'Clear the transcript view (session history is unchanged)',
@@ -1288,7 +1601,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 }
@@ -1358,6 +1671,7 @@ export function createTuiChat(
disposeStatus()
disposeError()
disposeAgent()
disposeTargetListeners()
}
rebuildTranscript(true)

View File

@@ -1,9 +1,10 @@
import { Context } from 'cordis'
import type { Terminal } from '@earendil-works/pi-tui'
import AgentRegistry, { type Agent, type AgentStatus } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent, type AgentOptions, type AgentStatus } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
import CommandService from '@deepseek-ai/dsh-commands'
import type { ContentBlock } 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'
@@ -22,6 +23,15 @@ export interface TuiHarnessOptions {
configureContext?: (ctx: Context) => Promise<void>
beforeMount?: (session: Session) => void
cwd?: string | null
agentOptions?: AgentOptions
contextWindow?: number
contextTokens?: number
now?: () => number
catalog?: {
providers: LlmProviderInfo[]
models: LlmModelInfo[]
listModels?: (provider: string) => Promise<LlmModelInfo[]>
}
}
export interface TuiHarness<TerminalType extends Terminal, Exit extends (code: number) => void> {
@@ -50,6 +60,28 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
const catalog = options.catalog ?? {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
models: [
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' },
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' },
],
}
ctx.provide('llm', {
listProviders() {
return catalog.providers.map(provider => ({ ...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', {
@@ -60,6 +92,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
} else {
await options.configureContext(ctx)
}
if (ctx.get('systemPrompt') === undefined) await ctx.plugin(SystemPrompt)
const sessionId = SessionId('main-session')
const session = ctx.sessions.create(
sessionId,
@@ -71,7 +104,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
const cancelled: string[] = []
const agent: FakeAgent = {
id: sessionId,
options: { model: 'deepseek-v4-flash' },
options: options.agentOptions ?? { provider: 'deepseek', model: 'deepseek-v4-flash' },
session,
status: options.status ?? 'idle',
ctx,
@@ -97,7 +130,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
welcome: 'Coding agent ready.',
sessionId,
color: false,
}, options.config), { terminal, exit })
}, options.config), { terminal, exit, now: options.now ?? (() => 0) })
return { ctx, session, agent, terminal, exit, controller }
}

View File

@@ -12,7 +12,15 @@ describe('dsh-tui plugin export shape', () => {
const unwrapped = loader.unwrapExports(tui) as Record<string, unknown>
expect(unwrapped).toBe(tui)
expect(unwrapped.name).toBe('ui-tui')
expect(unwrapped.inject).toEqual(['agents', 'commands', 'userInteraction', 'tools'])
expect(unwrapped.inject).toEqual([
'agents',
'commands',
'userInteraction',
'tools',
'llm',
'systemPrompt',
'tokenMeter',
])
expect(unwrapped.Config).toBeDefined()
expect(typeof unwrapped.apply).toBe('function')
})

View File

@@ -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| <blank>
@@ -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| <blank>
@@ -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

View File

@@ -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

View File

@@ -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| <blank>

View File

@@ -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| <blank>

View File

@@ -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| <blank>

View File

@@ -1,7 +1,7 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=1 progress=inactive
title "DSH snapshot"
cursor visible column=0 viewportRow=29 bufferRow=29
cursor visible column=0 viewportRow=30 bufferRow=30
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-91 fg=bright-blue
@@ -25,7 +25,7 @@ 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
@@ -38,28 +38,30 @@ buffer
style 1-47 fg=bright-black
14| " /help — Show keyboard shortcuts and commands "
style 1-44 fg=bright-black
15| " /reasoning — Toggle reasoning blocks "
15| " /model [[provider/]model] — Show or switch this session's model "
style 1-63 fg=bright-black
16| " /reasoning — Toggle reasoning blocks "
style 1-36 fg=bright-black
16| " /redraw — Invalidate components and redraw the terminal "
17| " /redraw — Invalidate components and redraw the terminal "
style 1-55 fg=bright-black
17| " /tools — Expand or collapse all tool cards "
18| " /tools — Expand or collapse all tool cards "
style 1-42 fg=bright-black
18| <blank>
19| " provider stream failed after partial output "
19| <blank>
20| " provider stream failed after partial output "
style 1-43 fg=red
20| <blank>
21| " The previous process ended during this turn. "
21| <blank>
22| " The previous process ended during this turn. "
style 1-44 fg=yellow
22| <blank>
23| " Unknown command: /unknown-advanced-command "
23| <blank>
24| " Unknown command: /unknown-advanced-command "
style 1-42 fg=yellow
24| "────────────────────────────────────────────────────────────────────────────────────────────"
25| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
25| " "
26| " "
style 1-1 inverse
26| "────────────────────────────────────────────────────────────────────────────────────────────"
27| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
27| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
28| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 59-91 dim
28-31| <blank>
style 34-91 dim
29-31| <blank>

View File

@@ -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| <blank>

View File

@@ -1,7 +1,7 @@
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=25 bufferRow=25
cursor hidden column=1 viewportRow=26 bufferRow=26
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-91 fg=bright-blue
@@ -25,7 +25,7 @@ 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
@@ -38,28 +38,30 @@ buffer
style 1-47 fg=bright-black
14| " /help — Show keyboard shortcuts and commands "
style 1-44 fg=bright-black
15| " /reasoning — Toggle reasoning blocks "
15| " /model [[provider/]model] — Show or switch this session's model "
style 1-63 fg=bright-black
16| " /reasoning — Toggle reasoning blocks "
style 1-36 fg=bright-black
16| " /redraw — Invalidate components and redraw the terminal "
17| " /redraw — Invalidate components and redraw the terminal "
style 1-55 fg=bright-black
17| " /tools — Expand or collapse all tool cards "
18| " /tools — Expand or collapse all tool cards "
style 1-42 fg=bright-black
18| <blank>
19| " provider stream failed after partial output "
19| <blank>
20| " provider stream failed after partial output "
style 1-43 fg=red
20| <blank>
21| " The previous process ended during this turn. "
21| <blank>
22| " The previous process ended during this turn. "
style 1-44 fg=yellow
22| <blank>
23| " Unknown command: /unknown-advanced-command "
23| <blank>
24| " Unknown command: /unknown-advanced-command "
style 1-42 fg=yellow
24| "────────────────────────────────────────────────────────────────────────────────────────────"
25| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
25| " "
26| " "
style 1-1 inverse
26| "────────────────────────────────────────────────────────────────────────────────────────────"
27| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
27| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
28| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 59-91 dim
28-31| <blank>
style 34-91 dim
29-31| <blank>

View File

@@ -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| <blank>
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| <blank>

View File

@@ -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| <blank>
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| <blank>

View File

@@ -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| <blank>
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| <blank>

View File

@@ -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| <blank>
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| <blank>

View File

@@ -42,7 +42,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| <blank>

View File

@@ -39,7 +39,7 @@ buffer
style 1-1 inverse
14| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
15| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
15| "/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
16-35| <blank>

View File

@@ -43,7 +43,7 @@ buffer
style 1-1 inverse
17| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
18| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
18| "/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
19-35| <blank>

View File

@@ -39,7 +39,7 @@ buffer
style 1-1 inverse
14| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
15| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
15| "/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
16-35| <blank>

View File

@@ -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| <blank>

View File

@@ -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| <blank>

View File

@@ -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

View File

@@ -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| <blank>
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| <blank>
@@ -88,19 +71,17 @@ buffer
30| " Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 1-63 fg=red
31| <blank>
32| " Unsafe live error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 1-63 fg=red
33| <blank>
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

View File

@@ -40,6 +40,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
@@ -201,6 +203,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,
@@ -463,25 +467,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' })
@@ -569,6 +577,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 () => {

View File

@@ -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 CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
@@ -112,14 +113,26 @@ async function dispose(setupResult: Awaited<ReturnType<typeof setup>>): 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',
@@ -128,8 +141,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',
@@ -137,8 +153,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',
@@ -148,7 +167,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, [
@@ -174,9 +197,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' })
@@ -254,13 +287,13 @@ describe('pi-tui chat lifecycle and transcript', () => {
)
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', {
@@ -277,6 +310,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)
@@ -446,6 +481,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' }]])
@@ -500,6 +536,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<never[]>()
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<never[]>()
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('discovers and executes plugin commands, then removes TUI-local commands on disposal', async () => {
const result = await setup()
const handler = vi.fn(({ rawInput }: CommandInvocation) => ({
@@ -701,7 +893,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', '{}'],
@@ -770,7 +962,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')
@@ -787,6 +979,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')
@@ -840,6 +1037,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')
@@ -859,7 +1057,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' }] })
@@ -933,9 +1131,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: [
@@ -982,6 +1182,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(CommandService)
@@ -1001,6 +1202,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(CommandService)
@@ -1030,6 +1232,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(CommandService)
@@ -1058,6 +1261,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(CommandService)
@@ -1079,6 +1283,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(CommandService)
@@ -1112,6 +1317,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(CommandService)
await ctx.plugin(UserInteractionService)

View File

@@ -26,6 +26,9 @@
{
"path": "../../llm/llm"
},
{
"path": "../../llm/token-meter"
},
{
"path": "../../llm/llm-retry"
},

3
pnpm-lock.yaml generated
View File

@@ -2516,6 +2516,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