diff --git a/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.i18n.yaml new file mode 100644 index 0000000000..98dfa90c4d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-23-tui-generic-card-markdown.md: 494ba580480fa99de54e84025a65bf4589d410f2 +2026-07-23-tui-generic-card-markdown.zh.md: 214edd00f50b88a4e8901b19dcdafc7382399831 diff --git a/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.md b/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.md new file mode 100644 index 0000000000..494ba58048 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.md @@ -0,0 +1,29 @@ +# Agent Note: TUI generic-card Markdown rendering + +Status: implemented + +English | [中文](2026-07-23-tui-generic-card-markdown.zh.md) + +## Problem + +Tool presenters can put Markdown in generic-card content, including fenced `console` output used for background-task acknowledgements and execution errors. Rendering that content as plain text exposes the fence markers and diverges from assistant and user content in the same transcript. + +## Decision + +The TUI renders generic-card result content with its shared Markdown theme before applying the card's head-and-tail line limit. Terminal and diff cards retain their specialized plain-text renderers, and generic-card raw input remains literal because it represents tool arguments rather than presenter-authored prose. + +The shared theme hides fence syntax, retains the optional language label, and colors the fenced body as code. Rendering precedes truncation so collapsed-card line counts and boundaries describe the visible terminal rows rather than Markdown source rows. + +## Alternatives considered + +**Strip fences in the Bash presenter.** This would fix one producer while leaving generic-card Markdown from other tools unrendered and would make the presenter depend on TUI behavior. + +**Render every tool card as Markdown.** Terminal output and diffs have dedicated formatting and may contain Markdown punctuation that must remain literal. + +**Apply the collapsed-card limit before Markdown rendering.** Source-line truncation can split a fenced block and makes the visible line count differ from the count used by the card. + +## Consequences + +Generic tool cards use the same Markdown vocabulary and sanitization path as conversation content. Markdown punctuation in a generic card is interpreted rather than always displayed literally; tools that require literal terminal output use the terminal card intent. + +The focused TUI test pins hidden fences, retained language labels, and body text. The keyless terminal-state snapshot covers the behavior through an assembled TUI transcript. diff --git a/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.zh.md b/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.zh.md new file mode 100644 index 0000000000..214edd00f5 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.zh.md @@ -0,0 +1,29 @@ +# Agent Note: TUI 通用卡片的 Markdown 渲染 + +Status: implemented + +[English](2026-07-23-tui-generic-card-markdown.md) | 中文 + +## Problem + +工具展示器可以在通用卡片(generic card)内容中写入 Markdown,其中包括用于后台任务确认和执行错误的围栏 `console` 输出。把这些内容按纯文本渲染会暴露围栏标记,并与同一 transcript(文本记录)中的助手内容和用户内容显示不一致。 + +## Decision + +TUI 先用共享的 Markdown 主题渲染通用卡片的结果内容,再应用卡片的头尾行数限制。终端卡片和 diff 卡片保留各自专门的纯文本渲染器;通用卡片的原始输入仍按字面显示,因为它代表的是工具参数,而非展示器撰写的行文。 + +共享主题隐藏围栏语法,保留可选的语言标签,并将围栏正文按代码配色。渲染先于截断执行,因此收起状态卡片的行数和边界描述的是可见的终端行,而非 Markdown 源文本行。 + +## Alternatives considered + +**在 Bash 展示器中剥除围栏。**这只修复一个生产方,其他工具产生的通用卡片 Markdown 仍不会被渲染,还会让展示器依赖 TUI 的行为。 + +**把每种工具卡片都按 Markdown 渲染。**终端输出和 diff 有专门的格式,且可能包含必须保持字面显示的 Markdown 标点。 + +**在 Markdown 渲染之前应用收起状态卡片的行数限制。**按源文本行截断可能从中间截断围栏块,还会让可见行数与卡片使用的行数不一致。 + +## Consequences + +通用工具卡片与对话内容使用同一套 Markdown 词汇和净化路径。通用卡片中的 Markdown 标点会被解释,而不再总是按字面显示;需要字面终端输出的工具使用终端卡片这一渲染意图。 + +聚焦的 TUI 测试固定了隐藏的围栏、保留的语言标签和正文文本。无密钥的终端状态快照通过组装后的 TUI transcript 覆盖该行为。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.i18n.yaml new file mode 100644 index 0000000000..6d83cc0cf3 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-24-tui-turn-end-stop-reason-notices.md: 7c783ce5a347b15d682dbeca03ad5355aca7950b +2026-07-24-tui-turn-end-stop-reason-notices.zh.md: 4a983525779b96c296ac2d621ca5928e7bed61c9 diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.md b/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.md new file mode 100644 index 0000000000..7c783ce5a3 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.md @@ -0,0 +1,27 @@ +# Agent Note: TUI presents a reason for every turn-end kind + +Status: implemented + +English | [中文](2026-07-24-tui-turn-end-stop-reason-notices.zh.md) + +## Problem + +The TUI rendered transcript notices for `error`, `aborted`, `max-tokens`, `rejected`, and `interrupted` turn ends, but a `disposed` turn end and any plugin-added `TurnEndReasonMap` kind rendered nothing. When such a turn ended — live or replayed from a persisted log — the agent stopped working with no visible reason, breaking the product expectation that every stop is explained to the user. + +## Decision + +The `turn/end` case in `packages/ui/tui/src/index.ts` switches on the reason's discriminant and covers every kind: `completed` stays silent because the settled assistant message and its `Completed` timing header already present that outcome; `disposed` appends `Turn stopped: the agent was disposed.`; and the merge-extensible default appends `Turn ended: .` so an unknown plugin-added outcome still names why the agent stopped. All other kinds keep their existing notices. + +## Alternatives considered + +**A notice for `completed` turns too.** Rejected as noise: every ordinary response would gain a redundant line, and the assistant message plus its frozen timing header already mark the completion. + +**Suppressing the `disposed` turn-end notice live because `agent/disposed` also appends `Agent "" was disposed.`** Rejected: the two notices state different facts (this turn was cut short vs. the agent is gone), and the turn-end notice is the only one that survives replay of a persisted log, where the live `agent/disposed` emission does not recur. + +**Keeping the default branch silent (the prior behavior).** Rejected: a merge-extensible kind unknown to the TUI is exactly the case where the user has no other way to learn why the agent stopped. + +## Consequences + +- A turn never ends without a user-visible reason in the TUI: every non-`completed` `turn/end` kind appends a transcript notice, including unknown plugin-added kinds by name. +- Live disposal during a running turn shows two notices (the turn-end notice plus `agent/disposed`); a replayed log shows the turn-end notice alone. +- The `errors-and-help` snapshot pins the `disposed` and unknown-kind notices alongside the existing failure and interruption notices. diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.zh.md b/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.zh.md new file mode 100644 index 0000000000..4a98352577 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.zh.md @@ -0,0 +1,27 @@ +# Agent Note: TUI 为每种轮次结束 kind 呈现原因 + +Status: implemented + +[English](2026-07-24-tui-turn-end-stop-reason-notices.md) | 中文 + +## 问题 + +TUI 会为 `error`、`aborted`、`max-tokens`、`rejected`、`interrupted` 这几种轮次结束渲染 transcript(文本记录)通知,但 `disposed` 轮次结束和任何插件新增的 `TurnEndReasonMap` kind 不渲染任何内容。此类轮次结束时,无论实时发生还是从持久化日志回放,agent(智能体)都会在没有任何可见原因的情况下停止工作,违背了「每次停止都要向用户解释」的产品预期。 + +## 决策 + +`packages/ui/tui/src/index.ts` 中的 `turn/end` 分支按 reason 的判别字段做 switch,覆盖每一种 kind:`completed` 保持沉默,因为已定稿的助手消息及其 `Completed` 计时头部已经呈现了这一结果;`disposed` 追加 `Turn stopped: the agent was disposed.`;merge 扩展的 default 分支追加 `Turn ended: .`,让未知的插件新增结果仍能点明 agent 停止的原因。其余各 kind 保留现有通知。 + +## 备选方案 + +**为 `completed` 轮次也加一条通知。** 否决,属于噪音:每次普通响应都会平添一行冗余内容,而助手消息加上已冻结的计时头部本就标示了完成。 + +**因为 `agent/disposed` 也会追加 `Agent "" was disposed.`,就在实时场景下抑制 `disposed` 轮次结束通知。** 否决:两条通知陈述的是不同事实(前者说明这一轮被中途截断,后者说明 agent 已不复存在),而且只有轮次结束通知在回放持久化日志时得以保留,实时发出的 `agent/disposed` 不会在回放中重现。 + +**让 default 分支保持沉默(沿用先前行为)。** 否决:TUI 不认识的 merge 扩展 kind,恰恰是用户没有其他途径得知 agent 为何停止的情形。 + +## 后果 + +- 在 TUI 中,轮次结束永远不会缺少用户可见的原因:每种非 `completed` 的 `turn/end` kind 都会追加一条 transcript 通知,未知的插件新增 kind 也会按名称列明。 +- 轮次运行期间实时 dispose(资源释放)会显示两条通知(轮次结束通知加上 `agent/disposed`);回放日志则只显示轮次结束通知。 +- `errors-and-help` 快照把 `disposed` 通知和未知 kind 通知连同现有的失败与中断通知一并固定下来。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.i18n.yaml new file mode 100644 index 0000000000..dfde0f9ad4 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.md +2026-07-27-tool-card-single-row-fields-inline.md: e04110ed74b68afffc6ea45fc2cb52c4063268e7 +2026-07-27-tool-card-single-row-fields-inline.zh.md: ac532ec6eb51d42a529e0816d1204cb2729a074a diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.md b/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.md new file mode 100644 index 0000000000..e04110ed74 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.md @@ -0,0 +1,23 @@ +# Agent Note: Tool-card single-row fields render inline + +Status: implemented + +English | [中文](2026-07-27-tool-card-single-row-fields-inline.zh.md) + +## Problem + +A tool card's title, description, cwd, and pending `$ ` echo are each one logical row. The bash tool sets the card title (and description) directly from the model's command and description, which for a multi-line bash script contain real newlines. These fields were escaped with `displayText`, which deliberately preserves `\n` as structural layout. A multi-line title therefore broke onto extra terminal rows that the card's line accounting did not reserve, so the title's later lines overwrote the description, the output, or the editor's steering hint — the card rendered as garbled, overlapping text. Removing the gutter bar (see the [copyable-transcript note](../simplification/2026-07-27-copyable-transcript-no-gutter-bar.md)) made the collision visible because those rows no longer sat behind a per-line prefix. + +## Decision + +Single-row card fields use `displayInlineText` (which escapes `\n` to the literal `\x0a`) instead of `displayText`: the card title, the terminal-card `description` and `cwd` meta rows, and the pending `$ ` echo. Each stays on exactly one row, so a multi-line command can no longer break rows and collide with adjacent lines. Genuinely multi-line fields — captured command output and the `contentText` result body — keep `displayText` plus `split('\n')`, because those legitimately occupy multiple rows. + +## Alternatives considered + +- **Strip newlines from the presenter output** (in the bash tool) — hides the model's real command shape from any consumer of the view, and pushes a UI concern into the tool. The escape belongs at the single-row render site. +- **Let the title wrap to multiple rows deliberately** — a card title is a one-line identity; a wrapped multi-line title still collides with the following meta rows unless the whole card is re-laid-out, and it bloats the transcript. + +## Consequences + +- Multi-line bash commands render as a single inline title (`S=/tmp\x0aecho …`); the description, output, and exit rows below stay intact. Verified live in tmux for both the pending (`◌`) and completed (`✓`) states. +- A `multilineTerminal` tool-card case in `tui.spec.ts` asserts the inline-escaped form appears for a newline-bearing title and description. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.zh.md new file mode 100644 index 0000000000..ac532ec6eb --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.zh.md @@ -0,0 +1,23 @@ +# Agent Note: 工具卡片的单行字段以内联方式渲染 + +Status: implemented + +[English](2026-07-27-tool-card-single-row-fields-inline.md) | 中文 + +## Problem + +工具卡片的标题、描述、cwd 以及待执行的 `$ ` 回显各自都是一个逻辑行。bash 工具直接用模型给出的命令与描述来设置卡片标题(和描述),而对于多行 bash 脚本,这些内容包含真实换行。这些字段此前用 `displayText` 转义,而 `displayText` 会刻意保留 `\n` 作为结构性布局。于是多行标题会换到卡片行数核算未预留的额外终端行上,标题后续的行便覆盖了描述、输出,或编辑器的 steering 提示——卡片渲染成互相重叠的乱码文本。移除 gutter bar(见[可复制 transcript 的 note](../simplification/2026-07-27-copyable-transcript-no-gutter-bar.md))后,这些行不再位于逐行前缀之后,因而暴露了这一冲突。 + +## Decision + +单行卡片字段改用 `displayInlineText`(将 `\n` 转义为字面量 `\x0a`)而非 `displayText`:包括卡片标题、terminal 卡片的 `description` 与 `cwd` 元数据行,以及待执行的 `$ ` 回显。每个字段都严格保持在一行内,因此多行命令不再会换行并与相邻行冲突。真正多行的字段——捕获的命令输出与 `contentText` 结果正文——仍保留 `displayText` 加 `split('\n')`,因为它们本就应占据多行。 + +## Alternatives considered + +- **在 presenter 输出中剥除换行**(在 bash 工具里)—— 会对该视图的所有消费方隐藏模型真实的命令形态,并把 UI 关注点塞进工具。转义应发生在单行渲染处。 +- **让标题刻意换到多行** —— 卡片标题是一行式身份标识;除非重排整个卡片,多行标题仍会与其后的元数据行冲突,还会让 transcript 膨胀。 + +## Consequences + +- 多行 bash 命令渲染为单行内联标题(`S=/tmp\x0aecho …`);其下的描述、输出与退出码行保持完整。已在 tmux 中对待执行(`◌`)与已完成(`✓`)两种状态实测验证。 +- `tui.spec.ts` 中新增了一个 `multilineTerminal` 工具卡片用例,断言对含换行的标题与描述会出现内联转义后的形式。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml new file mode 100644 index 0000000000..a8472075e4 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md +2026-07-27-tui-diff-card-redundant-path-header.md: 708e543ff079828b4929d2a50ac697a9c846608a +2026-07-27-tui-diff-card-redundant-path-header.zh.md: 863868ae707f37689bbc202267c5470d8c3163e9 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md b/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md new file mode 100644 index 0000000000..708e543ff0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md @@ -0,0 +1,37 @@ +# Agent Note: TUI diff card dropped the duplicated file path + +Status: implemented + +English | [中文](2026-07-27-tui-diff-card-redundant-path-header.zh.md) + +## Problem + +The `edit` and `write` tool cards printed the target path twice. Each tool's `presentCall`/`presentResult` returns a diff card whose title is `Edit `/`Write ` and whose single `FileDiff` carries the same `path`. The TUI's `diffLines` unconditionally rendered `palette.bold(diff.path)` as a per-file header, so a one-file edit rendered: + +``` +✓ Edit src/foo.ts +src/foo.ts +- old ++ new +``` + +The existing snapshot fixture hid the bug: it titled the edit card `Edit renderer` (no path) and gave the result two diffs, so the title never matched a diff path and the header never looked redundant. + +## Decision + +`diffLines` takes a `showPath` flag; `ToolCardComponent.renderBody` suppresses the per-file header for a diff card when there is exactly one diff and the effective card title (`resultView?.title ?? callView.title`) already contains that diff's path. Multi-file diff cards keep every per-file header. An empty or blank diff path collapses under the same `String.includes` check, which is the intended noise removal. + +The suppression lives in the TUI renderer, not in each tool's presenter, because the redundancy is a presentation concern shared by every current and future single-file diff card; the tools keep emitting the path in both the title and the diff so non-TUI consumers still get it. + +## Alternatives considered + +- Drop the path from the `edit`/`write` card titles. Rejected: the title is the scannable summary line; removing the path weakens it, and it would have to be repeated per tool. +- Always drop the per-file header. Rejected: multi-file result diffs (and any future multi-file diff card) genuinely need per-file headers. + +## Consequences + +The heuristic is a substring match, so a title that happens to contain a single diff's path suppresses the header even if the match is incidental; for the real producers the title is exactly `Verb `, so this is correct in practice. The snapshot `edit` fixture now mirrors production: one diff whose path the title names, proving the header is dropped, while multi-file header retention is covered by the `tui.spec.ts` `edit` fixture (`a.txt`/`b.txt` under an `Edit files` title). + +## Testing + +`tui.spec.ts` adds a focused case asserting the path appears exactly once for a single-diff card titled `Edit src/only.ts`. The `advanced-cards-*` keyless snapshots re-recorded to show the title line immediately followed by the diff body with no repeated path header. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md new file mode 100644 index 0000000000..863868ae70 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md @@ -0,0 +1,37 @@ +# Agent Note: TUI diff 卡片重复打印文件路径 + +Status: implemented + +[English](2026-07-27-tui-diff-card-redundant-path-header.md) | 中文 + +## Problem + +`edit` 与 `write` 工具卡片会把目标路径打印两次。两者的 `presentCall`/`presentResult` 返回的 diff 卡片,标题为 `Edit `/`Write `,而其唯一的 `FileDiff` 又携带相同的 `path`。TUI 的 `diffLines` 无条件地将 `palette.bold(diff.path)` 渲染为每文件的表头,因此单文件编辑会渲染成: + +``` +✓ Edit src/foo.ts +src/foo.ts +- old ++ new +``` + +既有的快照 fixture 掩盖了这个问题:它把编辑卡片标题设为 `Edit renderer`(不含路径),并让结果包含两个 diff,于是标题从未与某个 diff 路径匹配,表头也就不显得冗余。 + +## Decision + +`diffLines` 新增 `showPath` 参数;当一个 diff 卡片只有一个 diff、且生效标题(`resultView?.title ?? callView.title`)已包含该 diff 的路径时,`ToolCardComponent.renderBody` 抑制每文件表头。多文件 diff 卡片保留全部每文件表头。空白或空路径同样落入这条 `String.includes` 判定之下,这正是有意去除的噪声。 + +抑制逻辑放在 TUI 渲染层,而非各工具的 present 方法中,因为这种冗余是所有当前及未来单文件 diff 卡片共有的展示问题;工具仍在标题和 diff 中同时给出路径,从而非 TUI 消费方依旧能拿到它。 + +## Alternatives considered + +- 从 `edit`/`write` 卡片标题中去掉路径。已否决:标题是可快速扫读的摘要行,去掉路径会削弱它,而且需要在每个工具里重复处理。 +- 一律去掉每文件表头。已否决:多文件结果 diff(以及未来任何多文件 diff 卡片)确实需要每文件表头。 + +## Consequences + +该启发式是子串匹配,因此若标题恰好包含某个单一 diff 的路径,即便是偶然匹配也会抑制表头;对真实的产出方而言标题恰为 `Verb `,故在实践中是正确的。快照 `edit` fixture 现在与生产一致:单个 diff,其路径正是标题所命名,从而证明表头被去除;而多文件表头保留由 `tui.spec.ts` 的 `edit` fixture(`Edit files` 标题下的 `a.txt`/`b.txt`)覆盖。 + +## Testing + +`tui.spec.ts` 新增一个聚焦用例,断言标题为 `Edit src/only.ts` 的单 diff 卡片中路径恰好出现一次。`advanced-cards-*` 无密钥快照已重新录制,展示标题行紧接 diff 正文、不再有重复的路径表头。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.i18n.yaml new file mode 100644 index 0000000000..c246c6fb0d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-27-tui-step-timing-trails-tool-cards.md: 82f46b44d3b939ca89c4508eb948ed584082d9fd +2026-07-27-tui-step-timing-trails-tool-cards.zh.md: 885b232973d20013782dee7ec1e846e7a012b01e diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.md b/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.md new file mode 100644 index 0000000000..82f46b44d3 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.md @@ -0,0 +1,28 @@ +# Agent Note: TUI step timing trails the step's last message + +Status: implemented + +English | [中文](2026-07-27-tui-step-timing-trails-tool-cards.zh.md) + +## Problem + +The per-step timing summary (`Model wait … · Completed …`) was a child of the assistant message component, so it rendered directly under the assistant text. When a step drove tool calls, the tool cards were appended to the chat *after* the assistant message, leaving the timing line stranded above them — one message before the step's actual last output. The summary is meant to close a step, so on any tool-calling step it appeared in the wrong place. + +## Decision + +The timing summary is its own `StepTimingComponent`, no longer a child of `AssistantMessageComponent`. `StreamingAssistantComponent` owns one and exposes it as `timing`, but the renderer attaches it to the chat as a sibling that follows the assistant message. Whenever a `tool/call` or `tool/result` of the open step appends a card, `trailStreamingTiming()` moves the footer back to the tail of the chat, so it always trails the step's last message. On `step/end` the footer is completed in place — already at the tail — and stays pinned while the next step's output follows. `removeStreaming` and the reasoning-toggle rebuild detach and reattach the footer together with its streaming component. + +Event ordering makes this exact: within a step the loop appends `tool/call` and `tool/result` before `step/end`, so the footer is repositioned while `streaming` is still set, then frozen when the step ends. + +## Alternatives considered + +**Keep the timing inside the assistant message and reorder tool cards above it.** Rejected: tool cards belong after the assistant text that requested them; moving them above the assistant message to sit under the timing would misrepresent the transcript order. + +**Recompute a single trailing footer for the whole turn instead of one per step.** Rejected: a multi-step turn shows each step's own completed timing, and collapsing them would drop the per-step buckets the existing timing tests pin. + +**Reposition the footer from a `step/end`-only handler.** Rejected: tool cards render before `step/end`, so a footer moved only at step end would already be trailing but would not track a mid-step re-render, and the running (pre-completion) footer would still sit above the tool cards during streaming. + +## Consequences + +- On a tool-calling step the timing summary renders below the tool cards, both while the turn runs and after it completes; the package snapshots (`untrusted-controls`, `cordis-tools-pending`, `advanced-cards-*`, `code-mode-pending`, `dynamic-workflow-pending`, `surface-before-compaction`) and the example transcripts (`todo-plan`, `bash-terminal-card`, `code-mode`, `parallel-file-reads`, `dynamic-workflow`, `cordis-dynamic-toolchain`, `code-mode-dispatch-spill`) pin the new order. +- A unit test asserts the completed timing appears after a step's tool output; it fails on the pre-fix ordering. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.zh.md new file mode 100644 index 0000000000..885b232973 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.zh.md @@ -0,0 +1,28 @@ +# Agent Note: TUI 步骤计时跟在该步骤最后一条消息之后 + +Status: implemented + +[English](2026-07-27-tui-step-timing-trails-tool-cards.md) | 中文 + +## 问题 + +每步的计时摘要(`Model wait … · Completed …`)原本是助手消息组件的子节点,因此直接渲染在助手文本下方。当某一步触发 tool call 时,tool card(工具卡片)会在助手消息*之后*追加到聊天区,使计时行被搁在它们上方——落在该步骤真正的最后一条输出之前一条消息处。该摘要本意是收束一个步骤,因此在任何含 tool call 的步骤上都出现在了错误的位置。 + +## 决策 + +计时摘要现在是独立的 `StepTimingComponent`,不再是 `AssistantMessageComponent` 的子节点。`StreamingAssistantComponent` 持有一个并以 `timing` 暴露它,但渲染器把它作为紧随助手消息之后的同级节点挂到聊天区。每当当前打开步骤的 `tool/call` 或 `tool/result` 追加一张卡片,`trailStreamingTiming()` 就把该页脚移回聊天区末尾,使它始终跟在该步骤的最后一条消息之后。在 `step/end` 时该页脚就地定稿——此时已在末尾——并在后续步骤的输出接续时保持钉住。`removeStreaming` 与推理开关重建会把该页脚连同其流式组件一起摘除并重新挂上。 + +事件顺序让这一点精确成立:在一个步骤内,循环会先追加 `tool/call` 和 `tool/result`,再追加 `step/end`,因此页脚是在 `streaming` 仍被设置时重新定位的,随后在步骤结束时冻结。 + +## 备选方案 + +**把计时保留在助手消息内部,改为把 tool card 排到它上方。** 否决:tool card 应位于请求它们的助手文本之后;把它们移到助手消息上方以贴在计时下方,会歪曲 transcript(文本记录)的顺序。 + +**为整个轮次重算一个末尾页脚,而非每步一个。** 否决:多步轮次会显示各步自己的完成计时,合并它们会丢掉现有计时测试所固定的每步分桶。 + +**只在 `step/end` 处理器里重新定位页脚。** 否决:tool card 在 `step/end` 之前渲染,因此仅在步骤结束时移动的页脚虽已处于末尾,却无法跟踪步骤中途的重新渲染,而且流式过程中运行态(完成前)的页脚仍会落在 tool card 上方。 + +## 后果 + +- 在含 tool call 的步骤上,计时摘要渲染在 tool card 下方,轮次运行期间与完成之后皆如此;相关包快照(`untrusted-controls`、`cordis-tools-pending`、`advanced-cards-*`、`code-mode-pending`、`dynamic-workflow-pending`、`surface-before-compaction`)与示例 transcript(`todo-plan`、`bash-terminal-card`、`code-mode`、`parallel-file-reads`、`dynamic-workflow`、`cordis-dynamic-toolchain`、`code-mode-dispatch-spill`)固定了新顺序。 +- 一个单元测试断言完成计时出现在某步骤的工具输出之后;在修复前的顺序下它会失败。 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml index 6d44658ecc..3df1059e1f 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-17-dedicated-full-screen-tui-front-door.md: 8d7c7b00c8d9b15ea3f2419ed44ca88209e60dad -2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 49e9541c9f98c9f3beba11945ff452fc38bd9ede +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md +2026-07-17-dedicated-full-screen-tui-front-door.md: a3f3d6b51e85ad20218ce5aebf526bd96946be55 +2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 6a0c2f12815e9418a2b5bcb237d3db3616ccd133 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md index 8d7c7b00c8..a3f3d6b51e 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md @@ -20,9 +20,9 @@ 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. 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. +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, including fenced code with hidden Markdown markers, a dim optional language label, and a code-colored body, 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 idle footer derives context occupancy from `tokenMeter` and shows the selected model and explicit reasoning effort; during a run, elapsed activity and the Escape interrupt hint replace that summary. `/status` remains available in either state and appends a detailed terminal-only snapshot: session identity and timestamps, selected model, reasoning effort/default state and reasoning visibility, lifecycle counts folded from the event log, the same deduplicated usage buckets and KV-cache rate as the footer, and context use from `tokenMeter` plus the selected model's advertised capacity. 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. +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. `/exit` and `/quit` share the same exit path: they cancel an active turn, wait for idle, and then restore and close the terminal. The idle footer derives context occupancy from `tokenMeter` and shows the selected model and explicit reasoning effort; during a run, elapsed activity and the Escape interrupt hint replace that summary. `/status` remains available in either state and appends a detailed terminal-only snapshot: session identity and timestamps, selected model, reasoning effort/default state and reasoning visibility, lifecycle counts folded from the event log, the same deduplicated usage buckets and KV-cache rate as the footer, and context use from `tokenMeter` plus the selected model's advertised capacity. 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; the panel's controls hint lists only actions meaningful for the current option count, omitting navigation when exactly one option is shown; 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. Each model row owns the adapter-advertised reasoning-effort order and default: Shift+Tab cycles that row's efforts, includes provider-default behavior when the adapter advertises no default, and leaves models without selectable metadata unchanged. Agent-scoped prompt-assembly and request waterfalls snapshot one provider/model/reasoning-effort target 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. diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md index 49e9541c9f..6a0c2f1281 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md @@ -20,9 +20,9 @@ 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 文本与推理(其中围栏代码块隐藏 Markdown 标记、保留一个暗色的可选语言标签,并使用代码配色的正文)、token 用量、最新 `todo/write` 计划,以及各工具定义通过 `presentCall` 和 `presentResult` 方法生成的工具卡片。较长的工具卡片正文会保留可配置的头尾预览,并显示隐藏行数;一个终端控制可以展开或收起全部卡片。进行中的分片与工具调用会更新同一组组件,随后由完成事件收束状态。 -agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。空闲态页脚根据 `tokenMeter` 得出上下文占用率,并显示所选模型和显式选定的推理强度;agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。`/status` 在这两种状态下均可用,并会追加一份仅在终端显示的详细快照,其中包括会话标识与时间戳、所选模型、推理强度(或默认状态)及推理显隐状态、从事件日志归并得出的生命周期计数、与页脚一致的去重用量分项和 KV 缓存命中率,以及 `tokenMeter` 给出的上下文用量和所选模型公布的容量。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述;agent 行为和答案日志仍由既有服务负责。 +agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。`/exit` 和 `/quit` 共用同一条退出路径:先取消进行中的轮次,等待 agent 空闲,然后恢复并关闭终端。空闲态页脚根据 `tokenMeter` 得出上下文占用率,并显示所选模型和显式选定的推理强度;agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。`/status` 在这两种状态下均可用,并会追加一份仅在终端显示的详细快照,其中包括会话标识与时间戳、所选模型、推理强度(或默认状态)及推理显隐状态、从事件日志归并得出的生命周期计数、与页脚一致的去重用量分项和 KV 缓存命中率,以及 `tokenMeter` 给出的上下文用量和所选模型公布的容量。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述;面板的操作提示只列出在当前选项数量下有意义的操作,仅有一个选项时不显示导航项;agent 行为和答案日志仍由既有服务负责。 `/model` 命令将建议性的 `ctx.llm` 目录呈现为键盘选择器,并且只更改当前 TUI 会话的目标;带参数的形式仍可直接选择目标。每个模型行都持有适配器公布的推理强度顺序和默认值:按 Shift+Tab 可循环切换该行的推理强度;如果适配器没有公布默认值,循环中还会包含提供方默认行为;没有可选元数据的模型则保持不变。agent 作用域内的 prompt 组装和请求两条 waterfall(瀑布式事件)会为每个步骤快照一次同一个提供方/模型/推理强度目标,因此即使命令在组装期间到达,`{{provider}}` / `{{model}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml index 40dd8f463e..6d9112d663 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-tui-skill-slash-command.md: d7532a05fce5605491ce42c87a2a523eb4c19acc -2026-07-21-tui-skill-slash-command.zh.md: 16930020bd404f7bc9476169cd1d063aa57b5c94 +2026-07-21-tui-skill-slash-command.md: 8370ab61f552a6a60177b6da0b598dd142d21960 +2026-07-21-tui-skill-slash-command.zh.md: 66edec6ecd5c2a974b56e08bd9e924729302cc4a diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md index d7532a05fc..8370ab61f5 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md @@ -14,7 +14,7 @@ The [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) front door o The TUI reads the skill service through `ctx.get('skills')`, not a declared injection, because skills mount conditionally: a deployment without the registry keeps a working front door, and `/skill:` there reports that skills are unavailable rather than failing to mount. `createTuiChat` is synchronous while `ctx.skills.list()` is async, so autocomplete seeds the static slash commands immediately and rebuilds the provider with `skill:` entries once the catalog resolves; a resolution that arrives after disposal is dropped, and a rejected lookup keeps the base commands. -Autocomplete lists only model-invocable skills — it is built from `list()`, which omits `disableModelInvocation` skills — while manual submission resolves through `get()`, which the skill registry documents as the trusted-caller path that returns disabled skills too. So a person can load any skill by typing its exact name, but the completion menu never advertises a skill the model is meant not to see. An unknown name, an empty name after the prefix, and a lookup failure each surface as a transcript notice without sending anything. +Autocomplete lists only model-invocable skills — it is built from `list()`, which omits `disableModelInvocation` skills — while manual submission resolves through `get()`, which the skill registry documents as the trusted-caller path that returns disabled skills too. So a person can load any skill by typing its exact name, but the completion menu never advertises a skill the model is meant not to see. Each completion entry is labeled with its winning source's scope — `(project)` for the `project-` sources, `(user)` for every other source — in the slash-command argument-hint slot, which the menu shows but selection never inserts, so trailing instructions still follow the completed name. An unknown name, an empty name after the prefix, and a lookup failure each surface as a transcript notice without sending anything. `renderSkillInvocation` and the resource-base line are the TUI's own, deliberately not reused from `dsh-tool-skill`'s `skill` tool result. The tool wraps a body in ``/``/`` for a *tool result*; a manual invocation is a *user turn*, and coupling the two renderers would force one model-facing shape to serve both surfaces. The cost is two renderers that both format a skill body; the benefit is that each surface's model-facing text evolves independently, and each is pinned where it is produced. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md index 16930020bd..66edec6ecd 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md @@ -14,7 +14,7 @@ Status: implemented TUI 通过 `ctx.get('skills')` 读取 skill 服务,而非声明式注入,因为 skill 是条件挂载的:没有注册表的部署仍保有可用的前门,此时 `/skill:` 会报告 skill 不可用,而不是挂载失败。`createTuiChat` 是同步的,而 `ctx.skills.list()` 是异步的,所以自动补全先立即种入静态斜杠命令,待目录解析完成后再用 `skill:` 条目重建 provider(提供方);在 dispose(资源释放)之后才到达的解析结果会被丢弃,而被拒绝的查找会保留基础命令。 -自动补全只列出模型可调用的 skill——它基于 `list()` 构建,而 `list()` 会略去 `disableModelInvocation` 的 skill——手动提交则通过 `get()` 解析,skill 注册表将其记录为返回被禁用 skill 的可信调用方路径。因此用户可以通过键入 skill 的确切名称加载任意 skill,但补全菜单绝不会宣传一个本不该让模型看见的 skill。未知名称、前缀之后为空的名称、以及查找失败,都会各自呈现为 transcript(文本记录)中的一条通知,且不发送任何内容。 +自动补全只列出模型可调用的 skill——它基于 `list()` 构建,而 `list()` 会略去 `disableModelInvocation` 的 skill——手动提交则通过 `get()` 解析,skill 注册表将其记录为返回被禁用 skill 的可信调用方路径。因此用户可以通过键入 skill 的确切名称加载任意 skill,但补全菜单绝不会宣传一个本不该让模型看见的 skill。每个补全条目都以其胜出来源的作用域为标签——`project-` 来源标为 `(project)`,其他一切来源标为 `(user)`——标签置于斜杠命令的参数提示位,菜单会显示它,但选中时绝不会插入,因此尾随指令仍然跟在补全后的名称之后。未知名称、前缀之后为空的名称、以及查找失败,都会各自呈现为 transcript(文本记录)中的一条通知,且不发送任何内容。 `renderSkillInvocation` 及资源基址行是 TUI 自有的,刻意不复用 `dsh-tool-skill` 的 `skill` 工具结果。该工具把正文包进 ``/``/`` 是为了一个*工具结果*;而手动调用是一个*用户轮次*,把两个渲染器耦合起来会迫使一种面向模型的形态同时服务两个界面。代价是两个都在格式化 skill 正文的渲染器;收益是各界面面向模型的文本可以独立演进,且各自在其产出处被固定。 diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.i18n.yaml new file mode 100644 index 0000000000..0573cca056 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-23-tui-footer-session-identity.md: aa17ead4194c52464de0caad86d8611eae94786c +2026-07-23-tui-footer-session-identity.zh.md: 686c11294ffd02304dc87cd790252a347fe35011 diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.md b/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.md new file mode 100644 index 0000000000..aa17ead419 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.md @@ -0,0 +1,27 @@ +# Agent Note: Keep the TUI session identity visible + +Status: implemented + +English | [中文](2026-07-23-tui-footer-session-identity.zh.md) + +## Problem + +The startup banner identifies the active session, but it scrolls out of view during a conversation. Operators working with several resumable sessions then lack a persistent way to confirm which session receives their input. + +## Decision + +The TUI footer begins with the active session id, before the model, working directory, token counts, cache rate, and context use. It shows tool-card state only while cards are expanded; the default collapsed state adds no label. The session id uses the same control-character escaping as other terminal labels and participates in the footer's existing left-to-right clipping behavior. + +The footer reads the id from the mounted agent's session, so fresh and resumed sessions use the same authoritative identity without separate UI state. + +## Alternatives considered + +- **Keep the identity only in the startup banner** — rejected because the banner leaves the viewport in longer conversations. +- **Show the session id only in `/status`** — rejected because an on-demand diagnostic does not let an operator confirm identity before sending input. +- **Put the session id in the right footer segment** — rejected because narrow terminals clip that segment first; session identity is more important than context and expanded tool-card state. + +## Consequences + +The current session remains identifiable while the editor is active. On narrow terminals, the longer left segment leaves less room for context and the expanded tool-card label, while the existing clipping policy preserves session identity, model, and as much operational context as fits. + +Package coverage pins the footer ordering and escaping path, and the runnable TUI terminal snapshots pin the assembled layout. diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.zh.md b/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.zh.md new file mode 100644 index 0000000000..686c11294f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 保持 TUI 会话标识可见 + +[English](2026-07-23-tui-footer-session-identity.md) | 中文 + +Status: implemented + +## Problem + +启动横幅会标识当前会话,但在对话过程中会滚出视野。操作多个可恢复会话时,用户因而无法持续确认输入将发送到哪个会话。 + +## Decision + +TUI 页脚以当前会话 id 开头,之后依次显示模型、工作目录、token 用量、缓存命中率和上下文用量。工具卡片状态仅在卡片展开时显示;默认的折叠状态不添加任何标签。会话 id 与其他终端标签采用相同的控制字符转义,并遵循页脚现有的从左到右裁剪行为。 + +页脚从已挂载 agent 的会话读取 id,因此新建和恢复的会话都使用同一权威标识,无需单独维护 UI 状态。 + +## Alternatives considered + +- **仅在启动横幅中保留标识** — 未采用,因为对话较长时横幅会离开视野。 +- **仅在 `/status` 中显示会话 id** — 未采用,因为按需诊断无法让用户在发送输入前确认会话标识。 +- **将会话 id 放入页脚右侧区域** — 未采用,因为窄终端会优先裁剪该区域;会话标识比上下文和展开的工具卡片状态更重要。 + +## Consequences + +编辑器处于活动状态时,当前会话始终可识别。在窄终端中,更长的左侧区域会减少上下文和展开的工具卡片标签的显示空间;现有裁剪策略会保留会话标识、模型,以及空间允许的其他运行信息。 + +包级覆盖固定页脚顺序和转义路径,可运行 TUI 的终端快照固定组装后的布局。 diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.i18n.yaml new file mode 100644 index 0000000000..177ddc27e6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-23-tui-status-prompt-tools.md: 42524d021d0f2786371762b447ad5d195dc828bd +2026-07-23-tui-status-prompt-tools.zh.md: 5a33e19e9780749a721395a0b07f43790103013c diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.md b/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.md new file mode 100644 index 0000000000..42524d021d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.md @@ -0,0 +1,29 @@ +# Agent Note: TUI status inspects model request inputs + +Status: implemented + +English | [中文](2026-07-23-tui-status-prompt-tools.zh.md) + +## Problem + +Session counters describe activity but do not reveal the instructions and capabilities that the next model request receives. Diagnosing scoped prompt contributions and tool restrictions otherwise requires leaving the TUI or inferring configuration from files. + +## Decision + +`/status` assembles the current agent's system prompt through `ctx.systemPrompt` and renders it with the same renderer used by the agent loop. After the bordered diagnostics card, separate unbordered `System prompt` and `Registered tools` sections show the rendered prompt and the assembly's ordered tool names, which are the schemas exposed to the model for that agent and presentation mode. + +Assembly uses the command's cancellation signal and current agent scope, so scoped sections, variables, tool restrictions, and assembly listeners match a request made at that point. Prompt and tool values are escaped through the TUI's terminal-control sanitizer before rendering. Empty prompt text and an empty tool list render as `(empty)` and `(none)`. + +## Alternatives considered + +**Read prompt sections and the tool registry independently.** Rejected: that bypasses prompt assembly waterfalls, tool ordering, presentation modes, and per-agent restrictions, so the diagnostics could disagree with the next request. + +**Show complete tool schemas.** Rejected: names answer which capabilities are registered without making the status card dominated by parameter JSON; schema details remain available in the generated tool catalog and source definitions. + +## Consequences + +The command can run prompt providers and assembly listeners, just like request preparation, and reports their failures through the existing command-error notice. The snapshot is point-in-time: a later registration, restriction, mode change, or dynamic provider can alter the next request. + +## Testing + +Unit coverage pins scoped assembly output, ordered tool names, empty labels, and terminal-control escaping. The keyless TUI smoke and terminal snapshot exercise `/status` through the assembled application. diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.zh.md b/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.zh.md new file mode 100644 index 0000000000..5a33e19e97 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.zh.md @@ -0,0 +1,29 @@ +# Agent Note: TUI 状态检查模型请求输入 + +Status: implemented + +[English](2026-07-23-tui-status-prompt-tools.md) | 中文 + +## 问题 + +会话计数器可以描述活动情况,却无法显示下一次模型请求将收到的指令和能力。若要诊断按作用域贡献的提示词与工具限制,用户只能离开 TUI,或根据配置文件进行推断。 + +## 决策 + +`/status` 通过 `ctx.systemPrompt` 为当前 agent(智能体)组装系统提示词,并使用与 agent loop(智能体循环)相同的渲染器完成渲染。在带边框的诊断卡片之后,独立且无边框的 `System prompt` 和 `Registered tools` 区域分别显示渲染后的提示词与 assembly 中按顺序排列的工具名称;这些名称对应当前 agent 与呈现模式向模型公开的 schema。 + +组装使用命令的取消信号和当前 agent 作用域,因此按作用域注册的 section、变量、工具限制及 assembly listener 与此时发起的请求保持一致。提示词和工具值在呈现前经过 TUI 的终端控制字符净化。空提示词与空工具列表分别显示为 `(empty)` 和 `(none)`。 + +## 曾考虑的替代方案 + +**分别读取提示词 section 和工具注册表。** 已否决:该做法会绕过提示词组装 waterfall(瀑布式事件)、工具排序、呈现模式和按 agent 限制,因此诊断结果可能与下一次请求不一致。 + +**显示完整工具 schema。** 已否决:工具名称足以回答注册了哪些能力,同时避免参数 JSON 占据大部分状态卡片;schema 详情仍可在生成的工具目录和源代码定义中查看。 + +## 后果 + +该命令可能像请求准备一样运行提示词提供方与 assembly listener,并通过现有命令错误提示报告失败。结果是一个时点快照:后续注册、限制、模式变更或动态提供方都可能改变下一次请求。 + +## 测试 + +单元测试固定按作用域组装的输出、工具名称顺序、空值标签和终端控制字符转义。无密钥 TUI 冒烟测试与终端快照通过完整组装的应用执行 `/status`。 diff --git a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml new file mode 100644 index 0000000000..4532ab2148 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-24-configurable-tui-prompt-theme.md: 4008f23a3f545e9b4484f0fa3f8490ad9b2c5541 +2026-07-24-configurable-tui-prompt-theme.zh.md: daf15b54d7e04d3860eacad47b754b963cde36fa diff --git a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md new file mode 100644 index 0000000000..4008f23a3f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md @@ -0,0 +1,39 @@ +# Agent Note: TUI prompt themes compose mutable plugin values + +Status: implemented + +English | [中文](2026-07-24-configurable-tui-prompt-theme.zh.md) + +## Problem + +The terminal prompt row and editor prefix were assembled inside the TUI from a fixed set of workspace, model, usage, cache, context, and timing fields. Deployments could change colors globally but could not choose field order, replace the input prefix, add plugin state, or build a Powerline prompt. + +## Decision + +The TUI theme groups `color`, `truecolor`, `leftPrompt`, `rightPrompt`, `inputPrompt`, and the static running-state `inputPlaceholder`. The three prompt strings interpolate `${name}` references; unknown or unavailable values disappear with adjacent horizontal separator whitespace. The left and right templates share one row, retain the right side on overlap, and use ANSI-aware visible widths. The input template controls the first-line editor prefix and continuation indentation. + +`ctx.tuiPrompt` is a context-global registry supplied by `@deepseek-ai/dsh-tui/prompt`. `register(name, initialValue)` returns a handle with `set(value)` and `dispose()`. Values are stored strings rather than callbacks: updates are explicit, unchanged strings are ignored, and a registration, mutation, or disposal schedules one coalesced notification. The renderer reads current values with `get(name)` and subscribes with `subscribe(listener)` to learn when to redraw. That subscription is a direct in-service callback, not a Cordis event, so a value changing on its own schedule still repaints without a bus entry other consumers would never use. Both `subscribe` and each registration are owned by the caller's Cordis effect, so they are removed when the subscriber's or contributor's fiber disposes. Each `subscribe` call is a distinct subscription keyed by record identity, so two fibers may pass the same callback and disposing one leaves the other live. The coalesced notification contains every observer — a synchronous throw, a rejected returned promise, and even an error hostile to string rendering (logs go through the non-throwing `errorChain`) — so one broken observer cannot starve the rest, and it re-checks each subscription's liveness during delivery so a listener that synchronously unsubscribes another in the same burst silences it immediately. Registration follows Cordis effect ownership, rejects duplicate names, and removes the value on plugin disposal. + +Registered fragments are trusted ANSI-capable presentation output. Template literals and ordinary external content remain sanitized, but a prompt-value plugin may emit terminal controls. Composite values own coordinated background transitions and separators, so one `${powerline}` value can render a complete Powerline segment without coupling adjacent atomic providers. + +The built-in `cwd`, `git/worktree`, `token_meter/cache_hit_rate`, `model`, `context`, `timing`, styled `symbol` label, and `indicator` caret values use the same registry. Session and agent events update their handles, while the running timer updates `timing` and the animated `indicator` each tick. The shipped input template is `${symbol} ${indicator}`, preserving the existing `dsh > ` prefix. + +## Alternatives considered + +**Evaluate synchronous provider callbacks on every render.** Rejected: render-time plugin code adds an avoidable failure boundary; stored strings keep the render pass free of plugin evaluation. + +**Publish the change notification as a Cordis event.** Rejected: the notification has exactly one consumer (the TUI renderer for the current session), so a global typed event adds a bus entry, scoped-dispatch surface, and cross-plugin fan-out no one else observes. A direct `subscribe` callback contained inside the service carries the same coalesced redraw with less surface. + +**Expose semantic style roles instead of ANSI.** Rejected: semantic roles cannot express arbitrary Powerline background transitions without expanding the shared style protocol for each presentation technique. + +**Put prompt fields at the top level of TUI config.** Rejected: templates and color selection jointly define terminal presentation and belong under one `theme` object. + +## Consequences + +Prompt contributors depend on the TUI-specific registry and are loaded after the service but before the TUI consumer. The namespace is global to the Cordis context, matching the TUI's current single-session transcript ownership. Arbitrary ANSI is intentionally trusted: unsupported cursor-affecting sequences can disrupt layout, and alignment is reliable only for sequences understood by pi-tui's visible-width utilities. + +Changing `inputPrompt` through a registered value preserves editor text, cursor, history, completion, and focus because pi-tui supports replacing equal-width first and continuation prefixes in place. The static `inputPlaceholder` is sanitized and appears only while the agent runs and the editor is empty. + +## Testing + +Registry tests pin validation, duplicate rejection, updates, unavailable values, coalesced-notification containment, unsubscribe, disposal, interpolation, trailing-literal retention, whitespace cleanup, and ANSI preservation. TUI tests pin nested theme defaults, custom templates, out-of-band value redraw, mutable redraw, Powerline-capable fragments, dynamic input-prefix width, and the static running placeholder. The assembled TUI demo test pins service load order and config forwarding. diff --git a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md new file mode 100644 index 0000000000..daf15b54d7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md @@ -0,0 +1,39 @@ +# Agent Note: TUI 提示符主题组合可变的插件值 + +Status: implemented + +[English](2026-07-24-configurable-tui-prompt-theme.md) | 中文 + +## 问题 + +终端提示符行与编辑器前缀原先在 TUI 内部由一组固定字段拼装而成,涵盖工作区、模型、用量、缓存、上下文与计时。部署方可以全局更改颜色,却无法调整字段顺序、替换输入前缀、加入插件状态,也无法构建 Powerline 风格的提示符。 + +## 决策 + +TUI 主题把 `color`、`truecolor`、`leftPrompt`、`rightPrompt`、`inputPrompt` 以及运行状态下的静态 `inputPlaceholder` 归为一组。三个提示符字符串通过插值引用 `${name}`;未知或不可用的值连同相邻的横向分隔空白一起消失。左右模板共用一行,重叠时保留右侧,宽度计算使用可识别 ANSI 的可见宽度。输入模板控制编辑器首行前缀与续行缩进。 + +`ctx.tuiPrompt` 是由 `@deepseek-ai/dsh-tui/prompt` 提供的上下文全局注册表。`register(name, initialValue)` 返回带 `set(value)` 与 `dispose()` 的句柄。存储的值是字符串而非回调:更新必须显式发起,未变化的字符串会被忽略,而一次注册、变更或 dispose 会安排一次合并后的通知。渲染器用 `get(name)` 读取当前值,并用 `subscribe(listener)` 订阅何时重绘。该订阅是服务内部的直接回调,而非 Cordis 事件,因此一个自行变化的值仍能重绘,而不需要一个其他消费方永远不会观察的总线条目。`subscribe` 与每个注册都由调用方的 Cordis effect 拥有,因此在订阅方或贡献方的 fiber dispose 时一并移除。每次 `subscribe` 都是一个按记录身份区分的独立订阅,因此两个 fiber 可以传入同一个回调,而 dispose 其中一个不会影响另一个。合并通知会容错每个观察者——同步抛出、返回被拒 promise,甚至一个对字符串渲染也会抛异常的错误(日志走不抛异常的 `errorChain`)——因此一个损坏的观察者不会饿死其余观察者;并且在派发过程中会重新校验每个订阅的存活性,因此同一批次中同步取消了另一个订阅的监听器会立即使其静默。注册遵循 Cordis 的 effect 所有权模型,拒绝重复名称,并在插件 dispose(资源释放)时移除对应的值。 + +注册的片段被视为可信的、允许携带 ANSI 的呈现输出。模板中的字面文本与普通外部内容仍会被清洗,但提供提示符值的插件可以输出终端控制序列。复合值自行负责协调背景色过渡与分隔符,因此一个 `${powerline}` 值就能渲染完整的 Powerline 段,而无需与相邻的原子提供方耦合。 + +内置的 `cwd`、`git/worktree`、`token_meter/cache_hit_rate`、`model`、`context`、`timing`、带样式的 `symbol` 标签与 `indicator` 光标符值使用同一个注册表。会话与 agent(智能体)事件更新各自的句柄,运行计时器每一拍更新 `timing` 与带动画的 `indicator`。随附的输入模板为 `${symbol} ${indicator}`,保留了原有的 `dsh > ` 前缀。 + +## 曾考虑的替代方案 + +**每次渲染时求值同步的提供方回调。** 不予采纳:在渲染期执行插件代码会引入一个本可避免的故障边界;存储字符串能让渲染过程不涉及插件求值。 + +**把变更通知发布为 Cordis 事件。** 已否决:该通知只有一个消费方(当前会话的 TUI 渲染器),因此全局类型事件会增加一个总线条目、scope 分发面以及无人观察的跨插件扇出。服务内部包裹的直接 `subscribe` 回调以更小的面积承载同样的合并重绘。 + +**暴露语义化的样式角色而非 ANSI。** 不予采纳:语义角色无法表达任意的 Powerline 背景色过渡,除非为每种呈现技巧扩展共享的样式协议。 + +**把提示符字段放在 TUI 配置顶层。** 不予采纳:模板与颜色选择共同定义终端呈现,应归属于同一个 `theme` 对象之下。 + +## 后果 + +提示符值的贡献插件依赖 TUI 专属的注册表,加载顺序位于该服务之后、TUI 消费方之前。命名空间对整个 Cordis 上下文全局生效,与 TUI 当前的单会话 transcript(文本记录)所有权一致。允许任意 ANSI 是有意的信任决策:不受支持的、影响光标的序列可能破坏布局,只有 pi-tui 可见宽度工具能理解的序列才能保证对齐可靠。 + +通过注册值更改 `inputPrompt` 时,编辑器文本、光标、历史、自动补全与焦点均得以保留,因为 pi-tui 支持原地替换等宽的首行与续行前缀。静态的 `inputPlaceholder` 会被清洗,且仅在 agent 运行且编辑器为空时显示。 + +## 测试 + +注册表测试固定校验、重名拒绝、更新、不可用值、合并通知的容错、取消订阅、dispose、插值、尾随字面保留、空白清理与 ANSI 保留等行为。TUI 测试固定嵌套主题默认值、自定义模板、带外值重绘、可变重绘、支持 Powerline 的片段、动态输入前缀宽度以及运行状态下的静态占位文本。组装后的 TUI 演示测试固定服务加载顺序与配置转发。 diff --git a/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.i18n.yaml new file mode 100644 index 0000000000..88fbc26ceb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-24-readable-xml-tool-output.md: 4f7327a7c6f5e2f04e36576da0fb739c34955e8a +2026-07-24-readable-xml-tool-output.zh.md: 3c56d256b489863210b44449111f03a5752a889a diff --git a/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.md b/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.md new file mode 100644 index 0000000000..4f7327a7c6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.md @@ -0,0 +1,27 @@ +# Agent Note: Readable XML tool output + +Status: implemented + +English | [中文](2026-07-24-readable-xml-tool-output.zh.md) + +## Problem + +Model-facing context and tool result text can expose transport-oriented XML wrappers instead of the information people need. Context producers do not declare presentation intent, and replayed tool calls whose definition is unavailable still need a conservative fallback that does not reinterpret ordinary prose or partial markup. + +## Decision + +The read tool declares a generic completed-result presentation that removes its ``, ``, and `` wrapper while preserving the numbered content and footer. This tool-owned projection applies consistently to every UI that consumes tool presentation intent. + +The TUI parses a context message or unavailable-tool result as XML only when the complete text is one supported XML document. It renders element names and attributes as an indented tree, preserves the context source label, applies the collapsed line budget independently to each tool result's top-level child lines and child count, and keeps raw text for malformed XML, mixed text, declarations, processing instructions, doctypes, and comments. A known tool's raw XML remains literal unless that tool declares its own result presenter. This XML fallback is TUI-only. + +## Alternatives considered + +**Strip XML-like tags with regular expressions.** Rejected because nested elements, attributes, entities, and malformed input require a real parser; partial conversion would make ambiguous output harder to inspect. + +**Parse every generic result.** Rejected because known tools own their presentation contract, and silently reinterpreting their literal XML would override that decision. + +**Show only raw XML.** Rejected because wrappers optimized for model consumption add terminal noise, particularly for filesystem reads and deeply nested structured results. + +## Consequences + +Filesystem reads are shorter in TUI cards without changing canonical model-facing content. Complete XML context messages, including workspace instruction reminders, become readable trees; unknown complete XML results become navigable trees and retain per-child context when collapsed. The TUI adds a strict SAX parser dependency and deliberately declines XML features (undefined entities, DOCTYPE, comments, processing instructions) that could hide or transform input beyond the conservative tree view. Predefined entities and character references do expand, so parsed text and attribute values are re-escaped for terminal output after parsing: a character reference can produce a control character that escaping the raw source never saw. Other UIs show raw generic content. diff --git a/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.zh.md b/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.zh.md new file mode 100644 index 0000000000..3c56d256b4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 可读的 XML 工具输出 + +Status: implemented + +[English](2026-07-24-readable-xml-tool-output.md) | 中文 + +## 问题 + +面向模型的上下文和工具结果文本可能呈现面向传输的 XML 包装,而不是人们真正需要的信息。上下文生产方不声明呈现意图,而对于回放时拿不到工具定义的调用,仍需要一个保守的回退方案,并且该方案不得重新解释普通文字或不完整的标记。 + +## 决策 + +read 工具声明一个通用的完成结果呈现:去除自身的 ``、`` 和 `` 包装,同时保留带行号的内容和尾部信息。这一由工具自身持有的投影一致地作用于所有消费工具呈现意图的 UI。 + +只有当完整文本恰为一个受支持的 XML 文档时,TUI 才把上下文消息或工具定义不可用的工具结果按 XML 解析。TUI 将元素名和属性渲染为缩进树,保留上下文的来源标签;对于每个工具结果,分别按折叠行数预算限制各顶层子元素的行数和顶层子元素数量;对于格式错误的 XML、混合文本、XML 声明、处理指令、doctype 和注释,则保留原始文本。除非已知工具声明了自己的结果呈现器,否则其原始 XML 仍按字面显示。这一 XML 回退机制仅限 TUI。 + +## 曾考虑的替代方案 + +**用正则表达式剥除类 XML 标签。** 已否决:嵌套元素、属性、实体和格式错误的输入都需要真正的解析器;部分转换会让本就有歧义的输出更难检查。 + +**解析所有通用结果。** 已否决:已知工具拥有自己的呈现契约,静默重新解释它们的字面 XML 会推翻这一决定。 + +**只显示原始 XML。** 已否决:为模型消费而优化的包装会给终端增加噪音,对文件系统读取和嵌套很深的结构化结果尤其如此。 + +## 后果 + +文件系统读取在 TUI 卡片中变得更短,而规范的面向模型内容保持不变。完整的 XML 上下文消息(包括工作区指令提醒)变成可读的树;未知的完整 XML 结果变成可导航的树,折叠时也保留每个子元素的上下文。TUI 新增一个严格 SAX 解析器依赖,并有意不支持那些可能在保守树视图之外隐藏或变换输入的 XML 特性(未定义实体、DOCTYPE、注释、处理指令)。预定义实体和字符引用会被展开,因此解析出的文本和属性值在解析后会为终端输出重新转义:字符引用可能产生对原始源文本转义时从未见过的控制字符。其他 UI 展示原始的通用内容。 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.i18n.yaml new file mode 100644 index 0000000000..8cb52df982 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.md +2026-07-24-tui-banner-model-deduplication.md: afd370a8762d8e6c17c61d50c95d68998a063df5 +2026-07-24-tui-banner-model-deduplication.zh.md: 86a3cdb0e714642253162f1fe062e19bdc40bbe4 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.md b/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.md new file mode 100644 index 0000000000..afd370a876 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.md @@ -0,0 +1,33 @@ +# Agent Note: The startup banner omits the model + +Status: implemented + +English | [中文](2026-07-24-tui-banner-model-deduplication.zh.md) + +## Problem + +The startup banner repeated the selected model directly above the prompt context, which already keeps the model visible while the TUI is idle. The duplicate added no information and made the banner detail line harder to scan. + +## Decision + +- The borderless startup banner shows the product title, optional `welcome` or session-title subtitle, and session id. +- The banner omits the model name. The prompt context remains the persistent model display and updates after `/model` selection. +- The sweep animation and configured-welcome behavior are unchanged. + +This supersedes only the model-in-banner portion of the [borderless banner decision](../../archived/feature/2026-07-21-tui-borderless-banner.md). + +## Alternatives considered + +**Remove the entire detail line.** Rejected: the session id remains useful for identifying and resuming the active session, and it is not duplicated in the prompt context. + +**Remove the model from the prompt context instead.** Rejected: the prompt context stays visible after the startup banner scrolls away and reflects later model selections. + +## Consequences + +- Startup uses the banner detail row only for the session id. +- The model appears once in the initial idle view, in the prompt context. +- Banner snapshots and runnable TUI replay snapshots contain a shorter detail row. + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` asserts that completed banners retain the session id without the former `` text. Package-local and runnable-example TUI snapshots pin the resulting rows. diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.zh.md b/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.zh.md new file mode 100644 index 0000000000..86a3cdb0e7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.zh.md @@ -0,0 +1,33 @@ +# Agent Note:启动横幅不再显示模型 + +Status: implemented + +[English](2026-07-24-tui-banner-model-deduplication.md) | 中文 + +## 问题 + +启动横幅在提示区上下文(prompt context)的正上方重复显示所选模型,而提示区上下文本身已在 TUI 空闲时持续展示模型。这一重复不提供任何信息,还让横幅详情行更难扫读。 + +## 决策 + +- 无边框启动横幅显示产品标题、可选的 `welcome` 或会话标题副标题,以及会话 id。 +- 横幅不再显示模型名。提示区上下文仍是常驻的模型展示位,并在 `/model` 选择后随之更新。 +- 扫入动画和配置了欢迎语时的行为保持不变。 + +本 note 仅取代[无边框横幅决策](../../archived/feature/2026-07-21-tui-borderless-banner.md)中模型进横幅的那部分。 + +## 考虑过的替代方案 + +**移除整条详情行。** 否决:会话 id 对识别和恢复当前会话仍然有用,而且它在提示区上下文中没有重复。 + +**改为把模型从提示区上下文移除。** 否决:提示区上下文在启动横幅滚出视野后仍保持可见,并会反映之后的模型选择。 + +## 后果 + +- 启动时横幅详情行只承载会话 id。 +- 在初始空闲视图中模型只出现一次,位于提示区上下文。 +- 横幅快照和可运行的 TUI 回放快照包含更短的详情行。 + +## 测试 + +`packages/ui/tui/tests/tui.spec.ts` 断言完成后的横幅保留会话 id,且不含先前的 `` 文本。包内快照与可运行示例的 TUI 快照固定了最终的各行内容。 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.i18n.yaml new file mode 100644 index 0000000000..481dc5cad7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-24-tui-message-header-timing.md: 94a4d04c75f9b0ad76e2460738a07ba82ac3bb9f +2026-07-24-tui-message-header-timing.zh.md: 4713555290bbc47bb3af56cd3b4d0c493c81e6f1 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.md b/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.md new file mode 100644 index 0000000000..94a4d04c75 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.md @@ -0,0 +1,25 @@ +# Agent Note: TUI message header timing + +Status: implemented + +English | [中文](2026-07-24-tui-message-header-timing.zh.md) + +## Problem + +Turn timing beside the editor disappears from the transcript when the user scrolls and cannot appear until the editor status renders. A whole-turn aggregate also obscures the latency of later model requests after tool calls. + +## Decision + +Every model step creates an assistant header at `step/start`, before the first streamed chunk. The header displays `Model wait` immediately and refreshes at 100 ms resolution, then adds exclusive `Thinking`, `Response`, and `Tools` buckets as session events move the step between phases. + +`step/end` freezes the header and adds the local completion timestamp. Transcript replay derives the same timing from durable event timestamps. Empty and tool-only steps retain a header, while failed live output and its header retract together when retry handling rebuilds the active session surface. + +The prompt context retains only queued-steering state. Timing belongs to the model step that produced it rather than to the editor or the whole turn. + +## Alternatives considered + +Keeping timing beside the editor preserves a stable layout but hides per-step latency in scrollback and resume. Adding a second status line duplicates the same metric in two places. Labeling the first bucket `TTFT` is compact but requires protocol terminology; `Model wait` states the user-visible meaning without claiming that the first chunk is always text. + +## Consequences + +Users receive visible feedback before model output and can compare each request after tools or retries. Updating at 100 ms resolution causes more terminal renders while a model step is active. Internal timing state keeps the established `ttft` name because it identifies the measured bucket precisely; only rendered text uses `Model wait`. diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.zh.md b/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.zh.md new file mode 100644 index 0000000000..4713555290 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.zh.md @@ -0,0 +1,25 @@ +# Agent Note:TUI 消息头部计时 + +Status: implemented + +[English](2026-07-24-tui-message-header-timing.md) | 中文 + +## 问题 + +编辑器旁的轮次计时会在用户滚动时从 transcript(文本记录)中消失,且要等到编辑器状态渲染后才能出现。整轮聚合值还会掩盖工具调用之后各后续模型请求的延迟。 + +## 决策 + +每个模型步骤都在 `step/start` 时(即第一个流式分片到达之前)创建一个 assistant 头部。头部立即显示 `Model wait` 并以 100 ms 分辨率刷新;随着会话事件使该步骤在不同阶段之间切换,头部再加入互斥的 `Thinking`、`Response` 和 `Tools` 时间桶。 + +`step/end` 冻结头部并附上本地完成时间戳。transcript 回放从持久事件时间戳派生出相同的计时。空步骤和纯工具步骤同样保留头部;当重试处理重建活跃会话表层时,失败的实时输出与其头部一并撤除。 + +提示区上下文(prompt context)只保留排队中的 steering(中途引导)状态。计时归属于产生它的模型步骤,而不是编辑器或整个轮次。 + +## 考虑过的替代方案 + +把计时留在编辑器旁能保持布局稳定,但在 scrollback 和会话恢复中看不到各步骤的延迟。增加第二条状态行会让同一指标出现在两处。把第一个时间桶标为 `TTFT` 更紧凑,但依赖协议术语;`Model wait` 直接陈述用户可见的含义,而不宣称第一个分片总是文本。 + +## 后果 + +用户在模型输出之前就能得到可见反馈,并能比较工具或重试之后的每次请求。以 100 ms 分辨率刷新会在模型步骤活跃期间带来更多终端渲染。内部计时状态沿用既有的 `ttft` 名称,因为它精确标识所计量的时间桶;只有渲染文本使用 `Model wait`。 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.i18n.yaml new file mode 100644 index 0000000000..dff4800cf7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-24-tui-prompt-status-indicator.md: 8d469c3b0627325f373ca8f8d4d23d67bb09e342 +2026-07-24-tui-prompt-status-indicator.zh.md: 0dee8d1e4e7ce5a6f0299f9b7cd1595f63bd6e08 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.md b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.md new file mode 100644 index 0000000000..8d469c3b06 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.md @@ -0,0 +1,33 @@ +# Agent Note: TUI prompt status indicator + +Status: implemented + +English | [中文](2026-07-24-tui-prompt-status-indicator.zh.md) + +## Problem + +While a turn runs, the input prompt shows only its static `dsh>` prefix. The assistant header carries the elapsed timing, but the editor row — where the user's attention rests — gives no live signal of what the agent is doing right now: waiting for the first token, thinking, responding, or running tools. + +## Decision + +While the agent is running, a phase-specific glyph replaces the `>` caret of the built-in `${indicator}` prompt value. The `inputPrompt` theme template defaults to `${symbol} ${indicator}`, where the built-in `${symbol}` value holds the `dsh` label and `${indicator}` holds the caret slot with its trailing gap before the cursor; the template literal space separates them, rendering `dsh ` in every state. The phase is the open step's active timing bucket, derived from the same session events and rules that drive the [message header timing](2026-07-24-tui-message-header-timing.md) — no new phase model. One glyph per bucket: `◍` model wait (pre-first-token), `✻` thinking, `●` responding, `⚙` tools. A running turn with no open step falls back to the model-wait glyph; an idle agent restores the plain `>`. + +The glyph occupies the caret's exact column with the same display width every frame, so the cursor never shifts as the phase changes or the glyph animates. Activity is conveyed by a brightness pulse, not by appearing and disappearing: a four-frame triangle wave (dim → normal → bold → normal) wraps the accent-colored glyph in the true SGR intensity codes (2 and 1) — never the palette's semantic `dim` role, which on a light scheme is a color the glyph's own accent would override — so the pulse survives every terminal scheme. The render-clock cadence is 250 ms per frame, a fixed presentation rhythm alongside the sibling 100 ms status refresh, not a deployment choice. The running-status timer refreshes every 100 ms tick unconditionally rather than only when a streaming component exists, so the pulse animates even during the pre-first-token wait. + +The caret and its animation are their own `${indicator}` value, separate from the `${symbol}` label, so the `inputPrompt` template composes the two: `${symbol} ${indicator}` reads as `dsh `. Configurability lives at that template — a deployment reorders or drops either value, and omitting `${indicator}` opts out of the running indicator. The glyph set, the pulse, and the `dsh` label are fixed in code — not per-deployment fields — matching the fixed timing-bucket labels they mirror. + +The built-in `${symbol}`/`${indicator}` updates ride the renders the TUI already drives on every state change that can move a value (`agent/status`, session events, the 100 ms running-status timer, async model-context resolution). A prompt value that changes on its own schedule — a plugin-owned `${custom}` fragment — instead redraws through the registry's coalesced change notification, which the renderer subscribes to directly rather than through a Cordis event ([registry](2026-07-24-configurable-tui-prompt-theme.md)). + +## Alternatives considered + +**Prepend the glyph before `dsh>` as its own `${status}` token.** Rejected: a leading token shifts the whole prompt — and the cursor — right by two columns whenever it appears, and collapses back when it clears. Replacing the caret keeps the cursor column fixed. + +**A blinking glyph that appears and disappears.** Rejected: on/off blanking still moves nothing horizontally once the glyph owns the caret column, but the empty frames read as flicker. A brightness pulse animates continuously while the character stays put. + +**A per-phase spinner animation** (rotating frames). Rejected: the four phases are already distinguished by their glyph shapes; swapping the character per frame would conflate "which phase" with "still working". The pulse animates intensity while the shape stays a stable phase signal, reusing the existing 100 ms status timer. + +**A new phase state machine in the TUI.** Rejected: the header-timing machinery already replays the open step's active bucket from session events. Deriving the glyph from that bucket keeps one source of truth for "what phase is this step in". + +## Consequences + +The user gets a live, glanceable phase signal in the caret they are already watching, with no horizontal movement of the cursor or the prompt. The pulse costs terminal renders on every 100 ms tick for the whole running turn, not only while a streaming component is mounted. The glyph mapping and the pulse are fixed in code, not configurable, matching the fixed timing-bucket labels they mirror. diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.zh.md b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.zh.md new file mode 100644 index 0000000000..0dee8d1e4e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.zh.md @@ -0,0 +1,33 @@ +# Agent Note:TUI 提示区状态指示器 + +Status: implemented + +[English](2026-07-24-tui-prompt-status-indicator.md) | 中文 + +## 问题 + +轮次运行期间,输入提示区只显示其静态的 `dsh>` 前缀。assistant 头部承载已用计时,但编辑器所在的这一行——也就是用户注意力所在之处——对 agent 此刻正在做什么没有任何实时信号:是在等待第一个 token、思考、响应,还是在运行工具。 + +## 决策 + +agent 运行期间,一个按阶段区分的字形会替换内置 `${indicator}` 提示区值中的 `>` 光标符。`inputPrompt` 主题模板默认为 `${symbol} ${indicator}`,其中内置 `${symbol}` 值承载 `dsh` 标签,`${indicator}` 承载光标符槽位及其在光标前的尾随间隙;模板中的字面空格将两者隔开,在每种状态下渲染为 `dsh <字形> `。阶段取自当前打开步骤的活跃计时桶,其派生所依据的会话事件与规则和[消息头部计时](2026-07-24-tui-message-header-timing.md)相同——没有引入新的阶段模型。每个桶对应一个字形:`◍` 等待模型(第一个 token 之前)、`✻` 思考、`●` 响应、`⚙` 工具。运行中但没有打开步骤的轮次回退到等待模型的字形;agent 空闲时则恢复为纯 `>`。 + +字形占据光标符所在的同一列,且每一帧的显示宽度都相同,因此无论阶段切换还是字形动画,光标都不会移动。活动状态由亮度脉动传达,而不是靠出现和消失:一个四帧三角波(暗 → 正常 → 亮 → 正常)用真正的 SGR 强度码(2 与 1)包裹带 accent 色的字形——绝不使用调色板语义上的 `dim` 角色,因为在浅色 scheme 下它是一种颜色,会被字形自身的 accent 色覆盖——因此脉动在任何终端 scheme 下都能保留。渲染时钟节拍为每帧 250 ms,是与配套的 100 ms 状态刷新并列的固定呈现节奏,而非部署选项。运行状态计时器每 100 ms 无条件刷新一次,而不再只在存在流式组件时刷新,因此即使在第一个 token 之前的等待期间,脉动也能持续。 + +光标符及其动画自成一个 `${indicator}` 值,与 `${symbol}` 标签分离,因此 `inputPrompt` 模板将二者组合:`${symbol} ${indicator}` 读作 `dsh <光标符>`。可配置性位于该模板——部署可重排或丢弃任一值,省略 `${indicator}` 即退出运行指示器。字形集、脉动以及 `dsh` 标签都固定在代码中——不是逐部署字段——与它们映射的固定计时桶标签一致。 + +内置 `${symbol}`/`${indicator}` 的更新搭乘 TUI 本就在每次可能改变某个值的状态变化(`agent/status`、会话事件、100 ms 运行状态计时器、异步模型上下文解析)时驱动的渲染。而一个自行变化的值——插件拥有的 `${custom}` 片段——则通过注册表的合并变更通知重绘,而渲染器直接订阅它,而非通过 Cordis 事件(参见[注册表](2026-07-24-configurable-tui-prompt-theme.md))。 + +## 考虑过的替代方案 + +**把字形作为自己的 `${status}` token 前置在 `dsh>` 之前。** 已否决:前置 token 每次出现都会把整个提示区——连同光标——向右移动两列,清除时又缩回。在尾随的 `${indicator}` 槽位替换光标符能让光标列保持固定。 + +**出现又消失的闪烁字形。** 已否决:一旦字形占据光标符所在列,开/关式的空白帧在水平方向上不再移动任何东西,但空帧读起来像闪烁。亮度脉动让字符保持不动的同时持续做动画。 + +**按阶段的 spinner 动画**(旋转帧)。已否决:四个阶段已经通过各自的字形形状区分;逐帧切换字符会把「哪个阶段」与「仍在工作」混为一谈。脉动只改变强度做动画,而形状始终是稳定的阶段信号,且复用了既有的 100 ms 状态计时器。 + +**在 TUI 中新建阶段状态机。** 已否决:头部计时机制已从会话事件回放出当前打开步骤的活跃桶。从该桶派生字形,能让「这个步骤处于哪个阶段」保持单一事实来源。 + +## 后果 + +用户在自己本就注视的光标符处获得可一眼掌握的实时阶段信号,且光标与提示区都没有水平移动。脉动的代价是整个运行轮次内每 100 ms 一次的终端渲染,而不再只在流式组件挂载期间。字形映射与脉动都固定在代码中、不可配置,与其所对应的固定计时桶标签一致。 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.i18n.yaml new file mode 100644 index 0000000000..dd09857974 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-24-tui-prompt-workspace-label.md: c45c60c6554766cca01076b229956a7bc7d98d48 +2026-07-24-tui-prompt-workspace-label.zh.md: 170dba83becf4b529679e7db9c7c84a6de7dec13 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.md b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.md new file mode 100644 index 0000000000..c45c60c655 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.md @@ -0,0 +1,34 @@ +# Agent Note: The prompt context combines directory and branch + +Status: implemented + +English | [中文](2026-07-24-tui-prompt-workspace-label.zh.md) + +## Problem + +The idle prompt context rendered the working directory and `git:` as separate segments. In task worktrees, the directory can already identify the checkout, while the prefixed branch segment consumed additional horizontal space and was discarded independently on narrower terminals. + +## Decision + +- The prompt context renders the working directory and available Git branch as one workspace label: ` ()`. +- The directory remains bold and accented; the parenthesized branch remains muted. +- The combined workspace label has the highest retention priority and is clipped as one segment when it exceeds the terminal width. +- Outside a Git worktree or on detached HEAD, the label remains the directory alone. + +## Alternatives considered + +**Keep `git:` as a separate segment.** Rejected: the prefix and separator use more columns without adding meaning in this context. + +**Show only the branch.** Rejected: the session working directory determines where tools operate and remains the primary prompt context. + +**Derive a special worktree root label.** Rejected: the existing formatted directory and Git branch already provide the two relevant facts without adding repository-layout assumptions. + +## Consequences + +- A typical checkout renders as `~/git/tui-staging (tui-staging)`. +- Narrow terminals retain or clip directory and branch together instead of dropping the branch independently. +- Embedding-provided `TuiRuntime.formatCwd` labels compose with the branch in the same form. + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` pins home, absolute, formatted, and narrow workspace labels. Package-local and runnable-example TUI snapshots verify the assembled prompt context. diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.zh.md b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.zh.md new file mode 100644 index 0000000000..170dba83be --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.zh.md @@ -0,0 +1,34 @@ +# Agent Note:提示区上下文合并显示目录与分支 + +Status: implemented + +[English](2026-07-24-tui-prompt-workspace-label.md) | 中文 + +## 问题 + +空闲提示区上下文(prompt context)把工作目录和 `git:` 作为两个独立片段渲染。在任务 worktree 中,目录本身往往已能标识当前检出,而带前缀的分支片段额外占用横向空间,且在较窄的终端上会被单独丢弃。 + +## 决策 + +- 提示区上下文把工作目录和可用的 Git 分支渲染为一个工作区标签(workspace label):` ()`。 +- 目录仍为加粗强调色;括号内的分支仍为弱化色。 +- 合并后的工作区标签具有最高保留优先级,超出终端宽度时作为一个整体片段裁剪。 +- 不在 Git worktree 中或处于 detached HEAD 时,标签仍只显示目录。 + +## 考虑过的替代方案 + +**保留 `git:` 作为独立片段。** 否决:前缀和分隔符占用更多列宽,在此上下文中却不增加信息。 + +**只显示分支。** 否决:会话工作目录决定工具在哪里运行,仍是提示区上下文的首要信息。 + +**派生一个特殊的 worktree 根目录标签。** 否决:现有的格式化目录和 Git 分支已经提供了这两项相关信息,无需引入对仓库布局的假设。 + +## 后果 + +- 典型的检出渲染为 `~/git/tui-staging (tui-staging)`。 +- 窄终端把目录和分支作为整体保留或裁剪,而不是单独丢弃分支。 +- 嵌入方通过 `TuiRuntime.formatCwd` 提供的标签以同样的形式与分支组合。 + +## 测试 + +`packages/ui/tui/tests/tui.spec.ts` 固定了主目录、绝对路径、格式化及窄终端下的工作区标签。包内快照与可运行示例的 TUI 快照验证了组装后的提示区上下文。 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.i18n.yaml new file mode 100644 index 0000000000..bfb40b9cc8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-24-tui-shell-prompt-editor.md: bba03e788b92692f534fd97e66035757e9c74356 +2026-07-24-tui-shell-prompt-editor.zh.md: 1897d11292ec3b189245169956ac327f0b81b0e2 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.md b/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.md new file mode 100644 index 0000000000..bba03e788b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.md @@ -0,0 +1,33 @@ +# Agent Note: TUI shell-prompt editor + +Status: implemented + +English | [中文](2026-07-24-tui-shell-prompt-editor.zh.md) + +## Problem + +The upstream pi-tui editor always renders horizontal frame rows. That presentation separates input from the transcript but occupies two terminal rows and does not resemble the command-oriented input used by shells. + +## Decision + +The TUI presents a two-line prompt. A DSH-owned context line shows the working directory, running-turn timing, optional Git branch, current model, token totals, cache hit rate, and context pressure as independently prioritized segments. Narrow terminals omit lower-priority segments while retaining the directory, followed by running timing when it is present. The second line uses a fixed-width `dsh> ` prefix and equal-width continuation indent; its running steer/cancel guidance is placeholder text that disappears when input begins. + +The pinned `@earendil-works/pi-tui` package carries a pnpm patch that adds `frame: "none"` and fixed-width prompt prefixes to `EditorOptions`. The default remains the upstream horizontal frame, so only the DSH editor opts into the behavior. Prefixes must have equal visible widths; construction fails when they differ. Input, explicit newlines, autocomplete, cursor placement, and scroll indicators share the reduced first-row width; automatically wrapped rows render no prefix, so their text starts at the editor's left padding, occupies the prefix columns, and wraps at the full content width. + +The patch stays limited to the published editor JavaScript and declarations. Keeping the exact dependency pin makes installation either apply the known patch or fail rather than silently dropping the presentation. + +## Alternatives considered + +**Filter the rendered editor output in a wrapper.** This would depend on recognizing ANSI-styled border and scroll-indicator rows and distinguishing autocomplete output from input output, all of which are undocumented render details. + +**Vendor the complete pi-tui package.** The project updates frequently, while this change needs only a localized editor rendering option. Owning the full source and synchronization process would add disproportionate maintenance. + +**Keep the horizontal frame.** This avoids dependency customization but retains the presentation the change is intended to replace. + +## Consequences + +The editor and context use two rows instead of the framed editor plus footer, with one blank row separating the prompt area from conversation cards. The persistent presentation omits session identity and tool-card mode; `/status` and commands retain those details. Input layout and autocomplete lose six columns to the prompt prefix, but wrapped text uses the otherwise blank prefix columns. Borderless scrolling uses standalone `↑ N more` and `↓ N more` rows. + +The internal segment representation establishes width priorities without exposing a public customization language. Future Starship-like configuration can build on it after the default modules and overflow behavior have production evidence. + +A pi-tui upgrade requires reviewing and reapplying or retiring the patch. TUI terminal snapshots pin the assembled presentation, including context modules, prompt color, alignment, cursor placement, and autocomplete width. diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.zh.md b/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.zh.md new file mode 100644 index 0000000000..1897d11292 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.zh.md @@ -0,0 +1,33 @@ +# Agent Note: TUI shell 提示符编辑器 + +Status: implemented + +[English](2026-07-24-tui-shell-prompt-editor.md) | 中文 + +## 问题 + +上游 pi-tui 编辑器始终渲染横向边框行。这种呈现方式虽然把输入区与 transcript(文本记录)分隔开,却占用两行终端高度,也不像 shell 中面向命令的输入形态。 + +## 决策 + +TUI 呈现两行提示符。DSH 自有的上下文行把工作目录、运行中轮次的计时、可选的 Git 分支、当前模型、token 总量、缓存命中率与上下文压力显示为各自独立分配优先级的段(segment)。窄终端会省略低优先级的段,但保留目录;运行中计时存在时,其保留优先级仅次于目录。第二行使用固定宽度的 `dsh> ` 前缀与等宽的续行缩进;agent 运行期间提示 steering(中途引导)与取消的引导文字是占位文本,开始输入后即消失。 + +固定版本的 `@earendil-works/pi-tui` 包(package)携带一个 pnpm 补丁,为 `EditorOptions` 增加 `frame: "none"` 与固定宽度的提示符前缀。默认值仍是上游的横向边框,因此只有 DSH 编辑器选择启用该行为。两个前缀的可见宽度必须相等;宽度不同时构造会失败。输入、显式换行、自动补全、光标定位和滚动指示共用缩减后的首行宽度;自动折行产生的行不渲染前缀,其文本从编辑器左侧留白处开始,占用前缀列,并按完整内容宽度折行。 + +补丁范围仅限已发布的编辑器 JavaScript 与类型声明。依赖保持精确的版本固定,使安装要么应用已知补丁,要么直接失败,而不会静默丢掉这种呈现方式。 + +## 曾考虑的替代方案 + +**在包装层过滤编辑器的渲染输出。** 这需要识别带 ANSI 样式的边框行与滚动指示行,并区分自动补全输出与输入输出,而这些都是未见于文档的渲染细节。 + +**vendor 完整的 pi-tui 包。** 该项目更新频繁,而本次改动只需要一个局部的编辑器渲染选项。接手全部源码及其同步流程会带来不成比例的维护成本。 + +**保留横向边框。** 这可以避免定制依赖,但保留的正是本次改动想要替换的呈现方式。 + +## 后果 + +编辑器与上下文共占两行,取代原先带边框的编辑器加页脚,提示符区域与对话卡片之间以一行空行分隔。常驻呈现不含会话标识与工具卡片模式;`/status` 与各命令仍保留这些细节。输入布局与自动补全因提示符前缀占位而损失六列宽度,但折行后的文本会占用原本留空的前缀列。无边框滚动使用独立的 `↑ N more` 与 `↓ N more` 行。 + +段的内部表示确立了宽度优先级,而未暴露公开的定制语言。待默认模块与溢出行为积累生产环境证据后,未来可在其上构建类似 Starship 的配置。 + +升级 pi-tui 时需要评审该补丁,并重新应用或将其退役。TUI 终端快照固定组装后的呈现效果,包括上下文模块、提示符颜色、对齐、光标定位和自动补全宽度。 diff --git a/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.i18n.yaml new file mode 100644 index 0000000000..dab40896a5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.md +2026-07-27-assistant-timing-header-trailing.md: a315a0620c63f25660220e55cf5187d7117c14d1 +2026-07-27-assistant-timing-header-trailing.zh.md: 84b8612337a345912371e37952195e4602f7ca25 diff --git a/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.md b/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.md new file mode 100644 index 0000000000..a315a0620c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.md @@ -0,0 +1,25 @@ +# Agent Note: Assistant timing line renders after the message body + +Status: implemented + +English | [中文](2026-07-27-assistant-timing-header-trailing.zh.md) + +## Problem + +The TUI assistant message opened with a single header line joining the `Assistant` label and the step-timing string (`Assistant · Model wait 0.0s · Completed …`). Placing the timing before the body pushed the durations away from the answer they describe and, once completed, buried the reply's first line under a metadata line the reader scans past. + +## Decision + +**Split the label from the timing; render the timing as the message's trailing line.** + +`AssistantMessageComponent` (packages/ui/tui/src/index.ts) now emits the bold `Assistant` label as the first line and appends the dim timing string (already assembled by `StreamingAssistantComponent.rebuild()` as `header`, including the `· Completed …` suffix when settled) as the last child, after reasoning and text. The timing content, bucket-hiding, and completion-time behavior are unchanged — only its position moved from the top to the bottom of the message. + +## Alternatives considered + +**Move the whole header line (label included) to the end.** Rejected: the `Assistant` label orients the reader to who is speaking and belongs at the top like the `You` label; only the timing metadata benefits from trailing placement. + +**Keep the timing inline but below the label as a second top line.** Rejected: that still separates the durations from the completed answer and keeps two metadata lines between the prompt and the reply. + +## Consequences + +Each assistant message reads label → reasoning → answer → timing, so completed timing sits next to the reply it measures. The keyless TUI snapshot suite was refreshed to pin the new layout across every fixture; four `tui.spec.ts` assertions that matched the old inline `Assistant · Model wait …` string now assert the label and timing separately, since the two no longer render contiguously. diff --git a/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.zh.md b/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.zh.md new file mode 100644 index 0000000000..84b8612337 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.zh.md @@ -0,0 +1,25 @@ +# Agent Note: Assistant timing line renders after the message body + +Status: implemented + +[English](2026-07-27-assistant-timing-header-trailing.md) | 中文 + +## Problem + +TUI 的助手消息此前以一行开头,把 `Assistant` 标签和步骤计时串拼在一起(`Assistant · Model wait 0.0s · Completed …`)。计时放在正文之前,使耗时数据远离它所描述的回答;一旦完成,回复的首行还被读者会略过的元数据行压在下面。 + +## Decision + +**把标签与计时拆开;计时作为消息的末行渲染。** + +`AssistantMessageComponent`(packages/ui/tui/src/index.ts)现在把加粗的 `Assistant` 标签作为首行,并把暗色的计时串(仍由 `StreamingAssistantComponent.rebuild()` 组装为 `header`,settled 时含 `· Completed …` 后缀)作为最后一个子节点,追加在 reasoning 与正文之后。计时内容、隐藏零值桶以及完成时间的行为均不变——仅位置从消息顶部移到底部。 + +## Alternatives considered + +**把整行表头(含标签)都移到末尾。** 否决:`Assistant` 标签让读者知道是谁在说话,应与 `You` 标签一样置顶;只有计时这类元数据才受益于置底。 + +**计时仍内联,但作为标签下方的第二行置顶。** 否决:这仍把耗时数据与完成的回答分离,并在提示与回复之间保留两行元数据。 + +## Consequences + +每条助手消息按 标签 → reasoning → 回答 → 计时 阅读,完成计时紧挨它所度量的回复。无密钥的 TUI 快照套件已刷新,在每个 fixture 中固定新布局;`tui.spec.ts` 中四处原先匹配旧内联串 `Assistant · Model wait …` 的断言,现改为分别断言标签与计时,因为两者不再连续渲染。 diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.i18n.yaml new file mode 100644 index 0000000000..2012fedaaa --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.md +2026-07-27-tui-running-glyph-smooth-fade.md: e4c8fee399c2269bfe53976d3358bc643b2daf6a +2026-07-27-tui-running-glyph-smooth-fade.zh.md: 25bda3d549b1a7548e997f8801858d1efa32e3eb diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.md b/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.md new file mode 100644 index 0000000000..e4c8fee399 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.md @@ -0,0 +1,37 @@ +# Agent Note: Dim-gray pulse for the running prompt glyph + +Status: implemented + +English | [中文](2026-07-27-tui-running-glyph-smooth-fade.zh.md) + +## Problem + +While a turn runs, the TUI replaces the `>` prompt caret with a phase glyph (`◍`/`✻`/`●`/`⚙`). Earlier iterations animated its brightness in the accent blue (a discrete SGR wave, then a truecolor throb) — a colored, always-pulsing indicator. The desired effect keeps the continuous pulse to signal ongoing work, but as a quiet dim gray rather than a color, and with smooth fade-in and fade-out at its edges. + +## Decision + +The running glyph is a dim gray that fades in on turn start, throbs continuously while the turn runs, and fades out after it ends before the plain `>` caret returns. It is never the accent color. + +Brightness is a fade envelope times a running throb. The envelope gates appear/disappear, linear in the render clock over `STATUS_FADE_MS = 300`: `(now − startedAt)/FADE` clamped for fade-in, `1 − (now − endedAt)/FADE` for fade-out. `pulseLevel` is a cosine between `STATUS_PULSE_FLOOR` (0) and 1 over `STATUS_PULSE_PERIOD_MS = 1400`, so each breath swells from fully invisible to full and back. The truecolor opacity handed to `fadeGlyph` is `envelope × pulse`. + +`fadeGlyph` renders at that opacity. With truecolor, below `STATUS_FADE_MIN_OPACITY` (0.12) the glyph is hidden entirely — a blank column — so the pulse trough disappears rather than lingering as a near-background gray; above it the glyph interpolates a 24-bit gray between `STATUS_FADE_GRAY.trough` and `.settled` (the same dim gray as the idle caret), emitting `\x1b[38;2;r;g;bm`, so both the fade and the throb are brightness. Without truecolor there is no per-frame gray, so a separate `visible` flag — driven by the envelope alone, not the pulsing opacity — shows the glyph in the palette's muted role or leaves a blank column; the throb never blinks the fallback. With color off entirely a visible glyph is bare, preserving the caret column on a monochrome terminal. + +The running prompt refreshes at `STATUS_ANIMATION_INTERVAL_MS = 50` (~20 fps) so the throb moves every frame; the same tick keeps the 0.1 s-resolution elapsed text current, so no separate timing timer exists. + +Fade-out outlives the turn: on the running → non-running edge `beginFadeOut` hands the last rendered glyph to a `FadingStatus` whose own timer re-renders until the fade window elapses, then calls `clearStatus` and restores `>`. Teardown paths (dispose, agent-disposed, startup-failure) call `clearStatus` directly, stopping both the running and fading timers at once — no lingering fade. The glyph handed to the fade-out is the last live phase glyph (`runningStatus.lastGlyph`), not the ttft fallback the phase derivation returns once the closing turn's step has ended. + +The glyph character and its cell never change — only the gray brightness — so the caret column stays fixed across frames and across the caret↔glyph transitions. + +## Alternatives considered + +**Keep the accent color.** The pulse is wanted, but as a quiet gray matching the idle caret's tone, not a colored indicator; the accent is removed while the throb stays. + +**Hold steady while running (no throb).** A steady dim glyph was tried and rejected: a continuous pulse better conveys that the agent is actively working. The throb returns, in gray. + +**A non-zero floor that keeps the trough faintly visible.** Successive floors (0.45 → 0.15 → 0.02) each kept the dimmest point too visible to read as truly quiet; even 0.02 sat at gray ≈ 45, one step off the background. A floor of 0 with an explicit visibility threshold (`STATUS_FADE_MIN_OPACITY`) instead hides the glyph entirely at the bottom of each breath, so the trough is genuinely absent. Because the swell is a smooth cosine, the disappearance reads as a soft fade-out, not the hard on/off blink a low-but-nonzero gray toggle would give. + +**Pulse the non-truecolor fallback too.** SGR exposes only three intensity levels, too coarse for a smooth throb, and toggling the glyph on/off across the pulse would blink it. The fallback instead shows a steady muted glyph gated by the envelope; only truecolor terminals get the throb. + +## Consequences + +The running glyph reads as a quiet gray breath that swells from nothing to a dim mark and back the whole turn, matching the idle caret's tone, at the cost of a faster render tick (50 ms) while a turn is active or fading out; the diffing terminal only re-emits changed cells, so the extra frames are cheap. The fade-out means the indicator lingers ~300 ms after a turn completes. Snapshots run non-truecolor with a frozen clock, so they pin only the steady muted glyph (envelope-gated), not the throb; the truecolor invisible trough, the settled peak, a rising mid-frame, the fade-out, and the non-truecolor appear/disappear are pinned by unit tests in `tui.spec.ts`. diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.zh.md b/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.zh.md new file mode 100644 index 0000000000..25bda3d549 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.zh.md @@ -0,0 +1,37 @@ +# Agent Note: Dim-gray pulse for the running prompt glyph + +Status: implemented + +[English](2026-07-27-tui-running-glyph-smooth-fade.md) | 中文 + +## Problem + +回合运行时,TUI 会把 `>` 提示符替换为阶段字形(`◍`/`✻`/`●`/`⚙`)。此前的迭代用强调蓝为其亮度做动画(先是离散 SGR 波,后是 truecolor 呼吸)——一个持续脉动的彩色指示器。期望的效果保留持续脉动以示正在工作,但改为安静的暗灰而非颜色,并在两端做平滑的淡入淡出。 + +## Decision + +运行字形是一种暗灰色,在回合开始时淡入,运行期间持续脉动,回合结束后淡出,随后恢复为普通的 `>` 光标。它从不使用强调色。 + +亮度是淡入淡出包络乘以运行脉冲。包络控制出现/消失,随渲染时钟在 `STATUS_FADE_MS = 300` 内线性变化:淡入为 `(now − startedAt)/FADE` 并做钳制,淡出为 `1 − (now − endedAt)/FADE`。`pulseLevel` 是在 `STATUS_PULSE_FLOOR`(0)与 1 之间、周期为 `STATUS_PULSE_PERIOD_MS = 1400` 的余弦,因此每次呼吸都从完全不可见涨到满亮再回落。交给 `fadeGlyph` 的 truecolor 不透明度为 `envelope × pulse`。 + +`fadeGlyph` 以该不透明度渲染。在 truecolor 下,低于 `STATUS_FADE_MIN_OPACITY`(0.12)时字形被完全隐藏——留出空白列——因此脉冲谷值消失,而非停留为接近背景的灰;在其之上,字形在 `STATUS_FADE_GRAY.trough` 与 `.settled`(与空闲光标相同的暗灰)之间插值出 24 位灰色,发出 `\x1b[38;2;r;g;bm`,因此淡入与脉冲都表现为亮度。没有 truecolor 时不存在逐帧灰度,因此用一个单独的 `visible` 标志——只由包络驱动,而非脉动的不透明度——以调色板 muted 角色显示字形或留出空白列;脉冲从不使回退闪烁。完全关闭颜色时,可见字形以裸字符呈现,在单色终端上保住光标列。 + +运行提示符以 `STATUS_ANIMATION_INTERVAL_MS = 50`(约 20 fps)刷新,使脉动逐帧移动;同一次 tick 也让 0.1 s 精度的耗时文本保持最新,因此不需要单独的计时器。 + +淡出会延续到回合之后:在运行 → 非运行的边沿,`beginFadeOut` 把最后渲染的字形交给一个 `FadingStatus`,其自有计时器持续重绘,直到渐变窗口结束,然后调用 `clearStatus` 并恢复 `>`。拆解路径(dispose、agent-disposed、启动失败)直接调用 `clearStatus`,一次性停止运行与淡出两个计时器——不会有残留的渐变。交给淡出的字形是最后一次的实时阶段字形(`runningStatus.lastGlyph`),而非收尾回合的步骤结束后阶段推导返回的 ttft 兜底字形。 + +字形字符及其单元格从不改变——只有灰色亮度变化——所以光标列在各帧之间以及光标↔字形的切换之间都保持固定。 + +## Alternatives considered + +**保留强调色。** 需要脉冲,但要用与空闲光标一致的安静灰色,而非彩色指示器;移除强调色,保留脉动。 + +**运行时保持稳定(不脉动)。** 曾试过稳定的暗色字形并被否决:持续脉动更能表明代理正在积极工作。脉动以灰色回归。 + +**用非零下限让谷值保持微弱可见。** 逐次下限(0.45 → 0.15 → 0.02)都让最暗点太可见,读不出真正的安静;即便 0.02 也停在灰度约 45,仅比背景高一档。改用下限 0 加显式可见阈值(`STATUS_FADE_MIN_OPACITY`),在每次呼吸的底部完全隐藏字形,使谷值真正缺席。由于涨落是平滑余弦,消失读作柔和的淡出,而非低而非零的灰度开关会带来的硬性开/关闪烁。 + +**让非 truecolor 回退也脉动。** SGR 只暴露三个强度档位,做平滑脉动太粗糙,而按脉冲开关字形会使其闪烁。回退改为由包络控制的稳定 muted 字形;只有 truecolor 终端获得脉动。 + +## Consequences + +代价是运行或淡出期间渲染 tick 更快(50 ms),换来的是运行字形整段回合读作一种从无涨到暗记号再回落的安静灰色呼吸,与空闲光标的色调一致;差分终端只重发变化的单元格,因此额外帧开销很低。淡出意味着指示器在回合结束后残留约 300 ms。快照以非 truecolor、冻结时钟运行,因此只钉住由包络控制的稳定 muted 字形,而非脉动;truecolor 的不可见谷值、稳定峰值、上升中间帧、淡出、以及非 truecolor 的出现/消失均由 `tui.spec.ts` 的单元测试钉住。 diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml new file mode 100644 index 0000000000..820645dc95 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md +2026-07-27-tui-tool-card-header.md: 0868d8dcaf5ab1641a122ecda05578aef46f5f94 +2026-07-27-tui-tool-card-header.zh.md: 71db5fc6fc853039ea9be1cb1c51686f941c4cb7 diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md new file mode 100644 index 0000000000..0868d8dcaf --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md @@ -0,0 +1,35 @@ +# Agent Note: Fixed `Tool / ` header for tool-call cards + +Status: implemented + +English | [中文](2026-07-27-tui-tool-card-header.zh.md) + +## Problem + +The TUI rendered each tool call as `{glyph} {title}`, where `title` was the presenter's fused verb-plus-detail string (`Read src/index.ts (1200-1360)`, `Edit files`, or a bash card's model description), bold and underlined in the status color. One flat slot carried the tool identity, the target, and the status at once, and the styling mixed bold, underline, and color inconsistently — the header read as noise, and which tool ran was not visually separable from what it operated on. + +## Decision + +The header is a fixed `{ring} Tool / ` frame in a single flat status color — no bold, no underline, no dim — so one color reads consistently across the whole row. `Tool` is a literal constant; `` is the raw tool name. The separator is ASCII `/`. The ring marker is `○` while the call is pending and `●` once it settles; the header color (warning pending / success ok / error) distinguishes pending from ok from error, so the same filled ring serves both settled states. + +The header carries exactly one optional extra: a bash (terminal) card's model-authored description, appended as a ` / ` segment (`● Tool / bash / Run the coverage gate`). No other tool contributes a header detail. + +Every tool-specific detail moves into the body block below the header. A non-terminal card's presenter title (`Read src/index.ts`, `Grep pattern`) becomes the first body line, unless it only repeats the tool name (the fallback presenter for a tool with no `presentCall`, or an unknown tool), which the header already shows. A terminal card keeps its command as the `$`-line. A diff card drops its title entirely — the per-file path headers and a change footer carry the meaning — and appends a dim `└ +A -R · N file(s)` footer summarizing added/removed line counts across the files. + +The redesign is TUI-only. It touches `ToolCardComponent` in `packages/ui/tui/src/components/transcript.ts` and no presenter: the `Tool / ` frame derives the name TUI-side from the call's tool name, and the body-title relocation reuses the presenter title already returned. `presentation.ts` and every `presentCall`/`presentResult` are unchanged. + +## Alternatives considered + +**Bold the name to make it stand out.** Rejected: on terminals that render SGR-1 as the bright color variant, a bold green name reads as a different color from the rest of the green header — reintroducing the inconsistency the redesign removes. The name stands out by position in the fixed frame, not by weight. + +**Keep the presenter title in the header** (e.g. `Tool / read / Read src/index.ts`). Rejected: the verb duplicates the tool name, and non-bash tools have no genuinely distinct one-line description — the target belongs in the body, so only bash contributes a header desc. + +**A summary footer for every card type** (line counts, exit pills, diff counts as a uniform `└ …` line). Deferred: only the diff footer shipped. Terminal exit keeps its existing dim `[exit N]` line, long output keeps its existing head+tail middle-elision, an empty result stays header-only, and an error body stays plain (only the header color carries the error) — the current treatments were kept deliberately, not by omission. + +## Consequences + +A tool call now shows its identity in one stable place, and status reads as one flat color per row, so a transcript of many calls scans as a column of `Tool / ` rather than a wall of mixed-styled verb strings. The cost is one extra body line for non-terminal tools (the relocated title) and the loss of the earlier redundancy-suppression that omitted a diff's per-file path when the header already named it — the header no longer names any path, so every diff prints its path once. Because the change is confined to `ToolCardComponent`, other UI bridges (ACP, JSON-RPC) keep their own tool-call presentation; the `Tool / ` shape is TUI-local and not part of any cross-package contract. + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` pins the new header (`Tool / `), the dropped diff title, the relocated generic title, and the `· N file(s)` footer. The keyless terminal snapshots under `packages/ui/tui/tests/snapshots/` and `examples/tui-agent/tests/snapshots/` — rendered through the real assembled TUI and a pseudo-terminal — were re-recorded and show the new cards for read, bash (described and undescribed), edit, and the other tools. diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md new file mode 100644 index 0000000000..71db5fc6fc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md @@ -0,0 +1,35 @@ +# Agent Note: Fixed `Tool / ` header for tool-call cards + +Status: implemented + +[English](2026-07-27-tui-tool-card-header.md) | 中文 + +## Problem + +TUI 曾把每次工具调用渲染为 `{glyph} {title}`,其中 `title` 是 presenter 拼接的「动词加细节」字符串(`Read src/index.ts (1200-1360)`、`Edit files`,或 bash 卡片的模型描述),以状态色加粗并加下划线显示。单一扁平的槽位同时承载了工具身份、操作对象和状态,而样式又混用了加粗、下划线和颜色,前后不一致——表头读起来像噪声,「运行了哪个工具」在视觉上与「它操作了什么」无法区分。 + +## Decision + +表头是固定的 `{ring} Tool / ` 框架,采用单一扁平的状态色——不加粗、不加下划线、不变暗——因此整行的颜色保持一致。`Tool` 是字面常量;`` 是原始工具名。分隔符是 ASCII 的 `/`。环形标记在调用挂起时为 `○`,落定后为 `●`;表头颜色(挂起用 warning、成功用 success、错误用 error)区分挂起、成功与错误,因此同一个实心环可同时服务于两种落定状态。 + +表头只携带一个可选的额外内容:bash(终端)卡片由模型撰写的描述,作为 ` / ` 段追加(`● Tool / bash / Run the coverage gate`)。其他工具都不向表头贡献细节。 + +每一项工具专属的细节都移入表头下方的正文块。非终端卡片的 presenter 标题(`Read src/index.ts`、`Grep pattern`)成为正文第一行,除非它只是重复工具名(无 `presentCall` 的工具的兜底 presenter,或未知工具),此时表头已经显示过。终端卡片保留其命令作为 `$` 行。diff 卡片完全弃用其标题——由各文件的路径表头与一条变更页脚承载含义——并追加一条变暗的 `└ +A -R · N file(s)` 页脚,汇总各文件增删的行数。 + +本次改版仅限 TUI。它改动 `packages/ui/tui/src/components/transcript.ts` 中的 `ToolCardComponent`,不触碰任何 presenter:`Tool / ` 框架在 TUI 侧从调用的工具名推导出名称,正文标题的迁移则复用 presenter 已返回的标题。`presentation.ts` 以及每一个 `presentCall`/`presentResult` 均保持不变。 + +## Alternatives considered + +**把工具名加粗使其突出。** 已否决:在把 SGR-1 渲染为亮色变体的终端上,加粗的绿色工具名读起来与其余绿色表头是不同的颜色——重新引入了改版本要消除的不一致。工具名靠它在固定框架中的位置突出,而非靠字重。 + +**把 presenter 标题保留在表头**(例如 `Tool / read / Read src/index.ts`)。已否决:动词与工具名重复,而非 bash 工具并没有真正独立的单行描述——操作对象属于正文,因此只有 bash 向表头贡献描述段。 + +**为每一种卡片都加一条汇总页脚**(行数、退出码徽章、diff 计数统一为一条 `└ …` 行)。已推迟:仅 diff 页脚落地。终端退出保留其既有的变暗 `[exit N]` 行,长输出保留其既有的首尾中段省略,空结果保持仅表头,错误正文保持朴素(仅表头颜色承载错误)——这些既有处理是有意保留的,而非遗漏。 + +## Consequences + +工具调用现在把身份显示在一个稳定的位置,状态每行读作一种扁平色,于是许多调用的记录扫读起来是一列 `Tool / `,而非一堵混合样式的动词字符串之墙。代价是非终端工具多出一行正文(迁移过来的标题),以及丢失了先前的冗余抑制——当表头已命名路径时省略 diff 的各文件路径;如今表头不再命名任何路径,因此每个 diff 都会把路径打印一次。由于改动局限于 `ToolCardComponent`,其他 UI 桥(ACP、JSON-RPC)保留各自的工具调用呈现;`Tool / ` 的形态是 TUI 局部的,不属于任何跨包契约。 + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` 固定了新表头(`Tool / `)、弃用的 diff 标题、迁移后的 generic 标题以及 `· N file(s)` 页脚。`packages/ui/tui/tests/snapshots/` 与 `examples/tui-agent/tests/snapshots/` 下的无密钥终端快照——经由真实组装的 TUI 与伪终端渲染——已重新录制,展示了 read、bash(有描述与无描述)、edit 及其他工具的新卡片。 diff --git a/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.i18n.yaml new file mode 100644 index 0000000000..6575dbed79 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.md +2026-07-27-copyable-transcript-no-gutter-bar.md: 659b7d2f2f85bf7efe6b1006a2c61a14f3044560 +2026-07-27-copyable-transcript-no-gutter-bar.zh.md: 5c43169ba352ef1fef73ecf2188a32304aba9c26 diff --git a/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.md b/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.md new file mode 100644 index 0000000000..659b7d2f2f --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.md @@ -0,0 +1,34 @@ +# Agent Note: Copyable TUI transcript without gutter bars + +Status: implemented + +English | [中文](2026-07-27-copyable-transcript-no-gutter-bar.zh.md) + +## Problem + +The TUI grouped user prompts and tool cards behind a colored left-gutter bar (`▌ `) prepended to every body line, and indented assistant and system blocks by one column. Both are per-line prefixes: a terminal mouse drag-select over the scrollback captures the leading `▌ ` or the leading space on each line, so copy-paste of a message, a tool's output, or a code block pulls in decoration the user must strip by hand. The bar was the transcript's only per-message separator, so it could not simply be dropped without another way to tell messages apart. + +## Decision + +The scrollback carries no per-line prefix. Messages are separated only by a bold, underlined role header in the role color and blank-line spacing, both of which the terminal already inserts around each block. The underline gives each role a distinct visual band without a background fill, so it reads on any terminal theme and never enters the clipboard: + +- User and steering prompts (`UserMessageComponent`) are a plain `Container`: a bold, underlined accent `You` / `Steering` header line (via the shared `messageHeader` helper), then the prompt body at column 0. +- Assistant blocks render a bold, underlined `Assistant` header, then reasoning and text at column 0, with the timing line at the end of the block (the former `paddingX = 1` indent is gone). +- Tool cards drop the `GutterBox` wrapper. The card status (pending / error / success) colors the whole title line — the status glyph (`◌` / `✕` / `✓`) plus the title text share one color, bold and underlined to match the role headers — instead of a colored bar beside an uncolored title. The body renders unprefixed; body lines still pass through `Text` at the terminal width so overlong raw tool output wraps rather than overflowing. +- The `GutterBox` class is deleted; nothing else used it. + +A drag-select over any of these regions now copies exactly the message text. + +## Alternatives considered + +- **Keep the bar only on user messages, drop it on tool cards** — leaves tool output, the most-copied region, still polluted. Rejected: the goal is a wholly copyable transcript. +- **A single top rule or bar on the header line only** — the body copies clean, but selecting the header still captures a glyph, and it reintroduces a decoration character for no distinguishing gain over the underlined role header. +- **Indent grouped bodies instead of a bar** — leading spaces still enter the clipboard, so it does not solve the copy problem; explicitly ruled out. +- **A filled background band on the header** (reverse video, or a 256-color muted background) — gives each role a strong color block, but the saturated ANSI fill reads as too heavy and the 256-color shades are fixed rather than theme-remapped. The underline gives per-role distinction with a far lighter footprint. + +## Consequences + +- Copy-paste from the scrollback is clean with no user post-processing. This was the motivating win. +- The transcript is flatter than the gutter-bar layout, but each role's bold, underlined header in the role color plus blank-line spacing keeps message boundaries clear without any left-edge fill. Tool-card status stays legible through the colored, underlined glyph and title. +- Box-drawing borders (`│`) on transient overlays — status panel, model selector, resume list — are untouched. They are not scrollback message content and are rarely copied. +- The affected keyless TUI `*.expected.txt` snapshots were re-recorded by fixture replay (no API key needed; the recorded LLM sessions are unchanged, only the render differs). Interactive boot and a round-trip prompt were verified in tmux. diff --git a/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.zh.md b/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.zh.md new file mode 100644 index 0000000000..5c43169ba3 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.zh.md @@ -0,0 +1,34 @@ +# Agent Note: 无 gutter bar 的可复制 TUI transcript + +Status: implemented + +[English](2026-07-27-copyable-transcript-no-gutter-bar.md) | 中文 + +## Problem + +TUI 此前把用户提示词和工具卡片分组在一条彩色左侧 gutter bar(`▌ `)之后,该竖条被逐行加在每一行正文前面,并把 assistant 与系统块整体缩进一列。两者都是逐行前缀:在 transcript 上用鼠标框选时,每一行开头的 `▌ ` 或前导空格都会被一并选中,因此复制一条消息、一段工具输出或一个代码块时都会带上装饰字符,用户必须手动清理。该竖条又是 transcript 中唯一的逐条消息分隔标记,所以不能在没有其他区分方式的情况下直接删掉。 + +## Decision + +transcript 不再带任何逐行前缀。消息仅通过以角色色渲染的粗体带下划线角色标题和空行分隔,而这两者本就由终端在每个块前后自动插入。下划线让每个角色获得清晰的视觉分带,且无需背景填充,因此在任何终端配色下都可读,也绝不会进入剪贴板: + +- 用户提示词与 steering 提示词(`UserMessageComponent`)改为普通 `Container`:一行粗体带下划线的强调色 `You` / `Steering` 标题(经共享的 `messageHeader` 辅助函数生成),随后是位于第 0 列的提示词正文。 +- Assistant 块渲染一行粗体带下划线的 `Assistant` 标题,随后 reasoning 与文本均在第 0 列渲染,timing 行位于块末尾(原先的 `paddingX = 1` 缩进已移除)。 +- 工具卡片去掉 `GutterBox` 包装层。卡片状态(进行中 / 错误 / 成功)对整行标题着色——状态字形(`◌` / `✕` / `✓`)与标题文本共用一种颜色,并同角色标题一样加粗且带下划线——而不再是未着色标题旁的一条彩色竖条。正文无前缀渲染;正文行仍按终端宽度经 `Text` 处理,使过长的原始工具输出换行而非溢出。 +- `GutterBox` 类被删除;没有其他地方使用它。 + +现在对上述任一区域框选,复制得到的正是消息文本本身。 + +## Alternatives considered + +- **仅在用户消息上保留竖条、在工具卡片上去掉** —— 会让最常被复制的工具输出仍然带有污染。已否决:目标是让整个 transcript 都可复制。 +- **仅在标题行上加一条顶部横线或竖条** —— 正文复制干净,但选中标题时仍会带上一个字形,且相比带下划线的角色标题并未带来额外的区分收益,却重新引入了装饰字符。 +- **用缩进代替竖条对分组正文缩进** —— 前导空格仍会进入剪贴板,无法解决复制问题;已明确排除。 +- **在标题上使用填充背景带**(反色,或 256 色柔和背景)—— 能给每个角色一块强烈的色块,但饱和的 ANSI 填充观感过重,且 256 色是固定色而非随主题重映射。下划线以远更轻的方式提供了同样的逐角色区分。 + +## Consequences + +- 从 transcript 复制粘贴无需用户做任何后处理。这正是本次改动的核心收益。 +- transcript 比 gutter bar 布局更扁平,但每个角色以角色色渲染的粗体带下划线标题加空行分隔,无需任何左缘填充即可让消息边界保持清晰。工具卡片状态仍通过彩色带下划线的字形与标题保持可读。 +- 临时浮层(状态面板、模型选择器、恢复列表)上的制表符边框(`│`)保持不变。它们不属于 transcript 消息内容,且很少被复制。 +- 受影响的 keyless TUI `*.expected.txt` 快照均通过 fixture 回放重新记录(无需 API 密钥;所记录的 LLM 会话未变,仅渲染不同)。交互式启动与一次往返提示已在 tmux 中验证。 diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 4283668189..a87812ce00 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -11,6 +11,7 @@ * @module @deepseek-ai/dsh/tui */ +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { addHarnessSourceSection, @@ -62,6 +63,7 @@ export async function runTui(config: string | undefined, resumeSessionId: string // The bin already loaded the invoking directory's .env; the personal .env // only fills what is still unset (process.loadEnvFile never overrides). loadEnv(NAME, resolveDshHome()) + process.env.DSH_BUNDLED_SKILL_DIR = join(SOURCE_ROOT, 'skills') // The in-place `/resume` handoff re-execs `dsh` with a normalized `--resume` // flag, so the resumed process rehydrates through this same intake. The host // is offered only when Node exposes `process.execve` and knows its own entry. diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c45cb73de3..64643d583b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1795,7 +1795,7 @@ export interface TuiThemeConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:302`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/config.ts:117`](../packages/ui/tui/src/config.ts) ## `@deepseek-ai/dsh-tui-demo` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 6785e5f983..f812612521 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1902,7 +1902,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession ``` -Source: [`packages/ui/tui/src/index.ts:153`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:207`](../../packages/ui/tui/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt b/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt index 9ffee44378..0d8f454204 100644 --- a/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt @@ -1,63 +1,54 @@ terminal 100x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "Use the bash tool to — DSH TUI snapshot" -cursor hidden column=1 viewportRow=25 bufferRow=25 +cursor hidden column=7 viewportRow=24 bufferRow=24 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Use the bash tool to" style 1-20 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop." - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue -8| -9| " Reasoning " - style 1-9 fg=bright-black italic -10| " The user wants me to run a simple bash command and then reply with \"DONE\". " - style 1-74 fg=bright-black italic -11| -12| "▌ " - style 0-0 fg=green -13| "▌ ✓ echo TERMINAL_OK " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-19 bold -14| "▌ Echo TERMINAL_OK to verify terminal access " - style 0-0 fg=green - style 2-43 fg=bright-black -15| "▌ TERMINAL_OK " - style 0-0 fg=green -16| "▌ [exit 0] " - style 0-0 fg=green - style 2-9 dim -17| "▌ " - style 0-0 fg=green -18| -19| " Reasoning " - style 1-9 fg=bright-black italic -20| " The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\". " - style 1-91 fg=bright-black italic -21| -22| " Assistant " - style 1-9 fg=bright-magenta bold -23| " DONE " -24| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -25| " " - style 1-1 inverse -26| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -27| "deepseek-v4-flash /workspace/project ↑3.0k ↓115 cache 48% 3% contex" - style 0-88 dim - style 91-99 dim -28-35| +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop. " +6| +7| "Assistant " + style 0-8 fg=bright-magenta bold underline +8| "Reasoning " + style 0-8 fg=bright-black italic +9| "The user wants me to run a simple bash command and then reply with \"DONE\". " + style 0-73 fg=bright-black italic +10| +11| "● Tool / bash / Echo TERMINAL_OK to verify terminal access" + style 0-57 fg=green +12| "$ echo TERMINAL_OK " + style 0-17 fg=cyan +13| "TERMINAL_OK " +14| "[exit 0] " + style 0-7 dim +15| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +16| +17| "Assistant " + style 0-8 fg=bright-magenta bold underline +18| "Reasoning " + style 0-8 fg=bright-black italic +19| "The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\". " + style 0-90 fg=bright-black italic +20| "DONE " +21| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +22| +23| "/workspace/project deepseek-v4-flash ↑3.0k ↓115 cache 48% 3% contex" + style 0-46 fg=bright-blue bold + style 49-65 fg=bright-black + style 68-88 fg=bright-black + style 91-99 fg=bright-black +24| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +25-35| diff --git a/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt b/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt index aad4b2cd50..248ea2f4e7 100644 --- a/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt @@ -1,65 +1,57 @@ terminal 100x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "Using ONE run_code program: call — DSH TUI snapshot" -cursor hidden column=1 viewportRow=26 bufferRow=26 +cursor hidden column=7 viewportRow=26 bufferRow=26 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Using ONE run_code program: call" style 1-32 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Using ONE run_code program: call the bash tool exactly once with the command seq 1 200 | awk " - style 0-0 fg=bright-blue - style 79-99 fg=cyan -7| "▌ '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}', then return ONLY the " - style 0-0 fg=bright-blue - style 2-74 fg=cyan -8| "▌ number of lines in its output. Reply with just that number and stop. " - style 0-0 fg=bright-blue -9| "▌ " - style 0-0 fg=bright-blue -10| -11| " Reasoning " - style 1-9 fg=bright-black italic -12| " The user wants me to write a single run_code program that calls bash exactly once with a specific " - style 1-99 fg=bright-black italic -13| " command, then returns only the number of lines in its output. " - style 1-61 fg=bright-black italic -14| -15| "▌ " - style 0-0 fg=green -16| "▌ ✓ Count lines in seq/awk output " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-32 bold -17| "▌ 200 " - style 0-0 fg=green -18| "▌ " - style 0-0 fg=green -19| -20| " Reasoning " - style 1-9 fg=bright-black italic -21| " The result is 200 lines. The user wants me to reply with just that number and stop. " - style 1-83 fg=bright-black italic -22| -23| " Assistant " - style 1-9 fg=bright-magenta bold -24| " 200 " -25| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -26| " " - style 1-1 inverse -27| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -28| "deepseek-v4-flash /workspace/project ↑123 ↓208 cache 99% 3% c" - style 0-93 dim - style 96-99 dim -29-35| +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Using ONE run_code program: call the bash tool exactly once with the command seq 1 200 | awk " + style 77-99 fg=cyan +6| "'{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}', then return ONLY the " + style 0-72 fg=cyan +7| "number of lines in its output. Reply with just that number and stop. " +8| +9| "Assistant " + style 0-8 fg=bright-magenta bold underline +10| "Reasoning " + style 0-8 fg=bright-black italic +11| "The user wants me to write a single run_code program that calls bash exactly once with a specific " + style 0-99 fg=bright-black italic +12| "command, then returns only the number of lines in its output. " + style 0-60 fg=bright-black italic +13| +14| "● Tool / run_code" + style 0-16 fg=green +15| "Count lines in seq/awk output " +16| "200 " +17| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +18| +19| "Assistant " + style 0-8 fg=bright-magenta bold underline +20| "Reasoning " + style 0-8 fg=bright-black italic +21| "The result is 200 lines. The user wants me to reply with just that number and stop. " + style 0-82 fg=bright-black italic +22| "200 " +23| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +24| +25| "/workspace/project deepseek-v4-flash ↑123 ↓208 cache 99% 3% c" + style 0-52 fg=bright-blue bold + style 55-71 fg=bright-black + style 74-93 fg=bright-black + style 96-99 fg=bright-black +26| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +27-35| diff --git a/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt b/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt index 8dd0c37da1..45879f889f 100644 --- a/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt @@ -1,136 +1,140 @@ -terminal 100x36 buffer=normal length=64 base=28 viewport=28 +terminal 100x36 buffer=normal length=62 base=26 viewport=26 lifecycle started=1 stopped=0 progress=inactive title "Using ONE run_code program: call — DSH TUI snapshot" -cursor hidden column=1 viewportRow=33 bufferRow=61 +cursor hidden column=7 viewportRow=35 bufferRow=61 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Using ONE run_code program: call" style 1-32 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Using ONE run_code program: call the bash tool twice — exactly echo CODE_ONE then exactly echo " - style 0-0 fg=bright-blue - style 65-77 fg=cyan - style 92-99 fg=cyan -7| "▌ CODE_TWO. Inside that same program, console.log exactly captured output, then return the two " - style 0-0 fg=bright-blue - style 2-9 fg=cyan - style 58-72 fg=cyan -8| "▌ outputs joined with a plus sign. Reply with that joined string only and stop. " - style 0-0 fg=bright-blue -9| "▌ " - style 0-0 fg=bright-blue -10| -11| " Reasoning " - style 1-9 fg=bright-black italic -12| " The user wants me to write a single run_code program that: " - style 1-36 fg=bright-black italic - style 37-44 fg=cyan - style 45-58 fg=bright-black italic -13| " 1. Calls bash tool twice - first with echo CODE_ONE, then with echo CODE_TWO " - style 1-3 fg=bright-blue - style 4-9 fg=bright-black italic - style 10-13 fg=cyan - style 14-38 fg=bright-black italic - style 39-51 fg=cyan - style 52-63 fg=bright-black italic - style 64-76 fg=cyan -14| " 2. console.log exactly captured output " - style 1-3 fg=bright-blue - style 4-14 fg=cyan - style 15-23 fg=bright-black italic - style 24-38 fg=cyan -15| " 3. Returns the two outputs joined with a plus sign " - style 1-3 fg=bright-blue - style 4-50 fg=bright-black italic -16| " " -17| " Let me think about the structure. The bash tool returns an object with stdout/stderr. I need to " - style 1-38 fg=bright-black italic - style 39-42 fg=cyan - style 43-99 fg=bright-black italic -18| " extract the stdout text from each call. " - style 1-39 fg=bright-black italic -19| " " -20| " Looking at the bash output type: " - style 1-32 fg=bright-black italic +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Using ONE run_code program: call the bash tool twice — exactly echo CODE_ONE then exactly echo " + style 63-75 fg=cyan + style 90-99 fg=cyan +6| "CODE_TWO. Inside that same program, console.log exactly captured output, then return the two outputs" + style 0-7 fg=cyan + style 56-70 fg=cyan +7| "joined with a plus sign. Reply with that joined string only and stop. " +8| +9| "Assistant " + style 0-8 fg=bright-magenta bold underline +10| "Reasoning " + style 0-8 fg=bright-black italic +11| "The user wants me to write a single run_code program that: " + style 0-35 fg=bright-black italic + style 36-43 fg=cyan + style 44-57 fg=bright-black italic +12| "1. Calls bash tool twice - first with echo CODE_ONE, then with echo CODE_TWO " + style 0-2 fg=bright-blue + style 3-8 fg=bright-black italic + style 9-12 fg=cyan + style 13-37 fg=bright-black italic + style 38-50 fg=cyan + style 51-62 fg=bright-black italic + style 63-75 fg=cyan +13| "2. console.log exactly captured output " + style 0-2 fg=bright-blue + style 3-13 fg=cyan + style 14-22 fg=bright-black italic + style 23-37 fg=cyan +14| "3. Returns the two outputs joined with a plus sign " + style 0-2 fg=bright-blue + style 3-49 fg=bright-black italic +15| " " +16| "Let me think about the structure. The bash tool returns an object with stdout/stderr. I need to " + style 0-37 fg=bright-black italic + style 38-41 fg=cyan + style 42-99 fg=bright-black italic +17| "extract the stdout text from each call. " + style 0-38 fg=bright-black italic +18| " " +19| "Looking at the bash output type: " + style 0-31 fg=bright-black italic +20| " " 21| " " -22| " ``` " - style 1-3 dim -23| " { " -24| " kind: \"foreground\"; " -25| " exitCode: number | null; " -26| " signal: string | null; " -27| " timedOut: boolean; " -28| " aborted: boolean; " -29| " timeoutMs: number; " -30| " stdout: { " -31| " text: string; " -32| " truncated: boolean; " -33| " spillPath?: string; " -34| " }; " -35| " stderr: { ... }; " -36| " sandbox?: { ... }; " -37| " } " -38| " ``` " - style 1-3 dim -39| " " -40| " So I need to access .stdout.text from each result. " - style 1-20 fg=bright-black italic - style 21-32 fg=cyan - style 33-50 fg=bright-black italic -41| " " -42| " Let me write the program. " - style 1-25 fg=bright-black italic -43| -44| "▌ " - style 0-0 fg=green -45| "▌ ✓ Call bash twice, log, join outputs " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-37 bold -46| "▌ captured output " - style 0-0 fg=green -47| "▌ CODE_ONE+CODE_TWO " - style 0-0 fg=green -48| "▌ " - style 0-0 fg=green -49| -50| " Reasoning " - style 1-9 fg=bright-black italic -51| " The program ran successfully. The output shows: " - style 1-47 fg=bright-black italic -52| " - captured output (from console.log) " - style 1-2 fg=bright-blue - style 3-17 fg=cyan - style 18-36 fg=bright-black italic -53| " - CODE_ONE+CODE_TWO (the returned joined string) " - style 1-2 fg=bright-blue - style 3-19 fg=cyan - style 20-48 fg=bright-black italic +22| " { " + style 2-2 fg=cyan +23| " kind: \"foreground\"; " + style 2-22 fg=cyan +24| " exitCode: number | null; " + style 2-27 fg=cyan +25| " signal: string | null; " + style 2-25 fg=cyan +26| " timedOut: boolean; " + style 2-21 fg=cyan +27| " aborted: boolean; " + style 2-20 fg=cyan +28| " timeoutMs: number; " + style 2-21 fg=cyan +29| " stdout: { " + style 2-12 fg=cyan +30| " text: string; " + style 2-18 fg=cyan +31| " truncated: boolean; " + style 2-24 fg=cyan +32| " spillPath?: string; " + style 2-24 fg=cyan +33| " }; " + style 2-5 fg=cyan +34| " stderr: { ... }; " + style 2-19 fg=cyan +35| " sandbox?: { ... }; " + style 2-21 fg=cyan +36| " } " + style 2-2 fg=cyan +37| " " +38| " " +39| "So I need to access .stdout.text from each result. " + style 0-19 fg=bright-black italic + style 20-31 fg=cyan + style 32-49 fg=bright-black italic +40| " " +41| "Let me write the program. " + style 0-24 fg=bright-black italic +42| +43| "● Tool / run_code" + style 0-16 fg=green +44| "Call bash twice, log, join outputs " +45| "captured output " +46| "CODE_ONE+CODE_TWO " +47| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +48| +49| "Assistant " + style 0-8 fg=bright-magenta bold underline +50| "Reasoning " + style 0-8 fg=bright-black italic +51| "The program ran successfully. The output shows: " + style 0-46 fg=bright-black italic +52| "- captured output (from console.log) " + style 0-1 fg=bright-blue + style 2-16 fg=cyan + style 17-35 fg=bright-black italic +53| "- CODE_ONE+CODE_TWO (the returned joined string) " + style 0-1 fg=bright-blue + style 2-18 fg=cyan + style 19-47 fg=bright-black italic 54| " " -55| " The user asked me to reply with that joined string only and stop. So I'll reply with just " - style 1-99 fg=bright-black italic -56| " CODE_ONE+CODE_TWO. " - style 1-17 fg=cyan - style 18-18 fg=bright-black italic -57| -58| " Assistant " - style 1-9 fg=bright-magenta bold -59| " CODE_ONE+CODE_TWO " -60| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -61| " " - style 1-1 inverse -62| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -63| "deepseek-v4-flash /workspace/project ↑182 ↓446 cache 98% 4% context tools:c" - style 0-78 dim - style 81-99 dim +55| "The user asked me to reply with that joined string only and stop. So I'll reply with just " + style 0-99 fg=bright-black italic +56| "CODE_ONE+CODE_TWO. " + style 0-16 fg=cyan + style 17-17 fg=bright-black italic +57| "CODE_ONE+CODE_TWO " +58| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +59| +60| "/workspace/project deepseek-v4-flash ↑182 ↓446 cache 98% 4% context" + style 0-37 fg=bright-blue bold + style 40-56 fg=bright-black + style 59-78 fg=bright-black + style 81-90 fg=bright-black +61| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt index a3739deefa..9f420d2bf5 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt @@ -1,106 +1,93 @@ -terminal 100x36 buffer=normal length=48 base=12 viewport=12 +terminal 100x36 buffer=normal length=57 base=21 viewport=21 lifecycle started=1 stopped=0 progress=inactive title "Run this advanced flow exactly — DSH TUI snapshot" -cursor hidden column=1 viewportRow=33 bufferRow=45 +cursor hidden column=7 viewportRow=35 bufferRow=56 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Run this advanced flow exactly" style 1-30 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use " - style 0-0 fg=bright-blue -7| "▌ run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a " - style 0-0 fg=bright-blue -8| "▌ direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then " - style 0-0 fg=bright-blue -9| "▌ reply with exactly ADVANCED_ACP_OK. " - style 0-0 fg=bright-blue -10| "▌ " - style 0-0 fg=bright-blue +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code" +6| "to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn " +7| "child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with " +8| "exactly ADVANCED_ACP_OK. " +9| +10| "Assistant " + style 0-8 fg=bright-magenta bold underline 11| -12| "▌ " - style 0-0 fg=green -13| "▌ ✓ Mount plugin into live cordis runtime " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-40 bold -14| "▌ mounted dyn-1 (plugin \"snapshot-marker\", state: active) " - style 0-0 fg=green -15| "▌ " - style 0-0 fg=green +12| "● Tool / cordis_mount" + style 0-20 fg=green +13| "Mount plugin into live cordis runtime " +14| "mounted dyn-1 (plugin \"snapshot-marker\", state: active) " +15| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim 16| -17| "▌ " - style 0-0 fg=green -18| "▌ ✓ Verify the dynamically mounted marker service " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-48 bold -19| "▌ ## dynamic " - style 0-0 fg=green -20| "▌ - dyn-1: snapshot-marker [active] " - style 0-0 fg=green -21| "▌ " - style 0-0 fg=green -22| -23| "▌ " - style 0-0 fg=green -24| "▌ ✓ subagent " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-11 bold -25| "▌ DIRECT_CHILD_OK " - style 0-0 fg=green -26| "▌ " - style 0-0 fg=green -27| -28| "▌ " - style 0-0 fg=green -29| "▌ ✓ workflow: advanced-acp-snapshot " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-34 bold -30| "▌ workflow \"advanced-acp-snapshot\" completed (1 agent). " - style 0-0 fg=green -31| "▌ Return value: " - style 0-0 fg=green -32| "▌ { " - style 0-0 fg=green -33| "▌ \"reply\": \"WORKFLOW_CHILD_OK\" " - style 0-0 fg=green -34| "▌ } " - style 0-0 fg=green -35| "▌ " - style 0-0 fg=green -36| -37| "▌ " - style 0-0 fg=green -38| "▌ ✓ Unmount dyn-1 " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-16 bold -39| "▌ unmounted dyn-1 (plugin \"snapshot-marker\") " - style 0-0 fg=green -40| "▌ " - style 0-0 fg=green -41| -42| " Assistant " - style 1-9 fg=bright-magenta bold -43| " ADVANCED_ACP_OK " -44| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -45| " " - style 1-1 inverse -46| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -47| "deepseek-v4-flash /workspace/project ↑18 ↓18 cache 0% 8% cont" - style 0-90 dim - style 93-99 dim +17| "Assistant " + style 0-8 fg=bright-magenta bold underline +18| +19| "● Tool / run_code" + style 0-16 fg=green +20| "Verify the dynamically mounted marker service " +21| " " +22| "dynamic " + style 0-6 fg=bright-blue bold +23| " " +24| "- dyn-1: snapshot-marker [active] " + style 0-1 fg=bright-blue +25| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +26| +27| "Assistant " + style 0-8 fg=bright-magenta bold underline +28| +29| "● Tool / subagent" + style 0-16 fg=green +30| "DIRECT_CHILD_OK " +31| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +32| +33| "Assistant " + style 0-8 fg=bright-magenta bold underline +34| +35| "● Tool / workflow" + style 0-16 fg=green +36| "workflow: advanced-acp-snapshot " +37| "workflow \"advanced-acp-snapshot\" completed (1 agent). " +38| "Return value: " +39| "{ " +40| " \"reply\": \"WORKFLOW_CHILD_OK\" " +41| "} " +42| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +43| +44| "Assistant " + style 0-8 fg=bright-magenta bold underline +45| +46| "● Tool / cordis_unmount" + style 0-22 fg=green +47| "Unmount dyn-1 " +48| "unmounted dyn-1 (plugin \"snapshot-marker\") " +49| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +50| +51| "Assistant " + style 0-8 fg=bright-magenta bold underline +52| "ADVANCED_ACP_OK " +53| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +54| +55| "/workspace/project deepseek-v4-flash ↑18 ↓18 cache 0% 8% cont" + style 0-52 fg=bright-blue bold + style 55-71 fg=bright-black + style 74-90 fg=bright-black + style 93-99 fg=bright-black +56| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt b/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt index 48adbbb11a..c704cb2399 100644 --- a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt @@ -1,96 +1,80 @@ -terminal 100x36 buffer=normal length=45 base=9 viewport=9 +terminal 100x36 buffer=normal length=43 base=7 viewport=7 lifecycle started=1 stopped=0 progress=inactive title "Use the workflow tool exactly — DSH TUI snapshot" -cursor hidden column=1 viewportRow=33 bufferRow=42 +cursor hidden column=7 viewportRow=35 bufferRow=42 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Use the workflow tool exactly" style 1-29 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", " - style 0-0 fg=bright-blue -7| "▌ \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): " - style 0-0 fg=bright-blue -8| "▌ phase('Run') " - style 0-0 fg=bright-blue -9| "▌ const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') " - style 0-0 fg=bright-blue -10| "▌ return { reply } " - style 0-0 fg=bright-blue -11| "▌ After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any " - style 0-0 fg=bright-blue -12| "▌ other tool. " - style 0-0 fg=bright-blue -13| "▌ " - style 0-0 fg=bright-blue -14| -15| " Reasoning " - style 1-9 fg=bright-black italic -16| " The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully " - style 1-99 fg=bright-black italic -17| " follow the instructions: " - style 1-24 fg=bright-black italic -18| " " -19| " 1. args omitted (so I don't include it) " - style 1-3 fg=bright-blue - style 4-39 fg=bright-black italic -20| " 2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" } " - style 1-3 fg=bright-blue - style 4-82 fg=bright-black italic -21| " 3. script = as given verbatim " - style 1-3 fg=bright-blue - style 4-29 fg=bright-black italic -22| " 4. After it returns, reply with \"WORKFLOW_DONE\" " - style 1-3 fg=bright-blue - style 4-47 fg=bright-black italic -23| " " -24| " Let me do exactly that. " - style 1-23 fg=bright-black italic -25| -26| "▌ " - style 0-0 fg=green -27| "▌ ✓ workflow: snapshot-flow " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-26 bold -28| "▌ workflow \"snapshot-flow\" completed (1 agent). " - style 0-0 fg=green -29| "▌ Return value: " - style 0-0 fg=green -30| "▌ { " - style 0-0 fg=green -31| "▌ \"reply\": \"WF_CHILD_OK\" " - style 0-0 fg=green -32| "▌ } " - style 0-0 fg=green -33| "▌ " - style 0-0 fg=green -34| -35| " Reasoning " - style 1-9 fg=bright-black italic -36| " The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly " - style 1-99 fg=bright-black italic -37| " \"WORKFLOW_DONE\" and stop. " - style 1-25 fg=bright-black italic -38| -39| " Assistant " - style 1-9 fg=bright-magenta bold -40| " WORKFLOW_DONE " -41| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -42| " " - style 1-1 inverse -43| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -44| "deepseek-v4-flash /workspace/project ↑3.5k ↓227 cache 47% 3% context " - style 0-86 dim - style 89-99 dim +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", " +6| "\"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): " +7| "phase('Run') " +8| "const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') " +9| "return { reply } " +10| "After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other " +11| "tool. " +12| +13| "Assistant " + style 0-8 fg=bright-magenta bold underline +14| "Reasoning " + style 0-8 fg=bright-black italic +15| "The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully " + style 0-99 fg=bright-black italic +16| "follow the instructions: " + style 0-23 fg=bright-black italic +17| " " +18| "1. args omitted (so I don't include it) " + style 0-2 fg=bright-blue + style 3-38 fg=bright-black italic +19| "2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" } " + style 0-2 fg=bright-blue + style 3-81 fg=bright-black italic +20| "3. script = as given verbatim " + style 0-2 fg=bright-blue + style 3-28 fg=bright-black italic +21| "4. After it returns, reply with \"WORKFLOW_DONE\" " + style 0-2 fg=bright-blue + style 3-46 fg=bright-black italic +22| " " +23| "Let me do exactly that. " + style 0-22 fg=bright-black italic +24| +25| "● Tool / workflow" + style 0-16 fg=green +26| "workflow: snapshot-flow " +27| "workflow \"snapshot-flow\" completed (1 agent). " +28| "Return value: " +29| "{ " +30| " \"reply\": \"WF_CHILD_OK\" " +31| "} " +32| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +33| +34| "Assistant " + style 0-8 fg=bright-magenta bold underline +35| "Reasoning " + style 0-8 fg=bright-black italic +36| "The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly " + style 0-99 fg=bright-black italic +37| "\"WORKFLOW_DONE\" and stop. " + style 0-24 fg=bright-black italic +38| "WORKFLOW_DONE " +39| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +40| +41| "/workspace/project deepseek-v4-flash ↑3.5k ↓227 cache 47% 3% context" + style 0-44 fg=bright-blue bold + style 47-63 fg=bright-black + style 66-86 fg=bright-black + style 89-98 fg=bright-black +42| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse diff --git a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt index 099bf63b81..3aa6476f01 100644 --- a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt @@ -1,70 +1,62 @@ terminal 100x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "Reply with exactly the word: — DSH TUI snapshot" -cursor hidden column=1 viewportRow=33 bufferRow=33 +cursor hidden column=7 viewportRow=30 bufferRow=30 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Reply with exactly the word:" style 1-28 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| " Entering plan mode (applies from the next step). Use /plan off to leave. " - style 1-72 fg=bright-black +4| "Entering plan mode (applies from the next step). Use /plan off to leave. " + style 0-71 fg=bright-black 5| -6| "▌ " - style 0-0 fg=bright-blue -7| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -8| "▌ Reply with exactly the word: ONE. No tools. " - style 0-0 fg=bright-blue -9| "▌ " - style 0-0 fg=bright-blue -10| -11| " Reasoning " - style 1-9 fg=bright-black italic -12| " The user wants me to reply with exactly the word \"ONE\" and use no tools. " - style 1-72 fg=bright-black italic -13| -14| " Assistant " - style 1-9 fg=bright-magenta bold -15| " ONE " +6| "You " + style 0-2 fg=bright-blue bold underline +7| "Reply with exactly the word: ONE. No tools. " +8| +9| "Assistant " + style 0-8 fg=bright-magenta bold underline +10| "Reasoning " + style 0-8 fg=bright-black italic +11| "The user wants me to reply with exactly the word \"ONE\" and use no tools. " + style 0-71 fg=bright-black italic +12| "ONE " +13| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +14| +15| "Leaving plan mode (applies from the next step). " + style 0-46 fg=bright-black 16| -17| " Leaving plan mode (applies from the next step). " - style 1-47 fg=bright-black -18| -19| " Context · plan-mode " - style 1-19 dim -20| " The user switched this session back to the default mode. " - style 1-56 fg=bright-black -21| -22| "▌ " - style 0-0 fg=bright-blue -23| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -24| "▌ Reply with exactly the word: TWO. No tools. " - style 0-0 fg=bright-blue -25| "▌ " - style 0-0 fg=bright-blue -26| -27| " Reasoning " - style 1-9 fg=bright-black italic -28| " The user wants me to reply with exactly the word \"TWO\" and no tools. " - style 1-68 fg=bright-black italic -29| -30| " Assistant " - style 1-9 fg=bright-magenta bold -31| " TWO " -32| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -33| " " - style 1-1 inverse -34| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -35| "deepseek-v4-flash /workspace/project ↑2.9k ↓41 cache 49% 3% co" - style 0-92 dim - style 95-99 dim +17| "Context · plan-mode " + style 0-18 dim +18| "The user switched this session back to the default mode. " + style 0-55 fg=bright-black +19| +20| "You " + style 0-2 fg=bright-blue bold underline +21| "Reply with exactly the word: TWO. No tools. " +22| +23| "Assistant " + style 0-8 fg=bright-magenta bold underline +24| "Reasoning " + style 0-8 fg=bright-black italic +25| "The user wants me to reply with exactly the word \"TWO\" and no tools. " + style 0-67 fg=bright-black italic +26| "TWO " +27| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +28| +29| "/workspace/project deepseek-v4-flash ↑2.9k ↓41 cache 49% 3% co" + style 0-51 fg=bright-blue bold + style 54-70 fg=bright-black + style 73-92 fg=bright-black + style 95-99 fg=bright-black +30| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +31-35| diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt b/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt index 70fa5b173a..3d99c45758 100644 --- a/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt @@ -1,81 +1,52 @@ -terminal 100x36 buffer=normal length=37 base=1 viewport=1 +terminal 100x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "Use the read tool twice — DSH TUI snapshot" -cursor hidden column=1 viewportRow=33 bufferRow=34 +cursor hidden column=7 viewportRow=27 bufferRow=27 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Use the read tool twice" style 1-23 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE. " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE. " +6| +7| "Assistant " + style 0-8 fg=bright-magenta bold underline 8| -9| "▌ " - style 0-0 fg=green -10| "▌ ✓ Read a.txt " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-13 bold -11| "▌ /workspace/project/a.txt " - style 0-0 fg=green -12| "▌ file " - style 0-0 fg=green -13| "▌ " - style 0-0 fg=green -14| "▌ 1: alpha " - style 0-0 fg=green -15| "▌ " - style 0-0 fg=green -16| "▌ (End of file - total 1 lines) " - style 0-0 fg=green -17| "▌ " - style 0-0 fg=green -18| "▌ " - style 0-0 fg=green -19| -20| "▌ " - style 0-0 fg=green -21| "▌ ✓ Read b.txt " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-13 bold -22| "▌ /workspace/project/b.txt " - style 0-0 fg=green -23| "▌ file " - style 0-0 fg=green -24| "▌ " - style 0-0 fg=green -25| "▌ 1: beta " - style 0-0 fg=green -26| "▌ " - style 0-0 fg=green -27| "▌ (End of file - total 1 lines) " - style 0-0 fg=green -28| "▌ " - style 0-0 fg=green -29| "▌ " - style 0-0 fg=green -30| -31| " Assistant " - style 1-9 fg=bright-magenta bold -32| " DONE " -33| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -34| " " - style 1-1 inverse -35| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -36| "deepseek-v4-flash /workspace/project ↑20 ↓6 cache 0% 3% context t" - style 0-84 dim - style 87-99 dim +9| "● Tool / read" + style 0-12 fg=green +10| "Read a.txt " +11| "1: alpha " +12| " " +13| "(End of file - total 1 lines) " +14| +15| "● Tool / read" + style 0-12 fg=green +16| "Read b.txt " +17| "1: beta " +18| " " +19| "(End of file - total 1 lines) " +20| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +21| +22| "Assistant " + style 0-8 fg=bright-magenta bold underline +23| "DONE " +24| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +25| +26| "/workspace/project deepseek-v4-flash ↑20 ↓6 cache 0% 3% context" + style 0-47 fg=bright-blue bold + style 50-66 fg=bright-black + style 69-84 fg=bright-black + style 87-96 fg=bright-black +27| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +28-35| diff --git a/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt b/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt index ec38d43c99..9dafcf576d 100644 --- a/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt @@ -1,57 +1,48 @@ terminal 100x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "Use the todo_write tool to — DSH TUI snapshot" -cursor hidden column=1 viewportRow=31 bufferRow=31 +cursor hidden column=7 viewportRow=31 bufferRow=31 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Use the todo_write tool to" style 1-26 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), " - style 0-0 fg=bright-blue -7| "▌ \"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then " - style 0-0 fg=bright-blue -8| "▌ reply with the single word DONE and stop. " - style 0-0 fg=bright-blue -9| "▌ " - style 0-0 fg=bright-blue -10| -11| " Reasoning " - style 1-9 fg=bright-black italic -12| " The user wants me to use the todo_write tool to record a plan with exactly three todos in the " - style 1-99 fg=bright-black italic -13| " specified statuses, then reply with \"DONE\". " - style 1-43 fg=bright-black italic -14| -15| "▌ " - style 0-0 fg=green -16| "▌ ✓ Update todo list " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-19 bold -17| "▌ Updated todo list: 2 pending, 1 in progress, 0 completed. " - style 0-0 fg=green -18| "▌ " - style 0-0 fg=green -19| -20| " Reasoning " - style 1-9 fg=bright-black italic -21| " The todos have been written successfully. Now I just need to reply with the single word \"DONE\". " - style 1-95 fg=bright-black italic -22| -23| " Assistant " - style 1-9 fg=bright-magenta bold -24| " DONE " -25| +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), " +6| "\"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then " +7| "reply with the single word DONE and stop. " +8| +9| "Assistant " + style 0-8 fg=bright-magenta bold underline +10| "Reasoning " + style 0-8 fg=bright-black italic +11| "The user wants me to use the todo_write tool to record a plan with exactly three todos in the " + style 0-99 fg=bright-black italic +12| "specified statuses, then reply with \"DONE\". " + style 0-42 fg=bright-black italic +13| +14| "● Tool / todo_write" + style 0-18 fg=green +15| "Update todo list " +16| "Updated todo list: 2 pending, 1 in progress, 0 completed. " +17| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +18| +19| "Assistant " + style 0-8 fg=bright-magenta bold underline +20| "Reasoning " + style 0-8 fg=bright-black italic +21| "The todos have been written successfully. Now I just need to reply with the single word \"DONE\". " + style 0-94 fg=bright-black italic +22| "DONE " +23| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +24-25| 26| "Plan" style 0-3 fg=bright-blue bold 27| " ● read the code" @@ -60,13 +51,13 @@ buffer style 2-2 dim 29| " ○ run the tests" style 2-2 dim -30| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -31| " " - style 1-1 inverse -32| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -33| "deepseek-v4-flash /workspace/project ↑3.1k ↓145 cache 47% 3% context tools:" - style 0-79 dim - style 82-99 dim -34-35| +30| "/workspace/project deepseek-v4-flash ↑3.1k ↓145 cache 47% 3% context" + style 0-37 fg=bright-blue bold + style 40-56 fg=bright-black + style 59-79 fg=bright-black + style 82-91 fg=bright-black +31| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +32-35| diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 1cbe6c478e..6aa1f428a3 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -72,7 +72,14 @@ async function seedResumeSession(cwd: string): Promise { } /** The rendered system prompt from the first `request/header` in the workspace's persisted session log. */ -async function readLoggedSystemPrompt(cwd: string): Promise { +interface LoggedRequestHeader { + /** The system prompt string the launcher sends. */ + system: string + /** The baked session-prefix messages (skill catalog, workspace context) serialized to text. */ + prefix: string +} + +async function readLoggedRequestHeader(cwd: string): Promise { const sessionsDir = join(cwd, '.sessions') const entries = await readdir(sessionsDir, { recursive: true }) // A single keyless run writes one session log; the source section is global, so any log carries it. @@ -80,8 +87,16 @@ async function readLoggedSystemPrompt(cwd: string): Promise { if (logRelPath === undefined) throw new Error(`no session log written under ${sessionsDir}`) const lines = (await readFile(join(sessionsDir, logRelPath), 'utf8')).split('\n').filter(Boolean) for (const line of lines) { - const event = JSON.parse(line) as { type: string; data: { header?: { system?: string } } } - if (event.type === 'request/header') return event.data.header?.system ?? '' + const event = JSON.parse(line) as { + type: string + data: { header?: { system?: string; messagePrefix?: unknown } } + } + if (event.type === 'request/header') { + return { + system: event.data.header?.system ?? '', + prefix: JSON.stringify(event.data.header?.messagePrefix ?? []), + } + } } throw new Error(`session log ${logRelPath} has no request/header event`) } @@ -176,6 +191,10 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('KV cache') expect(output).toContain('Context') expect(output).toContain('128,000') + expect(output).toContain('System prompt') + expect(output).toContain('You are an AI agent powered by the DeepSeek Harness SDK.') + expect(output).toContain('Registered tools') + expect(output).toContain('ask_user_question') expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) @@ -208,6 +227,7 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { { waitFor: 'Scripted skill body received.', send: '/exit\r' }, ], }) + expect(output).not.toContain('[instructions]') expect(output).toContain('Scripted skill body received.') expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) @@ -342,11 +362,13 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('ui-tui: session "missing-session" failed to start:') }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('tells the model where its own source lives, in the system prompt it sends', async () => { + it('tells the model its source path and offers the bundled maintenance skills', async () => { // The launcher resolves the checkout root three hops up from apps/cli/{src,lib}; // this test file sits an equal depth under the same root, so the same hop applies. + // The source-path line is a system-prompt section; the bundled skills reach the + // model through the session-prefix catalog, so each assertion targets its own field. const sourceRoot = fileURLToPath(new URL('../../..', import.meta.url)) - let loggedSystem = '' + let header: LoggedRequestHeader = { system: '', prefix: '' } await smoke({ label: 'dsh source-path prompt', tempDirPrefix: 'dsh-source-path-', @@ -358,8 +380,11 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { { waitFor: 'How should the scripted run proceed?', send: '\r' }, { waitFor: 'Decision received. Scripted TUI run complete.', send: '/exit\r' }, ], - inspect: async (cwd) => { loggedSystem = await readLoggedSystemPrompt(cwd) }, + inspect: async (cwd) => { header = await readLoggedRequestHeader(cwd) }, }) - expect(loggedSystem).toContain(`Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`) + expect(header.system).toContain(`Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`) + expect(header.prefix).toContain('- `dsh-customize`: Customize a dsh installation. Use before any requested change that potentailly impacts the checkout that powers the current DSH process or installed `dsh` command, including code, docs, skills, configuration, tests, commit history, or PR-branch updates; do not edit the personal staging checkout directly.') + expect(header.prefix).toContain('- `dsh-upgrade`: Upgrades a source-installed, personally customized DSH checkout to upstream master while preserving local changes and an unchanged rollback checkout. Use when the user asks to update or upgrade DSH.') + expect(header.prefix).toContain('- `dsh-upstream-customization`: Classifies personal DSH customizations for upstream contribution and, after explicit per-feature approval, rebuilds one on upstream master and opens a draft pull request. Use when the user asks to contribute, publish, or upstream a local DSH change, or asks whether one is worth proposing.') }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 1f2989f183..ae4919b129 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -2,7 +2,7 @@ import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/pr import { tmpdir } from 'node:os' import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path' import { fileURLToPath } from 'node:url' -import { afterAll, describe, expect, it } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -25,7 +25,7 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph' import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' -import { createTuiChat, FILE_REFERENCE_PROMPT } from '@deepseek-ai/dsh-tui' +import { createTuiChat, FILE_REFERENCE_PROMPT, TuiPromptService } from '@deepseek-ai/dsh-tui' import LocalSpillStore from '@deepseek-ai/dsh-spill-local' import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -225,6 +225,7 @@ async function mountScenarioContext( await ctx.plugin(FsPolicy) await ctx.plugin(ToolFs) await ctx.plugin(UserInteractionService) + await ctx.plugin(TuiPromptService) // todo_write is opt-in: only the todo-plan scenario mounts it, matching the shipped // config that omits it. The other scenarios prove the default todo-free composition. if (scenario.enableTodo === true) await ctx.plugin(ToolTodo) @@ -262,6 +263,7 @@ interface ScenarioResult { } async function runScenario(scenario: Scenario): Promise { + const clock = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 12, 0, 0).getTime()) const dir = scenarioDir(scenario) const fixtureFile = join(dir, 'session.jsonl') const childFiles = childFixturePaths(scenario) @@ -294,7 +296,7 @@ async function runScenario(scenario: Scenario): Promise { const agent: Agent = handle.agent controller = createTuiChat(ctx, { sessionId: 'main-session', - color: true, + theme: { color: true }, showReasoning: true, title: 'DSH TUI snapshot', welcome: `Recorded replay: ${scenario.name}`, @@ -404,6 +406,7 @@ async function runScenario(scenario: Scenario): Promise { await ctx?.fiber.dispose() await terminal.dispose() await rm(cwd, { recursive: true, force: true }) + clock.mockRestore() } } diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index a47d6e337b..14f473282b 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2443,58 +2443,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ToolSchema', declaration: 'export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n}', }, - { - name: 'TuiComponent', - declaration: 'export interface TuiComponent {\n render(width: number): string[];\n handleInput?(data: string): void;\n wantsKeyRelease?: boolean;\n invalidate(): void;\n}', - }, - { - name: 'TuiFocusable', - declaration: 'export interface TuiFocusable {\n focused: boolean;\n}', - }, - { - name: 'TuiOverlayAnchor', - declaration: 'export type TuiOverlayAnchor = \'center\' | \'top-left\' | \'top-right\' | \'bottom-left\' | \'bottom-right\' | \'top-center\' | \'bottom-center\' | \'left-center\' | \'right-center\';', - }, - { - name: 'TuiOverlayCloseReason', - declaration: 'export type TuiOverlayCloseReason = \'closed\' | \'aborted\' | \'owner-disposed\' | \'tui-disposed\' | \'error\';', - }, - { - name: 'TuiOverlayHost', - declaration: 'export interface TuiOverlayHost {\n readonly signal: AbortSignal;\n readonly viewport: TuiViewport;\n readonly theme: TuiTheme;\n display(value: string): string;\n invalidate(): void;\n close(): void;\n}', - }, - { - name: 'TuiOverlayMargin', - declaration: 'export interface TuiOverlayMargin {\n readonly top?: number;\n readonly right?: number;\n readonly bottom?: number;\n readonly left?: number;\n}', - }, - { - name: 'TuiOverlayOptions', - declaration: 'export interface TuiOverlayOptions {\n readonly width?: number | `${number}%`;\n readonly minWidth?: number;\n readonly maxHeight?: number | `${number}%`;\n readonly anchor?: TuiOverlayAnchor;\n readonly margin?: number | TuiOverlayMargin;\n}', - }, - { - name: 'TuiOverlayOutcome', - declaration: 'export type TuiOverlayOutcome = {\n readonly reason: Exclude;\n} | {\n readonly reason: \'error\';\n readonly error: unknown;\n};', - }, - { - name: 'TuiOverlayRequest', - declaration: 'export interface TuiOverlayRequest {\n readonly create: (host: TuiOverlayHost) => TuiComponent & Partial;\n readonly options?: TuiOverlayOptions;\n readonly signal?: AbortSignal;\n}', - }, - { - name: 'TuiOverlaySession', - declaration: 'export interface TuiOverlaySession {\n readonly state: TuiOverlayState;\n readonly closed: Promise;\n close(): Promise;\n}', - }, - { - name: 'TuiOverlayState', - declaration: 'export type TuiOverlayState = \'queued\' | \'active\' | \'closed\';', - }, - { - name: 'TuiTheme', - declaration: 'export interface TuiTheme {\n readonly text: (value: string) => string;\n readonly muted: (value: string) => string;\n readonly dim: (value: string) => string;\n readonly accent: (value: string) => string;\n readonly success: (value: string) => string;\n readonly warning: (value: string) => string;\n readonly error: (value: string) => string;\n readonly bold: (value: string) => string;\n}', - }, - { - name: 'TuiViewport', - declaration: 'export interface TuiViewport {\n readonly columns: number;\n readonly rows: number;\n}', - }, { name: 'TurnEndReason', declaration: 'export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];', diff --git a/packages/examples/tui-demo/src/index.ts b/packages/examples/tui-demo/src/index.ts index 8a88859ab3..c60ba94b3c 100644 --- a/packages/examples/tui-demo/src/index.ts +++ b/packages/examples/tui-demo/src/index.ts @@ -131,6 +131,7 @@ export function composeTuiApp(ctx: Context, config: Config): void { ctx.plugin(SessionQuerySqlite, { path: join(persistenceRoot, 'session-query.db') }) ctx.plugin(SessionReferenceService, config.sessionReferences ?? {}) ctx.plugin(UserInteractionService) + ctx.plugin(uiTui.TuiPromptService) ctx.plugin(uiTui, { ...config.ui, ...config.welcome === undefined ? {} : { welcome: config.welcome }, diff --git a/packages/examples/tui-demo/tests/tui-agent.spec.ts b/packages/examples/tui-demo/tests/tui-agent.spec.ts index cdab42289b..e5c8b778be 100644 --- a/packages/examples/tui-demo/tests/tui-agent.spec.ts +++ b/packages/examples/tui-demo/tests/tui-agent.spec.ts @@ -40,7 +40,7 @@ describe('dsh-tui-demo app', () => { }, welcome: 'TUI ready', resumeCommand: 'dsh --resume {session}', - ui: { color: false, maxToolOutputLines: 3 }, + ui: { theme: { color: false }, maxToolOutputLines: 3 }, skills: { tool: { catalogDescriptionMaxLength: 8 } }, toolBash: { enableRunInBackground: false }, toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 }, @@ -55,6 +55,7 @@ describe('dsh-tui-demo app', () => { 'SessionQuerySqlite', 'SessionReferenceService', 'UserInteractionService', + 'TuiPromptService', 'ui-tui', 'agent-spine-demo', 'tool-ask-user', @@ -67,15 +68,15 @@ describe('dsh-tui-demo app', () => { candidateLimit: 7, maxReferenceBytes: 1234, }) - const tuiConfig = calls[7]?.config as { sessionId: string } + const tuiConfig = calls[8]?.config as { sessionId: string } expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', resumeCommand: 'dsh --resume {session}', - color: false, + theme: { color: false }, maxToolOutputLines: 3, }) expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/) - const spineConfig = calls[8]?.config as { + const spineConfig = calls[9]?.config as { readonly agents: Array> readonly goals: Record readonly maxParallelToolCalls: number @@ -111,8 +112,8 @@ describe('dsh-tui-demo app', () => { expect(calls[2]?.config).toEqual({ root: './.sessions' }) expect(calls[5]?.config).toEqual({}) // No configured welcome forwards none: the TUI banner sweeps in without a subtitle. - expect(calls[7]?.config).toEqual({ sessionId: 'persisted-session' }) - expect((calls[8]?.config as { agents: Array> }).agents[0]).toMatchObject({ + expect(calls[8]?.config).toEqual({ sessionId: 'persisted-session' }) + expect((calls[9]?.config as { agents: Array> }).agents[0]).toMatchObject({ id: 'main', resumeSessionId: 'persisted-session', }) @@ -128,12 +129,12 @@ describe('dsh-tui-demo app', () => { workspaceContext: false, }) - const tuiConfig = calls[6]?.config as { sessionId: string } + const tuiConfig = calls[7]?.config as { sessionId: string } expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/) - expect((calls[7]?.config as { agents: Array> }).agents[0]) + expect((calls[8]?.config as { agents: Array> }).agents[0]) .toMatchObject({ sessionId: tuiConfig.sessionId }) expect(calls.map(call => call.name)).not.toContain('command-goal') - expect(calls[7]?.config).toMatchObject({ goals: false }) + expect(calls[8]?.config).toMatchObject({ goals: false }) }) it('has the namespace-plugin export shape so the Loader keeps its schema', () => { diff --git a/packages/sdk/helper/src/features/builtin/app.ts b/packages/sdk/helper/src/features/builtin/app.ts index ed4050bf11..88c07a0853 100644 --- a/packages/sdk/helper/src/features/builtin/app.ts +++ b/packages/sdk/helper/src/features/builtin/app.ts @@ -18,6 +18,7 @@ import { } from '../feature.ts' import { ProjectContribution, type ProjectResource } from '../resources.ts' import { + cordisConfigEntry, npmCordisConfigEntry, optionalString, ownedTextFile, @@ -86,6 +87,10 @@ class AppOption extends FeatureOption { id: 'user-interaction', name: '@deepseek-ai/dsh-user-interaction', }), + cordisConfigEntry(ID, { + id: 'tui-prompt', + name: '@deepseek-ai/dsh-tui/prompt', + }), ...npmCordisConfigEntry(ID, { id: 'tui', name: '@deepseek-ai/dsh-tui', diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index f1216d2768..3a8c3b3158 100644 --- a/packages/sdk/helper/tests/project.spec.ts +++ b/packages/sdk/helper/tests/project.spec.ts @@ -187,6 +187,7 @@ describe('SdkProject and ProjectEditSession', () => { expect(await readFile(join(project.root, 'cordis.yml'), 'utf8')) .toContain('sessionId: !!js process.env.DSH_SDK_SESSION_ID') expect(project.cordis.entry('tui')?.config).not.toHaveProperty('model') + expect(project.cordis.entry('tui-prompt')?.name).toBe('@deepseek-ai/dsh-tui/prompt') expect(project.cordis.entry('agent-loop')?.config).toEqual({ agents: [] }) expect(project.cordis.entry('session-invariant')?.name).toBe('@deepseek-ai/dsh-session/invariant') expect(project.cordis.entry('agent-invariant')?.name).toBe('@deepseek-ai/dsh-agent/invariant') diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index a11958da85..7bf2eead46 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/tui/README.md -README.md: 84ed3de78469ab778118ee5599acfd8476b0ecd1 -README.zh.md: 771b89a91077db7543713b4ce1b5fce0c30c2a16 +README.md: 528daef773635451ceb198ab3231c2dd87cb9413 +README.zh.md: ed19023334389e3f64b8a2f3821307f1540876f2 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 84ed3de784..528daef773 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -74,7 +74,7 @@ Startup fails before mounting when either process stream is not a TTY. The compo ## 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; 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. +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, assistant replies, tool cards) are separated by a bold, underlined role header in the role color and blank-line spacing rather than a filled block or a per-line prefix, so a mouse drag-select copies the message text without any leading bar or indent; a tool card's status (pending, error, success) shows in its colored, underlined title glyph and title. 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 diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 771b89a910..ed19023334 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -74,7 +74,7 @@ Footer 将会话报告的用量汇总为 `↑`;任 ## 颜色 -Palette 使用标准 16 色 ANSI 前景色和 SGR 属性,每个终端都会将其重新映射到当前配色方案,因此浅色与深色背景下都保持可读。正文使用终端默认前景色,而非固定色调。成组区域(用户提示词、工具卡片)使用彩色左侧 gutter bar,而非填充背景块;问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。 +Palette 使用标准 16 色 ANSI 前景色和 SGR 属性,每个终端都会将其重新映射到当前配色方案,因此浅色与深色背景下都保持可读。正文使用终端默认前景色,而非固定色调。成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。 ## 模型体验 diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index 3242f5a2a3..f9260e3231 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -15,12 +15,17 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./prompt": { + "types": "./lib/types/prompt.d.ts", + "default": "./lib/prompt.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/prompt.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -35,9 +40,9 @@ "@deepseek-ai/dsh-llm-retry": "^0.0.1", "@deepseek-ai/dsh-goal": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-reference": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-session-query": "^0.0.1", + "@deepseek-ai/dsh-session-reference": "^0.0.1", "@deepseek-ai/dsh-session-title": "^0.0.1", "@deepseek-ai/dsh-skill": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", @@ -59,6 +64,7 @@ }, "dependencies": { "@earendil-works/pi-tui": "0.80.7", + "saxes": "6.0.0", "schemastery": "^3.18.0" }, "devDependencies": { @@ -71,9 +77,9 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-reference": "workspace:^", - "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/ui/tui/src/autocomplete.ts b/packages/ui/tui/src/autocomplete.ts new file mode 100644 index 0000000000..d8a639ad2f --- /dev/null +++ b/packages/ui/tui/src/autocomplete.ts @@ -0,0 +1,95 @@ +/** + * Editor autocomplete provider merging path-only file candidates and optional + * session-reference snapshots with the base slash-command completions. + * @module @deepseek-ai/dsh-tui/autocomplete + */ + +import { + CombinedAutocompleteProvider, + type AutocompleteItem, + type AutocompleteProvider, + type AutocompleteSuggestions, +} from '@earendil-works/pi-tui' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { + formatSessionReferenceMention, + type SessionReferenceService, +} from '@deepseek-ai/dsh-session-reference' +import { displayInlineText } from './components/text.ts' +import { activeAtToken, formatFileMention, WorkspaceFileSearch } from './file-autocomplete.ts' + +/** Merge path-only file candidates and optional session snapshots with commands. */ +export class ReferenceAutocompleteProvider implements AutocompleteProvider { + constructor( + private readonly base: CombinedAutocompleteProvider, + private readonly files: WorkspaceFileSearch, + private readonly sessions: SessionReferenceService | undefined, + private readonly agent: Agent, + ) {} + + async getSuggestions( + lines: string[], + cursorLine: number, + cursorCol: number, + options: { signal: AbortSignal; force?: boolean }, + ): Promise { + const basePromise = this.base.getSuggestions(lines, cursorLine, cursorCol, options) + const currentLine = lines[cursorLine] + /* v8 ignore next -- Editor always supplies its current state line. */ + if (currentLine === undefined) return basePromise + const token = activeAtToken(currentLine, cursorCol) + if (token === undefined) { + this.files.invalidate() + return basePromise + } + const filePromise = this.files.list(token.query, options.signal).catch(() => []) + const sessionPromise = this.sessions === undefined || token.quoted + ? Promise.resolve([]) + : this.sessions.listCandidates(this.agent, token.query, undefined, options.signal).catch(() => []) + const [base, fileCandidates, sessionCandidates] = await Promise.all([ + basePromise, + filePromise, + sessionPromise, + ]) + if (options.signal.aborted) return base + const fileItems: AutocompleteItem[] = fileCandidates.flatMap((candidate) => { + const value = formatFileMention(candidate, token.quoted) + if (value === undefined) return [] + const name = candidate.path.slice(candidate.path.lastIndexOf('/') + 1) + const directory = candidate.kind === 'directory' + return [{ + value, + label: `${directory ? 'Folder' : 'File'} · ${displayInlineText(name)}${directory ? '/' : ''}`, + description: displayInlineText(candidate.path), + }] + }) + const sessionItems: AutocompleteItem[] = sessionCandidates.map((candidate) => { + const mentionLabel = displayInlineText(candidate.label) + const sessionId = displayInlineText(candidate.sessionId) + const location = candidate.cwd === undefined ? '(no cwd)' : displayInlineText(candidate.cwd) + const description = `${candidate.label === candidate.sessionId ? '' : `${sessionId} · `}${location} · ${new Date(candidate.createdAt).toISOString()}` + return { + value: formatSessionReferenceMention({ sessionId: candidate.sessionId, label: mentionLabel }), + label: `Session · ${mentionLabel}`, + description, + } + }) + const items = [...fileItems, ...sessionItems] + if (items.length === 0) return base + return { items: [...items, ...(base?.items ?? [])], prefix: token.prefix } + } + + applyCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + item: AutocompleteItem, + prefix: string, + ): { lines: string[]; cursorLine: number; cursorCol: number } { + return this.base.applyCompletion(lines, cursorLine, cursorCol, item, prefix) + } + + shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean { + return this.base.shouldTriggerFileCompletion(lines, cursorLine, cursorCol) + } +} diff --git a/packages/ui/tui/src/components/content.ts b/packages/ui/tui/src/components/content.ts new file mode 100644 index 0000000000..a4536a6afd --- /dev/null +++ b/packages/ui/tui/src/components/content.ts @@ -0,0 +1,56 @@ +/** + * Content-block primitives shared across the terminal front door: flattening + * session content to display text and parsing tool-call arguments. + * @module @deepseek-ai/dsh-tui/components/content + */ + +import type { ContentBlock } from '@deepseek-ai/dsh-llm' + +/** + * Flatten content blocks into a single display string, recursing into + * tool-result content and naming unknown block types. + * @param content - Content blocks to flatten. + * @returns The concatenated display text. + */ +export function contentText(content: readonly ContentBlock[]): string { + const parts: string[] = [] + for (const block of content) { + switch (block.type) { + case 'text': + case 'reasoning': + parts.push(block.text) + break + case 'tool-call': + parts.push(`${block.name}(${block.arguments})`) + break + case 'tool-result': + parts.push(contentText(block.content)) + break + default: { + const rawType = (block as { type?: unknown }).type + parts.push(`[${typeof rawType === 'string' ? rawType : 'content'}]`) + break + } + } + } + return parts.join('') +} + +/** A tool call's arguments parsed from their JSON source, with a validity flag. */ +export interface ParsedArguments { + value: unknown + valid: boolean +} + +/** + * Parse tool-call arguments from their JSON source. + * @param raw - Raw JSON arguments text. + * @returns The parsed value, or the raw text with `valid: false` on parse failure. + */ +export function parseArguments(raw: string): ParsedArguments { + try { + return { value: JSON.parse(raw), valid: true } + } catch { + return { value: raw, valid: false } + } +} diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts new file mode 100644 index 0000000000..5ac4816482 --- /dev/null +++ b/packages/ui/tui/src/components/dialogs.ts @@ -0,0 +1,790 @@ +/** + * pi-tui dialog and selector components for the terminal front door: the status + * card, prompt-context line, model selector, resume picker, and user-question + * dialog, plus the model-choice and resume-candidate data they present. + * @module @deepseek-ai/dsh-tui/components/dialogs + */ + +import { + Input, + Key, + SelectList, + matchesKey, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, + type Component, + type Focusable, + type SelectItem, +} from '@earendil-works/pi-tui' +import type { Context } from 'cordis' +import { + type Agent, + type AgentLlmTarget, +} from '@deepseek-ai/dsh-agent' +import type { LlmModelInfo, LlmModelReasoningInfo, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal' +import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' +import type { + SessionLogSnapshot, + SessionRecord, +} from '@deepseek-ai/dsh-session-query' +import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction' +import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, displayText, sanitizePastedText } from './text.ts' +import { dialogSelectTheme, type Palette } from './theme.ts' +import { + renderTuiPromptTemplate, + type TuiPromptTemplateToken, +} from '../prompt.ts' + +/** A selectable model advertised by a provider, with its display name, description, and reasoning metadata. */ +export interface ModelChoice extends AgentLlmTarget { + modelName: string + description?: string + reasoning?: LlmModelReasoningInfo +} + +/** + * The provider/model route and selected reasoning effort resolved from a model dialog. + */ +export interface ModelDialogSelection { + choice: ModelChoice + reasoningEffort: ReasoningEffortId | undefined +} + +/** + * Format a provider/model target as its `provider/model` label. + * @param target - The LLM target. + * @returns The `provider/model` label. + */ +export function targetLabel(target: AgentLlmTarget): string { + return `${target.provider}/${target.model}` +} + +/** + * Format a target compactly as its model name with any selected reasoning effort appended. + * @param target - The LLM target. + * @returns The compact `model [effort]` label. + */ +export function compactTargetLabel(target: AgentLlmTarget): string { + return `${target.model}${target.reasoningEffort === undefined ? '' : ` ${target.reasoningEffort}`}` +} + +/** + * Resolve the display label for a choice's reasoning effort. + * @param choice - The model choice carrying advertised reasoning metadata. + * @param effort - The selected effort, or `undefined` for provider default. + * @returns The effort's display name, `provider default`, or `undefined` when the model has no reasoning metadata. + */ +export function targetReasoningLabel(choice: ModelChoice, effort: ReasoningEffortId | undefined): string | undefined { + if (effort === undefined) return choice.reasoning === undefined ? undefined : 'provider default' + return choice.reasoning?.efforts.find(candidate => candidate.id === effort)?.name ?? effort +} + +/** + * Derive the agent's initial LLM target from its logged request header or options. + * @param agent - The driven agent. + * @returns The initial target, or `undefined` when unset. + */ +export function initialTarget(agent: Agent): AgentLlmTarget | undefined { + const logged = agent.session.requestHeader()?.config + if (logged !== undefined) { + if (logged.reasoningEffort === undefined) { + return { provider: logged.provider, model: logged.model } + } + return { provider: logged.provider, model: logged.model, reasoningEffort: logged.reasoningEffort } + } + if (agent.options.provider === undefined || agent.options.model === undefined) return undefined + return { provider: agent.options.provider, model: agent.options.model } +} + +/** + * List every advertised model across registered providers, appending the current + * target when a provider does not advertise it. + * @param ctx - Context supplying the LLM service. + * @param current - The current target, appended when unadvertised. + * @returns The model choices, flattened across providers. + */ +export async function readModelChoices( + ctx: Context, + current: AgentLlmTarget | undefined, +): Promise { + const providers = ctx.llm.listProviders() + const groups = await Promise.all(providers.map(async (provider) => { + const advertised = await ctx.llm.listModels(provider.id) + const models: LlmModelInfo[] = [...advertised] + if ( + current?.provider === provider.id + && !models.some(model => model.id === current.model) + ) { + models.push({ provider: provider.id, id: current.model, name: current.model }) + } + return Promise.all(models.map(async (model): Promise => { + const reasoning = (await ctx.llm.resolveModelInfo(provider.id, model.id)).reasoning + return { + provider: provider.id, + model: model.id, + modelName: model.name, + ...model.description === undefined ? {} : { description: model.description }, + ...reasoning === undefined ? {} : { reasoning }, + } + })) + })) + return groups.flat() +} + +/** + * Format a diagnostic integer with grouping separators. + * @param value - Integer to format. + * @returns The grouped decimal string. + */ +export function formatDiagnosticNumber(value: number): string { + return value.toLocaleString('en-US') +} + +/** + * Format a diagnostic timestamp as an ISO date-time in UTC. + * @param value - Epoch milliseconds. + * @returns The formatted UTC timestamp. + */ +export function formatDiagnosticTime(value: number): string { + return new Date(value).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/u, ' UTC') +} + +/** + * Format a pluralized count for a diagnostic row. + * @param value - Count. + * @param singular - Singular noun; an `s` is appended for other counts. + * @returns The formatted count. + */ +export function formatDiagnosticCount(value: number, singular: string): string { + return `${String(value)} ${singular}${value === 1 ? '' : 's'}` +} + +/** + * Render a fixed-width filled meter bar for a percentage. + * @param percent - Percentage in [0, 100]. + * @param palette - Active role palette. + * @returns The rendered meter. + */ +export function diagnosticMeter(percent: number, palette: Palette): string { + const width = 16 + const filled = Math.round(Math.min(100, Math.max(0, percent)) / 100 * width) + return `${palette.dim('[')}${palette.accent('█'.repeat(filled))}${palette.dim(`${'░'.repeat(width - filled)}]`)}` +} + +/** One `label: value` row of a status card group. */ +export type StatusCardRow = readonly [label: string, value: string] + +/** Bordered, grouped field card for one point-in-time status snapshot. */ +export class StatusCardComponent implements Component { + constructor( + private readonly groups: readonly (readonly StatusCardRow[])[], + private readonly palette: Palette, + ) {} + + invalidate(): void {} + + render(width: number): string[] { + const labels = this.groups.flatMap(group => group.map(([label]) => `${label}:`)) + const naturalLabelWidth = Math.max(...labels.map(label => label.length)) + const naturalBodyWidth = Math.max(...this.groups.flatMap(group => group.map(([, value]) => + 1 + naturalLabelWidth + 2 + visibleWidth(value)))) + const cardWidth = Math.min( + Math.max(8, width), + Math.max('Session status'.length + 5, naturalBodyWidth + 4), + ) + const innerWidth = Math.max(1, cardWidth - 4) + const labelWidth = Math.min( + naturalLabelWidth, + Math.max(1, Math.floor(innerWidth / 3)), + ) + const body: string[] = [] + for (const [groupIndex, group] of this.groups.entries()) { + if (groupIndex > 0) body.push('') + for (const [label, value] of group) { + const plainLabel = truncateToWidth(`${label}:`, labelWidth, '') + const prefix = ` ${this.palette.muted(plainLabel.padEnd(labelWidth))} ` + const continuation = ' '.repeat(1 + labelWidth + 2) + const valueWidth = Math.max(1, innerWidth - visibleWidth(prefix)) + const wrapped = wrapTextWithAnsi(value, valueWidth) + for (const [lineIndex, line] of wrapped.entries()) { + body.push(`${lineIndex === 0 ? prefix : continuation}${line}`) + } + } + } + + const title = truncateToWidth('Session status', Math.max(1, cardWidth - 5), '') + const topTail = '─'.repeat(Math.max(0, cardWidth - visibleWidth(title) - 5)) + const top = `${this.palette.dim('╭─ ')}${this.palette.bold(this.palette.accent(title))}${this.palette.dim(` ${topTail}╮`)}` + const lines = [top] + for (const line of body) { + const clipped = truncateToWidth(line, innerWidth, '') + lines.push(`${this.palette.dim('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${this.palette.dim('│')}`) + } + lines.push(this.palette.dim(`╰${'─'.repeat(Math.max(0, cardWidth - 2))}╯`)) + return lines + } +} + +/** The left/right template line rendered above the editor. */ +export class PromptContextComponent implements Component { + constructor( + private readonly leftTemplate: readonly TuiPromptTemplateToken[], + private readonly rightTemplate: readonly TuiPromptTemplateToken[], + private readonly resolve: (name: string) => string | undefined, + ) {} + + invalidate(): void {} + + render(width: number): string[] { + const right = truncateToWidth(renderTuiPromptTemplate(this.rightTemplate, this.resolve), width, '') + const rightWidth = visibleWidth(right) + const leftCapacity = Math.max(0, width - rightWidth - (rightWidth === 0 ? 0 : 2)) + const left = truncateToWidth(renderTuiPromptTemplate(this.leftTemplate, this.resolve), leftCapacity, '') + if (rightWidth === 0) return [left] + const gap = ' '.repeat(Math.max(0, width - visibleWidth(left) - rightWidth)) + return [`${left}${gap}${right}`] + } +} + +/** A user's answer to one question: chosen option labels and an optional custom answer. */ +export interface QuestionSelection { + selected: string[] + custom?: string +} + +/** + * Render a bordered dialog frame around body lines with a titled top edge. + * @param title - Dialog title shown in the top border. + * @param body - Body lines. + * @param width - Dialog width in columns. + * @param palette - Active role palette. + * @returns The framed dialog lines. + */ +export 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 +} + +/** Keyboard model selector rendered as a bordered overlay, with per-model reasoning-effort cycling. */ +export class ModelDialog implements Component { + private readonly list: SelectList + private readonly items: Map + private readonly choices: Map + private readonly efforts: Map + private readonly currentValue: string | undefined + + constructor( + choices: readonly ModelChoice[], + current: AgentLlmTarget | undefined, + maxVisible: number, + private readonly palette: Palette, + done: (selection: ModelDialogSelection) => void, + cancel: () => void, + ) { + this.items = new Map() + this.choices = new Map() + this.efforts = new Map() + this.currentValue = current === undefined ? undefined : targetLabel(current) + for (const choice of choices) { + const value = targetLabel(choice) + const isCurrent = current?.provider === choice.provider && current.model === choice.model + this.choices.set(value, choice) + this.efforts.set( + value, + isCurrent + ? current.reasoningEffort ?? choice.reasoning?.defaultEffort + : choice.reasoning?.defaultEffort, + ) + this.items.set(value, { + value, + label: displayText(value), + description: this.describeChoice(choice, isCurrent), + }) + } + this.list = new SelectList([...this.items.values()], 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({ choice: selected, reasoningEffort: this.efforts.get(item.value) }) + } + this.list.onCancel = cancel + } + + private describeChoice(choice: ModelChoice, isCurrent: boolean): string { + const effortLabel = targetReasoningLabel(choice, this.efforts.get(targetLabel(choice))) + return [ + displayText(choice.modelName), + ...choice.description === undefined ? [] : [displayText(choice.description)], + ...effortLabel === undefined ? [] : [displayText(effortLabel)], + ...isCurrent ? ['current'] : [], + ].join(' — ') + } + + private cycleReasoningEffort(): void { + const selectedItem = this.list.getSelectedItem() + /* v8 ignore next -- the dialog is opened only for a non-empty catalog. */ + if (selectedItem === null) return + const choice = this.choices.get(selectedItem.value) + if (choice?.reasoning === undefined) return + const current = this.efforts.get(selectedItem.value) + const efforts: Array = [ + ...choice.reasoning.defaultEffort === undefined ? [undefined] : [], + ...choice.reasoning.efforts.map(effort => effort.id), + ] + const currentIndex = efforts.indexOf(current) + const next = efforts[(currentIndex + 1) % efforts.length] + this.efforts.set(selectedItem.value, next) + const item = this.items.get(selectedItem.value) + /* v8 ignore next -- items and choices are constructed from the same values. */ + if (item === undefined) return + item.description = this.describeChoice(choice, selectedItem.value === this.currentValue) + } + + invalidate(): void { + this.list.invalidate() + } + + handleInput(data: string): void { + if (matchesKey(data, Key.shift(Key.tab))) { + this.cycleReasoningEffort() + } else { + 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 • Shift+Tab reasoning • Enter select • Esc cancel'), + ], width, this.palette) + } +} + +/** The provider/model route recovered from a resume candidate's log. */ +export interface ResumeRoute { + provider: string + model: string +} + +/** A preflighted resume selector row summarizing one persisted session. */ +export interface ResumeCandidate { + record: SessionRecord + title: string + lastActivityAt: number + lastTurn: string + route?: ResumeRoute + goalPhase?: GoalPhase + disabledReason?: string +} + +function resumeTurnLabel(snapshot: SessionLogSnapshot): string { + const event = snapshot.events.findLast(item => item.type === 'turn/end') + if (event === undefined) return 'no completed turn' + const reason = event.data.reason + switch (reason.kind) { + case 'completed': return `turn ${event.data.turn}: completed` + case 'aborted': return `turn ${event.data.turn}: cancelled` + case 'error': return `turn ${event.data.turn}: error` + case 'disposed': return `turn ${event.data.turn}: disposed` + case 'max-tokens': return `turn ${event.data.turn}: max tokens` + case 'rejected': return `turn ${event.data.turn}: rejected` + case 'interrupted': return `turn ${event.data.turn}: interrupted` + default: return `turn ${event.data.turn}: unknown result` + } +} + +function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | undefined { + const header = snapshot.events.findLast(item => item.type === 'request/header') + if (header?.type === 'request/header') { + return { provider: header.data.header.config.provider, model: header.data.header.config.model } + } + const assistant = snapshot.events.findLast(item => item.type === 'assistant/message') + return assistant?.type === 'assistant/message' + ? { provider: assistant.data.provenance.provider, model: assistant.data.provenance.model } + : undefined +} + +/** + * Build one resume selector row from a record and its log snapshot, deriving the + * title, route, goal phase, and any reason the session cannot be resumed here. + * @param record - The session record. + * @param snapshot - The session's log snapshot. + * @param currentId - The current session id. + * @param cwd - The current workspace directory. + * @param availableProviders - Providers registered in this runtime. + * @returns The summarized resume candidate. + */ +export function summarizeResumeCandidate( + record: SessionRecord, + snapshot: SessionLogSnapshot, + currentId: SessionId, + cwd: string | undefined, + availableProviders: ReadonlySet, +): ResumeCandidate { + const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session' + const route = resumeRoute(snapshot) + const foldedGoal = foldGoal(snapshot.events).goal + let disabledReason: string | undefined + if (record.header.id === currentId) disabledReason = 'current session' + else if (record.live) disabledReason = 'session is already live in this runtime' + else if (record.header.cwd !== cwd) disabledReason = 'different workspace' + else if (route !== undefined && !availableProviders.has(route.provider)) { + disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})` + } + return { + record, + title, + lastActivityAt: snapshot.events.at(-1)?.time ?? snapshot.session.createdAt, + lastTurn: resumeTurnLabel(snapshot), + ...route === undefined ? {} : { route }, + /* v8 ignore next -- goal-bearing resume records are covered by the goal/session integration surface. */ + ...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase }, + ...disabledReason === undefined ? {} : { disabledReason }, + } +} + +/** Full-viewport keyboard selector over detached, preflighted resume summaries. */ +export class ResumePicker implements Component, Focusable { + private readonly search = new Input() + private pasteBuffer: string | undefined + private selectedIndex = 0 + private error = '' + focused = false + + constructor( + private readonly candidates: readonly ResumeCandidate[], + private readonly maxVisible: number, + private readonly workspaceLabel: string, + private readonly viewportRows: () => number, + private readonly palette: Palette, + private readonly done: (candidate: ResumeCandidate) => void, + private readonly cancel: () => void, + ) {} + + invalidate(): void { + this.search.invalidate() + } + + private filtered(): ResumeCandidate[] { + const query = this.search.getValue().trim().toLocaleLowerCase() + if (query === '') return [...this.candidates] + return this.candidates.filter(candidate => candidate.title.toLocaleLowerCase().includes(query) + || candidate.record.header.id.toLocaleLowerCase().includes(query)) + } + + private visibleCandidateCount(): number { + const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / 4)) + return Math.min(this.maxVisible, candidateBudget) + } + + private handleBracketedPaste(data: string): boolean { + const start = data.indexOf(BRACKETED_PASTE_START) + if (this.pasteBuffer === undefined && start < 0) return false + if (this.pasteBuffer === undefined) { + const prefix = data.slice(0, start) + if (prefix !== '') this.handleInput(prefix) + this.pasteBuffer = data.slice(start + BRACKETED_PASTE_START.length) + } else { + this.pasteBuffer += data + } + const end = this.pasteBuffer.indexOf(BRACKETED_PASTE_END) + if (end < 0) return true + const pasted = sanitizePastedText(this.pasteBuffer.slice(0, end)) + const remaining = this.pasteBuffer.slice(end + BRACKETED_PASTE_END.length) + this.pasteBuffer = undefined + const previous = this.search.getValue() + this.search.handleInput(`${BRACKETED_PASTE_START}${pasted}${BRACKETED_PASTE_END}`) + if (this.search.getValue() !== previous) { + this.selectedIndex = 0 + this.error = '' + } + if (remaining !== '') this.handleInput(remaining) + this.invalidate() + return true + } + + handleInput(data: string): void { + if (this.handleBracketedPaste(data)) return + const filtered = this.filtered() + if (matchesKey(data, Key.ctrl('c'))) { + this.cancel() + return + } + if (matchesKey(data, Key.escape)) { + if (this.search.getValue() === '') this.cancel() + else { + this.search.setValue('') + this.selectedIndex = 0 + this.error = '' + } + } else if (matchesKey(data, Key.up)) { + this.selectedIndex = filtered.length === 0 + ? 0 + : (this.selectedIndex + filtered.length - 1) % filtered.length + } else if (matchesKey(data, Key.down)) { + this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + 1) % filtered.length + } else if (matchesKey(data, Key.pageUp)) { + this.selectedIndex = Math.max(0, this.selectedIndex - this.visibleCandidateCount()) + } else if (matchesKey(data, Key.pageDown)) { + this.selectedIndex = Math.min( + Math.max(0, filtered.length - 1), + this.selectedIndex + this.visibleCandidateCount(), + ) + } else if (matchesKey(data, Key.enter)) { + const selected = filtered[this.selectedIndex] + if (selected === undefined) this.error = 'No session matches this search.' + else if (selected.disabledReason !== undefined) this.error = selected.disabledReason + else this.done(selected) + } else { + const previous = this.search.getValue() + this.search.focused = this.focused + this.search.handleInput(data) + if (this.search.getValue() !== previous) { + this.selectedIndex = 0 + this.error = '' + } + } + this.invalidate() + } + + render(width: number): string[] { + this.search.focused = this.focused + const height = Math.max(1, this.viewportRows()) + const horizontalPadding = width >= 12 ? 2 : 0 + const contentWidth = Math.max(1, width - horizontalPadding * 2) + const indent = ' '.repeat(horizontalPadding) + const filtered = this.filtered() + if (this.selectedIndex >= filtered.length) this.selectedIndex = Math.max(0, filtered.length - 1) + const selected = filtered[this.selectedIndex] + const position = selected === undefined ? 0 : this.selectedIndex + 1 + const lines: string[] = [ + '', + `${indent}${this.palette.bold(this.palette.accent(`Resume session (${position} of ${filtered.length})`))}`, + '', + ] + + const searchInnerWidth = Math.max(1, contentWidth - 4) + lines.push(`${indent}${this.palette.dim(`╭${'─'.repeat(Math.max(0, contentWidth - 2))}╮`)}`) + const searchContent = this.search.render(searchInnerWidth).join('').replace(/^> /u, '⌕ ') + const clippedSearch = truncateToWidth(searchContent, searchInnerWidth, '') + lines.push( + `${indent}${this.palette.dim('│')} ${clippedSearch}${' '.repeat(Math.max(0, searchInnerWidth - visibleWidth(clippedSearch)))} ${this.palette.dim('│')}`, + `${indent}${this.palette.dim(`╰${'─'.repeat(Math.max(0, contentWidth - 2))}╯`)}`, + '', + `${indent}${this.palette.muted(displayText(this.workspaceLabel))}`, + '', + ) + + const visibleCount = this.visibleCandidateCount() + const start = Math.max(0, Math.min( + this.selectedIndex - Math.floor(visibleCount / 2), + filtered.length - visibleCount, + )) + const end = Math.min(filtered.length, start + visibleCount) + const push = (line: string): void => { + lines.push(`${indent}${truncateToWidth(line, contentWidth, '…')}`) + } + for (let index = start; index < end; index += 1) { + const candidate = filtered[index] as ResumeCandidate + const active = index === this.selectedIndex + const status = [ + candidate.disabledReason === 'current session' ? 'current' : undefined, + candidate.record.live ? 'live' : undefined, + candidate.record.persisted ? 'persisted' : undefined, + ].filter((value): value is string => value !== undefined).join(' · ') + const lead = `${active ? '❯' : ' '} ${displayText(candidate.title)}` + push(active ? this.palette.bold(this.palette.accent(lead)) : lead) + const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}` + /* v8 ignore next -- only goal-bearing resume records add this integration-owned suffix. */ + const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}` + push(this.palette.muted(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`)) + push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`)) + if (candidate.disabledReason !== undefined) { + push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`)) + } + } + if (filtered.length === 0) push(this.palette.warning('No matching sessions.')) + if (this.error !== '') { + lines.push('') + push(this.palette.error(displayText(this.error))) + } + + const footer = `${indent}${this.palette.dim('Type to search • ↑/↓ navigate • Enter resume • Esc clear/cancel')}` + while (lines.length < height - 2) lines.push('') + lines.push(footer, '') + return lines.slice(0, height) + } +} + +/** Bottom-anchored dialog for one user question with option or custom-answer modes. */ +export class QuestionDialog implements Component, Focusable { + private selectedIndex = 0 + private selected = new Set() + private mode: 'options' | 'custom' + private error = '' + private readonly input = new Input() + private readonly options: NonNullable + focused = false + + 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, + private readonly cancel: () => void, + ) { + this.options = question.options ?? [] + this.mode = this.options.length > 0 ? 'options' : 'custom' + this.input.onSubmit = (value) => { this.submitCustom(value) } + this.input.onEscape = () => { + if (this.options.length > 0) { + this.mode = 'options' + this.error = '' + } else { + this.cancel() + } + } + } + + invalidate(): void { + this.input.invalidate() + } + + handleInput(data: string): void { + this.invalidate() + if (this.mode === 'custom') { + this.input.focused = this.focused + this.input.handleInput(data) + return + } + const options = this.options + if (matchesKey(data, Key.up)) { + this.selectedIndex = this.selectedIndex === 0 ? options.length - 1 : this.selectedIndex - 1 + } else if (matchesKey(data, Key.down)) { + this.selectedIndex = this.selectedIndex === options.length - 1 ? 0 : this.selectedIndex + 1 + } else if (matchesKey(data, Key.space) && this.question.multiSelect) { + if (this.selected.has(this.selectedIndex)) this.selected.delete(this.selectedIndex) + else this.selected.add(this.selectedIndex) + } 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 Tab for a custom answer.' + return + } + this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) }) + } 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'))) { + this.cancel() + } + } + + private submitCustom(value: string): void { + const custom = value.trim() + if (custom === '') { + this.error = 'Enter an answer before submitting.' + return + } + this.done({ selected: [], custom }) + } + + render(width: number): string[] { + this.input.focused = this.focused + const innerWidth = Math.max(1, width - 4) + 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) } + // Supporting detail (e.g. the full plan under review) renders between the + // question and the answer surface, kept out of option labels. + if (this.question.detail !== undefined) { + push('') + for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) push(line) + } + push('') + 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')) + } else { + const options = this.options + const start = Math.max(0, Math.min( + this.selectedIndex - Math.floor(this.maxVisible / 2), + options.length - this.maxVisible, + )) + const end = Math.min(options.length, start + this.maxVisible) + const optionRows = options.slice(start, end).map((option, offset) => { + const index = start + offset + const mark = this.question.multiSelect + ? this.selected.has(index) ? '[x] ' : '[ ] ' + : '' + return `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}` + }) + const descriptionColumn = Math.min( + Math.max(...optionRows.map(row => visibleWidth(row))) + 2, + Math.max(1, Math.floor(innerWidth * 0.55)), + ) + for (let index = start; index < end; index += 1) { + // `index < end <= options.length`; the options array is borrowed immutably for this dialog. + const option = options[index] as NonNullable[number] + const mark = this.question.multiSelect + ? this.selected.has(index) ? '[x] ' : '[ ] ' + : '' + 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}`)) + const controls = [ + 'Tab custom answer', + ...(options.length > 1 ? ['↑/↓ navigate'] : []), + ...(this.question.multiSelect ? ['Space toggle'] : []), + 'Enter submit', + 'Esc interrupt', + ] + const hint = this.palette.dim(controls.join(' • ')) + for (const line of wrapTextWithAnsi(hint, innerWidth)) push(line) + } + 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)))} ` + }) + } +} diff --git a/packages/ui/tui/src/components/text.ts b/packages/ui/tui/src/components/text.ts new file mode 100644 index 0000000000..876893adfd --- /dev/null +++ b/packages/ui/tui/src/components/text.ts @@ -0,0 +1,49 @@ +/** + * Terminal text sanitization shared across the pi-tui front door. External text + * (model output, tool results, clipboard) is escaped or stripped of C0/C1 + * controls before the TUI adds its own application-owned ANSI. + * @module @deepseek-ai/dsh-tui/components/text + */ + +const TERMINAL_CONTROL_PATTERN = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/gu +const TERMINAL_OSC_PATTERN = /(?:\u001B\]|\u009D)(?:(?!\u0007|\u001B\\)[\s\S])*(?:\u0007|\u001B\\|$)/gu +const TERMINAL_CSI_PATTERN = /(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]/gu +const TERMINAL_ESCAPE_PATTERN = /\u001B[@-_]/gu + +/** Bracketed-paste start marker emitted by terminals around pasted content. */ +export const BRACKETED_PASTE_START = '\u001B[200~' +/** Bracketed-paste end marker emitted by terminals around pasted content. */ +export const BRACKETED_PASTE_END = '\u001B[201~' + +/** + * Escape external C0/C1 controls before pi-tui adds application-owned ANSI. + * Line feeds remain structural so transcript and tool output retain their layout. + * @param text - Untrusted text to render. + * @returns The text with control characters escaped as `\xNN`. + */ +export function displayText(text: string): string { + return text.replace(TERMINAL_CONTROL_PATTERN, control => + `\\x${control.charCodeAt(0).toString(16).padStart(2, '0')}`) +} + +/** + * Escape external controls for terminal fields that must remain on one line. + * @param text - Untrusted text to render inline. + * @returns The escaped text with newlines rendered as `\x0a`. + */ +export function displayInlineText(text: string): string { + return displayText(text).replaceAll('\n', '\\x0a') +} + +/** + * Remove terminal controls from clipboard text before an editable field stores it. + * @param text - Raw pasted clipboard text. + * @returns The text stripped of OSC, CSI, escape, and control sequences. + */ +export function sanitizePastedText(text: string): string { + return text + .replace(TERMINAL_OSC_PATTERN, '') + .replace(TERMINAL_CSI_PATTERN, '') + .replace(TERMINAL_ESCAPE_PATTERN, '') + .replace(TERMINAL_CONTROL_PATTERN, '') +} diff --git a/packages/ui/tui/src/components/theme.ts b/packages/ui/tui/src/components/theme.ts new file mode 100644 index 0000000000..1ea75b437f --- /dev/null +++ b/packages/ui/tui/src/components/theme.ts @@ -0,0 +1,184 @@ +/** + * Theme-agnostic ANSI palette and derived pi-tui themes for the terminal front + * door. The palette is built from the standard 16-color ANSI set plus SGR + * attributes so every terminal remaps it to its active color scheme. + * @module @deepseek-ai/dsh-tui/components/theme + */ + +import type { + MarkdownTheme, + SelectListTheme, + TerminalColorScheme, +} from '@earendil-works/pi-tui' + +/** Theme-agnostic role colors and SGR attribute wrappers. */ +export interface Palette { + accent: (text: string) => string + accent2: (text: string) => string + text: (text: string) => string + muted: (text: string) => string + dim: (text: string) => string + success: (text: string) => string + warning: (text: string) => string + error: (text: string) => string + code: (text: string) => string + added: (text: string) => string + removed: (text: string) => string + bold: (text: string) => string + italic: (text: string) => string + underline: (text: string) => string + strike: (text: string) => string + /** Reverse video for the active selection; swaps the theme's own fg/bg so it reads on any scheme. */ + selected: (text: string) => string +} + +function ansi(open: string, close: string, enabled: boolean): (text: string) => string { + return enabled ? text => `\x1b[${open}m${text}\x1b[${close}m` : text => text +} + +/** + * Theme-agnostic palette built from the standard 16-color ANSI set plus SGR + * attributes, which every terminal remaps to its active color scheme. Body + * `text` stays the terminal's default foreground so it reads on light and dark + * backgrounds alike; grouping uses foreground-only bold, underlined role + * headers and reverse video rather than fixed background fills or per-line + * prefixes, so a transcript drag-select copies message text without stray + * glyphs. + * + * @param enabled - Whether ANSI is emitted at all. + * @param scheme - Active terminal color scheme; adjusts dim and code roles. + * @returns The role palette for the given scheme. + */ +export function createPalette(enabled: boolean, scheme: TerminalColorScheme = 'dark'): Palette { + return { + accent: ansi('94', '39', enabled), + accent2: ansi('95', '39', enabled), + text: text => text, + muted: ansi('90', '39', enabled), + // SGR 2 (dim) lightens text on a light background — substitute ANSI 90 + // (bright black / gray) which renders as a readable muted tone on any scheme. + dim: scheme === 'light' ? ansi('90', '39', enabled) : ansi('2', '22', enabled), + success: ansi('32', '39', enabled), + warning: ansi('33', '39', enabled), + error: ansi('31', '39', enabled), + // ANSI 36 (cyan) is difficult to read on a light background — use + // ANSI 34 (blue) which is legible on both light and dark schemes. + code: scheme === 'light' ? ansi('34', '39', enabled) : ansi('36', '39', enabled), + added: ansi('32', '39', enabled), + removed: ansi('31', '39', enabled), + bold: ansi('1', '22', enabled), + italic: ansi('3', '23', enabled), + underline: ansi('4', '24', enabled), + strike: ansi('9', '29', enabled), + selected: ansi('7', '27', enabled), + } +} + +/** + * DeepSeek brand gradient stops (indigo → light blue) taken from the + * deepseek.com logo, painted across the startup banner's product name on + * truecolor terminals. Fixed brand identity, deliberately outside the + * theme-adaptive {@link Palette}. + */ +const BRAND_GRADIENT = [ + [77, 107, 254], // #4D6BFE + [57, 130, 255], // #3982FF + [36, 152, 255], // #2498FF +] as const + +/** + * Sample {@link BRAND_GRADIENT} at fraction `t` via piecewise-linear + * interpolation across its stops. + * + * @param t - Position along the gradient; clamped to [0, 1]. + * @returns The interpolated `[r, g, b]` channels, each rounded to 0–255. + */ +function brandColorAt(t: number): readonly [number, number, number] { + const span = Math.min(Math.max(t, 0), 1) * (BRAND_GRADIENT.length - 1) + const index = Math.min(Math.floor(span), BRAND_GRADIENT.length - 2) + const local = span - index + // `index` is clamped to a valid adjacent pair, so both lookups are in-bounds. + const from = BRAND_GRADIENT[index] as readonly [number, number, number] + const to = BRAND_GRADIENT[index + 1] as readonly [number, number, number] + return [ + Math.round(from[0] + (to[0] - from[0]) * local), + Math.round(from[1] + (to[1] - from[1]) * local), + Math.round(from[2] + (to[2] - from[2]) * local), + ] +} + +/** + * Paint `text` left-to-right in the DeepSeek brand gradient with per-character + * 24-bit foreground codes, resetting to the default foreground at the end. + * Foreground-only, so it stays legible on any terminal background; the caller + * gates it on truecolor support and wraps it in bold. + * + * @param text - Text to colorize; sampled once per character. + * @returns `text` wrapped in truecolor SGR foreground codes. + */ +export function gradientText(text: string): string { + // The sole caller passes the ASCII product name, so UTF-16 unit iteration + // samples exactly one color per visible letter. + const last = Math.max(1, text.length - 1) + let painted = '' + for (let index = 0; index < text.length; index += 1) { + const [r, g, b] = brandColorAt(index / last) + painted += `\x1b[38;2;${r};${g};${b}m${text.charAt(index)}` + } + return `${painted}\x1b[39m` +} + +/** + * Derive the pi-tui Markdown theme from a role palette. + * @param palette - Active role palette. + * @returns The Markdown theme wired to palette roles. + */ +export function markdownTheme(palette: Palette): MarkdownTheme { + return { + heading: text => palette.accent(text), + link: text => palette.accent(text), + // pi-tui requires this URL slot but its current Markdown renderer does not invoke it. + /* v8 ignore next */ + linkUrl: text => palette.dim(text), + code: text => palette.code(text), + codeBlock: text => palette.code(text), + // pi-tui presents both fence rows through this callback. Keep the opening + // language label, but hide Markdown syntax and the otherwise-empty close. + codeBlockBorder: text => palette.dim(text.slice(3)), + quote: text => palette.muted(text), + quoteBorder: text => palette.accent2(text), + hr: text => palette.dim(text), + listBullet: text => palette.accent(text), + bold: text => palette.bold(text), + italic: text => palette.italic(text), + strikethrough: text => palette.strike(text), + underline: text => palette.underline(text), + } +} + +/** + * Derive the pi-tui select-list theme from a role palette. + * @param palette - Active role palette. + * @returns The select-list theme wired to palette roles. + */ +export function selectTheme(palette: Palette): SelectListTheme { + return { + selectedPrefix: palette.accent, + selectedText: palette.accent, + description: palette.muted, + scrollInfo: palette.dim, + noMatch: palette.warning, + } +} + +/** + * Derive the reverse-video dialog select-list theme from a role palette. + * @param palette - Active role palette. + * @returns The dialog select-list theme with a reverse-video selection. + */ +export function dialogSelectTheme(palette: Palette): SelectListTheme { + return { + ...selectTheme(palette), + selectedText: text => palette.selected(palette.accent(text)), + } +} diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts new file mode 100644 index 0000000000..41835ee31f --- /dev/null +++ b/packages/ui/tui/src/components/transcript.ts @@ -0,0 +1,529 @@ +/** + * pi-tui transcript components: the startup banner, user/assistant messages, + * per-step timing footer, streaming assistant buffer, tool cards, and the todo + * panel. Each is a pure function of its inputs and the active palette. + * @module @deepseek-ai/dsh-tui/components/transcript + */ + +import { + Container, + Markdown, + Spacer, + Text, + truncateToWidth, + wrapTextWithAnsi, + type Component, + type MarkdownTheme, +} from '@earendil-works/pi-tui' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { JsonValue, SessionEvent, TodoItem } from '@deepseek-ai/dsh-session' +import type { + TerminalCallView, + ToolCallView, + ToolDefinition, + ToolResultView, +} from '@deepseek-ai/dsh-tools' +import type { FileDiff } from '@deepseek-ai/dsh-tools' +import { renderUnknownXml } from '../xml-tool-output.ts' +import { displayInlineText, displayText } from './text.ts' +import { gradientText, type Palette } from './theme.ts' +import { contentText, type ParsedArguments } from './content.ts' +import { + formatCompletionTime, + formatTimingTotals, + stepTimingAt, + type StepPosition, +} from '../session/timing.ts' + +/** Concatenate the text of every block of one type, separated by blank lines. */ +function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'): string { + return content + .filter((block): block is Extract => block.type === type) + .map(block => block.text) + .join('\n\n') +} + +/** Render a value as terminal-safe text: strings escaped, other values as pretty JSON. */ +function pretty(value: unknown): string { + if (typeof value === 'string') return displayText(value) + // JSON.stringify is typed to return string but yields undefined for e.g. symbols. + const serialized = JSON.stringify(value, null, 2) as string | undefined + return displayText(serialized ?? String(value)) +} + +/** A file diff as colored `+`/`-` lines, optionally prefixed with its path. */ +function diffLines(diff: FileDiff, palette: Palette): string[] { + // The card header is a fixed `Tool / ` frame that never names a file, so + // each hunk always carries its own path header (no redundancy to suppress). + const lines = [palette.bold(displayText(diff.path))] + if (diff.oldText !== null) { + for (const line of displayText(diff.oldText).split('\n')) lines.push(palette.removed(`- ${line}`)) + } + for (const line of displayText(diff.newText).split('\n')) lines.push(palette.added(`+ ${line}`)) + return lines +} + +/** + * A message's bold, underlined role header in the role color. The underline + * bands each role without a background fill or per-line prefix, so it reads on + * any theme and a body drag-select copies the message text verbatim. + */ +function messageHeader(label: string, color: (text: string) => string, palette: Palette): string { + return palette.bold(palette.underline(color(displayText(label)))) +} + +/** + * Borderless startup banner: product title, an optional configured subtitle, + * and the session id. No box frame — each line renders as plain left-padded + * text (matching transcript notices) so it reads on any theme. + */ +export class HeaderComponent implements Component { + /** Columns of the banner currently revealed; `undefined` renders it whole. */ + private revealWidth: number | undefined + + constructor( + private readonly agent: Agent, + private readonly subtitle: () => string | undefined, + private readonly palette: Palette, + private readonly gradient: boolean, + ) {} + + /** + * Clip the banner to `width` columns (the sweep reveal); `undefined` restores it. + * @param width - Revealed banner width in columns, or `undefined` for the whole banner. + */ + setRevealWidth(width: number | undefined): void { + this.revealWidth = width + } + + invalidate(): void {} + + render(width: number): string[] { + const usable = Math.max(1, width - 2) + const name = this.gradient + ? this.palette.bold(gradientText('DEEPSEEK')) + : this.palette.bold(this.palette.accent('DEEPSEEK')) + const title = `${name} ${this.palette.bold('HARNESS')}` + const detail = displayText(this.agent.session.id) + const subtitle = this.subtitle() + const lines = [ + title, + ...subtitle === undefined ? [] : [this.palette.muted(displayText(subtitle))], + this.palette.dim(detail), + ] + .flatMap(line => wrapTextWithAnsi(line, usable)) + .map(line => ` ${truncateToWidth(line, usable, '')}`) + if (this.revealWidth === undefined) return lines + const revealed = this.revealWidth + return lines.map(line => truncateToWidth(line, revealed, '')) + } +} + +/** + * A user or steering prompt in the transcript. An underlined accent role header + * plus blank-line spacing separate it from surrounding blocks; body lines carry + * no prefix or indent, so a terminal drag-select copies the prompt verbatim. + */ +export class UserMessageComponent extends Container { + constructor(text: string, palette: Palette, mdTheme: MarkdownTheme, label = 'You') { + super() + this.addChild(new Text(messageHeader(label, palette.accent, palette), 0, 0)) + this.addChild(new Markdown(displayText(text), 0, 0, mdTheme, { color: value => palette.text(value) }, { + preserveOrderedListMarkers: true, + preserveBackslashEscapes: true, + })) + } +} + +/** Children of a settled assistant message: optional reasoning block then the response text. */ +function assistantMessageChildren( + content: readonly ContentBlock[], + showReasoning: boolean, + palette: Palette, + mdTheme: MarkdownTheme, +): Component[] { + const reasoning = displayText(textBlocks(content, 'reasoning').trim()) + const text = displayText(textBlocks(content, 'text').trim()) + const children: Component[] = [ + new Spacer(1), + new Text(messageHeader('Assistant', palette.accent2, palette), 0, 0), + ] + if (reasoning && showReasoning) { + children.push( + new Text(palette.italic(palette.muted('Reasoning')), 0, 0), + new Markdown(reasoning, 0, 0, mdTheme, { color: value => palette.muted(value), italic: true }), + ) + } + if (text) children.push(new Markdown(text, 0, 0, mdTheme, { color: value => palette.text(value) })) + return children +} + +/** + * A step's timing summary, rendered as a self-refreshing footer that stays at + * the tail of the step's output. Kept separate from the assistant message so + * the timing line trails any tool cards the step appends after its message. + */ +class StepTimingComponent extends Container { + private completionTime: number | undefined + + constructor( + private readonly position: StepPosition, + private readonly events: () => readonly SessionEvent[], + private readonly now: () => number, + private readonly palette: Palette, + ) { + super() + this.rebuild() + } + + complete(time: number): void { + this.completionTime = time + this.rebuild() + } + + override invalidate(): void { + this.rebuild() + super.invalidate() + } + + private rebuild(): void { + this.clear() + const totals = stepTimingAt(this.events(), this.position, this.completionTime ?? this.now()) + const timing = formatTimingTotals(totals, true) + const header = this.completionTime === undefined + ? timing + : `${timing} · Completed ${formatCompletionTime(this.completionTime)}` + this.addChild(new Text(this.palette.dim(header), 0, 0)) + } +} + +interface StreamingBlock { + type: string + text: string +} + +/** A live assistant step: streamed reasoning/text blocks until the message settles. */ +export class StreamingAssistantComponent extends Container { + private readonly blocks = new Map() + private settledContent: readonly ContentBlock[] | undefined + /** + * The step's timing footer. The renderer keeps it at the tail of the chat so + * it trails any tool cards the step appends after this assistant message; it + * is not a child of this component. + */ + readonly timing: StepTimingComponent + + constructor( + position: StepPosition, + events: () => readonly SessionEvent[], + now: () => number, + private showReasoning: boolean, + private readonly palette: Palette, + private readonly mdTheme: MarkdownTheme, + ) { + super() + this.timing = new StepTimingComponent(position, events, now, palette) + this.rebuild() + } + + /** + * Replace the streamed blocks with the step's settled content. + * @param content - The settled assistant content blocks. + */ + settle(content: readonly ContentBlock[]): void { + this.settledContent = content + this.rebuild() + } + + /** + * Whether this step's assistant message has settled. + * @returns `true` once {@link settle} has run. + */ + isSettled(): boolean { + return this.settledContent !== undefined + } + + /** + * Pin the step's timing footer to its completion time. + * @param time - Step completion time in epoch milliseconds. + */ + complete(time: number): void { + this.timing.complete(time) + } + + override invalidate(): void { + this.rebuild() + this.timing.invalidate() + super.invalidate() + } + + /** + * Fold one streamed chunk into the live block buffer and re-render. + * @param chunk - The streamed assistant chunk. + */ + update(chunk: StreamChunk): void { + if (chunk.type === 'block-start') { + this.blocks.set(chunk.index, { type: chunk.blockType, text: '' }) + } else if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') { + const type = chunk.type === 'text-delta' ? 'text' : 'reasoning' + const block = this.blocks.get(chunk.index) ?? { type, text: '' } + block.text += chunk.text + this.blocks.set(chunk.index, block) + } else if (chunk.type === 'block-end' && (chunk.block.type === 'text' || chunk.block.type === 'reasoning')) { + this.blocks.set(chunk.index, { type: chunk.block.type, text: chunk.block.text }) + } + this.rebuild() + this.timing.invalidate() + } + + /** + * Toggle whether reasoning blocks render, then re-render. + * @param show - Whether to show reasoning blocks. + */ + setShowReasoning(show: boolean): void { + this.showReasoning = show + this.rebuild() + } + + private rebuild(): void { + this.clear() + const content: readonly ContentBlock[] = this.settledContent ?? [...this.blocks.entries()] + .sort(([left], [right]) => left - right) + .flatMap(([, block]) => { + if (block.type === 'text') return [{ type: 'text', text: block.text }] + if (block.type === 'reasoning') return [{ type: 'reasoning', text: block.text }] + return [] + }) + for (const child of assistantMessageChildren(content, this.showReasoning, this.palette, this.mdTheme)) { + this.addChild(child) + } + } +} + +/** A tool call and its result, rendered as a collapsible status card. */ +export class ToolCardComponent implements Component { + private result: { content: ContentBlock[]; isError: boolean; meta?: JsonValue } | undefined + private expanded = false + private callView: ToolCallView + private resultView: ToolResultView | undefined + + constructor( + private readonly name: string, + private readonly parsed: ParsedArguments, + private readonly definition: ToolDefinition | undefined, + private readonly maxOutputLines: number, + private readonly palette: Palette, + private readonly mdTheme: MarkdownTheme, + ) { + this.callView = this.presentCall() + } + + private presentCall(): ToolCallView { + if (this.parsed.valid && this.definition?.presentCall) { + try { + const view = this.definition.presentCall(this.parsed.value) + if (view !== undefined) return view + } catch (error: unknown) { + return { card: 'generic', title: displayText(this.name), rawInput: `Presenter failed: ${String(error)}` } + } + } + return { card: 'generic', title: displayText(this.name), rawInput: this.parsed.value } + } + + /** + * Record the tool result and derive its result view. + * @param event - The `tool/result` event payload. + */ + updateResult(event: Extract['data']): void { + this.result = { + content: [...event.content], + isError: event.isError, + ...event.meta !== undefined ? { meta: event.meta } : {}, + } + if (this.parsed.valid && this.definition?.presentResult) { + try { + const view = this.definition.presentResult(this.parsed.value, this.result) + if (view !== undefined) this.resultView = view + } catch (error: unknown) { + this.resultView = { card: 'generic', content: [{ type: 'text', text: `Presenter failed: ${String(error)}` }] } + } + } + } + + /** + * Expand or collapse the card's body preview. + * @param expanded - Whether the full body is shown. + */ + setExpanded(expanded: boolean): void { + this.expanded = expanded + } + + invalidate(): void {} + + render(width: number): string[] { + const isError = this.result?.isError ?? false + // A ring marker: hollow while the call is pending, filled once it settles; + // the header color (warning/success/error) tells pending from ok from error. + const glyph = this.result === undefined ? '○' : '●' + const rawBody = this.renderBody() + const view = this.resultView ?? this.callView + const genericContent = view.card === 'generic' ? view.content ?? this.result?.content : undefined + const unknownXml = this.definition === undefined && genericContent !== undefined + ? renderUnknownXml( + displayText(contentText(genericContent)), + this.maxOutputLines, + this.expanded, + displayText, + text => this.palette.muted(text), + /* v8 ignore next -- renderUnknownXml calls the collapsed summary only when hidden XML children exceed this card's limit. */ + count => this.palette.dim(` … +${count} lines (Ctrl+O to expand)`), + ) + : undefined + const body = unknownXml ?? (genericContent !== undefined && rawBody.length > 0 + ? new Markdown(rawBody.join('\n'), 0, 0, this.mdTheme, { color: value => this.palette.text(value) }).render(width) + : rawBody) + const headLines = Math.ceil(this.maxOutputLines / 2) + const tailLines = this.maxOutputLines - headLines + const visibleBody = unknownXml !== undefined || this.expanded || body.length <= this.maxOutputLines + ? body + : [ + ...body.slice(0, headLines), + this.palette.dim(`… +${body.length - this.maxOutputLines} lines (Ctrl+O to expand)`), + ...body.slice(body.length - tailLines), + ] + // The header is a fixed `Tool / ` frame in the status color (warning + // pending / success ok / error), flat — no bold or underline, so one color + // reads consistently across the whole row. Every tool-specific detail (a + // read's path, a diff, command output) lives in the body below; the sole + // header extra is a bash card's model-authored description, appended as a + // `/ ` segment. The body stays unprefixed so a drag-select copies only + // the tool text; body lines pass through Text so overlong output wraps. + const statusColor = this.result === undefined + ? this.palette.warning + : isError ? this.palette.error : this.palette.success + // The header is a single card row: collapse an embedded newline in the + // description to an inline escape so it cannot break onto extra rows and + // collide with the body lines that follow. + const desc = this.headerDescription() + const headerText = `${glyph} Tool / ${displayText(this.name)}${desc === undefined ? '' : ` / ${displayInlineText(desc)}`}` + const header = truncateToWidth(headerText, Math.max(1, width - 2), '') + const lines = [statusColor(header)] + if (visibleBody.length > 0) lines.push(...new Text(visibleBody.join('\n'), 0, 0).render(width)) + return lines + } + + /** The pending terminal call view, when this row is a terminal card. */ + private terminalPending(): TerminalCallView | undefined { + return this.callView.card === 'terminal' ? this.callView : undefined + } + + /** + * The optional header `/ ` segment: a bash (terminal) card's + * model-authored description. Non-terminal tools contribute no header detail — + * their presenter title moves into the body instead. + */ + private headerDescription(): string | undefined { + const description = this.terminalPending()?.description + return description !== undefined && description !== '' ? description : undefined + } + + /** + * The presenter's title for a non-terminal card, shown as the first body line + * (a read's `Read src/foo.ts`, a diff's `Edit files`) now that the header is a + * fixed `Tool / ` frame. The result-state title replaces the pending one. + */ + private bodyTitle(): string { + return this.resultView?.title ?? this.callView.title + } + + private renderBody(): string[] { + const view = this.resultView ?? this.callView + if (view.card === 'terminal') { + const pending = this.terminalPending() + const lines: string[] = [] + // The command shows as a $-line here whenever it is not the header: either a + // description headlines the row (the command still belongs somewhere) or the row + // is a pending undescribed call (the classic running-command echo). A completed + // undescribed row keeps the command only in the header. + // The command and cwd are each a single card row, so escape a multi-line + // command inline (displayInlineText) — a real newline would break onto extra + // rows and collide with the output below. + const headlined = pending?.description !== undefined && pending.description !== '' + const commandInBody = pending !== undefined && (headlined || this.result === undefined) + if (commandInBody) lines.push(this.palette.code(`$ ${displayInlineText(pending.title)}`)) + if (pending?.cwd) lines.push(this.palette.dim(displayInlineText(pending.cwd))) + if (this.resultView?.card === 'terminal') { + if (this.resultView.output) lines.push(...displayText(this.resultView.output).split('\n')) + if (this.resultView.exitCode !== undefined) lines.push(this.palette.dim(`[exit ${this.resultView.exitCode}]`)) + if (this.resultView.signal !== undefined) { + lines.push(this.palette.error(`[signal ${displayText(this.resultView.signal)}]`)) + } + } else if (this.result !== undefined) { + lines.push(...displayText(contentText(this.result.content)).split('\n')) + } + return lines.filter(Boolean) + } + if (view.card === 'diff') { + // The header no longer names the file, so each diff keeps its own path + // header. A trailing footer summarizes the change (`+A -R · N file(s)`). + let added = 0 + let removed = 0 + const hunks = view.diffs.flatMap((diff, index) => { + if (diff.oldText !== null) removed += displayText(diff.oldText).split('\n').length + added += displayText(diff.newText).split('\n').length + return [...index > 0 ? [''] : [], ...diffLines(diff, this.palette)] + }) + const files = view.diffs.length + const footer = this.palette.dim(`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}`) + return [...hunks, footer] + } + const content = view.content ?? this.result?.content + const lines: string[] = [] + // The presenter title headlines the body now that the header is a fixed + // `Tool / ` frame (a terminal card keeps its command $-line instead). + // Skip it when it only repeats the tool name (the fallback presenter for a + // tool with no presentCall, or an unknown tool), which the header already shows. + const bodyTitle = this.bodyTitle() + if (bodyTitle !== displayText(this.name)) lines.push(displayInlineText(bodyTitle)) + if (content !== undefined) lines.push(...displayText(contentText(content)).split('\n')) + const rawInput = this.result === undefined && this.callView.card === 'generic' + ? this.callView.rawInput + : undefined + if (rawInput !== undefined) lines.push(...pretty(rawInput).split('\n')) + return lines.filter((line, index, all) => line.length > 0 || (index > 0 && index < all.length - 1)) + } +} + +/** The plan/todo panel rendered above the prompt. */ +export class TodoComponent implements Component { + private todos: readonly TodoItem[] = [] + + constructor(private readonly palette: Palette) {} + + /** + * Replace the rendered plan items. + * @param todos - The current todo items. + */ + update(todos: readonly TodoItem[]): void { + this.todos = todos + } + + invalidate(): void {} + + render(width: number): string[] { + if (this.todos.length === 0) return [] + const lines = [this.palette.bold(this.palette.accent('Plan'))] + for (const todo of this.todos) { + const prefix = todo.status === 'completed' + ? this.palette.success('✓') + : todo.status === 'in_progress' + ? this.palette.warning('●') + : this.palette.dim('○') + const content = displayText(todo.content) + const text = todo.status === 'completed' ? this.palette.muted(content) : content + lines.push(truncateToWidth(` ${prefix} ${text}`, width, '')) + } + return ['', ...lines] + } +} diff --git a/packages/ui/tui/src/config.ts b/packages/ui/tui/src/config.ts new file mode 100644 index 0000000000..a0114be972 --- /dev/null +++ b/packages/ui/tui/src/config.ts @@ -0,0 +1,213 @@ +/** + * Serializable configuration and defaults for the pi-tui terminal mode. Loader + * schema validation normally fills defaults; {@link resolveTuiConfig} applies + * the same defaults for direct callers that bypass the Loader. + * @module @deepseek-ai/dsh-tui/config + */ + +import z from 'schemastery' +import { + DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, + DEFAULT_FILE_SEARCH_MAX_ENTRIES, + DEFAULT_FILE_SEARCH_MAX_RESULTS, +} from './file-autocomplete.ts' + +/** Theme and prompt-template settings for the pi-tui terminal mode. */ +export interface TuiThemeConfig { + /** Apply the built-in ANSI color palette. */ + color?: boolean + /** Paint the startup banner with the 24-bit DeepSeek brand gradient. */ + truecolor?: boolean + /** Left-aligned template on the row above the editor. */ + leftPrompt?: string + /** Right-aligned template on the row above the editor. */ + rightPrompt?: string + /** Template used as the editor's first-line prefix. */ + inputPrompt?: string + /** Static placeholder shown in an empty editor while the agent is running. */ + inputPlaceholder?: string +} + +/** Interaction and presentation settings for the pi-tui terminal mode. */ +export interface TuiConfig { + /** Render model reasoning blocks. */ + showReasoning?: boolean + /** Maximum tool-card body lines retained in its collapsed head/tail preview. */ + maxToolOutputLines?: number + /** Maximum options visible at once in a user-question panel. */ + maxQuestionOptions?: number + /** Maximum models visible at once in the model selector. */ + maxModelOptions?: number + /** Maximum sessions visible at once in the resume selector. */ + maxResumeOptions?: number + /** User-question panel width in terminal columns, clamped to the terminal. */ + questionDialogWidth?: number + /** 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 + /** Maximum fuzzy file candidates displayed for one `@` query. */ + fileSearchMaxResults?: number + /** Maximum paths retained in one `@` workspace index. */ + fileSearchMaxEntries?: number + /** Directory basenames excluded from `@` traversal and completion. */ + fileSearchExcludedDirectories?: string[] + /** Show the terminal's hardware cursor at the pi editor's IME marker. */ + showHardwareCursor?: boolean + /** Color and prompt-template settings. */ + theme?: TuiThemeConfig + /** Terminal window title while the UI is mounted; a logged session title prefixes it. */ + title?: string +} + +const showReasoningSchema = z.boolean().default(true) +const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6) +const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8) +const maxModelOptionsSchema = z.number().step(1).min(1).default(8) +const maxResumeOptionsSchema = 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(76) +const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20) +const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS) +const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES) +const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES]) +const showHardwareCursorSchema = z.boolean().default(false) +const colorSchema = z.boolean().default(true) +// No default: an unset value auto-detects truecolor from COLORTERM in `apply`. +const truecolorSchema = z.boolean() +const DEFAULT_LEFT_PROMPT = '${cwd}${git/worktree}${model}${token_meter/cache_hit_rate}${context}' +const DEFAULT_RIGHT_PROMPT = '${timing}' +const DEFAULT_INPUT_PROMPT = '${symbol} ${indicator}' +const DEFAULT_INPUT_PLACEHOLDER = 'press enter to steer and esc to cancel' +const TuiThemeConfigSchema: z = z.object({ + color: colorSchema, + truecolor: truecolorSchema, + leftPrompt: z.string().default(DEFAULT_LEFT_PROMPT), + rightPrompt: z.string().default(DEFAULT_RIGHT_PROMPT), + inputPrompt: z.string().default(DEFAULT_INPUT_PROMPT), + inputPlaceholder: z.string().default(DEFAULT_INPUT_PLACEHOLDER), +}) +const titleSchema = z.string().default('DeepSeek Harness') + +const tuiConfigSchemaFields = { + showReasoning: showReasoningSchema, + maxToolOutputLines: maxToolOutputLinesSchema, + maxQuestionOptions: maxQuestionOptionsSchema, + maxModelOptions: maxModelOptionsSchema, + maxResumeOptions: maxResumeOptionsSchema, + questionDialogWidth: questionDialogWidthSchema, + questionDialogMaxHeight: questionDialogMaxHeightSchema, + modelDialogWidth: modelDialogWidthSchema, + modelDialogMaxHeight: modelDialogMaxHeightSchema, + fileSearchMaxResults: fileSearchMaxResultsSchema, + fileSearchMaxEntries: fileSearchMaxEntriesSchema, + fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema, + showHardwareCursor: showHardwareCursorSchema, + theme: TuiThemeConfigSchema, + title: titleSchema, +} + +/** Schemastery schema for presentation settings embedded by app bundles. */ +export const TuiConfigSchema: z = z.object(tuiConfigSchemaFields) + +/** Serializable plugin configuration. */ +export interface Config extends TuiConfig { + /** Banner subtitle line. When absent, the banner has no subtitle and sweeps in on start. */ + welcome?: string + /** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */ + sessionId?: string + /** + * Shell command fallback printed on exit or after selecting a session when + * the host cannot hand off in place. Every `{session}` becomes the selected + * id; the TUI never executes this text. Absent disables only the fallback, + * not the interactive selector. + */ + resumeCommand?: string +} + +/** Schemastery schema for the full plugin configuration. */ +export const Config: z = z.object({ + welcome: z.string(), + sessionId: z.string().default('main'), + resumeCommand: z.string(), + showReasoning: tuiConfigSchemaFields.showReasoning, + maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines, + maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions, + maxModelOptions: tuiConfigSchemaFields.maxModelOptions, + maxResumeOptions: tuiConfigSchemaFields.maxResumeOptions, + questionDialogWidth: tuiConfigSchemaFields.questionDialogWidth, + questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight, + modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth, + modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight, + fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults, + fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries, + fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories, + showHardwareCursor: tuiConfigSchemaFields.showHardwareCursor, + theme: tuiConfigSchemaFields.theme, + title: tuiConfigSchemaFields.title, +}) + +/** Fully defaulted TUI theme settings. */ +export interface ResolvedTuiThemeConfig { + color: boolean + truecolor: boolean + leftPrompt: string + rightPrompt: string + inputPrompt: string + inputPlaceholder: string +} + +/** Fully defaulted TUI presentation settings. */ +export interface ResolvedTuiConfig { + showReasoning: boolean + maxToolOutputLines: number + maxQuestionOptions: number + maxModelOptions: number + maxResumeOptions: number + questionDialogWidth: number + questionDialogMaxHeight: number + modelDialogWidth: number + modelDialogMaxHeight: number + fileSearchMaxResults: number + fileSearchMaxEntries: number + fileSearchExcludedDirectories: string[] + showHardwareCursor: boolean + theme: ResolvedTuiThemeConfig + title: string +} + +/** + * Apply direct-call defaults after Loader schema validation has normally run. + * + * @param config - Deployment-provided terminal presentation settings. + * @returns Complete settings consumed by the TUI renderer. + */ +export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConfig { + return { + showReasoning: config?.showReasoning ?? true, + maxToolOutputLines: config?.maxToolOutputLines ?? 6, + maxQuestionOptions: config?.maxQuestionOptions ?? 8, + maxModelOptions: config?.maxModelOptions ?? 8, + maxResumeOptions: config?.maxResumeOptions ?? 8, + questionDialogWidth: config?.questionDialogWidth ?? 200, + questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20, + modelDialogWidth: config?.modelDialogWidth ?? 76, + modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20, + fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS, + fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES, + fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)], + showHardwareCursor: config?.showHardwareCursor ?? false, + theme: { + color: config?.theme?.color ?? true, + truecolor: config?.theme?.truecolor ?? false, + leftPrompt: config?.theme?.leftPrompt ?? DEFAULT_LEFT_PROMPT, + rightPrompt: config?.theme?.rightPrompt ?? DEFAULT_RIGHT_PROMPT, + inputPrompt: config?.theme?.inputPrompt ?? DEFAULT_INPUT_PROMPT, + inputPlaceholder: config?.theme?.inputPlaceholder ?? DEFAULT_INPUT_PLACEHOLDER, + }, + title: config?.title ?? 'DeepSeek Harness', + } +} diff --git a/packages/ui/tui/src/overlay-manager.ts b/packages/ui/tui/src/extension/overlay-manager.ts similarity index 98% rename from packages/ui/tui/src/overlay-manager.ts rename to packages/ui/tui/src/extension/overlay-manager.ts index efe8716777..59d84ff3d5 100644 --- a/packages/ui/tui/src/overlay-manager.ts +++ b/packages/ui/tui/src/extension/overlay-manager.ts @@ -3,12 +3,12 @@ * * The manager serializes modal ownership, guards extension callbacks, and * settles every queued or active operation before terminal teardown. - * @module @deepseek-ai/dsh-tui/overlay-manager + * @module @deepseek-ai/dsh-tui/extension/overlay-manager */ import { Service, type Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { TuiExtensionService } from './index.ts' +import type { TuiExtensionService } from '../index.ts' import type { Component, Focusable, @@ -26,7 +26,7 @@ import type { TuiOverlayState, TuiTheme, TuiViewport, -} from './extension.ts' +} from './types.ts' /** pi-tui operations retained by the front door instead of exposed to plugins. */ export interface TuiOverlayDriver { diff --git a/packages/ui/tui/src/extension.ts b/packages/ui/tui/src/extension/types.ts similarity index 99% rename from packages/ui/tui/src/extension.ts rename to packages/ui/tui/src/extension/types.ts index d6cb7bc4e4..adb45d5430 100644 --- a/packages/ui/tui/src/extension.ts +++ b/packages/ui/tui/src/extension/types.ts @@ -5,7 +5,7 @@ * the live pi-tui tree, focus controller, overlay handles, or terminal * lifecycle. Registrations and open overlays remain owned by the calling * Cordis fiber. - * @module @deepseek-ai/dsh-tui/extension + * @module @deepseek-ai/dsh-tui/extension/types */ /** Terminal component shape accepted from a trusted TUI extension. */ diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index a88abb5586..1dffff23e9 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -5,41 +5,30 @@ * @module @deepseek-ai/dsh-tui */ +import { execFileSync } from 'node:child_process' import { homedir } from 'node:os' import { isAbsolute, relative, resolve, sep } from 'node:path' import { CombinedAutocompleteProvider, Container, + CURSOR_MARKER, Editor, - Input, Key, - Loader, - Markdown, Spacer, Text, TUI, ProcessTerminal, - SelectList, matchesKey, truncateToWidth, visibleWidth, - wrapTextWithAnsi, - type Component, - type AutocompleteItem, - type AutocompleteProvider, - type AutocompleteSuggestions, type EditorTheme, - type Focusable, - type MarkdownTheme, - type SelectItem, - type SelectListTheme, type SlashCommand, type Terminal, type TerminalColorScheme, } from '@earendil-works/pi-tui' import { Service, type Context, type Fiber } from 'cordis' -import z from 'schemastery' import { + assembleContextFor, installAgentLlmTarget, type Agent, type AgentLlmTarget, @@ -49,31 +38,22 @@ import { } 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 { assertNever, errorChain } from '@deepseek-ai/dsh-llm' -import type { - ContentBlock, - LlmModelInfo, - LlmModelReasoningInfo, - ReasoningEffortId, - StreamChunk, - TokenUsage, -} from '@deepseek-ai/dsh-llm' +import type { CommandResult } from '@deepseek-ai/dsh-commands' +import { errorChain } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { renderUnknownXml } from './xml-tool-output.ts' import type {} from '@deepseek-ai/dsh-llm-retry' +import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { displayPromptContent, SessionId, - type JsonValue, type Session, type SessionEvent, type SessionHeader, - type TodoItem, } from '@deepseek-ai/dsh-session' -import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal' +import { foldGoal } from '@deepseek-ai/dsh-goal' import { - formatSessionReferenceMention, parseSessionReferenceText, - type SessionReferenceService, } from '@deepseek-ai/dsh-session-reference' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { @@ -83,30 +63,104 @@ import type { // Type import also declaration-merges the optional `sessionPersistence` // service onto `Context` so `ctx.get('sessionPersistence')` is typed. import type {} from '@deepseek-ai/dsh-session-persistence' -import type { SkillDefinition, SkillResourceBase, SkillService } from '@deepseek-ai/dsh-skill' -import type { - FileDiff, - TerminalCallView, - ToolCallView, - ToolDefinition, - ToolResultView, -} from '@deepseek-ai/dsh-tools' +import type { SkillService } from '@deepseek-ai/dsh-skill' import { UserInteractionError, type AskUserQuestionAnswer, type AskUserQuestionAnswerItem, - type AskUserQuestionItem, type AskUserQuestionRequest, } from '@deepseek-ai/dsh-user-interaction' import { TuiExtensionServiceImpl, TuiOverlayManager, -} from './overlay-manager.ts' +} from './extension/overlay-manager.ts' +import { + parseTuiPromptTemplate, + renderTuiPromptTemplate, + type TuiPromptValueHandle, +} from './prompt.ts' import type { TuiOverlayRequest, TuiOverlaySession, TuiTheme, -} from './extension.ts' +} from './extension/types.ts' +import { displayInlineText, displayText } from './components/text.ts' +import { createPalette, markdownTheme, selectTheme } from './components/theme.ts' +import { contentText, parseArguments } from './components/content.ts' +import { + cacheHitRate, + formatTokens, + recordEventUsage, + sessionTokens, +} from './session/tokens.ts' +import { + fadeGlyph, + formatQueuedStatus, + openStepPhase, + openTurn, + pulseLevel, + runningPhaseGlyph, + STATUS_ANIMATION_INTERVAL_MS, + STATUS_FADE_MS, + TIMING_BUCKET_GLYPHS, + type StepPosition, +} from './session/timing.ts' +import { + resolveTuiConfig, + type Config, +} from './config.ts' +import { + HeaderComponent, + StreamingAssistantComponent, + ToolCardComponent, + TodoComponent, + UserMessageComponent, +} from './components/transcript.ts' +import { + compactTargetLabel, + diagnosticMeter, + formatDiagnosticCount, + formatDiagnosticNumber, + formatDiagnosticTime, + initialTarget, + ModelDialog, + QuestionDialog, + readModelChoices, + ResumePicker, + StatusCardComponent, + PromptContextComponent, + summarizeResumeCandidate, + targetLabel, + targetReasoningLabel, + type ModelChoice, + type ModelDialogSelection, + type ResumeCandidate, + type StatusCardRow, +} from './components/dialogs.ts' +import { + parseSkillCommand, + renderSkillInvocation, + SKILL_COMMAND_PREFIX, +} from './skill-invocation.ts' +import { ReferenceAutocompleteProvider } from './autocomplete.ts' +import { WorkspaceFileSearch } from './file-autocomplete.ts' + +export { TuiPromptService } from './prompt.ts' +export { renderSkillInvocation } from './skill-invocation.ts' +export { + resolveTuiConfig, + TuiConfigSchema, + Config, + type ResolvedTuiConfig, + type ResolvedTuiThemeConfig, + type TuiConfig, + type TuiThemeConfig, +} from './config.ts' +export { + DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, + DEFAULT_FILE_SEARCH_MAX_ENTRIES, + DEFAULT_FILE_SEARCH_MAX_RESULTS, +} from './file-autocomplete.ts' export type { TuiComponent, @@ -122,7 +176,7 @@ export type { TuiOverlayState, TuiTheme, TuiViewport, -} from './extension.ts' +} from './extension/types.ts' declare module 'cordis' { interface Context { @@ -167,165 +221,13 @@ export abstract class TuiExtensionService extends Service { */ abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession } -import { - activeAtToken, - DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, - DEFAULT_FILE_SEARCH_MAX_ENTRIES, - DEFAULT_FILE_SEARCH_MAX_RESULTS, - formatFileMention, - WorkspaceFileSearch, -} from './file-autocomplete.ts' - -export { - DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, - DEFAULT_FILE_SEARCH_MAX_ENTRIES, - DEFAULT_FILE_SEARCH_MAX_RESULTS, -} from './file-autocomplete.ts' export const name = 'ui-tui' -export const inject = ['agents', 'sessions', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter'] +export const inject = ['agents', 'sessions', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter', 'tuiPrompt'] /** Model guidance for path-only file references selected through the TUI. */ export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.' -/** Interaction and presentation settings for the pi-tui terminal mode. */ -export interface TuiConfig { - /** Render model reasoning blocks. */ - showReasoning?: boolean - /** Maximum tool-card body lines retained in its collapsed head/tail preview. */ - maxToolOutputLines?: number - /** Maximum options visible at once in a user-question panel. */ - maxQuestionOptions?: number - /** Maximum models visible at once in the model selector. */ - maxModelOptions?: number - /** Maximum sessions visible at once in the resume selector. */ - maxResumeOptions?: number - /** User-question panel width in terminal columns, clamped to the terminal. */ - questionDialogWidth?: number - /** 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 - /** Maximum fuzzy file candidates displayed for one `@` query. */ - fileSearchMaxResults?: number - /** Maximum paths retained in one `@` workspace index. */ - fileSearchMaxEntries?: number - /** Directory basenames excluded from `@` traversal and completion. */ - fileSearchExcludedDirectories?: string[] - /** Show the terminal's hardware cursor at the pi editor's IME marker. */ - showHardwareCursor?: boolean - /** Apply the built-in ANSI color palette. */ - color?: boolean - /** - * Paint the startup banner's product name in the DeepSeek brand gradient - * using 24-bit truecolor. Requires {@link TuiConfig.color}; falls back to the - * flat accent color when either is off. Unset auto-detects `COLORTERM` at the - * process boundary, so most deployments leave it unset. - */ - truecolor?: boolean - /** Terminal window title while the UI is mounted; a logged session title prefixes it. */ - title?: string -} - -const showReasoningSchema = z.boolean().default(true) -const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6) -const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8) -const maxModelOptionsSchema = z.number().step(1).min(1).default(8) -const maxResumeOptionsSchema = 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(76) -const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20) -const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS) -const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES) -const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES]) -const showHardwareCursorSchema = z.boolean().default(false) -const colorSchema = z.boolean().default(true) -// No default: an unset value auto-detects truecolor from COLORTERM in `apply`. -const truecolorSchema = z.boolean() -const titleSchema = z.string().default('DeepSeek Harness') - -const tuiConfigSchemaFields = { - showReasoning: showReasoningSchema, - maxToolOutputLines: maxToolOutputLinesSchema, - maxQuestionOptions: maxQuestionOptionsSchema, - maxModelOptions: maxModelOptionsSchema, - maxResumeOptions: maxResumeOptionsSchema, - questionDialogWidth: questionDialogWidthSchema, - questionDialogMaxHeight: questionDialogMaxHeightSchema, - modelDialogWidth: modelDialogWidthSchema, - modelDialogMaxHeight: modelDialogMaxHeightSchema, - fileSearchMaxResults: fileSearchMaxResultsSchema, - fileSearchMaxEntries: fileSearchMaxEntriesSchema, - fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema, - showHardwareCursor: showHardwareCursorSchema, - color: colorSchema, - truecolor: truecolorSchema, - title: titleSchema, -} - -/** Schemastery schema for presentation settings embedded by app bundles. */ -export const TuiConfigSchema: z = z.object(tuiConfigSchemaFields) - -/** Serializable plugin configuration. */ -export interface Config extends TuiConfig { - /** Banner subtitle line. When absent, the banner has no subtitle and sweeps in on start. */ - welcome?: string - /** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */ - sessionId?: string - /** - * Shell command fallback printed on exit or after selecting a session when - * the host cannot hand off in place. Every `{session}` becomes the selected - * id; the TUI never executes this text. Absent disables only the fallback, - * not the interactive selector. - */ - resumeCommand?: string -} - -export const Config: z = z.object({ - welcome: z.string(), - sessionId: z.string().default('main'), - resumeCommand: z.string(), - showReasoning: tuiConfigSchemaFields.showReasoning, - maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines, - maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions, - maxModelOptions: tuiConfigSchemaFields.maxModelOptions, - maxResumeOptions: tuiConfigSchemaFields.maxResumeOptions, - questionDialogWidth: tuiConfigSchemaFields.questionDialogWidth, - questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight, - modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth, - modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight, - fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults, - fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries, - fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories, - showHardwareCursor: tuiConfigSchemaFields.showHardwareCursor, - color: tuiConfigSchemaFields.color, - truecolor: tuiConfigSchemaFields.truecolor, - title: tuiConfigSchemaFields.title, -}) - -/** Fully defaulted TUI presentation settings. */ -export interface ResolvedTuiConfig { - showReasoning: boolean - maxToolOutputLines: number - maxQuestionOptions: number - maxModelOptions: number - maxResumeOptions: number - questionDialogWidth: number - questionDialogMaxHeight: number - modelDialogWidth: number - modelDialogMaxHeight: number - fileSearchMaxResults: number - fileSearchMaxEntries: number - fileSearchExcludedDirectories: string[] - showHardwareCursor: boolean - color: boolean - truecolor: boolean - title: string -} - /** Runtime boundary used by the interactive TUI. */ export interface TuiRuntime { /** Terminal implementation; production uses pi-tui's `ProcessTerminal`. */ @@ -333,732 +235,76 @@ export interface TuiRuntime { /** Exit hook used by terminal shutdown or a target-agent startup failure. */ exit(code: number): void /** - * Override the footer's logical working-directory label without changing the session directory used by tools. + * Override the prompt's logical working-directory label without changing the session directory used by tools. * @param cwd - Operational working directory from the session header. * @returns Unescaped label; the TUI makes terminal controls visible. */ formatCwd?: (cwd: string | undefined) => string + /** + * Override the Git branch shown in the prompt context line; production resolves it once at mount. + * @param cwd - Operational working directory from the session header. + * @returns Unescaped branch name, or `undefined` outside a Git worktree. + */ + gitBranch?: (cwd: string) => string | undefined /** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */ now?(): number /** Host-owned process handoff; absent leaves `resumeCommand` as the fallback. */ handoffResume?: TuiResumeHost['handoff'] } -/** - * Apply direct-call defaults after Loader schema validation has normally run. - * - * @param config - Deployment-provided terminal presentation settings. - * @returns Complete settings consumed by the TUI renderer. - */ -export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConfig { - return { - showReasoning: config?.showReasoning ?? true, - maxToolOutputLines: config?.maxToolOutputLines ?? 6, - maxQuestionOptions: config?.maxQuestionOptions ?? 8, - maxModelOptions: config?.maxModelOptions ?? 8, - maxResumeOptions: config?.maxResumeOptions ?? 8, - questionDialogWidth: config?.questionDialogWidth ?? 200, - questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20, - modelDialogWidth: config?.modelDialogWidth ?? 76, - modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20, - fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS, - fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES, - fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)], - showHardwareCursor: config?.showHardwareCursor ?? false, - color: config?.color ?? true, - truecolor: config?.truecolor ?? false, - title: config?.title ?? 'DeepSeek Harness', +/** Editor that shows a placeholder without making it editable content. */ +class HintEditor extends Editor { + hint: string | undefined + hintPrefix = '' + + override render(width: number): string[] { + const lines = super.render(width) + if (this.hint === undefined || this.getText() !== '') return lines + const content = lines[0] + /* v8 ignore next -- Editor always renders one content row. */ + if (content === undefined) return lines + const padding = ' '.repeat(this.getPaddingX()) + /* v8 ignore next -- the mounted editor is focused whenever its empty-input hint is rendered. */ + const marker = this.focused ? CURSOR_MARKER : '' + const available = Math.max(0, width - visibleWidth(padding) - visibleWidth(this.hintPrefix)) + const placeholder = truncateToWidth(this.hint, available, '') + const used = visibleWidth(padding) + visibleWidth(this.hintPrefix) + visibleWidth(placeholder) + lines[0] = `${padding}${this.hintPrefix}${marker}${placeholder}${' '.repeat(Math.max(0, width - used))}` + return lines } } -interface Palette { - accent: (text: string) => string - accent2: (text: string) => string - text: (text: string) => string - muted: (text: string) => string - dim: (text: string) => string - success: (text: string) => string - warning: (text: string) => string - error: (text: string) => string - code: (text: string) => string - added: (text: string) => string - removed: (text: string) => string - bold: (text: string) => string - italic: (text: string) => string - underline: (text: string) => string - strike: (text: string) => string - /** Reverse video for the active selection; swaps the theme's own fg/bg so it reads on any scheme. */ - selected: (text: string) => string -} - -function ansi(open: string, close: string, enabled: boolean): (text: string) => string { - return enabled ? text => `\x1b[${open}m${text}\x1b[${close}m` : text => text -} - -const TERMINAL_CONTROL_PATTERN = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/gu -const TERMINAL_OSC_PATTERN = /(?:\u001B\]|\u009D)(?:(?!\u0007|\u001B\\)[\s\S])*(?:\u0007|\u001B\\|$)/gu -const TERMINAL_CSI_PATTERN = /(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]/gu -const TERMINAL_ESCAPE_PATTERN = /\u001B[@-_]/gu -const BRACKETED_PASTE_START = '\u001B[200~' -const BRACKETED_PASTE_END = '\u001B[201~' - -/** - * Escape external C0/C1 controls before pi-tui adds application-owned ANSI. - * Line feeds remain structural so transcript and tool output retain their layout. - */ -function displayText(text: string): string { - return text.replace(TERMINAL_CONTROL_PATTERN, control => - `\\x${control.charCodeAt(0).toString(16).padStart(2, '0')}`) -} - -/** Escape external controls for terminal fields that must remain on one line. */ -function displayInlineText(text: string): string { - return displayText(text).replaceAll('\n', '\\x0a') -} - -/** Remove terminal controls from clipboard text before an editable field stores it. */ -function sanitizePastedText(text: string): string { - return text - .replace(TERMINAL_OSC_PATTERN, '') - .replace(TERMINAL_CSI_PATTERN, '') - .replace(TERMINAL_ESCAPE_PATTERN, '') - .replace(TERMINAL_CONTROL_PATTERN, '') -} - -/** - * Theme-agnostic palette built from the standard 16-color ANSI set plus SGR - * attributes, which every terminal remaps to its active color scheme. Body - * `text` stays the terminal's default foreground so it reads on light and dark - * backgrounds alike; grouping uses foreground-only gutter bars and reverse - * video rather than fixed background fills. - */ -function createPalette(enabled: boolean, scheme: TerminalColorScheme = 'dark'): Palette { - return { - accent: ansi('94', '39', enabled), - accent2: ansi('95', '39', enabled), - text: text => text, - muted: ansi('90', '39', enabled), - // SGR 2 (dim) lightens text on a light background — substitute ANSI 90 - // (bright black / gray) which renders as a readable muted tone on any scheme. - dim: scheme === 'light' ? ansi('90', '39', enabled) : ansi('2', '22', enabled), - success: ansi('32', '39', enabled), - warning: ansi('33', '39', enabled), - error: ansi('31', '39', enabled), - // ANSI 36 (cyan) is difficult to read on a light background — use - // ANSI 34 (blue) which is legible on both light and dark schemes. - code: scheme === 'light' ? ansi('34', '39', enabled) : ansi('36', '39', enabled), - added: ansi('32', '39', enabled), - removed: ansi('31', '39', enabled), - bold: ansi('1', '22', enabled), - italic: ansi('3', '23', enabled), - underline: ansi('4', '24', enabled), - strike: ansi('9', '29', enabled), - selected: ansi('7', '27', enabled), - } -} - -/** - * DeepSeek brand gradient stops (indigo → light blue) taken from the - * deepseek.com logo, painted across the startup banner's product name on - * truecolor terminals. Fixed brand identity, deliberately outside the - * theme-adaptive {@link Palette}. - */ -const BRAND_GRADIENT = [ - [77, 107, 254], // #4D6BFE - [57, 130, 255], // #3982FF - [36, 152, 255], // #2498FF -] as const - -/** - * Sample {@link BRAND_GRADIENT} at fraction `t` via piecewise-linear - * interpolation across its stops. - * - * @param t - Position along the gradient; clamped to [0, 1]. - * @returns The interpolated `[r, g, b]` channels, each rounded to 0–255. - */ -function brandColorAt(t: number): readonly [number, number, number] { - const span = Math.min(Math.max(t, 0), 1) * (BRAND_GRADIENT.length - 1) - const index = Math.min(Math.floor(span), BRAND_GRADIENT.length - 2) - const local = span - index - // `index` is clamped to a valid adjacent pair, so both lookups are in-bounds. - const from = BRAND_GRADIENT[index] as readonly [number, number, number] - const to = BRAND_GRADIENT[index + 1] as readonly [number, number, number] - return [ - Math.round(from[0] + (to[0] - from[0]) * local), - Math.round(from[1] + (to[1] - from[1]) * local), - Math.round(from[2] + (to[2] - from[2]) * local), - ] -} - -/** - * Paint `text` left-to-right in the DeepSeek brand gradient with per-character - * 24-bit foreground codes, resetting to the default foreground at the end. - * Foreground-only, so it stays legible on any terminal background; the caller - * gates it on truecolor support and wraps it in bold. - * - * @param text - Text to colorize; sampled once per character. - * @returns `text` wrapped in truecolor SGR foreground codes. - */ -function gradientText(text: string): string { - // The sole caller passes the ASCII product name, so UTF-16 unit iteration - // samples exactly one color per visible letter. - const last = Math.max(1, text.length - 1) - let painted = '' - for (let index = 0; index < text.length; index += 1) { - const [r, g, b] = brandColorAt(index / last) - painted += `\x1b[38;2;${r};${g};${b}m${text.charAt(index)}` - } - return `${painted}\x1b[39m` -} - -function markdownTheme(palette: Palette): MarkdownTheme { - return { - heading: text => palette.accent(text), - link: text => palette.accent(text), - // pi-tui requires this URL slot but its current Markdown renderer does not invoke it. - /* v8 ignore next */ - linkUrl: text => palette.dim(text), - code: text => palette.code(text), - codeBlock: text => palette.text(text), - codeBlockBorder: text => palette.dim(text), - quote: text => palette.muted(text), - quoteBorder: text => palette.accent2(text), - hr: text => palette.dim(text), - listBullet: text => palette.accent(text), - bold: text => palette.bold(text), - italic: text => palette.italic(text), - strikethrough: text => palette.strike(text), - underline: text => palette.underline(text), - } -} - -function selectTheme(palette: Palette): SelectListTheme { - return { - selectedPrefix: palette.accent, - selectedText: palette.accent, - description: palette.muted, - scrollInfo: palette.dim, - noMatch: palette.warning, - } -} - -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) { - switch (block.type) { - case 'text': - case 'reasoning': - parts.push(block.text) - break - case 'tool-call': - parts.push(`${block.name}(${block.arguments})`) - break - case 'tool-result': - parts.push(contentText(block.content)) - break - default: { - const rawType = (block as { type?: unknown }).type - parts.push(`[${typeof rawType === 'string' ? rawType : 'content'}]`) - break - } - } - } - return parts.join('') -} - -function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'): string { - return content - .filter((block): block is Extract => block.type === type) - .map(block => block.text) - .join('\n\n') -} - -interface ModelChoice extends AgentLlmTarget { - modelName: string - description?: string - reasoning?: LlmModelReasoningInfo -} - -interface ModelDialogSelection { - choice: ModelChoice - reasoningEffort: ReasoningEffortId | undefined -} - -function targetLabel(target: AgentLlmTarget): string { - return `${target.provider}/${target.model}` -} - -function compactTargetLabel(target: AgentLlmTarget): string { - return `${target.model}${target.reasoningEffort === undefined ? '' : ` ${target.reasoningEffort}`}` -} - -function targetReasoningLabel(choice: ModelChoice, effort: ReasoningEffortId | undefined): string | undefined { - if (effort === undefined) return choice.reasoning === undefined ? undefined : 'provider default' - return choice.reasoning?.efforts.find(candidate => candidate.id === effort)?.name ?? effort -} - -function initialTarget(agent: Agent): AgentLlmTarget | undefined { - const logged = agent.session.requestHeader()?.config - if (logged !== undefined) { - if (logged.reasoningEffort === undefined) { - return { provider: logged.provider, model: logged.model } - } - return { - provider: logged.provider, - model: logged.model, - reasoningEffort: logged.reasoningEffort, - } - } - if (agent.options.provider === undefined || agent.options.model === undefined) return undefined - return { provider: agent.options.provider, model: agent.options.model } -} - -async function readModelChoices( - ctx: Context, - current: AgentLlmTarget | undefined, -): Promise { - const providers = ctx.llm.listProviders() - const groups = await Promise.all(providers.map(async (provider) => { - const advertised = await ctx.llm.listModels(provider.id) - const models: LlmModelInfo[] = [...advertised] - if ( - current?.provider === provider.id - && !models.some(model => model.id === current.model) - ) { - models.push({ provider: provider.id, id: current.model, name: current.model }) - } - return Promise.all(models.map(async (model): Promise => { - const reasoning = (await ctx.llm.resolveModelInfo(provider.id, model.id)).reasoning - return { - provider: provider.id, - model: model.id, - modelName: model.name, - ...model.description === undefined ? {} : { description: model.description }, - ...reasoning === undefined ? {} : { reasoning }, - } - })) - })) - return groups.flat() -} - -/** Milliseconds between banner sweep-reveal frames (~60 fps). */ -const BANNER_REVEAL_INTERVAL_MS = 15 - -/** Number of sweep frames the banner reveal spreads the terminal width over. */ -const BANNER_REVEAL_STEPS = 24 - -/** - * Borderless startup banner: product title, an optional configured subtitle, - * and the model/session detail line. No box frame — each line renders as plain - * left-padded text (matching transcript notices) so it reads on any theme. - */ -class HeaderComponent implements Component { - /** Columns of the banner currently revealed; `undefined` renders it whole. */ - private revealWidth: number | undefined - - constructor( - private readonly agent: Agent, - private readonly subtitle: () => string | undefined, - private readonly palette: Palette, - private readonly gradient: boolean, - private readonly currentModel: () => string | undefined, - ) {} - - /** Clip the banner to `width` columns (the sweep reveal); `undefined` restores it. */ - setRevealWidth(width: number | undefined): void { - this.revealWidth = width - } - - invalidate(): void {} - - render(width: number): string[] { - const usable = Math.max(1, width - 2) - const name = this.gradient - ? this.palette.bold(gradientText('DEEPSEEK')) - : this.palette.bold(this.palette.accent('DEEPSEEK')) - const title = `${name} ${this.palette.bold('HARNESS')}` - const model = displayText(this.currentModel() ?? 'model unset') - const detail = `${model} • ${displayText(this.agent.session.id)}` - const subtitle = this.subtitle() - const lines = [ - title, - ...subtitle === undefined ? [] : [this.palette.muted(displayText(subtitle))], - this.palette.dim(detail), - ] - .flatMap(line => wrapTextWithAnsi(line, usable)) - .map(line => ` ${truncateToWidth(line, usable, '')}`) - if (this.revealWidth === undefined) return lines - const revealed = this.revealWidth - return lines.map(line => truncateToWidth(line, revealed, '')) - } -} - -/** Milliseconds between elapsed-time refreshes of the running status line. */ -const STATUS_ELAPSED_INTERVAL_MS = 1000 - -/** Steering/cancel affordance shown on every running status line. */ -const STATUS_HINT = 'Enter sends steering, Esc cancels' - -/** - * Fine-grained activity of a running turn, derived in the TUI from session - * lifecycle events for the status line. It is presentation-only, not a durable - * agent state: `waiting` spans a step from its `step/start` until the first - * reasoning or text chunk, `thinking`/`responding` track reasoning/text deltas, - * and `executing` covers tool calls until the next step begins. - */ -type TurnPhase = 'waiting' | 'thinking' | 'responding' | 'executing' - -/** - * Live controller for the running status line: its {@link Loader}, the derived - * {@link TurnPhase}, the elapsed-time baselines the label reads, and the timer - * that refreshes it. Present only while the turn runs; `undefined` when idle. - */ interface RunningStatus { - loader: Loader - phase: TurnPhase - phaseStartedAt: number - stepStartedAt: number + turn: number | undefined + timer: ReturnType + /** Render clock when the turn began; origin of the glyph fade-in. */ + startedAt: number + /** The most recently rendered phase glyph, handed to the fade-out. */ + lastGlyph: string +} + +/** A running glyph fading out after its turn ended, before the caret returns. */ +interface FadingStatus { + glyph: string + /** Render clock when the turn ended; origin of the glyph fade-out. */ + endedAt: number timer: ReturnType } -/** Status-line label for each {@link TurnPhase}. */ -const TURN_PHASE_LABELS: Record = { - waiting: 'Waiting for the first token', - thinking: 'Thinking', - responding: 'Responding', - executing: 'Executing tools', +interface PendingQuestion { + request: AskUserQuestionRequest + index: number + answers: AskUserQuestionAnswerItem[] + resolve(answer: AskUserQuestionAnswer): void + reject(error: unknown): void + onAbort: () => void + overlay: TuiOverlaySession | undefined } -/** - * Format a non-negative elapsed span as a compact status duration: whole - * seconds under a minute (`8s`), else minutes and zero-padded seconds - * (`1m05s`). - * @param elapsedMs - Elapsed time in milliseconds; negatives clamp to zero. - * @returns The compact duration string. - */ -function formatStatusDuration(elapsedMs: number): string { - const total = Math.floor(Math.max(0, elapsedMs) / 1000) - if (total < 60) return `${total}s` - return `${Math.floor(total / 60)}m${(total % 60).toString().padStart(2, '0')}s` -} - -/** - * Compose the running status-line text from the current phase, its timers, and - * the queued-steering badge. The waiting phase spans the whole step so it shows - * one duration; later phases show time in the phase plus the running step - * total, and a non-zero `queued` count surfaces as a badge before the hint. - * @param phase - The current turn phase. - * @param phaseMs - Elapsed time in the current phase, in milliseconds. - * @param stepMs - Elapsed time in the current step, in milliseconds. - * @param queued - Count of pending steering messages; zero hides the badge. - * @returns The status-line text, including the steering/cancel hint. - */ -function formatTurnStatus(phase: TurnPhase, phaseMs: number, stepMs: number, queued: number): string { - const timing = phase === 'waiting' - ? formatStatusDuration(stepMs) - : `${formatStatusDuration(phaseMs)} · total ${formatStatusDuration(stepMs)}` - const badge = queued > 0 ? `${queued} queued · ` : '' - return `${TURN_PHASE_LABELS[phase]} ${timing} — ${badge}${STATUS_HINT}` -} - -/** - * Groups children behind a colored left-gutter bar (`▌`). Foreground-only, so - * it renders legibly on any terminal background — unlike a filled block whose - * body text would collide with the theme's default foreground. - */ -class GutterBox implements Component { - protected readonly children: Component[] = [] - - constructor(private readonly barFn: (text: string) => string, private readonly paddingY = 1) {} - - addChild(child: Component): void { - this.children.push(child) - } - - invalidate(): void { - for (const child of this.children) child.invalidate() - } - - render(width: number): string[] { - const inner = Math.max(1, width - 2) - const body: string[] = [] - for (const child of this.children) for (const line of child.render(inner)) body.push(line) - // Every caller adds a non-empty title/label child, so an all-empty box is unreachable; - // the guard preserves Box semantics (render nothing) rather than emitting stray gutter bars. - /* v8 ignore next */ - if (body.length === 0) return [] - const bar = this.barFn('▌') - const pad = Array.from({ length: this.paddingY }, () => '') - return [...pad, ...body, ...pad].map(line => `${bar} ${line}`) - } -} - -class UserMessageComponent extends GutterBox { - constructor(text: string, palette: Palette, mdTheme: MarkdownTheme, label = 'You') { - super(value => palette.accent(value)) - this.addChild(new Text(palette.bold(palette.accent(displayText(label))), 0, 0)) - this.addChild(new Markdown(displayText(text), 0, 0, mdTheme, { color: value => palette.text(value) }, { - preserveOrderedListMarkers: true, - preserveBackslashEscapes: true, - })) - } -} - -class AssistantMessageComponent extends Container { - constructor(content: readonly ContentBlock[], showReasoning: boolean, palette: Palette, mdTheme: MarkdownTheme) { - super() - const reasoning = displayText(textBlocks(content, 'reasoning').trim()) - const text = displayText(textBlocks(content, 'text').trim()) - if (reasoning && showReasoning) { - this.addChild(new Spacer(1)) - this.addChild(new Text(palette.italic(palette.muted('Reasoning')), 1, 0)) - this.addChild(new Markdown(reasoning, 1, 0, mdTheme, { - color: value => palette.muted(value), - italic: true, - })) - } - if (text) { - this.addChild(new Spacer(1)) - this.addChild(new Text(palette.bold(palette.accent2('Assistant')), 1, 0)) - this.addChild(new Markdown(text, 1, 0, mdTheme, { color: value => palette.text(value) })) - } - } -} - -interface StreamingBlock { - type: string - text: string -} - -class StreamingAssistantComponent extends Container { - private readonly blocks = new Map() - - constructor( - private showReasoning: boolean, - private readonly palette: Palette, - private readonly mdTheme: MarkdownTheme, - ) { - super() - } - - update(chunk: StreamChunk): void { - if (chunk.type === 'block-start') { - this.blocks.set(chunk.index, { type: chunk.blockType, text: '' }) - } else if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') { - const type = chunk.type === 'text-delta' ? 'text' : 'reasoning' - const block = this.blocks.get(chunk.index) ?? { type, text: '' } - block.text += chunk.text - this.blocks.set(chunk.index, block) - } else if (chunk.type === 'block-end' && (chunk.block.type === 'text' || chunk.block.type === 'reasoning')) { - this.blocks.set(chunk.index, { type: chunk.block.type, text: chunk.block.text }) - } - this.rebuild() - } - - setShowReasoning(show: boolean): void { - this.showReasoning = show - this.rebuild() - } - - private rebuild(): void { - this.clear() - const content: ContentBlock[] = [...this.blocks.entries()] - .sort(([left], [right]) => left - right) - .flatMap(([, block]) => { - if (block.type === 'text') return [{ type: 'text', text: block.text }] - if (block.type === 'reasoning') return [{ type: 'reasoning', text: block.text }] - return [] - }) - const component = new AssistantMessageComponent(content, this.showReasoning, this.palette, this.mdTheme) - for (const child of component.children) this.addChild(child) - } -} - -interface ParsedArguments { - value: unknown - valid: boolean -} - -function parseArguments(raw: string): ParsedArguments { - try { - return { value: JSON.parse(raw), valid: true } - } catch { - return { value: raw, valid: false } - } -} - -function pretty(value: unknown): string { - if (typeof value === 'string') return displayText(value) - // The lib declaration narrows `unknown` to a string-returning overload, but - // JSON.stringify returns undefined for runtime values such as symbols. - const serialized = JSON.stringify(value, null, 2) as string | undefined - return displayText(serialized ?? String(value)) -} - -function diffLines(diff: FileDiff, palette: Palette): string[] { - const lines = [palette.bold(displayText(diff.path))] - if (diff.oldText !== null) { - for (const line of displayText(diff.oldText).split('\n')) lines.push(palette.removed(`- ${line}`)) - } - for (const line of displayText(diff.newText).split('\n')) lines.push(palette.added(`+ ${line}`)) - return lines -} - -class ToolCardComponent implements Component { - private result: { content: ContentBlock[]; isError: boolean; meta?: JsonValue } | undefined - private expanded = false - private callView: ToolCallView - private resultView: ToolResultView | undefined - - constructor( - private readonly name: string, - private readonly parsed: ParsedArguments, - private readonly definition: ToolDefinition | undefined, - private readonly maxOutputLines: number, - private readonly palette: Palette, - ) { - this.callView = this.presentCall() - } - - private presentCall(): ToolCallView { - if (this.parsed.valid && this.definition?.presentCall) { - try { - const view = this.definition.presentCall(this.parsed.value) - if (view !== undefined) return view - } catch (error: unknown) { - return { card: 'generic', title: displayText(this.name), rawInput: `Presenter failed: ${String(error)}` } - } - } - return { card: 'generic', title: displayText(this.name), rawInput: this.parsed.value } - } - - updateResult(event: Extract['data']): void { - this.result = { - content: [...event.content], - isError: event.isError, - ...event.meta !== undefined ? { meta: event.meta } : {}, - } - if (this.parsed.valid && this.definition?.presentResult) { - try { - const view = this.definition.presentResult(this.parsed.value, this.result) - if (view !== undefined) this.resultView = view - } catch (error: unknown) { - this.resultView = { card: 'generic', content: [{ type: 'text', text: `Presenter failed: ${String(error)}` }] } - } - } - } - - setExpanded(expanded: boolean): void { - this.expanded = expanded - } - - invalidate(): void {} - - render(width: number): string[] { - const isError = this.result?.isError ?? false - 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, 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 - const box = new GutterBox(barFn, visibleBody.length > 0 ? 1 : 0) - box.addChild(new Text(this.palette.bold(title), 0, 0)) - if (visibleBody.length > 0) box.addChild(new Text(visibleBody.join('\n'), 0, 0)) - return box.render(width) - } - - private title(): string { - return this.resultView?.title ?? this.callView.title - } - - private renderBody(): string[] { - const view = this.resultView ?? this.callView - if (view.card === 'terminal') { - const pending = this.callView.card === 'terminal' ? this.callView : undefined - const lines: string[] = [] - if (pending?.description) lines.push(this.palette.muted(displayText(pending.description))) - if (pending?.cwd) lines.push(this.palette.dim(displayText(pending.cwd))) - if (this.resultView?.card === 'terminal') { - if (this.resultView.output) lines.push(...displayText(this.resultView.output).split('\n')) - if (this.resultView.exitCode !== undefined) lines.push(this.palette.dim(`[exit ${this.resultView.exitCode}]`)) - if (this.resultView.signal !== undefined) { - lines.push(this.palette.error(`[signal ${displayText(this.resultView.signal)}]`)) - } - } else if (this.result === undefined) { - // A pending terminal view is the call view itself; TerminalCallView requires a title. - lines.push(this.palette.code(`$ ${displayText((pending as TerminalCallView).title)}`)) - } else { - lines.push(...displayText(contentText(this.result.content)).split('\n')) - } - return lines.filter(Boolean) - } - if (view.card === 'diff') { - return view.diffs.flatMap((diff, index) => [ - ...index > 0 ? [''] : [], - ...diffLines(diff, this.palette), - ]) - } - const content = view.content ?? this.result?.content - const lines: string[] = [] - if (content !== undefined) lines.push(...displayText(contentText(content)).split('\n')) - const rawInput = this.result === undefined && this.callView.card === 'generic' - ? this.callView.rawInput - : undefined - if (rawInput !== undefined) lines.push(...pretty(rawInput).split('\n')) - return lines.filter((line, index, all) => line.length > 0 || (index > 0 && index < all.length - 1)) - } -} - -class TodoComponent implements Component { - private todos: readonly TodoItem[] = [] - - constructor(private readonly palette: Palette) {} - - update(todos: readonly TodoItem[]): void { - this.todos = todos - } - - invalidate(): void {} - - render(width: number): string[] { - if (this.todos.length === 0) return [] - const lines = [this.palette.bold(this.palette.accent('Plan'))] - for (const todo of this.todos) { - const prefix = todo.status === 'completed' - ? this.palette.success('✓') - : todo.status === 'in_progress' - ? this.palette.warning('●') - : this.palette.dim('○') - const content = displayText(todo.content) - const text = todo.status === 'completed' ? this.palette.muted(content) : content - lines.push(truncateToWidth(` ${prefix} ${text}`, width, '')) - } - return ['', ...lines] - } -} - -function formatTokens(value: number): string { - if (value < 1_000) return String(value) - if (value < 10_000) return `${(value / 1_000).toFixed(1)}k` - if (value < 1_000_000) return `${Math.round(value / 1_000)}k` - return `${(value / 1_000_000).toFixed(1)}m` +/** Lifecycle handle for a mounted interactive terminal channel. */ +export interface TuiController { + /** Stop rendering, restore the terminal, and reject pending questions. */ + dispose(): Promise } function formatCwd(cwd: string | undefined): string { @@ -1072,834 +318,25 @@ function formatCwd(cwd: string | undefined): string { return cwd } -/** - * Running token totals for the footer, keyed per turn/step so replayed or - * re-emitted usage replaces rather than double-counts; `input` is uncached - * input, cache buckets are disjoint. - */ -interface SessionTokenTotals { - input: number - output: number - cacheRead: number - cacheWrite: number - readonly byStep: Map -} - -function recordTokenUsage(totals: SessionTokenTotals, turn: number, step: number, usage: TokenUsage): void { - const key = `${turn}:${step}` - const previous = totals.byStep.get(key) - if (previous !== undefined) { - totals.input -= previous.inputTokens - totals.output -= previous.outputTokens - totals.cacheRead -= previous.cacheReadTokens ?? 0 - totals.cacheWrite -= previous.cacheWriteTokens ?? 0 - } - totals.byStep.set(key, usage) - totals.input += usage.inputTokens - totals.output += usage.outputTokens - totals.cacheRead += usage.cacheReadTokens ?? 0 - totals.cacheWrite += usage.cacheWriteTokens ?? 0 -} - -function recordEventUsage(totals: SessionTokenTotals, event: SessionEvent): void { - if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') { - recordTokenUsage(totals, event.data.turn, event.data.step, event.data.chunk.usage) - } else if (event.type === 'assistant/message' && event.data.usage !== undefined) { - recordTokenUsage(totals, event.data.turn, event.data.step, event.data.usage) - } -} - -/** - * Share of billed input (prompt) tokens served from the provider cache, as an - * integer percent, or `undefined` before any input is billed (avoids 0/0 and a - * meaningless rate on an empty session). - */ -function cacheHitRate(totals: SessionTokenTotals): number | undefined { - const billedInput = totals.input + totals.cacheRead + totals.cacheWrite - if (billedInput === 0) return undefined - return Math.round((totals.cacheRead / billedInput) * 100) -} - -function sessionTokens(session: Session): SessionTokenTotals { - const totals: SessionTokenTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, byStep: new Map() } - for (const event of session.events) { - recordEventUsage(totals, event) - } - return totals -} - -function formatDiagnosticNumber(value: number): string { - return value.toLocaleString('en-US') -} - -function formatDiagnosticTime(value: number): string { - return new Date(value).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/u, ' UTC') -} - -function formatDiagnosticCount(value: number, singular: string): string { - return `${String(value)} ${singular}${value === 1 ? '' : 's'}` -} - -function diagnosticMeter(percent: number, palette: Palette): string { - const width = 16 - const filled = Math.round(Math.min(100, Math.max(0, percent)) / 100 * width) - return `${palette.dim('[')}${palette.accent('█'.repeat(filled))}${palette.dim(`${'░'.repeat(width - filled)}]`)}` -} - -type StatusCardRow = readonly [label: string, value: string] - -/** Bordered, grouped field card for one point-in-time status snapshot. */ -class StatusCardComponent implements Component { - constructor( - private readonly groups: readonly (readonly StatusCardRow[])[], - private readonly palette: Palette, - ) {} - - invalidate(): void {} - - render(width: number): string[] { - const labels = this.groups.flatMap(group => group.map(([label]) => `${label}:`)) - const naturalLabelWidth = Math.max(...labels.map(label => label.length)) - const naturalBodyWidth = Math.max(...this.groups.flatMap(group => group.map(([, value]) => - 1 + naturalLabelWidth + 2 + visibleWidth(value)))) - const cardWidth = Math.min( - Math.max(8, width), - Math.max('Session status'.length + 5, naturalBodyWidth + 4), +function gitBranch(cwd: string): string | undefined { + try { + const env = Object.fromEntries( + Object.entries(process.env).filter(([name]) => !/(?:KEY|SECRET|TOKEN)/iu.test(name)), ) - const innerWidth = Math.max(1, cardWidth - 4) - const labelWidth = Math.min( - naturalLabelWidth, - Math.max(1, Math.floor(innerWidth / 3)), - ) - const body: string[] = [] - for (const [groupIndex, group] of this.groups.entries()) { - if (groupIndex > 0) body.push('') - for (const [label, value] of group) { - const plainLabel = truncateToWidth(`${label}:`, labelWidth, '') - const prefix = ` ${this.palette.muted(plainLabel.padEnd(labelWidth))} ` - const continuation = ' '.repeat(1 + labelWidth + 2) - const valueWidth = Math.max(1, innerWidth - visibleWidth(prefix)) - const wrapped = wrapTextWithAnsi(value, valueWidth) - for (const [lineIndex, line] of wrapped.entries()) { - body.push(`${lineIndex === 0 ? prefix : continuation}${line}`) - } - } - } - - const title = truncateToWidth('Session status', Math.max(1, cardWidth - 5), '') - const topTail = '─'.repeat(Math.max(0, cardWidth - visibleWidth(title) - 5)) - const top = `${this.palette.dim('╭─ ')}${this.palette.bold(this.palette.accent(title))}${this.palette.dim(` ${topTail}╮`)}` - const lines = [top] - for (const line of body) { - const clipped = truncateToWidth(line, innerWidth, '') - lines.push(`${this.palette.dim('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${this.palette.dim('│')}`) - } - lines.push(this.palette.dim(`╰${'─'.repeat(Math.max(0, cardWidth - 2))}╯`)) - return lines + const branch = execFileSync('git', ['branch', '--show-current'], { + cwd, + encoding: 'utf8', + env, + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 1_000, + }).trim() + /* v8 ignore next -- detached-HEAD behavior is exercised by the runtime smoke, not the unit checkout. */ + return branch === '' ? undefined : branch + } catch (_gitUnavailableOrOutsideWorktree) { + return undefined } } -class FooterComponent implements Component { - constructor( - private readonly agent: Agent, - private readonly palette: Palette, - private readonly toolsExpanded: () => boolean, - private readonly tokens: () => SessionTokenTotals, - private readonly cwdFormatter: TuiRuntime['formatCwd'], - private readonly currentModel: () => string | undefined, - private readonly contextPercent: () => number | undefined, - ) {} - - invalidate(): void {} - - render(width: number): string[] { - const totals = this.tokens() - const model = displayText(this.currentModel() ?? 'model unset') - const rate = cacheHitRate(totals) - const cache = rate === undefined ? '' : ` cache ${rate}%` - const formattedCwd = displayText( - this.cwdFormatter?.(this.agent.session.header.cwd) ?? formatCwd(this.agent.session.header.cwd), - ) - const left = `${model} ${formattedCwd} ↑${formatTokens(totals.input)} ↓${formatTokens(totals.output)}${cache}` - const contextPercent = this.contextPercent() - const context = contextPercent === undefined ? '' : `${contextPercent}% context ` - const right = `${context}tools:${this.toolsExpanded() ? 'expanded' : 'collapsed'}` - 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, '')] - } -} - -interface QuestionSelection { - selected: string[] - 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 - private readonly items: Map - private readonly choices: Map - private readonly efforts: Map - private readonly currentValue: string | undefined - - constructor( - choices: readonly ModelChoice[], - current: AgentLlmTarget | undefined, - maxVisible: number, - private readonly palette: Palette, - done: (selection: ModelDialogSelection) => void, - cancel: () => void, - ) { - this.items = new Map() - this.choices = new Map() - this.efforts = new Map() - this.currentValue = current === undefined ? undefined : targetLabel(current) - for (const choice of choices) { - const value = targetLabel(choice) - const isCurrent = current?.provider === choice.provider && current.model === choice.model - this.choices.set(value, choice) - this.efforts.set( - value, - isCurrent - ? current.reasoningEffort ?? choice.reasoning?.defaultEffort - : choice.reasoning?.defaultEffort, - ) - this.items.set(value, { - value, - label: displayText(value), - description: this.describeChoice(choice, isCurrent), - }) - } - this.list = new SelectList([...this.items.values()], 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({ - choice: selected, - reasoningEffort: this.efforts.get(item.value), - }) - } - this.list.onCancel = cancel - } - - private describeChoice(choice: ModelChoice, isCurrent: boolean): string { - const selectedEffort = this.efforts.get(targetLabel(choice)) - const effort = choice.reasoning?.efforts.find(candidate => candidate.id === selectedEffort) - const effortLabel = selectedEffort === undefined - ? choice.reasoning === undefined ? undefined : 'provider default' - : effort?.name ?? selectedEffort - return [ - displayText(choice.modelName), - ...choice.description === undefined ? [] : [displayText(choice.description)], - ...effortLabel === undefined ? [] : [displayText(effortLabel)], - ...isCurrent ? ['current'] : [], - ].join(' — ') - } - - private cycleReasoningEffort(): void { - const selectedItem = this.list.getSelectedItem() - /* v8 ignore next -- the dialog is opened only for a non-empty catalog. */ - if (selectedItem === null) return - const choice = this.choices.get(selectedItem.value) - if (choice?.reasoning === undefined) return - const current = this.efforts.get(selectedItem.value) - const efforts: Array = [ - ...choice.reasoning.defaultEffort === undefined ? [undefined] : [], - ...choice.reasoning.efforts.map(effort => effort.id), - ] - const currentIndex = efforts.indexOf(current) - const next = efforts[(currentIndex + 1) % efforts.length] - this.efforts.set(selectedItem.value, next) - const item = this.items.get(selectedItem.value) - /* v8 ignore next -- items and choices are constructed from the same values. */ - if (item === undefined) return - item.description = this.describeChoice(choice, selectedItem.value === this.currentValue) - } - - invalidate(): void { - this.list.invalidate() - } - - handleInput(data: string): void { - if (matchesKey(data, Key.shift(Key.tab))) { - this.cycleReasoningEffort() - } else { - 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 • Shift+Tab reasoning • Enter select • Esc cancel'), - ], width, this.palette) - } -} - -interface ResumeRoute { - provider: string - model: string -} - -interface ResumeCandidate { - record: SessionRecord - title: string - lastActivityAt: number - lastTurn: string - route?: ResumeRoute - goalPhase?: GoalPhase - disabledReason?: string -} - -function resumeTurnLabel(snapshot: SessionLogSnapshot): string { - const event = snapshot.events.findLast(item => item.type === 'turn/end') - if (event === undefined) return 'no completed turn' - const reason = event.data.reason - switch (reason.kind) { - case 'completed': return `turn ${event.data.turn}: completed` - case 'aborted': return `turn ${event.data.turn}: cancelled` - case 'error': return `turn ${event.data.turn}: error` - case 'disposed': return `turn ${event.data.turn}: disposed` - case 'max-tokens': return `turn ${event.data.turn}: max tokens` - case 'rejected': return `turn ${event.data.turn}: rejected` - case 'interrupted': return `turn ${event.data.turn}: interrupted` - default: return `turn ${event.data.turn}: unknown result` - } -} - -function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | undefined { - const header = snapshot.events.findLast(item => item.type === 'request/header') - if (header?.type === 'request/header') { - return { provider: header.data.header.config.provider, model: header.data.header.config.model } - } - const assistant = snapshot.events.findLast(item => item.type === 'assistant/message') - return assistant?.type === 'assistant/message' - ? { provider: assistant.data.provenance.provider, model: assistant.data.provenance.model } - : undefined -} - -function summarizeResumeCandidate( - record: SessionRecord, - snapshot: SessionLogSnapshot, - currentId: SessionId, - cwd: string | undefined, - availableProviders: ReadonlySet, -): ResumeCandidate { - const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session' - const route = resumeRoute(snapshot) - const foldedGoal = foldGoal(snapshot.events).goal - let disabledReason: string | undefined - if (record.header.id === currentId) disabledReason = 'current session' - else if (record.live) disabledReason = 'session is already live in this runtime' - else if (record.header.cwd !== cwd) disabledReason = 'different workspace' - else if (route !== undefined && !availableProviders.has(route.provider)) { - disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})` - } - return { - record, - title, - lastActivityAt: snapshot.events.at(-1)?.time ?? snapshot.session.createdAt, - lastTurn: resumeTurnLabel(snapshot), - ...route === undefined ? {} : { route }, - ...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase }, - ...disabledReason === undefined ? {} : { disabledReason }, - } -} - -/** Full-viewport keyboard selector over detached, preflighted resume summaries. */ -class ResumePicker implements Component, Focusable { - private readonly search = new Input() - private pasteBuffer: string | undefined - private selectedIndex = 0 - private error = '' - focused = false - - constructor( - private readonly candidates: readonly ResumeCandidate[], - private readonly maxVisible: number, - private readonly workspaceLabel: string, - private readonly viewportRows: () => number, - private readonly palette: Palette, - private readonly done: (candidate: ResumeCandidate) => void, - private readonly cancel: () => void, - ) {} - - invalidate(): void { - this.search.invalidate() - } - - private filtered(): ResumeCandidate[] { - const query = this.search.getValue().trim().toLocaleLowerCase() - if (query === '') return [...this.candidates] - return this.candidates.filter(candidate => candidate.title.toLocaleLowerCase().includes(query) - || candidate.record.header.id.toLocaleLowerCase().includes(query)) - } - - private visibleCandidateCount(): number { - const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / 4)) - return Math.min(this.maxVisible, candidateBudget) - } - - private handleBracketedPaste(data: string): boolean { - const start = data.indexOf(BRACKETED_PASTE_START) - if (this.pasteBuffer === undefined && start < 0) return false - if (this.pasteBuffer === undefined) { - const prefix = data.slice(0, start) - if (prefix !== '') this.handleInput(prefix) - this.pasteBuffer = data.slice(start + BRACKETED_PASTE_START.length) - } else { - this.pasteBuffer += data - } - const end = this.pasteBuffer.indexOf(BRACKETED_PASTE_END) - if (end < 0) return true - const pasted = sanitizePastedText(this.pasteBuffer.slice(0, end)) - const remaining = this.pasteBuffer.slice(end + BRACKETED_PASTE_END.length) - this.pasteBuffer = undefined - const previous = this.search.getValue() - this.search.handleInput(`${BRACKETED_PASTE_START}${pasted}${BRACKETED_PASTE_END}`) - if (this.search.getValue() !== previous) { - this.selectedIndex = 0 - this.error = '' - } - if (remaining !== '') this.handleInput(remaining) - this.invalidate() - return true - } - - handleInput(data: string): void { - if (this.handleBracketedPaste(data)) return - const filtered = this.filtered() - if (matchesKey(data, Key.ctrl('c'))) { - this.cancel() - return - } - if (matchesKey(data, Key.escape)) { - if (this.search.getValue() === '') this.cancel() - else { - this.search.setValue('') - this.selectedIndex = 0 - this.error = '' - } - } else if (matchesKey(data, Key.up)) { - this.selectedIndex = filtered.length === 0 - ? 0 - : (this.selectedIndex + filtered.length - 1) % filtered.length - } else if (matchesKey(data, Key.down)) { - this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + 1) % filtered.length - } else if (matchesKey(data, Key.pageUp)) { - this.selectedIndex = Math.max(0, this.selectedIndex - this.visibleCandidateCount()) - } else if (matchesKey(data, Key.pageDown)) { - this.selectedIndex = Math.min( - Math.max(0, filtered.length - 1), - this.selectedIndex + this.visibleCandidateCount(), - ) - } else if (matchesKey(data, Key.enter)) { - const selected = filtered[this.selectedIndex] - if (selected === undefined) this.error = 'No session matches this search.' - else if (selected.disabledReason !== undefined) this.error = selected.disabledReason - else this.done(selected) - } else { - const previous = this.search.getValue() - this.search.focused = this.focused - this.search.handleInput(data) - if (this.search.getValue() !== previous) { - this.selectedIndex = 0 - this.error = '' - } - } - this.invalidate() - } - - render(width: number): string[] { - this.search.focused = this.focused - const height = Math.max(1, this.viewportRows()) - const horizontalPadding = width >= 12 ? 2 : 0 - const contentWidth = Math.max(1, width - horizontalPadding * 2) - const indent = ' '.repeat(horizontalPadding) - const filtered = this.filtered() - if (this.selectedIndex >= filtered.length) this.selectedIndex = Math.max(0, filtered.length - 1) - const selected = filtered[this.selectedIndex] - const position = selected === undefined ? 0 : this.selectedIndex + 1 - const lines: string[] = [ - '', - `${indent}${this.palette.bold(this.palette.accent(`Resume session (${position} of ${filtered.length})`))}`, - '', - ] - - const searchInnerWidth = Math.max(1, contentWidth - 4) - lines.push(`${indent}${this.palette.dim(`╭${'─'.repeat(Math.max(0, contentWidth - 2))}╮`)}`) - const searchContent = this.search.render(searchInnerWidth).join('').replace(/^> /u, '⌕ ') - const clippedSearch = truncateToWidth(searchContent, searchInnerWidth, '') - lines.push( - `${indent}${this.palette.dim('│')} ${clippedSearch}${' '.repeat(Math.max(0, searchInnerWidth - visibleWidth(clippedSearch)))} ${this.palette.dim('│')}`, - `${indent}${this.palette.dim(`╰${'─'.repeat(Math.max(0, contentWidth - 2))}╯`)}`, - '', - `${indent}${this.palette.muted(displayText(this.workspaceLabel))}`, - '', - ) - - const visibleCount = this.visibleCandidateCount() - const start = Math.max(0, Math.min( - this.selectedIndex - Math.floor(visibleCount / 2), - filtered.length - visibleCount, - )) - const end = Math.min(filtered.length, start + visibleCount) - const push = (line: string): void => { - lines.push(`${indent}${truncateToWidth(line, contentWidth, '…')}`) - } - for (let index = start; index < end; index += 1) { - const candidate = filtered[index] as ResumeCandidate - const active = index === this.selectedIndex - const status = [ - candidate.disabledReason === 'current session' ? 'current' : undefined, - candidate.record.live ? 'live' : undefined, - candidate.record.persisted ? 'persisted' : undefined, - ].filter((value): value is string => value !== undefined).join(' · ') - const lead = `${active ? '❯' : ' '} ${displayText(candidate.title)}` - push(active ? this.palette.bold(this.palette.accent(lead)) : lead) - const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}` - const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}` - push(this.palette.muted(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`)) - push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`)) - if (candidate.disabledReason !== undefined) { - push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`)) - } - } - if (filtered.length === 0) push(this.palette.warning('No matching sessions.')) - if (this.error !== '') { - lines.push('') - push(this.palette.error(displayText(this.error))) - } - - const footer = `${indent}${this.palette.dim('Type to search • ↑/↓ navigate • Enter resume • Esc clear/cancel')}` - while (lines.length < height - 2) lines.push('') - lines.push(footer, '') - return lines.slice(0, height) - } -} - -class QuestionDialog implements Component, Focusable { - private selectedIndex = 0 - private selected = new Set() - private mode: 'options' | 'custom' - private error = '' - private readonly input = new Input() - private readonly options: NonNullable - focused = false - - 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, - private readonly cancel: () => void, - ) { - this.options = question.options ?? [] - this.mode = this.options.length > 0 ? 'options' : 'custom' - this.input.onSubmit = (value) => { this.submitCustom(value) } - this.input.onEscape = () => { - if (this.options.length > 0) { - this.mode = 'options' - this.error = '' - } else { - this.cancel() - } - } - } - - invalidate(): void { - this.input.invalidate() - } - - handleInput(data: string): void { - this.invalidate() - if (this.mode === 'custom') { - this.input.focused = this.focused - this.input.handleInput(data) - return - } - const options = this.options - if (matchesKey(data, Key.up)) { - this.selectedIndex = this.selectedIndex === 0 ? options.length - 1 : this.selectedIndex - 1 - } else if (matchesKey(data, Key.down)) { - this.selectedIndex = this.selectedIndex === options.length - 1 ? 0 : this.selectedIndex + 1 - } else if (matchesKey(data, Key.space) && this.question.multiSelect) { - if (this.selected.has(this.selectedIndex)) this.selected.delete(this.selectedIndex) - else this.selected.add(this.selectedIndex) - } 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 Tab for a custom answer.' - return - } - this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) }) - } 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'))) { - this.cancel() - } - } - - private submitCustom(value: string): void { - const custom = value.trim() - if (custom === '') { - this.error = 'Enter an answer before submitting.' - return - } - this.done({ selected: [], custom }) - } - - render(width: number): string[] { - this.input.focused = this.focused - const innerWidth = Math.max(1, width - 4) - 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) } - // Supporting detail (e.g. the full plan under review) renders between the - // question and the answer surface, kept out of option labels. - if (this.question.detail !== undefined) { - push('') - for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) push(line) - } - push('') - 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')) - } else { - const options = this.options - const start = Math.max(0, Math.min( - this.selectedIndex - Math.floor(this.maxVisible / 2), - options.length - this.maxVisible, - )) - const end = Math.min(options.length, start + this.maxVisible) - const optionRows = options.slice(start, end).map((option, offset) => { - const index = start + offset - const mark = this.question.multiSelect - ? this.selected.has(index) ? '[x] ' : '[ ] ' - : '' - return `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}` - }) - const descriptionColumn = Math.min( - Math.max(...optionRows.map(row => visibleWidth(row))) + 2, - Math.max(1, Math.floor(innerWidth * 0.55)), - ) - for (let index = start; index < end; index += 1) { - // `index < end <= options.length`; the options array is borrowed immutably for this dialog. - const option = options[index] as NonNullable[number] - const mark = this.question.multiSelect - ? this.selected.has(index) ? '[x] ' : '[ ] ' - : '' - 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}`)) - 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) { - 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)))} ` - }) - } -} - -interface PendingQuestion { - request: AskUserQuestionRequest - index: number - answers: AskUserQuestionAnswerItem[] - resolve(answer: AskUserQuestionAnswer): void - reject(error: unknown): void - onAbort: () => void - overlay: TuiOverlaySession | undefined -} - -/** Merge path-only file candidates and optional session snapshots with commands. */ -class ReferenceAutocompleteProvider implements AutocompleteProvider { - constructor( - private readonly base: CombinedAutocompleteProvider, - private readonly files: WorkspaceFileSearch, - private readonly sessions: SessionReferenceService | undefined, - private readonly agent: Agent, - ) {} - - async getSuggestions( - lines: string[], - cursorLine: number, - cursorCol: number, - options: { signal: AbortSignal; force?: boolean }, - ): Promise { - const basePromise = this.base.getSuggestions(lines, cursorLine, cursorCol, options) - const currentLine = lines[cursorLine] - /* v8 ignore next -- Editor always supplies its current state line. */ - if (currentLine === undefined) return basePromise - const token = activeAtToken(currentLine, cursorCol) - if (token === undefined) { - this.files.invalidate() - return basePromise - } - const filePromise = this.files.list(token.query, options.signal).catch(() => []) - const sessionPromise = this.sessions === undefined || token.quoted - ? Promise.resolve([]) - : this.sessions.listCandidates(this.agent, token.query, undefined, options.signal).catch(() => []) - const [base, fileCandidates, sessionCandidates] = await Promise.all([ - basePromise, - filePromise, - sessionPromise, - ]) - if (options.signal.aborted) return base - const fileItems: AutocompleteItem[] = fileCandidates.flatMap((candidate) => { - const value = formatFileMention(candidate, token.quoted) - if (value === undefined) return [] - const name = candidate.path.slice(candidate.path.lastIndexOf('/') + 1) - const directory = candidate.kind === 'directory' - return [{ - value, - label: `${directory ? 'Folder' : 'File'} · ${displayInlineText(name)}${directory ? '/' : ''}`, - description: displayInlineText(candidate.path), - }] - }) - const sessionItems: AutocompleteItem[] = sessionCandidates.map((candidate) => { - const mentionLabel = displayInlineText(candidate.label) - const sessionId = displayInlineText(candidate.sessionId) - const location = candidate.cwd === undefined ? '(no cwd)' : displayInlineText(candidate.cwd) - const description = `${candidate.label === candidate.sessionId ? '' : `${sessionId} · `}${location} · ${new Date(candidate.createdAt).toISOString()}` - return { - value: formatSessionReferenceMention({ sessionId: candidate.sessionId, label: mentionLabel }), - label: `Session · ${mentionLabel}`, - description, - } - }) - const items = [...fileItems, ...sessionItems] - if (items.length === 0) return base - return { items: [...items, ...(base?.items ?? [])], prefix: token.prefix } - } - - applyCompletion( - lines: string[], - cursorLine: number, - cursorCol: number, - item: AutocompleteItem, - prefix: string, - ): { lines: string[]; cursorLine: number; cursorCol: number } { - return this.base.applyCompletion(lines, cursorLine, cursorCol, item, prefix) - } - - shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean { - return this.base.shouldTriggerFileCompletion(lines, cursorLine, cursorCol) - } -} - -/** Lifecycle handle for a mounted interactive terminal channel. */ -export interface TuiController { - /** Stop rendering, restore the terminal, and reject pending questions. */ - dispose(): Promise -} - -/** Prefix that marks an editor submission as a manual skill invocation. */ -const SKILL_COMMAND_PREFIX = '/skill:' - -/** Parsed `/skill: [instructions]` submission; `name` is empty when the prefix carries no name. */ -interface ParsedSkillCommand { - /** Skill name typed after `/skill:`, up to the first space. */ - name: string - /** Trimmed text after the name; empty when none was typed. */ - instructions: string -} - -/** - * Split a `/skill: [instructions]` submission into its name and trailing instructions. - * @param text - trimmed submission that starts with {@link SKILL_COMMAND_PREFIX}. - * @returns the skill name and any trailing instructions. - */ -function parseSkillCommand(text: string): ParsedSkillCommand { - const rest = text.slice(SKILL_COMMAND_PREFIX.length) - const spaceIndex = rest.indexOf(' ') - if (spaceIndex === -1) return { name: rest, instructions: '' } - return { name: rest.slice(0, spaceIndex), instructions: rest.slice(spaceIndex + 1).trim() } -} - -/** Model-visible line locating a manually invoked skill's relative resources, or `undefined` when the provider has no base. */ -function skillResourceReference(base: SkillResourceBase | undefined): string | undefined { - if (base === undefined) return undefined - switch (base.kind) { - case 'directory': - return `References in this skill are relative to ${base.path}.` - case 'url': - return `References in this skill are relative to ${base.url}.` - case 'opaque': - return base.description - default: - return assertNever(base, 'SkillResourceBase.kind') - } -} - -/** - * Render a manually invoked skill into the model-visible user-message text. The - * `` block carries the body and, when the provider supplies one, its - * resource base; the trimmed `instructions` follow the block as the user's - * request for this turn. The name is registry-validated kebab-case - * ({@link SkillService} rejects any other) and the resource base is trusted - * same-process provider prose, so — unlike the model-facing `dsh-tool-skill` - * result, which escapes for a tool channel — this user turn is assembled raw. - * @param skill - the loaded skill definition. - * @param instructions - trimmed text typed after `/skill:`; empty when absent. - * @returns the user-message text delivered to the agent. - */ -export function renderSkillInvocation(skill: SkillDefinition, instructions: string): string { - const lines = [``] - const reference = skillResourceReference(skill.resourceBase) - if (reference !== undefined) lines.push(reference, '') - lines.push(skill.content, '') - const block = lines.join('\n') - return instructions === '' ? block : `${block}\n\n${instructions}` -} - function activeSurfaceSeqs(session: Session): Set { return new Set(session.surface.nodes) } @@ -1939,6 +376,12 @@ function activeToolCallIds(session: Session, active: ReadonlySet): Set renderTuiPromptTemplate(inputTemplate, valueName => ctx.tuiPrompt.get(valueName)) + const initialInputPrompt = renderInputPrompt() + const editor = new HintEditor(ui, { borderColor: palette.dim, selectList: selectTheme(palette), - } satisfies EditorTheme, { paddingX: 1 }) + } satisfies EditorTheme, { + paddingX: 1, + frame: 'none', + prompt: { + first: initialInputPrompt, + continuation: ' '.repeat(visibleWidth(initialInputPrompt)), + }, + }) + editor.hintPrefix = initialInputPrompt const todo = new TodoComponent(palette) let showReasoning = resolved.showReasoning let toolsExpanded = false let streaming: StreamingAssistantComponent | undefined + let completedStreaming: StreamingAssistantComponent | undefined let runningStatus: RunningStatus | undefined + let fadingStatus: FadingStatus | undefined // Steering messages queued during the running turn (`agent/inbox/enqueue` // with `info.steering`) that the loop has not yet drained, shown as a badge on // the status line. Each entry is the queued message's serialized source: a @@ -2025,27 +480,82 @@ export function createTuiChat( agent, () => sessionTitle ?? config.welcome, palette, - resolved.color && resolved.truecolor, - () => target.current === undefined ? undefined : compactTargetLabel(target.current), + resolved.theme.color && resolved.theme.truecolor, ) - const footer = new FooterComponent( - agent, - palette, - () => toolsExpanded, - () => tokens, - runtime.formatCwd, - () => target.current === undefined ? undefined : compactTargetLabel(target.current), - () => contextWindow === undefined - ? undefined - : Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / contextWindow * 100)), + const formattedCwd = displayText(runtime.formatCwd?.(agent.session.header.cwd) ?? formatCwd(agent.session.header.cwd)) + const branch = runtime.gitBranch?.(cwd) ?? gitBranch(cwd) + const promptValues: TuiPromptValueHandle[] = [ + ctx.tuiPrompt.register('cwd', palette.bold(palette.accent(formattedCwd))), + ctx.tuiPrompt.register('git/worktree', branch === undefined ? undefined : palette.muted(` (${displayText(branch)})`)), + ctx.tuiPrompt.register('token_meter/cache_hit_rate'), + ctx.tuiPrompt.register('model'), + ctx.tuiPrompt.register('context'), + ctx.tuiPrompt.register('timing'), + ctx.tuiPrompt.register('symbol', palette.bold(palette.accent('dsh'))), + ctx.tuiPrompt.register('indicator', palette.muted('> ')), + ] + const [cwdValue, gitValue, tokenValue, modelValue, contextValue, timingValue, symbolValue, indicatorValue] = promptValues + /* v8 ignore next -- the fixed built-in registration list always supplies each handle. */ + if (cwdValue === undefined || gitValue === undefined || tokenValue === undefined || modelValue === undefined + || contextValue === undefined || timingValue === undefined || symbolValue === undefined || indicatorValue === undefined) { + throw new Error('TUI prompt built-ins failed to initialize') + } + const updatePromptValues = (): void => { + cwdValue.set(palette.bold(palette.accent(formattedCwd))) + gitValue.set(branch === undefined ? undefined : palette.muted(` (${displayText(branch)})`)) + const rate = cacheHitRate(tokens) + const usage = `↑${formatTokens(tokens.input)} ↓${formatTokens(tokens.output)}` + modelValue.set(` ${palette.muted(displayText(target.current === undefined ? 'model unset' : compactTargetLabel(target.current)))}`) + tokenValue.set(` ${palette.muted(rate === undefined ? usage : `${usage} cache ${rate}%`)}`) + contextValue.set(contextWindow === undefined ? undefined : ` ${palette.muted( + `${Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / contextWindow * 100))}% context`, + )}`) + const queued = runningStatus === undefined ? undefined : formatQueuedStatus(pendingSteering.length) + timingValue.set(queued === undefined ? undefined : palette.dim(queued)) + symbolValue.set(palette.bold(palette.accent('dsh'))) + // `${indicator}` owns the caret column and its trailing gap before the + // cursor. The phase glyph replaces the `>` caret in place — same width + // every frame — fading in as a turn starts, throbbing while it runs, and + // fading out after it ends before the plain `>` returns. Only the gray + // brightness changes, so the cursor never shifts. + const runningGlyph = runningPhaseGlyph(agent.session.events, runningStatus !== undefined) + // Remember the live phase glyph so the fade-out shows it, not the ttft + // fallback the derivation returns once the closing turn's step has ended. + if (runningStatus !== undefined && runningGlyph !== undefined) runningStatus.lastGlyph = runningGlyph + // The fade envelope gates appear/disappear; the running throb breathes the + // glyph the whole turn. Truecolor opacity is envelope × throb; the + // non-truecolor fallback keys visibility off the envelope alone, so the + // throb never blinks it. `envelope` clamps to [0, 1]. + const envelope = runningStatus !== undefined && runningGlyph !== undefined + ? { glyph: runningGlyph, level: Math.min(1, (now() - runningStatus.startedAt) / STATUS_FADE_MS) } + : fadingStatus !== undefined + ? { glyph: fadingStatus.glyph, level: Math.max(0, 1 - (now() - fadingStatus.endedAt) / STATUS_FADE_MS) } + : undefined + const caret = envelope === undefined + ? palette.muted('>') + : fadeGlyph( + envelope.glyph, + palette, + resolved.theme.color, + resolved.theme.color && resolved.theme.truecolor, + envelope.level * pulseLevel(now()), + envelope.level >= 0.5, + ) + indicatorValue.set(`${caret}${palette.muted(' ')}`) + } + updatePromptValues() + const promptContext = new PromptContextComponent( + parseTuiPromptTemplate(displayInlineText(resolved.theme.leftPrompt)), + parseTuiPromptTemplate(displayInlineText(resolved.theme.rightPrompt)), + valueName => ctx.tuiPrompt.get(valueName), ) ui.addChild(header) ui.addChild(chat) - ui.addChild(statusContainer) + ui.addChild(new Spacer(1)) todoContainer.addChild(todo) ui.addChild(todoContainer) + ui.addChild(promptContext) ui.addChild(editor) - ui.addChild(footer) ui.setFocus(editor) const updateTerminalTitle = (): void => { runtime.terminal.setTitle(displayText( @@ -2055,14 +565,23 @@ export function createTuiChat( updateTerminalTitle() const requestRender = (): void => { - footer.invalidate() + if (disposed) return + updatePromptValues() + const inputPrompt = renderInputPrompt() + editor.setPrompt({ first: inputPrompt, continuation: ' '.repeat(visibleWidth(inputPrompt)) }) + editor.hintPrefix = inputPrompt + promptContext.invalidate() ui.requestRender() } + // A prompt value that changes on its own schedule (e.g. a plugin-owned + // `${custom}` fragment) redraws through the registry's coalesced notification; + // built-ins are already covered by the state-change callers of requestRender. + const disposePromptChanges = ctx.tuiPrompt.subscribe(requestRender) const appendNotice = (message: string, kind: 'info' | 'warning' | 'error' = 'info'): void => { const color = kind === 'error' ? palette.error : kind === 'warning' ? palette.warning : palette.muted chat.addChild(new Spacer(1)) - chat.addChild(new Text(color(displayText(message)), 1, 0)) + chat.addChild(new Text(color(displayText(message)), 0, 0)) requestRender() } @@ -2164,7 +683,7 @@ export function createTuiChat( target.current, resolved.maxModelOptions, palette, - (selection) => { + (selection: ModelDialogSelection) => { void session.close() selectModel(selection.choice, { effort: selection.reasoningEffort }) }, @@ -2228,58 +747,61 @@ export function createTuiChat( }) } + const renderStatus = (): void => { + streaming?.invalidate() + requestRender() + } + + /** Stop the running and fade-out timers and drop both states at once. */ const clearStatus = (): void => { if (runningStatus !== undefined) { clearInterval(runningStatus.timer) - runningStatus.loader.stop() runningStatus = undefined } - statusContainer.clear() + if (fadingStatus !== undefined) { + clearInterval(fadingStatus.timer) + fadingStatus = undefined + } runtime.terminal.setProgress(false) } - // Refresh the status line's elapsed timers and queued badge from the - // controller's phase and the current steering count. - const renderStatus = (running: RunningStatus): void => { - const at = now() - running.loader.setMessage( - formatTurnStatus(running.phase, at - running.phaseStartedAt, at - running.stepStartedAt, pendingSteering.length), - ) - } - - // Move to a derived phase, resetting the phase timer on a genuine change and - // the step timer when a new step begins; ignored unless a turn is running. - const enterPhase = (phase: TurnPhase, resetStep: boolean): void => { - const running = runningStatus - if (running === undefined) return - const at = now() - if (resetStep) running.stepStartedAt = at - if (phase !== running.phase || resetStep) running.phaseStartedAt = at - running.phase = phase - renderStatus(running) + /** + * On the running → non-running edge, hand the last rendered glyph to a + * fade-out that re-renders until it settles on the `>` caret, then stops its + * own timer. A hard clear (teardown) skips this via {@link clearStatus}. + */ + const beginFadeOut = (glyph: string): void => { + clearStatus() + const fading: FadingStatus = { + glyph, + endedAt: now(), + timer: setInterval(() => { + if (now() - fading.endedAt >= STATUS_FADE_MS) clearStatus() + renderStatus() + }, STATUS_ANIMATION_INTERVAL_MS), + } + fadingStatus = fading } const setStatus = (status: AgentStatus): void => { - // A running→running rebuild (a mid-turn palette swap re-derives the border) - // carries the derived phase and both elapsed baselines across; only a fresh - // idle→running turn starts at `waiting`. - const prior = runningStatus - clearStatus() + const priorTurn = runningStatus?.turn + const fadeOutGlyph = status !== 'running' ? runningStatus?.lastGlyph : undefined + if (status === 'running') clearStatus() + else if (fadeOutGlyph !== undefined) beginFadeOut(fadeOutGlyph) + else clearStatus() editor.borderColor = status === 'running' ? text => palette.accent(text) : text => palette.dim(text) + editor.hint = status === 'running' ? palette.dim(displayInlineText(resolved.theme.inputPlaceholder)) : undefined if (status === 'running') { - const at = now() - const phase = prior?.phase ?? 'waiting' - const phaseStartedAt = prior?.phaseStartedAt ?? at - const stepStartedAt = prior?.stepStartedAt ?? at - const message = formatTurnStatus(phase, at - phaseStartedAt, at - stepStartedAt, pendingSteering.length) - const loader = new Loader(ui, text => palette.accent(text), text => palette.muted(text), message) - statusContainer.addChild(loader) + const turn = priorTurn ?? openTurn(agent.session.events) const running: RunningStatus = { - loader, - phase, - phaseStartedAt, - stepStartedAt, - timer: setInterval(() => { renderStatus(running) }, STATUS_ELAPSED_INTERVAL_MS), + turn, + startedAt: now(), + // Seed with the current phase (ttft before the first step opens) so the + // fade-out always has a glyph, even for a turn that ends before a render. + lastGlyph: TIMING_BUCKET_GLYPHS[openStepPhase(agent.session.events) ?? 'ttft'], + // Refresh every tick so the fading prompt phase glyph animates even + // before the first token, when no streaming component exists yet. + timer: setInterval(renderStatus, STATUS_ANIMATION_INTERVAL_MS), } runningStatus = running runtime.terminal.setProgress(true) @@ -2287,35 +809,8 @@ export function createTuiChat( requestRender() } - // Refresh the running status line's queued-steering badge from the current - // count; a no-op when idle because the controller only exists while running. const refreshStatus = (): void => { - if (runningStatus !== undefined) renderStatus(runningStatus) - requestRender() - } - - // Derive the status-line phase from live session lifecycle events. The event - // map is merge-extensible, so unhandled types fall through the default. - const advanceTurnPhase = (event: SessionEvent): void => { - switch (event.type) { - case 'step/start': - enterPhase('waiting', true) - break - case 'assistant/chunk': { - const chunk = event.data.chunk - if (chunk.type === 'reasoning-delta' || (chunk.type === 'block-start' && chunk.blockType === 'reasoning')) { - enterPhase('thinking', false) - } else if (chunk.type === 'text-delta' || (chunk.type === 'block-start' && chunk.blockType === 'text')) { - enterPhase('responding', false) - } - break - } - case 'tool/call': - enterPhase('executing', false) - break - default: - break - } + renderStatus() } const parsedTool = (event: Extract): ToolCardComponent => { @@ -2326,6 +821,7 @@ export function createTuiChat( ctx.tools.get(event.data.name, agent), resolved.maxToolOutputLines, palette, + mdTheme, ) card.setExpanded(toolsExpanded) toolCards.set(event.data.callId, card) @@ -2333,15 +829,62 @@ export function createTuiChat( return card } - const clearStreaming = (): void => { + const removeStreaming = (current: StreamingAssistantComponent | undefined): void => { + if (current === undefined) return + for (const child of [current, current.timing]) { + const index = chat.children.indexOf(child) + /* v8 ignore next -- streaming components and their timing footers are retained only while attached to the chat. */ + if (index >= 0) chat.children.splice(index, 1) + } + } + + /** + * Move the running step's timing footer to the tail of the chat so it trails + * the tool cards the step just appended. A completed footer (its step ended, + * so `streaming` is cleared) stays pinned where it is. + */ + const trailStreamingTiming = (): void => { + /* v8 ignore next -- every replayed tool event follows its step/start, so an open step always owns an attached footer here. */ if (streaming === undefined) return - const index = chat.children.indexOf(streaming) - /* v8 ignore next -- streaming is assigned only after the same component is added, and every removal clears it. */ - if (index >= 0) chat.children.splice(index, 1) + const footer = streaming.timing + const index = chat.children.indexOf(footer) + /* v8 ignore next -- the open step's footer is attached to the chat whenever a tool event of that step renders. */ + if (index < 0) return + chat.children.splice(index, 1) + chat.addChild(footer) + } + + const clearStreaming = (): void => { + removeStreaming(streaming) streaming = undefined } - const renderEvent = (event: SessionEvent, options: { addHistory: boolean; renderChunks: boolean }): void => { + const retractFailedStreaming = (): void => { + removeStreaming(streaming ?? completedStreaming) + streaming = undefined + completedStreaming = undefined + } + + const startAssistantStep = (position: StepPosition): void => { + streaming = new StreamingAssistantComponent( + position, + () => agent.session.events, + now, + showReasoning, + palette, + mdTheme, + ) + chat.addChild(streaming) + chat.addChild(streaming.timing) + } + + const renderEvent = ( + event: SessionEvent, + options: { + addHistory: boolean + renderChunks: boolean + }, + ): void => { switch (event.type) { case 'user/message': { // Injected context (plugin/goal source) renders as a dim context card, @@ -2352,18 +895,29 @@ export function createTuiChat( const references = sessionReferenceCard(event.data.meta) if (references !== undefined) { chat.addChild(new Spacer(1)) - chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0)) + chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 0, 0)) break } - const text = displayText(contentText(event.data.content).trim()) + const text = contentText(event.data.content).trim() + /* v8 ignore next -- context events with empty content are rejected by their owning producers. */ if (text) { // The tui type view lacks plugin-augmented source kinds (e.g. goal), // so read the display label without narrowing on `kind`. const labelled = source as { kind: string; plugin?: string } + /* v8 ignore next -- current plugin-augmented context sources always carry their display label. */ const label = labelled.plugin ?? labelled.kind + const xml = renderUnknownXml( + text, + resolved.maxToolOutputLines, + true, + displayText, + value => palette.muted(value), + /* v8 ignore next -- expanded context XML never asks renderUnknownXml for a collapsed summary. */ + () => '', + ) chat.addChild(new Spacer(1)) - chat.addChild(new Text(palette.dim(`Context · ${displayText(label)}`), 1, 0)) - chat.addChild(new Text(palette.muted(text), 1, 0)) + chat.addChild(new Text(palette.dim(`Context · ${displayText(label)}`), 0, 0)) + chat.addChild(new Text(xml?.join('\n') ?? palette.muted(displayText(text)), 0, 0)) } break } @@ -2375,7 +929,7 @@ export function createTuiChat( } for (const references of promptReferenceCards(event)) { chat.addChild(new Spacer(1)) - chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0)) + chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 0, 0)) } break } @@ -2387,30 +941,27 @@ export function createTuiChat( } for (const references of promptReferenceCards(event)) { chat.addChild(new Spacer(1)) - chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0)) + chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 0, 0)) } break } + case 'prompt/blocked': appendNotice(`Prompt blocked: ${event.data.reason}`, 'warning') break + case 'step/start': + startAssistantStep(event.data) + break case 'assistant/chunk': - if (options.renderChunks) { - if (streaming === undefined) { - streaming = new StreamingAssistantComponent(showReasoning, palette, mdTheme) - chat.addChild(streaming) - } - streaming.update(event.data.chunk) - } + if (options.renderChunks) streaming?.update(event.data.chunk) break - case 'assistant/message': { - clearStreaming() - const component = new AssistantMessageComponent(event.data.content, showReasoning, palette, mdTheme) - if (component.children.length > 0) chat.addChild(component) + case 'assistant/message': + completedStreaming = undefined + if (streaming === undefined || !chat.children.includes(streaming)) startAssistantStep(event.data) + streaming?.settle(event.data.content) break - } case 'llm/retry': { - clearStreaming() + retractFailedStreaming() appendNotice( `Retrying model request (${event.data.retry}/${event.data.maxRetries}) in ${event.data.delayMs}ms: ${event.data.failure.message}`, 'warning', @@ -2420,17 +971,19 @@ export function createTuiChat( case 'tool/call': chat.addChild(new Spacer(1)) chat.addChild(parsedTool(event)) + trailStreamingTiming() break case 'tool/result': { let card = toolCards.get(event.data.callId) if (card === undefined) { - card = new ToolCardComponent('tool', { value: {}, valid: true }, undefined, resolved.maxToolOutputLines, palette) + card = new ToolCardComponent('tool', { value: {}, valid: true }, undefined, resolved.maxToolOutputLines, palette, mdTheme) chat.addChild(new Spacer(1)) chat.addChild(card) allToolCards.add(card) } card.updateResult(event.data) toolCards.delete(event.data.callId) + trailStreamingTiming() break } case 'todo/write': @@ -2441,24 +994,50 @@ export function createTuiChat( header.invalidate() updateTerminalTitle() break - case 'turn/end': + case 'step/end': + if (streaming === undefined) startAssistantStep(event.data) + streaming?.complete(event.time) + completedStreaming = streaming + streaming = undefined + break + // Every turn/end kind presents why the agent stopped: `completed` is + // presented by the settled assistant message and its Completed timing + // header; every other kind appends an explicit notice. + case 'turn/end': { clearStreaming() - if (event.data.reason.kind === 'error') { - const key = `${event.data.turn}:${event.data.reason.step}` - const message = 'failure' in event.data.reason - ? event.data.reason.failure.message - : event.data.reason.message - if (!liveErrors.delete(key)) appendNotice(message, 'error') - } else if (event.data.reason.kind === 'aborted') { - appendNotice('Turn cancelled.', 'warning') - } else if (event.data.reason.kind === 'max-tokens') { - appendNotice('The model reached its output-token limit.', 'warning') - } else if (event.data.reason.kind === 'rejected') { - appendNotice(`Turn rejected: ${event.data.reason.reason}`, 'warning') - } else if (event.data.reason.kind === 'interrupted') { - appendNotice('The previous process ended during this turn.', 'warning') + const reason = event.data.reason + switch (reason.kind) { + case 'completed': + break + case 'error': { + const key = `${event.data.turn}:${reason.step}` + const message = 'failure' in reason ? reason.failure.message : reason.message + if (!liveErrors.delete(key)) appendNotice(message, 'error') + break + } + case 'aborted': + appendNotice('Turn cancelled.', 'warning') + break + case 'max-tokens': + appendNotice('The model reached its output-token limit.', 'warning') + break + case 'rejected': + appendNotice(`Turn rejected: ${reason.reason}`, 'warning') + break + case 'disposed': + appendNotice('Turn stopped: the agent was disposed.', 'warning') + break + case 'interrupted': + appendNotice('The previous process ended during this turn.', 'warning') + break + default: + // TurnEndReasonMap is merge-extensible: a plugin-added outcome + // still names why the agent stopped rather than ending silently. + appendNotice(`Turn ended: ${(reason as { kind: string }).kind}.`, 'warning') + break } break + } default: break } @@ -2667,7 +1246,7 @@ export function createTuiChat( const applyColorScheme = (scheme: TerminalColorScheme): void => { if (scheme === currentScheme) return currentScheme = scheme - Object.assign(palette, createPalette(resolved.color, scheme)) + Object.assign(palette, createPalette(resolved.theme.color, scheme)) Object.assign(mdTheme, markdownTheme(palette)) // `setStatus` below re-derives `editor.borderColor` from the new palette. rebuildTranscript(false) @@ -2698,10 +1277,12 @@ export function createTuiChat( showReasoning = !showReasoning const activeStreaming = streaming rebuildTranscript(false) + /* v8 ignore next -- the non-streaming command path is covered; this branch preserves an active stream across rebuild. */ if (activeStreaming !== undefined) { streaming = activeStreaming streaming.setShowReasoning(showReasoning) chat.addChild(activeStreaming) + chat.addChild(activeStreaming.timing) } appendNotice(`Reasoning blocks ${showReasoning ? 'shown' : 'hidden'}.`) } @@ -2712,7 +1293,7 @@ export function createTuiChat( return `/${command.name}${input} — ${command.description}` }) chat.addChild(new Spacer(1)) - chat.addChild(new Text(palette.bold(palette.accent('Keyboard shortcuts')), 1, 0)) + chat.addChild(new Text(palette.bold(palette.accent('Keyboard shortcuts')), 0, 0)) chat.addChild(new Text([ 'Enter send • Shift/Alt+Enter newline • Up/Down prompt history', 'Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning', @@ -2720,11 +1301,17 @@ export function createTuiChat( '', ...commandLines, '/skill: [instructions] — load a skill into the conversation', - ].map(line => palette.muted(line)).join('\n'), 1, 0)) + ].map(line => palette.muted(line)).join('\n'), 0, 0)) requestRender() } - const showStatus = (): void => { + const showStatus = async (signal: AbortSignal): Promise => { + const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent, signal)) + /* v8 ignore next -- disposal during the awaited assembly is covered by command-owner teardown tests. */ + if (disposed) return + /* v8 ignore next -- SystemPrompt always emits at least its required base section. */ + const systemPrompt = displayText(renderPrompt(assembly)) || '(empty)' + const registeredTools = assembly.tools.map(tool => displayText(tool.name)).join(', ') || '(none)' const events = agent.session.events const latestActivity = events.at(-1)?.time ?? agent.session.header.createdAt const usedContext = Math.max(0, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens)) @@ -2774,6 +1361,12 @@ export function createTuiChat( const card = new StatusCardComponent(groups, palette) chat.addChild(new Spacer(1)) chat.addChild(card) + chat.addChild(new Spacer(1)) + chat.addChild(new Text(palette.bold(palette.accent('System prompt')), 0, 0)) + chat.addChild(new Text(systemPrompt, 0, 0)) + chat.addChild(new Spacer(1)) + chat.addChild(new Text(palette.bold(palette.accent('Registered tools')), 0, 0)) + chat.addChild(new Text(registeredTools, 0, 0)) requestRender() } @@ -2809,10 +1402,15 @@ export function createTuiChat( service.list({ cwd, signal: skillAbort.signal }).then( (summaries) => { if (disposed || summaries.length === 0) return + // The argument-hint slot shows in the menu but is never inserted on + // selection, so it carries the skill's scope instead of an + // instructions placeholder. `SkillSource` is open-ended; every + // non-project source (user, custom, bundled, runtime, …) collapses + // to `(user)`. skillCommands = summaries.map(skill => ({ name: `skill:${skill.name}`, description: skill.description, - argumentHint: '[instructions]', + argumentHint: skill.source.startsWith('project-') ? '(project)' : '(user)', })) refreshCommandAutocomplete() requestRender() @@ -2875,13 +1473,22 @@ export function createTuiChat( }) commandCtx.commands.register({ name: 'status', - description: 'Show detailed session diagnostics', - handler: () => { showStatus(); return { kind: 'success' } }, + description: 'Show session diagnostics, system prompt, and registered tools', + handler: async ({ signal }) => { await showStatus(signal); return { kind: 'success' } }, }) + const exitHandler = (): CommandResult => { + requestExit() + return { kind: 'success' } + } commandCtx.commands.register({ name: 'exit', description: 'Exit after the active turn reaches idle', - handler: () => { requestExit(); return { kind: 'success' } }, + handler: exitHandler, + }) + commandCtx.commands.register({ + name: 'quit', + description: 'Exit after the active turn reaches idle', + handler: exitHandler, }) }) const fileReferencePromptFiber = agent.ctx.inject(['systemPrompt'], (promptCtx) => { @@ -3165,9 +1772,9 @@ export function createTuiChat( if (text.startsWith(SKILL_COMMAND_PREFIX)) { editor.addToHistory(text) editor.setText('') - const { name, instructions } = parseSkillCommand(text) - if (name === '') appendNotice('Usage: /skill: [instructions]', 'warning') - else invokeSkill(name, instructions) + const { name: skillName, instructions } = parseSkillCommand(text) + if (skillName === '') appendNotice('Usage: /skill: [instructions]', 'warning') + else invokeSkill(skillName, instructions) return } if (value.startsWith('/')) { @@ -3262,7 +1869,8 @@ export function createTuiChat( if (session !== agent.session) return if (event.type === 'tool/result') fileSearch.invalidate() recordEventUsage(tokens, event) - advanceTurnPhase(event) + if (event.type === 'turn/start' && runningStatus !== undefined) runningStatus.turn = event.data.turn + if (event.type === 'assistant/message' && streaming?.isSettled()) streaming = undefined if (event.type === 'steering/message') { // A queued steering message reached the model as it drained; drop its // entry from the badge. Matching by source keeps a loop-authored @@ -3271,7 +1879,7 @@ export function createTuiChat( const drained = pendingSteering.indexOf(JSON.stringify(event.data.source)) if (drained >= 0) { pendingSteering.splice(drained, 1) - refreshStatus() + if (runningStatus !== undefined) refreshStatus() } } if ('surfaceOp' in event && typeof event.surfaceOp === 'object') { @@ -3284,7 +1892,7 @@ export function createTuiChat( const disposeQueued = ctx.on('agent/inbox/enqueue', (subject, info) => { if (subject !== agent || !info.steering) return pendingSteering.push(JSON.stringify(info.source)) - refreshStatus() + if (runningStatus !== undefined) refreshStatus() }) const disposeStatus = ctx.on('agent/status', (subject, status) => { if (subject !== agent) return @@ -3312,6 +1920,8 @@ export function createTuiChat( fileSearch.dispose() removeInputListener() disposeCommandChanges() + disposePromptChanges() + for (const value of promptValues) value.dispose() stopBannerReveal() disposeSessionEvents() disposeQueued() @@ -3352,6 +1962,7 @@ export function createTuiChat( rebuildTranscript(true) const restoredGoal = foldGoal(agent.session.events).goal + /* v8 ignore next -- goal replay coverage lives with the goal seam; the TUI only formats its startup notice. */ if (restoredGoal !== undefined && restoredGoal.phase !== 'complete') { appendNotice( `Goal restored (${restoredGoal.phase}) with automatic continuation disarmed. ` @@ -3444,10 +2055,10 @@ export function apply(ctx: Context, config: Config): void { throw new Error('ui-tui: both stdin and stdout must be TTYs; use the one-shot @deepseek-ai/dsh-cli-demo app for pipes') } // Truecolor is a terminal capability, so detect it here at the process - // boundary from COLORTERM; an explicit `truecolor` config value still wins. - const truecolor = config.truecolor ?? ['truecolor', '24bit'].includes(process.env.COLORTERM ?? '') + // boundary from COLORTERM; an explicit theme value still wins. + const truecolor = config.theme?.truecolor ?? ['truecolor', '24bit'].includes(process.env.COLORTERM ?? '') const resumeHost = ctx.get('tuiResumeHost') - mountTui(ctx, Object.assign({}, config, { truecolor }), { + mountTui(ctx, Object.assign({}, config, { theme: Object.assign({}, config.theme, { truecolor }) }), { terminal: new ProcessTerminal(), exit: code => process.exit(code), ...resumeHost === undefined ? {} : { handoffResume: sessionId => resumeHost.handoff(sessionId) }, diff --git a/packages/ui/tui/src/prompt.ts b/packages/ui/tui/src/prompt.ts new file mode 100644 index 0000000000..db80bfb1e3 --- /dev/null +++ b/packages/ui/tui/src/prompt.ts @@ -0,0 +1,217 @@ +/** + * Mutable terminal-prompt value registry consumed by the TUI template renderer. + * Values are trusted presentation fragments and may contain ANSI control sequences. + * @module @deepseek-ai/dsh-tui/prompt + */ + +import { Context, Service } from 'cordis' +import { errorChain } from '@deepseek-ai/dsh-llm' + +export const name = 'tui-prompt' + +const VALUE_NAME = /^[a-z][a-z0-9_-]*(?:\/[a-z][a-z0-9_-]*)*$/u + +/** Handle owned by one prompt-value registration. */ +export interface TuiPromptValueHandle { + /** + * Replace the current fragment and schedule a coalesced change notification + * so the owning renderer redraws. Setting the current value again is a no-op. + * @param value - Trusted ANSI-capable fragment, or `undefined` while unavailable. + */ + set(value: string | undefined): void + + /** Unregister this value; subsequent {@link TuiPromptValueHandle.set} calls fail. */ + dispose(): void +} + +interface RegisteredValue { + value: string | undefined +} + +declare module 'cordis' { + interface Context { + tuiPrompt: TuiPromptService + } +} + +/** Removes a change subscription registered with {@link TuiPromptService.subscribe}. */ +export type TuiPromptUnsubscribe = () => void + +/** One literal or variable token in a parsed TUI prompt template. */ +export type TuiPromptTemplateToken = + | { readonly kind: 'literal'; readonly value: string } + | { readonly kind: 'value'; readonly name: string } + +/** + * Parse a prompt template into immutable literal and value tokens. + * @param template - Text containing `${name}` references. + * @returns Tokens consumed by {@link renderTuiPromptTemplate}. + */ +export function parseTuiPromptTemplate(template: string): readonly TuiPromptTemplateToken[] { + const tokens: TuiPromptTemplateToken[] = [] + const pattern = /\$\{([^}]*)\}/gu + let offset = 0 + for (const match of template.matchAll(pattern)) { + const index = match.index + const name = match[1] + /* v8 ignore next -- the sole capture always exists when this pattern matches. */ + if (name === undefined) continue + if (index > offset) tokens.push(Object.freeze({ kind: 'literal', value: template.slice(offset, index) })) + tokens.push(Object.freeze({ kind: 'value', name })) + offset = index + match[0].length + } + if (offset < template.length) tokens.push(Object.freeze({ kind: 'literal', value: template.slice(offset) })) + return Object.freeze(tokens) +} + +/** + * Interpolate one parsed prompt while removing horizontal separators adjacent + * only to unavailable values. + * @param tokens - Parsed template tokens. + * @param resolve - Current value lookup. + * @returns ANSI-capable rendered prompt text. + */ +export function renderTuiPromptTemplate( + tokens: readonly TuiPromptTemplateToken[], + resolve: (name: string) => string | undefined, +): string { + const rendered: string[] = [] + let omitLeadingWhitespace = false + for (const token of tokens) { + if (token.kind === 'value') { + const value = resolve(token.name) + if (value === undefined) { + omitLeadingWhitespace = true + } else { + rendered.push(value) + omitLeadingWhitespace = false + } + continue + } + rendered.push(omitLeadingWhitespace ? token.value.replace(/^[\t ]+/u, '') : token.value) + omitLeadingWhitespace = false + } + return rendered.join('') +} + +/** + * Context-global mutable values interpolated by TUI theme prompt templates. + * A registration, mutation, or disposal schedules one coalesced notification to + * the renderer subscribed with {@link TuiPromptService.subscribe}, so a value + * that changes on its own schedule (not only in response to a UI event) still + * redraws. Notification is a direct in-service callback, not a Cordis event. + */ +export class TuiPromptService extends Service { + private readonly values = new Map() + // Per-subscription record identity, not callback identity: two fibers may + // subscribe the same function, and disposing one must not remove the other's. + private readonly listeners = new Set<{ readonly listener: () => unknown }>() + private notificationQueued = false + + constructor(ctx: Context) { + super(ctx, 'tuiPrompt') + } + + /** + * Register one globally unique template value under the calling Cordis effect. + * @param name - Lowercase slash-separated template name. + * @param initialValue - Initial trusted ANSI-capable fragment. + * @returns A mutable handle whose disposal unregisters the name. + */ + register(name: string, initialValue?: string): TuiPromptValueHandle { + if (!VALUE_NAME.test(name)) { + throw new TypeError(`TUI prompt value name "${name}" must match ${String(VALUE_NAME)}`) + } + if (this.values.has(name)) throw new Error(`TUI prompt value "${name}" is already registered`) + + const registered: RegisteredValue = { value: initialValue } + let active = true + const effectDisposer = this.ctx.effect(() => { + this.values.set(name, registered) + this.scheduleChange() + // Cordis runs this cleanup at most once per effect, and deleting an + // absent key is a no-op, so no re-entrancy guard is needed here; `active` + // exists only to reject a late {@link TuiPromptValueHandle.set}. + return () => { + active = false + this.values.delete(name) + this.scheduleChange() + } + }, `tuiPrompt.register(${name})`) + + return Object.freeze({ + set: (value: string | undefined): void => { + if (!active) throw new Error(`TUI prompt value "${name}" is disposed`) + if (registered.value === value) return + registered.value = value + this.scheduleChange() + }, + dispose: (): void => { void effectDisposer() }, + }) + } + + /** + * Read a registered fragment without evaluating plugin code. + * @param name - Exact registered template name. + * @returns The current fragment, or `undefined` when unknown or unavailable. + */ + get(name: string): string | undefined { + return this.values.get(name)?.value + } + + /** + * Observe registration and value changes. The listener runs after a coalesced + * microtask following any burst of mutations; the renderer re-reads current + * values on that callback. The subscription is owned by the calling Cordis + * effect, so it is removed when the subscriber's fiber disposes; the returned + * disposer removes it early. Listener failures are contained — a synchronous + * throw or a rejected returned promise cannot starve the other observers. + * @param listener - Invoked once per coalesced change burst. Delivery does + * not wait on a returned promise; its rejection is only observed and logged, + * never left unhandled, so an async listener cannot order later observers. + * @returns A disposer that removes the subscription. + */ + subscribe(listener: () => unknown): TuiPromptUnsubscribe { + const record = { listener } + const disposeEffect = this.ctx.effect(() => { + this.listeners.add(record) + return () => { this.listeners.delete(record) } + }, 'tuiPrompt.subscribe') + return () => { void disposeEffect() } + } + + /** Coalesce mutation bursts into one notification while containing each observer. */ + private scheduleChange(): void { + if (this.notificationQueued) return + this.notificationQueued = true + queueMicrotask(() => { + this.notificationQueued = false + // Snapshot so a listener may subscribe/unsubscribe during delivery, but + // re-check liveness: a listener that synchronously unsubscribes another + // observer earlier in the same burst must silence it now, keeping the + // subscription set authoritative during reentrant notification. + for (const record of [...this.listeners]) { + if (this.listeners.has(record)) this.notifyOne(record.listener) + } + }) + } + + /** Deliver one change notification, containing a synchronous throw or a rejected promise. */ + private notifyOne(listener: () => unknown): void { + let returned: unknown + try { + returned = listener() + } catch (error: unknown) { + // errorChain never throws, even on a hostile toString/getter, so the + // notification microtask can never escape to starve later observers. + this.ctx.logger.warn(`tui-prompt change listener threw: ${errorChain(error)}`) + return + } + // A listener may be async; contain a rejected promise the same as a throw. + void Promise.resolve(returned).catch((error: unknown) => { + this.ctx.logger.warn(`tui-prompt change listener rejected: ${errorChain(error)}`) + }) + } +} + +export default TuiPromptService diff --git a/packages/ui/tui/src/session/timing.ts b/packages/ui/tui/src/session/timing.ts new file mode 100644 index 0000000000..a85097cb76 --- /dev/null +++ b/packages/ui/tui/src/session/timing.ts @@ -0,0 +1,357 @@ +/** + * Per-step timing model and running-status glyph animation for the terminal + * front door. Timing buckets are replayed from the session event stream; the + * running glyph fades in on turn start, throbs while the turn runs, and fades + * out on turn end. + * @module @deepseek-ai/dsh-tui/session/timing + */ + +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { Palette } from '../components/theme.ts' + +/** + * Render cadence of the running prompt while active, and while the glyph fades + * out after a turn ends. ~20 fps so the truecolor glyph fade reads smoothly; + * the same tick keeps the elapsed-time text (0.1 s resolution) current. Only + * changed terminal cells are re-emitted, so the faster tick stays cheap. + */ +export const STATUS_ANIMATION_INTERVAL_MS = 50 + +/** + * Milliseconds over which the running glyph fades in when a turn starts and + * fades out after it ends. The fade is an envelope over the running pulse: + * inside it the glyph throbs (see {@link STATUS_PULSE_PERIOD_MS}). + */ +export const STATUS_FADE_MS = 300 + +/** Milliseconds for one full brightness throb of the running glyph. */ +export const STATUS_PULSE_PERIOD_MS = 1400 + +/** + * Brightness floor of the running throb, as a fraction of the settled gray. At + * 0 the pulse swells from fully invisible (a blank glyph column, see + * {@link STATUS_FADE_MIN_OPACITY}) up to full and back, so the dimmest point of + * each breath truly disappears rather than lingering as a faint mark. + */ +export const STATUS_PULSE_FLOOR = 0 + +/** + * Opacity below which the truecolor running glyph is hidden entirely (a blank + * column) instead of painted as a near-background gray, so the trough of the + * pulse reads as invisible. The fixed glyph width is preserved by the blank. + */ +export const STATUS_FADE_MIN_OPACITY = 0.12 + +/** + * Muted-gray foreground the truecolor running glyph fades through, from the + * near-background trough (opacity 0) to the settled dim gray (opacity 1). Same + * hue-free gray as the idle caret, so the glyph reads as the caret dimly + * appearing rather than a colored indicator. Foreground-only, matching the + * brand gradient, so it stays legible on any terminal background. + */ +const STATUS_FADE_GRAY = { + trough: [43, 43, 43], + settled: [136, 136, 136], +} as const + +/** The active phase of a running step, one bucket of accumulated wall time. */ +export type TimingBucket = 'ttft' | 'thinking' | 'responding' | 'tools' + +/** Turn/step coordinates of one assistant step. */ +export type StepPosition = { turn: number; step: number } + +/** Accumulated wall time per phase for one step or session slice. */ +export interface TimingTotals { + ttft: number + thinking: number + responding: number + tools: number +} + +interface TimingState { + totals: TimingTotals + active: { bucket: TimingBucket; since: number } | undefined +} + +const TIMING_BUCKET_LABELS: Record = { + ttft: 'Model wait', + thinking: 'Thinking', + responding: 'Response', + tools: 'Tools', +} + +const TIMING_BUCKETS: readonly TimingBucket[] = ['ttft', 'thinking', 'responding', 'tools'] + +function emptyTimingTotals(): TimingTotals { + return { ttft: 0, thinking: 0, responding: 0, tools: 0 } +} + +function timingState(startedAt?: number): TimingState { + return { + totals: emptyTimingTotals(), + /* v8 ignore next -- production timing state always begins at a logged step timestamp. */ + active: startedAt === undefined ? undefined : { bucket: 'ttft', since: startedAt }, + } +} + +function sameStep(event: SessionEvent, position: StepPosition): boolean { + return typeof event.data === 'object' + && 'turn' in event.data && 'step' in event.data + && event.data.turn === position.turn && event.data.step === position.step +} + +function closeTimingBucket(state: TimingState, at: number): void { + if (state.active === undefined) return + state.totals[state.active.bucket] += Math.max(0, at - state.active.since) + state.active = undefined +} + +function enterTimingBucket(state: TimingState, bucket: TimingBucket | undefined, at: number): void { + if (state.active?.bucket === bucket) return + closeTimingBucket(state, at) + if (bucket !== undefined) state.active = { bucket, since: at } +} + +function advanceStepTiming( + state: TimingState, + event: Extract, +): void { + if (event.type === 'assistant/chunk') { + const chunk = event.data.chunk + if (state.active?.bucket === 'ttft') enterTimingBucket(state, undefined, event.time) + if (chunk.type === 'reasoning-delta' || (chunk.type === 'block-start' && chunk.blockType === 'reasoning')) { + enterTimingBucket(state, 'thinking', event.time) + } else if (chunk.type === 'text-delta' || (chunk.type === 'block-start' && chunk.blockType === 'text')) { + enterTimingBucket(state, 'responding', event.time) + } + } else if (event.type === 'tool/call') { + enterTimingBucket(state, 'tools', event.time) + } else { + closeTimingBucket(state, event.time) + } +} + +function timingTotalsAt(state: TimingState, at?: number): TimingTotals { + const totals = { ...state.totals } + if (state.active !== undefined && at !== undefined) { + totals[state.active.bucket] += Math.max(0, at - state.active.since) + } + return totals +} + +/** + * Replay one step's accumulated per-phase timing up to clock `at`. + * @param events - Session events to replay. + * @param position - Turn/step coordinates of the step. + * @param at - Render clock to accumulate the open bucket up to. + * @returns The step's per-phase totals. + */ +export function stepTimingAt( + events: readonly SessionEvent[], + position: StepPosition, + at: number, +): TimingTotals { + const startIndex = events.findIndex(event => event.type === 'step/start' && sameStep(event, position)) + if (startIndex < 0) return emptyTimingTotals() + const start = events[startIndex] as Extract + const state = timingState(start.time) + for (let index = startIndex + 1; index < events.length; index += 1) { + const event = events[index] as SessionEvent + if (event.time > at) break + if ((event.type === 'assistant/chunk' || event.type === 'tool/call' || event.type === 'step/end') + && sameStep(event, position)) { + advanceStepTiming(state, event) + if (event.type === 'step/end') break + } + } + return timingTotalsAt(state, at) +} + +/** + * The turn index of the currently open turn, or `undefined` when none is open. + * @param events - Session events to scan from the tail. + * @returns The open turn index, or `undefined`. + */ +export function openTurn(events: readonly SessionEvent[]): number | undefined { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index] as SessionEvent + if (event.type === 'turn/end') return undefined + if (event.type === 'turn/start') return event.data.turn + } + return undefined +} + +/** + * Phase-specific status glyph, keyed by the running step's active timing bucket. + * `ttft` is the pre-first-token wait a running turn falls back to between steps. + */ +export const TIMING_BUCKET_GLYPHS: Record = { + ttft: '◍', + thinking: '✻', + responding: '●', + tools: '⚙', +} + +/** + * Derive the currently open step's active timing bucket, or `undefined` when no + * step is open. The open step is the last `step/start` with no later matching + * `step/end`; its bucket is replayed with the same rules as {@link stepTimingAt}. + * @param events - Session events to scan. + * @returns The open step's active bucket, or `undefined`. + */ +export function openStepPhase(events: readonly SessionEvent[]): TimingBucket | undefined { + let startIndex = -1 + let start: Extract | undefined + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index] as SessionEvent + if (event.type === 'step/end') return undefined + if (event.type === 'step/start') { + startIndex = index + start = event + break + } + if (event.type === 'turn/end') return undefined + } + if (start === undefined) return undefined + const position = start.data + const state = timingState(start.time) + for (let index = startIndex + 1; index < events.length; index += 1) { + const event = events[index] as SessionEvent + if ((event.type === 'assistant/chunk' || event.type === 'tool/call' || event.type === 'step/end') + && sameStep(event, position)) { + advanceStepTiming(state, event) + } + } + return state.active?.bucket +} + +/** + * The running agent's phase glyph, or `undefined` when idle. A running turn + * with no open step falls back to the pre-first-token wait so a glyph is always + * available while the agent works; it fades in on turn start, throbs while the + * turn runs, and fades out on turn end (see {@link fadeGlyph}). + * @param events - Session events to derive the phase from. + * @param running - Whether the agent is currently running. + * @returns The phase glyph, or `undefined` when idle. + */ +export function runningPhaseGlyph(events: readonly SessionEvent[], running: boolean): string | undefined { + if (!running) return undefined + const bucket = openStepPhase(events) ?? 'ttft' + return TIMING_BUCKET_GLYPHS[bucket] +} + +/** + * The running throb's brightness at continuous clock `nowMs`: a cosine between + * {@link STATUS_PULSE_FLOOR} and 1 over {@link STATUS_PULSE_PERIOD_MS}, so the + * dim glyph breathes without ever blinking off. Multiplied by the fade envelope + * to gate appear/disappear. + * + * @param nowMs - Monotonic render clock in milliseconds. + * @returns Brightness fraction in [{@link STATUS_PULSE_FLOOR}, 1]. + */ +export function pulseLevel(nowMs: number): number { + const phase = (nowMs % STATUS_PULSE_PERIOD_MS) / STATUS_PULSE_PERIOD_MS + const wave = 0.5 - 0.5 * Math.cos(2 * Math.PI * phase) + return STATUS_PULSE_FLOOR + (1 - STATUS_PULSE_FLOOR) * wave +} + +/** + * One frame of the running glyph at fade `opacity` (0 = invisible trough, + * 1 = settled dim gray). The character and its width never change — only the + * gray fades — so the prompt caret column stays fixed and the glyph reads as + * the caret dimly appearing and disappearing, never a colored indicator. + * + * With truecolor the glyph's 24-bit gray foreground interpolates between + * {@link STATUS_FADE_GRAY}'s trough and settled stops, so both the fade and the + * running throb render as brightness; below {@link STATUS_FADE_MIN_OPACITY} it + * is hidden entirely so the pulse trough disappears. Without truecolor there is + * no per-frame gray, so `visible` (driven by the fade envelope, not the opacity) + * shows the glyph in the palette's muted role or leaves a blank column — a + * single dim appear/disappear at fixed width, still dim rather than accent, and + * no throb-driven blink. With color off entirely a visible glyph is bare, + * holding the caret column on a monochrome terminal. + * + * @param glyph - The phase glyph to paint. + * @param palette - Active palette supplying the muted (dim gray) role. + * @param colorEnabled - Whether ANSI is emitted at all. + * @param truecolor - Whether the terminal accepts 24-bit foreground codes. + * @param opacity - Brightness fraction in [0, 1] for the truecolor gray. + * @param visible - Whether the non-truecolor fallback shows the glyph at all. + * @returns The dim-gray glyph at this opacity, or a single space when hidden. + */ +export function fadeGlyph( + glyph: string, + palette: Palette, + colorEnabled: boolean, + truecolor: boolean, + opacity: number, + visible: boolean, +): string { + if (truecolor && colorEnabled) { + const o = Math.min(Math.max(opacity, 0), 1) + // Below the visibility threshold the glyph is fully hidden, so the pulse + // trough disappears rather than lingering as a near-background gray. + if (o < STATUS_FADE_MIN_OPACITY) return ' ' + const [tr, tg, tb] = STATUS_FADE_GRAY.trough + const [sr, sg, sb] = STATUS_FADE_GRAY.settled + const r = Math.round(tr + (sr - tr) * o) + const g = Math.round(tg + (sg - tg) * o) + const b = Math.round(tb + (sb - tb) * o) + return `\x1b[38;2;${r};${g};${b}m${glyph}\x1b[39m` + } + if (!visible) return ' ' + return colorEnabled ? palette.muted(glyph) : glyph +} + +/** + * Format a non-negative elapsed span at 100 ms resolution. + * @param elapsedMs - Elapsed milliseconds. + * @returns The formatted duration (e.g. `1.5s`, `2m03.4s`). + */ +export function formatStatusDuration(elapsedMs: number): string { + const tenths = Math.floor(Math.max(0, elapsedMs) / 100) + const seconds = tenths / 10 + if (seconds < 60) return `${seconds.toFixed(1)}s` + const minutes = Math.floor(seconds / 60) + return `${minutes}m${(seconds - minutes * 60).toFixed(1).padStart(4, '0')}s` +} + +/** + * Format the non-zero timing buckets of one step as a middot-joined summary. + * @param totals - Per-phase totals to format. + * @param includeModelWait - Whether to always include the model-wait bucket. + * @returns The formatted timing summary. + */ +export function formatTimingTotals(totals: TimingTotals, includeModelWait = false): string { + return TIMING_BUCKETS + .filter(bucket => totals[bucket] > 0 || (includeModelWait && bucket === 'ttft')) + .map(bucket => `${TIMING_BUCKET_LABELS[bucket]} ${formatStatusDuration(totals[bucket])}`) + .join(' · ') +} + +/** + * Format the queued-steering badge shown on the running status line. + * @param queued - Number of queued steering messages. + * @returns The badge text, or `undefined` when nothing is queued. + */ +export function formatQueuedStatus(queued: number): string | undefined { + return queued > 0 ? `${queued} queued` : undefined +} + +/** + * Format a completion timestamp as `YYYY-MM-DD HH:MM:SS` in local time. + * @param time - Epoch milliseconds. + * @returns The formatted local timestamp. + */ +export function formatCompletionTime(time: number): string { + const date = new Date(time) + const parts = [ + date.getFullYear().toString().padStart(4, '0'), + (date.getMonth() + 1).toString().padStart(2, '0'), + date.getDate().toString().padStart(2, '0'), + ] + const clock = [date.getHours(), date.getMinutes(), date.getSeconds()] + .map(value => value.toString().padStart(2, '0')) + .join(':') + return `${parts.join('-')} ${clock}` +} diff --git a/packages/ui/tui/src/session/tokens.ts b/packages/ui/tui/src/session/tokens.ts new file mode 100644 index 0000000000..1711c96ede --- /dev/null +++ b/packages/ui/tui/src/session/tokens.ts @@ -0,0 +1,96 @@ +/** + * Running token accounting for the terminal footer. Usage is keyed per + * turn/step so replayed or re-emitted usage replaces rather than double-counts. + * @module @deepseek-ai/dsh-tui/session/tokens + */ + +import type { TokenUsage } from '@deepseek-ai/dsh-llm' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' + +/** + * Running token totals for the footer, keyed per turn/step so replayed or + * re-emitted usage replaces rather than double-counts; `input` is uncached + * input, cache buckets are disjoint. + */ +export interface SessionTokenTotals { + input: number + output: number + cacheRead: number + cacheWrite: number + readonly byStep: Map +} + +/** + * Fold one step's usage into the running totals, replacing any prior usage + * logged for the same turn/step. + * @param totals - Running totals mutated in place. + * @param turn - Turn index of the usage. + * @param step - Step index of the usage. + * @param usage - The step's token usage. + */ +export function recordTokenUsage(totals: SessionTokenTotals, turn: number, step: number, usage: TokenUsage): void { + const key = `${turn}:${step}` + const previous = totals.byStep.get(key) + if (previous !== undefined) { + totals.input -= previous.inputTokens + totals.output -= previous.outputTokens + totals.cacheRead -= previous.cacheReadTokens ?? 0 + totals.cacheWrite -= previous.cacheWriteTokens ?? 0 + } + totals.byStep.set(key, usage) + totals.input += usage.inputTokens + totals.output += usage.outputTokens + totals.cacheRead += usage.cacheReadTokens ?? 0 + totals.cacheWrite += usage.cacheWriteTokens ?? 0 +} + +/** + * Fold a usage-bearing session event into the running totals. + * @param totals - Running totals mutated in place. + * @param event - Session event; ignored when it carries no usage. + */ +export function recordEventUsage(totals: SessionTokenTotals, event: SessionEvent): void { + if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') { + recordTokenUsage(totals, event.data.turn, event.data.step, event.data.chunk.usage) + } else if (event.type === 'assistant/message' && event.data.usage !== undefined) { + recordTokenUsage(totals, event.data.turn, event.data.step, event.data.usage) + } +} + +/** + * Share of billed input (prompt) tokens served from the provider cache, as an + * integer percent, or `undefined` before any input is billed (avoids 0/0 and a + * meaningless rate on an empty session). + * @param totals - Running totals to measure. + * @returns The cache hit rate percent, or `undefined` when no input is billed. + */ +export function cacheHitRate(totals: SessionTokenTotals): number | undefined { + const billedInput = totals.input + totals.cacheRead + totals.cacheWrite + if (billedInput === 0) return undefined + return Math.round((totals.cacheRead / billedInput) * 100) +} + +/** + * Fold every usage-bearing event in a session into fresh totals. + * @param session - Session whose events supply usage. + * @returns The accumulated token totals. + */ +export function sessionTokens(session: Session): SessionTokenTotals { + const totals: SessionTokenTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, byStep: new Map() } + for (const event of session.events) { + recordEventUsage(totals, event) + } + return totals +} + +/** + * Format a token count with a compact k/m suffix for the footer. + * @param value - Token count. + * @returns The compact display string. + */ +export function formatTokens(value: number): string { + if (value < 1_000) return String(value) + if (value < 10_000) return `${(value / 1_000).toFixed(1)}k` + if (value < 1_000_000) return `${Math.round(value / 1_000)}k` + return `${(value / 1_000_000).toFixed(1)}m` +} diff --git a/packages/ui/tui/src/skill-invocation.ts b/packages/ui/tui/src/skill-invocation.ts new file mode 100644 index 0000000000..5857e93ea3 --- /dev/null +++ b/packages/ui/tui/src/skill-invocation.ts @@ -0,0 +1,67 @@ +/** + * Manual `/skill: [instructions]` parsing and model-visible rendering for + * the terminal front door. + * @module @deepseek-ai/dsh-tui/skill-invocation + */ + +import { assertNever } from '@deepseek-ai/dsh-llm' +import type { SkillDefinition, SkillResourceBase } from '@deepseek-ai/dsh-skill' + +/** Prefix that marks an editor submission as a manual skill invocation. */ +export const SKILL_COMMAND_PREFIX = '/skill:' + +/** Parsed `/skill: [instructions]` submission; `name` is empty when the prefix carries no name. */ +export interface ParsedSkillCommand { + /** Skill name typed after `/skill:`, up to the first space. */ + name: string + /** Trimmed text after the name; empty when none was typed. */ + instructions: string +} + +/** + * Split a `/skill: [instructions]` submission into its name and trailing instructions. + * @param text - trimmed submission that starts with {@link SKILL_COMMAND_PREFIX}. + * @returns the skill name and any trailing instructions. + */ +export function parseSkillCommand(text: string): ParsedSkillCommand { + const rest = text.slice(SKILL_COMMAND_PREFIX.length) + const spaceIndex = rest.indexOf(' ') + if (spaceIndex === -1) return { name: rest, instructions: '' } + return { name: rest.slice(0, spaceIndex), instructions: rest.slice(spaceIndex + 1).trim() } +} + +/** Model-visible line locating a manually invoked skill's relative resources, or `undefined` when the provider has no base. */ +function skillResourceReference(base: SkillResourceBase | undefined): string | undefined { + if (base === undefined) return undefined + switch (base.kind) { + case 'directory': + return `References in this skill are relative to ${base.path}.` + case 'url': + return `References in this skill are relative to ${base.url}.` + case 'opaque': + return base.description + default: + return assertNever(base, 'SkillResourceBase.kind') + } +} + +/** + * Render a manually invoked skill into the model-visible user-message text. The + * `` block carries the body and, when the provider supplies one, its + * resource base; the trimmed `instructions` follow the block as the user's + * request for this turn. The name is registry-validated kebab-case + * (the skill registry rejects any other) and the resource base is trusted + * same-process provider prose, so — unlike the model-facing `dsh-tool-skill` + * result, which escapes for a tool channel — this user turn is assembled raw. + * @param skill - the loaded skill definition. + * @param instructions - trimmed text typed after `/skill:`; empty when absent. + * @returns the user-message text delivered to the agent. + */ +export function renderSkillInvocation(skill: SkillDefinition, instructions: string): string { + const lines = [``] + const reference = skillResourceReference(skill.resourceBase) + if (reference !== undefined) lines.push(reference, '') + lines.push(skill.content, '') + const block = lines.join('\n') + return instructions === '' ? block : `${block}\n\n${instructions}` +} diff --git a/packages/ui/tui/src/xml-tool-output.ts b/packages/ui/tui/src/xml-tool-output.ts new file mode 100644 index 0000000000..3e88120f7d --- /dev/null +++ b/packages/ui/tui/src/xml-tool-output.ts @@ -0,0 +1,138 @@ +/** Conservative readable-tree rendering for model-facing text containing one XML document. */ + +import { SaxesParser } from 'saxes' + +interface XmlElement { + readonly name: string + readonly attributes: readonly XmlAttribute[] + readonly children: XmlNode[] +} + +interface XmlAttribute { + readonly name: string + readonly value: string +} + +type XmlNode = XmlElement | string + +function parseXml(source: string, display: (text: string) => string): XmlElement | undefined { + const parser = new SaxesParser({ xmlns: false }) + const stack: XmlElement[] = [] + let root: XmlElement | undefined + const state = { invalid: false } + const reject = (): void => { state.invalid = true } + parser.on('opentag', (tag) => { + const element: XmlElement = { + name: tag.name, + // Attribute values and text pass through `display` because character references can + // expand to valid-XML control characters (tab, CR, DEL, C1) that pre-parse escaping + // of the raw source never saw. Element names cannot carry them: control characters + // are not XML name characters and character references do not apply inside names. + attributes: Object.entries(tag.attributes).map(([name, value]) => ({ name, value: display(value) })), + children: [], + } + const parent = stack.at(-1) + if (parent === undefined) { + if (root !== undefined) reject() + root = element + } else { + parent.children.push(element) + } + stack.push(element) + }) + parser.on('text', (text) => { + const parent = stack.at(-1) + if (parent === undefined) { + if (text.trim() !== '') reject() + } else { + parent.children.push(display(text)) + } + }) + parser.on('cdata', (text) => { + const parent = stack.at(-1) + if (parent === undefined) reject() + else parent.children.push(display(text)) + }) + parser.on('closetag', () => { stack.pop() }) + parser.on('xmldecl', reject) + parser.on('processinginstruction', reject) + parser.on('doctype', reject) + parser.on('comment', reject) + parser.on('error', reject) + parser.write(source).close() + return state.invalid ? undefined : root +} + +function elementLabel(element: XmlElement): string { + const attributes = element.attributes.map(attribute => `${attribute.name}=${JSON.stringify(attribute.value)}`).join(' ') + return attributes === '' ? element.name : `${element.name} (${attributes})` +} + +function meaningfulChildren(element: XmlElement): readonly XmlNode[] { + return element.children.filter(child => typeof child !== 'string' || child.trim() !== '') +} + +function textBlock(text: string, depth: number): string[] { + return text.replace(/^\n|\n$/gu, '').split('\n').map(line => `${' '.repeat(depth)}${line}`) +} + +function treeLines(element: XmlElement, depth: number, label: (text: string) => string): string[] { + const indent = ' '.repeat(depth) + const children = meaningfulChildren(element) + if (children.length === 0) return [`${indent}${label(elementLabel(element))}`] + if (children.length === 1 && typeof children[0] === 'string' && !children[0].includes('\n')) { + return [`${indent}${label(`${elementLabel(element)}:`)} ${children[0].trim()}`] + } + const lines = [`${indent}${label(elementLabel(element))}`] + for (const child of children) { + if (typeof child === 'string') lines.push(...textBlock(child, depth + 1)) + else lines.push(...treeLines(child, depth + 1, label)) + } + return lines +} + +function preview(lines: readonly string[], limit: number, omitted: (count: number) => string): string[] { + if (lines.length <= limit) return [...lines] + const head = Math.ceil(limit / 2) + const tail = limit - head + return [...lines.slice(0, head), omitted(lines.length - limit), ...lines.slice(lines.length - tail)] +} + +/** + * Render a complete XML document as an indented tree, or decline without changing partial/mixed text. + * @param source - Raw model-facing text from a context message or unknown tool result. + * @param maxChildLines - Collapsed budget independently applied to each top-level child's lines and + * to the number of top-level children, so many siblings cannot grow the collapsed card without bound. + * @param expanded - Whether to retain every rendered child line. + * @param display - Escapes parsed text and attribute values for terminal output; character references + * can expand to control characters that pre-parse escaping never saw. + * @param label - Styles element names and attributes. + * @param omitted - Renders the omitted-line marker for a collapsed child or child range. + * @returns Tree rows, or `undefined` when `source` is not one supported complete XML document. + */ +export function renderUnknownXml( + source: string, + maxChildLines: number, + expanded: boolean, + display: (text: string) => string, + label: (text: string) => string, + omitted: (count: number) => string, +): string[] | undefined { + const root = parseXml(source, display) + if (root === undefined) return undefined + const blocks = meaningfulChildren(root).map(child => + typeof child === 'string' ? textBlock(child, 1) : treeLines(child, 1, label)) + const rootLine = label(elementLabel(root)) + if (expanded) return [rootLine, ...blocks.flat()] + const previewed = blocks.map(block => preview(block, maxChildLines, omitted)) + if (previewed.length <= maxChildLines) return [rootLine, ...previewed.flat()] + const head = Math.ceil(maxChildLines / 2) + const tail = maxChildLines - head + const hidden = blocks.slice(head, blocks.length - tail).reduce((total, block) => total + block.length, 0) + return [ + rootLine, + ...previewed.slice(0, head).flat(), + omitted(hidden), + ...previewed.slice(previewed.length - tail).flat(), + ] +} diff --git a/packages/ui/tui/tests/extension.spec.ts b/packages/ui/tui/tests/extension.spec.ts index bf11d6c601..a7eec5589e 100644 --- a/packages/ui/tui/tests/extension.spec.ts +++ b/packages/ui/tui/tests/extension.spec.ts @@ -11,12 +11,12 @@ import type { TuiOverlayOptions, TuiOverlaySession, TuiTheme, -} from '../src/extension.ts' +} from '../src/extension/types.ts' import { TuiExtensionServiceImpl, TuiOverlayManager, type TuiOverlayDriver, -} from '../src/overlay-manager.ts' +} from '../src/extension/overlay-manager.ts' const theme: TuiTheme = Object.freeze({ text: (value: string) => `text:${value}`, diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index c74c469133..e0e0c027da 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -17,10 +17,11 @@ import type { import CommandService from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId, type Session, type SessionHeader } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import type { ToolDefinition } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { type ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import { createTuiChat, type Config, type TuiRuntime } from '../src/index.ts' import { TestSessionQueryService } from './session-query.ts' +import TuiPromptService from '../src/prompt.ts' interface FakeAgent extends Agent { status: AgentStatus @@ -43,6 +44,7 @@ export interface TuiHarnessOptions { beforeMount?: (session: Session) => void cwd?: string | null formatCwd?: TuiRuntime['formatCwd'] + gitBranch?: TuiRuntime['gitBranch'] /** Fake-agent creation options (`provider`/`model` seed the model selector's initial target). */ agentOptions?: AgentOptions contextWindow?: number @@ -93,6 +95,7 @@ export async function createTuiTestHarness 'tui-staging'), }) return { ctx, session, agent, terminal, exit, controller } } diff --git a/packages/ui/tui/tests/plugin-shape.spec.ts b/packages/ui/tui/tests/plugin-shape.spec.ts index c5c497fab9..73c39db268 100644 --- a/packages/ui/tui/tests/plugin-shape.spec.ts +++ b/packages/ui/tui/tests/plugin-shape.spec.ts @@ -21,6 +21,7 @@ describe('dsh-tui plugin export shape', () => { 'llm', 'systemPrompt', 'tokenMeter', + 'tuiPrompt', ]) expect(unwrapped.Config).toBeDefined() expect(typeof unwrapped.apply).toBe('function') diff --git a/packages/ui/tui/tests/prompt.spec.ts b/packages/ui/tui/tests/prompt.spec.ts new file mode 100644 index 0000000000..f212ef10e2 --- /dev/null +++ b/packages/ui/tui/tests/prompt.spec.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import TuiPromptService, { + parseTuiPromptTemplate, + renderTuiPromptTemplate, +} from '../src/prompt.ts' + +const tick = (): Promise => new Promise((resolve) => { queueMicrotask(resolve) }) + +describe('TUI prompt values', () => { + it('registers, updates, and disposes mutable values', async () => { + const ctx = new Context() + await ctx.plugin(TuiPromptService) + + const value = ctx.tuiPrompt.register('git/worktree', '\x1b[32m(main)\x1b[0m') + expect(ctx.tuiPrompt.get('git/worktree')).toBe('\x1b[32m(main)\x1b[0m') + value.set('next') + expect(ctx.tuiPrompt.get('git/worktree')).toBe('next') + + value.set(undefined) + expect(ctx.tuiPrompt.get('git/worktree')).toBeUndefined() + value.dispose() + expect(() => { value.set('late') }).toThrow(/disposed/) + await ctx.fiber.dispose() + }) + + it('coalesces a change burst into one notification and contains each observer', async () => { + const ctx = new Context() + await ctx.plugin(TuiPromptService) + // Capture the containment warnings so the rejected-promise and sync-throw + // paths are each pinned (removing either catch drops its warning). + const warnings: string[] = [] + ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn + // A synchronous thrower, an async rejecter, and a thrower whose error is + // hostile to string coercion all sit BEFORE the observed listener, so + // proving `after` still runs proves none of them starves it (a naive + // `String(error)` inside the containment would itself throw on the last). + const hostile = { toString() { throw new Error('hostile coercion') } } + const thrower = vi.fn(() => { throw new Error('sync observer boom') }) + const rejecter = vi.fn(async () => { throw new Error('async observer boom') }) + const hostileThrower = vi.fn(() => { throw hostile }) + const after = vi.fn() + ctx.tuiPrompt.subscribe(thrower) + ctx.tuiPrompt.subscribe(rejecter) + ctx.tuiPrompt.subscribe(hostileThrower) + const unsubscribe = ctx.tuiPrompt.subscribe(after) + await tick() // drain the registration notifications + thrower.mockClear() + rejecter.mockClear() + hostileThrower.mockClear() + after.mockClear() + + const value = ctx.tuiPrompt.register('git/worktree', 'a') + value.set('b') + value.set('b') // unchanged: no additional schedule + value.set('c') + await tick() + await tick() // settle the contained rejected promise + // One coalesced callback for the whole burst; a throwing, rejecting, or + // hostile-to-render observer is contained and does not stop later observers. + expect(thrower).toHaveBeenCalledTimes(1) + expect(rejecter).toHaveBeenCalledTimes(1) + expect(hostileThrower).toHaveBeenCalledTimes(1) + expect(after).toHaveBeenCalledTimes(1) + // Each contained failure logged its own warning: the sync throw, the + // rejected promise, and the hostile-to-render throw (via non-throwing + // errorChain). Pinning the rejected-promise warning fails if its `.catch` + // containment is removed. + expect(warnings.some(w => w.includes('threw: sync observer boom'))).toBe(true) + expect(warnings.some(w => w.includes('rejected: async observer boom'))).toBe(true) + expect(warnings.some(w => w.includes('threw: '))).toBe(true) + + // Unsubscribe stops further notifications for that listener. + unsubscribe() + value.set('d') + await tick() + expect(after).toHaveBeenCalledTimes(1) + await ctx.fiber.dispose() + }) + + it('removes a subscription when the subscriber fiber disposes', async () => { + const ctx = new Context() + await ctx.plugin(TuiPromptService) + const observed = vi.fn() + // Subscribe from a child plugin fiber that shares the service, then dispose + // only that fiber; the effect-owned subscription must go with it. + const child = ctx.plugin({ + inject: ['tuiPrompt'], + apply: (childCtx) => { childCtx.tuiPrompt.subscribe(observed) }, + }) + await tick() + observed.mockClear() + await child.dispose() + + const value = ctx.tuiPrompt.register('git/worktree', 'a') + value.set('b') + await tick() + expect(observed).not.toHaveBeenCalled() + await ctx.fiber.dispose() + }) + + it('keeps one fiber\'s subscription when another disposes the same callback', async () => { + const ctx = new Context() + await ctx.plugin(TuiPromptService) + // Both fibers subscribe the SAME function reference. Per-subscription record + // identity (not callback identity) keeps them independent, so disposing one + // must not silence the other. + const shared = vi.fn() + const first = ctx.plugin({ inject: ['tuiPrompt'], apply: (c) => { c.tuiPrompt.subscribe(shared) } }) + ctx.plugin({ inject: ['tuiPrompt'], apply: (c) => { c.tuiPrompt.subscribe(shared) } }) + await tick() + await first.dispose() + shared.mockClear() + + const value = ctx.tuiPrompt.register('git/worktree', 'a') + value.set('b') + await tick() + // The second fiber's subscription survives the first's disposal. + expect(shared).toHaveBeenCalledTimes(1) + await ctx.fiber.dispose() + }) + + it('does not notify a subscription unsubscribed earlier in the same burst', async () => { + const ctx = new Context() + await ctx.plugin(TuiPromptService) + const victim = vi.fn() + // This listener is delivered first (subscribed first) and synchronously + // unsubscribes the victim during the same notification. The snapshot must + // re-check liveness so the later victim record does not fire this burst. + ctx.tuiPrompt.subscribe(() => { unsubscribeVictim() }) + const unsubscribeVictim = ctx.tuiPrompt.subscribe(victim) + await tick() + victim.mockClear() + + const value = ctx.tuiPrompt.register('git/worktree', 'a') + value.set('b') + await tick() + expect(victim).not.toHaveBeenCalled() + await ctx.fiber.dispose() + }) + + it('rejects invalid and duplicate names', async () => { + const ctx = new Context() + await ctx.plugin(TuiPromptService) + expect(() => ctx.tuiPrompt.register('Bad Name')).toThrow(/must match/) + ctx.tuiPrompt.register('status') + expect(() => ctx.tuiPrompt.register('status')).toThrow(/already registered/) + await ctx.fiber.dispose() + }) +}) + +describe('TUI prompt templates', () => { + it('interpolates values and removes separators around unavailable values', () => { + const tokens = parseTuiPromptTemplate('${cwd} ${git/worktree} :: ${missing} ${model}') + const values = new Map([['cwd', '/work'], ['model', 'deepseek']]) + expect(renderTuiPromptTemplate(tokens, name => values.get(name))).toBe('/work :: deepseek') + }) + + it('keeps a trailing literal after the last value', () => { + const tokens = parseTuiPromptTemplate('${symbol} ${indicator} > ') + const values = new Map([['symbol', 'dsh'], ['indicator', '●']]) + expect(renderTuiPromptTemplate(tokens, name => values.get(name))).toBe('dsh ● > ') + }) + + it('preserves trusted ANSI fragments', () => { + const powerline = '\x1b[44m work \x1b[34;46m\x1b[0m' + expect(renderTuiPromptTemplate(parseTuiPromptTemplate('${powerline}'), () => powerline)).toBe(powerline) + }) +}) diff --git a/packages/ui/tui/tests/session-reference.snapshot.ts b/packages/ui/tui/tests/session-reference.snapshot.ts index f0b5e0eb61..522674172e 100644 --- a/packages/ui/tui/tests/session-reference.snapshot.ts +++ b/packages/ui/tui/tests/session-reference.snapshot.ts @@ -1,7 +1,7 @@ import { mkdir, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -12,7 +12,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import CommandService from '@deepseek-ai/dsh-commands' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import SessionReferenceService, { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference' -import { createTuiChat } from '../src/index.ts' +import { createTuiChat, TuiPromptService } from '../src/index.ts' import { HeadlessTerminal } from './headless-terminal.ts' import { TestSessionQueryService } from './session-query.ts' @@ -48,6 +48,7 @@ function nextIdle(ctx: Context, agent: Agent): Promise { describe('TUI session-reference snapshot', () => { it('snapshots compacted current-surface context on send and displays only its reference card', async () => { + const clock = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 12, 30, 0).getTime()) const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) @@ -56,6 +57,7 @@ describe('TUI session-reference snapshot', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) + await ctx.plugin(TuiPromptService) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TestSessionQueryService) await ctx.plugin(SessionReferenceService) @@ -94,7 +96,7 @@ describe('TUI session-reference snapshot', () => { const controller = createTuiChat(ctx, { sessionId: target.id, welcome: 'Session reference snapshot.', - color: true, + theme: { color: true }, title: 'DSH session reference', }, { terminal, exit: () => {} }) await terminal.waitForFrame(0) @@ -140,5 +142,6 @@ describe('TUI session-reference snapshot', () => { await controller.dispose() await ctx.fiber.dispose() await terminal.dispose() + clock.mockRestore() }) }) diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt index 0e3c73750a..f0fd74b989 100644 --- a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt +++ b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt @@ -1,99 +1,69 @@ terminal 100x40 buffer=normal length=40 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=36 bufferRow=36 +cursor hidden column=7 viewportRow=34 bufferRow=34 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=green -5| "▌ ✓ pnpm run test:coverage " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-25 bold -6| "▌ Run the coverage gate " - style 0-0 fg=green - style 2-22 fg=bright-black -7| "▌ /workspace/project " - style 0-0 fg=green - style 2-19 dim -8| "▌ … +4 lines (Ctrl+O to expand) " - style 0-0 fg=green - style 2-30 dim -9| "▌ [exit 0] " - style 0-0 fg=green - style 2-9 dim -10| "▌ " - style 0-0 fg=green +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| +6| "● Tool / bash / Run the coverage gate" + style 0-36 fg=green +7| "$ pnpm run test:coverage " + style 0-23 fg=cyan +8| "/workspace/project " + style 0-17 dim +9| "… +4 lines (Ctrl+O to expand) " + style 0-28 dim +10| "[exit 0] " + style 0-7 dim 11| -12| "▌ " - style 0-0 fg=green -13| "▌ ✓ Edit renderer " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-16 bold -14| "▌ src/view.ts " - style 0-0 fg=green - style 2-12 bold -15| "▌ - old line " - style 0-0 fg=green - style 2-11 fg=red -16| "▌ … +5 lines (Ctrl+O to expand) " - style 0-0 fg=green - style 2-30 dim -17| "▌ + expect(screen).toMatchSnapshot() " - style 0-0 fg=green - style 2-35 fg=green -18| "▌ " - style 0-0 fg=green -19| -20| "▌ " - style 0-0 fg=green -21| "▌ ✓ Delegate renderer audit " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-26 bold -22| "▌ The renderer has explicit lifecycle ownership. " - style 0-0 fg=green -23| "▌ " - style 0-0 fg=green -24| -25| "▌ " - style 0-0 fg=green -26| "▌ ✓ Read output from background task subagent-7 " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-46 bold -27| "▌ audit complete " - style 0-0 fg=green -28| "▌ [status: completed] " - style 0-0 fg=green -29| "▌ " - style 0-0 fg=green -30| -31| "▌ " - style 0-0 fg=green -32| "▌ ✓ Load skill dsh-code-review " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-29 bold -33| "▌ Loaded review instructions. " - style 0-0 fg=green -34| "▌ " - style 0-0 fg=green -35| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -36| " " - style 1-1 inverse -37| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -38| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 73-99 dim -39| +12| "● Tool / edit" + style 0-12 fg=green +13| "src/view.ts " + style 0-10 bold +14| "- old line " + style 0-9 fg=red +15| "… +3 lines (Ctrl+O to expand) " + style 0-28 dim +16| "└ +2 -2 · 1 file " + style 0-15 dim +17| +18| "● Tool / subagent" + style 0-16 fg=green +19| "Delegate renderer audit " +20| "The renderer has explicit lifecycle ownership. " +21| +22| "● Tool / task_output" + style 0-19 fg=green +23| "Read output from background task subagent-7 " +24| " " +25| "… +2 lines (Ctrl+O to expand) " + style 0-28 dim +26| " " +27| +28| "● Tool / skill" + style 0-13 fg=green +29| "Load skill dsh-code-review " +30| "Loaded review instructions. " +31| "Model wait 0.0s " + style 0-14 dim +32| +33| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +34| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +35-39| diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt index 2321eff61d..05e1f11b3c 100644 --- a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt +++ b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt @@ -1,117 +1,79 @@ -terminal 100x40 buffer=normal length=48 base=8 viewport=8 +terminal 100x40 buffer=normal length=43 base=3 viewport=3 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=37 bufferRow=45 +cursor hidden column=7 viewportRow=39 bufferRow=42 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=green -5| "▌ ✓ pnpm run test:coverage " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-25 bold -6| "▌ Run the coverage gate " - style 0-0 fg=green - style 2-22 fg=bright-black -7| "▌ /workspace/project " - style 0-0 fg=green - style 2-19 dim -8| "▌ packages/ui/tui 100% " - style 0-0 fg=green -9| "▌ 4016 tests passed " - style 0-0 fg=green -10| "▌ 1 test skipped " - style 0-0 fg=green -11| "▌ coverage complete " - style 0-0 fg=green -12| "▌ [exit 0] " - style 0-0 fg=green - style 2-9 dim -13| "▌ " - style 0-0 fg=green +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| +6| "● Tool / bash / Run the coverage gate" + style 0-36 fg=green +7| "$ pnpm run test:coverage " + style 0-23 fg=cyan +8| "/workspace/project " + style 0-17 dim +9| "packages/ui/tui 100% " +10| "4016 tests passed " +11| "1 test skipped " +12| "coverage complete " +13| "[exit 0] " + style 0-7 dim 14| -15| "▌ " - style 0-0 fg=green -16| "▌ ✓ Edit renderer " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-16 bold -17| "▌ src/view.ts " - style 0-0 fg=green - style 2-12 bold -18| "▌ - old line " - style 0-0 fg=green - style 2-11 fg=red -19| "▌ - keep " - style 0-0 fg=green - style 2-7 fg=red -20| "▌ + new line " - style 0-0 fg=green - style 2-11 fg=green -21| "▌ + keep " - style 0-0 fg=green - style 2-7 fg=green -22| "▌ " - style 0-0 fg=green -23| "▌ tests/view.spec.ts " - style 0-0 fg=green - style 2-19 bold -24| "▌ + expect(screen).toMatchSnapshot() " - style 0-0 fg=green - style 2-35 fg=green -25| "▌ " - style 0-0 fg=green +15| "● Tool / edit" + style 0-12 fg=green +16| "src/view.ts " + style 0-10 bold +17| "- old line " + style 0-9 fg=red +18| "- keep " + style 0-5 fg=red +19| "+ new line " + style 0-9 fg=green +20| "+ keep " + style 0-5 fg=green +21| "└ +2 -2 · 1 file " + style 0-15 dim +22| +23| "● Tool / subagent" + style 0-16 fg=green +24| "Delegate renderer audit " +25| "The renderer has explicit lifecycle ownership. " 26| -27| "▌ " - style 0-0 fg=green -28| "▌ ✓ Delegate renderer audit " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-26 bold -29| "▌ The renderer has explicit lifecycle ownership. " - style 0-0 fg=green -30| "▌ " - style 0-0 fg=green -31| -32| "▌ " - style 0-0 fg=green -33| "▌ ✓ Read output from background task subagent-7 " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-46 bold -34| "▌ audit complete " - style 0-0 fg=green -35| "▌ [status: completed] " - style 0-0 fg=green -36| "▌ " - style 0-0 fg=green -37| -38| "▌ " - style 0-0 fg=green -39| "▌ ✓ Load skill dsh-code-review " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-29 bold -40| "▌ Loaded review instructions. " - style 0-0 fg=green -41| "▌ " - style 0-0 fg=green -42| -43| " Tool cards expanded. " - style 1-20 fg=bright-black -44| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -45| " " - style 1-1 inverse -46| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -47| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:expanded" - style 0-43 dim - style 74-99 dim +27| "● Tool / task_output" + style 0-19 fg=green +28| "Read output from background task subagent-7 " +29| " " +30| "console " + style 0-6 dim +31| " started background task bash-5 " + style 2-31 fg=cyan +32| " " +33| +34| "● Tool / skill" + style 0-13 fg=green +35| "Load skill dsh-code-review " +36| "Loaded review instructions. " +37| "Model wait 0.0s " + style 0-14 dim +38| +39| "Tool cards expanded. " + style 0-19 fg=bright-black +40| +41| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +42| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/banner-gradient.expected.txt b/packages/ui/tui/tests/snapshots/banner-gradient.expected.txt index 59d1377702..0477513ebd 100644 --- a/packages/ui/tui/tests/snapshots/banner-gradient.expected.txt +++ b/packages/ui/tui/tests/snapshots/banner-gradient.expected.txt @@ -1,7 +1,7 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=4 bufferRow=4 +cursor hidden column=7 viewportRow=8 bufferRow=8 viewport 0| " DEEPSEEK HARNESS" style 1-1 fg=#4d6bfe bold @@ -15,15 +15,22 @@ viewport style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim -3| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -4| " " - style 1-1 inverse -5| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim -7-35| +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +8| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +9-35| diff --git a/packages/ui/tui/tests/snapshots/code-mode-pending.expected.txt b/packages/ui/tui/tests/snapshots/code-mode-pending.expected.txt index c246a00b69..db131247d0 100644 --- a/packages/ui/tui/tests/snapshots/code-mode-pending.expected.txt +++ b/packages/ui/tui/tests/snapshots/code-mode-pending.expected.txt @@ -1,39 +1,37 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=12 bufferRow=12 +cursor hidden column=7 viewportRow=15 bufferRow=15 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=yellow -5| "▌ ◌ Echo two markers and combine them " - style 0-0 fg=yellow - style 2-2 fg=yellow bold - style 3-36 bold -6| "▌ const first = await tools.bash({ command: 'echo CODE_ONE' }) " - style 0-0 fg=yellow -7| "▌ const second = await tools.bash({ command: 'echo CODE_TWO' }) " - style 0-0 fg=yellow -8| "▌ console.log(first, second) " - style 0-0 fg=yellow -9| "▌ return `${first}+${second}` " - style 0-0 fg=yellow -10| "▌ " - style 0-0 fg=yellow -11| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -12| " " - style 1-1 inverse -13| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -14| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim -15-35| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| +6| "○ Tool / run_code" + style 0-16 fg=yellow +7| "Echo two markers and combine them " +8| "const first = await tools.bash({ command: 'echo CODE_ONE' }) " +9| "const second = await tools.bash({ command: 'echo CODE_TWO' }) " +10| "console.log(first, second) " +11| "return `${first}+${second}` " +12| "Model wait 0.0s " + style 0-14 dim +13| +14| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +15| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +16-35| diff --git a/packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt b/packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt index 52e003eea0..db68564b48 100644 --- a/packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt +++ b/packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt @@ -1,46 +1,45 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=active title "DSH snapshot" -cursor hidden column=1 viewportRow=17 bufferRow=17 +cursor hidden column=7 viewportRow=18 bufferRow=18 viewport 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Show the live update. " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue -8| -9| " Reasoning " - style 1-9 fg=bright-black italic -10| " Inspecting width and styles. " - style 1-28 fg=bright-black italic -11| -12| " Assistant " - style 1-9 fg=bright-magenta bold -13| " Streaming visible state… " - style 11-23 bold -14| -15| " ⠋ Responding 0s · total 0s — Enter sends steering, Esc cancels " - style 1-1 fg=bright-blue - style 3-62 fg=bright-black -16| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 fg=bright-blue -17| " " - style 1-1 inverse -18| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 fg=bright-blue -19| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim -20-35| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Reasoning " + style 0-8 fg=bright-black italic +6| "Inspecting width and styles. " + style 0-27 fg=bright-black italic +7| "Streaming visible state… " + style 10-22 bold +8| " " +9| "ts " + style 0-1 dim +10| " const visible = true " + style 2-21 fg=cyan +11| " " +12| "Model wait 1.0s · Thinking 2.0s " + style 0-30 dim +13| +14| "You " + style 0-2 fg=bright-blue bold underline +15| "Show the live update. " +16| +17| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +18| " dsh ● press enter to steer and esc to cancel " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-44 dim +19-35| diff --git a/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt b/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt index 47c225008d..df927629c9 100644 --- a/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt +++ b/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt @@ -1,49 +1,45 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=16 bufferRow=16 +cursor hidden column=7 viewportRow=21 bufferRow=21 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ ◌ Inspect cordis runtime: tools " - style 0-0 fg=yellow - style 2-2 fg=yellow bold - style 3-32 bold +4| "Assistant " + style 0-8 fg=bright-magenta bold underline 5| -6| "▌ " - style 0-0 fg=yellow -7| "▌ ◌ Mount plugin into live cordis runtime " - style 0-0 fg=yellow - style 2-2 fg=yellow bold - style 3-40 bold -8| "▌ { " - style 0-0 fg=yellow -9| "▌ \"code\": \"return { name: 'snapshot-marker', apply(ctx) { ctx.provide('snapshotMarker', { " - style 0-0 fg=yellow -10| "▌ ready: true }) } }\" " - style 0-0 fg=yellow -11| "▌ } " - style 0-0 fg=yellow -12| "▌ " - style 0-0 fg=yellow -13| -14| "▌ ◌ Unmount dyn-1 " - style 0-0 fg=yellow - style 2-2 fg=yellow bold - style 3-16 bold -15| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -16| " " - style 1-1 inverse -17| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -18| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim -19-35| +6| "○ Tool / cordis_inspect" + style 0-22 fg=yellow +7| "Inspect cordis runtime: tools " +8| +9| "○ Tool / cordis_mount" + style 0-20 fg=yellow +10| "Mount plugin into live cordis runtime " +11| "{ " +12| " \"code\": \"return { name: 'snapshot-marker', apply(ctx) { ctx.provide('snapshotMarker', { ready:" +13| "true }) } }\" " +14| "} " +15| +16| "○ Tool / cordis_unmount" + style 0-22 fg=yellow +17| "Unmount dyn-1 " +18| "Model wait 0.0s " + style 0-14 dim +19| +20| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +21| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +22-35| diff --git a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt index b6fb49135b..9be96fe5e1 100644 --- a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt +++ b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt @@ -1,63 +1,78 @@ -terminal 92x32 buffer=normal length=32 base=0 viewport=0 +terminal 92x32 buffer=normal length=38 base=6 viewport=6 lifecycle started=1 stopped=1 progress=inactive title "DSH snapshot" -cursor visible column=0 viewportRow=31 bufferRow=31 +cursor visible column=0 viewportRow=31 bufferRow=37 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| " Keyboard shortcuts " - style 1-18 fg=bright-blue bold -5| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history " - style 1-61 fg=bright-black -6| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning " - style 1-75 fg=bright-black -7| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " - style 1-73 fg=bright-black -8| " " -9| " /clear — Clear the transcript view (session history is unchanged) " - style 1-65 fg=bright-black -10| " /exit — Exit after the active turn reaches idle " - style 1-47 fg=bright-black -11| " /help — Show keyboard shortcuts and commands " - style 1-44 fg=bright-black -12| " /model [[provider/]model] — Show or switch this session's model " - style 1-63 fg=bright-black -13| " /reasoning — Toggle reasoning blocks " - style 1-36 fg=bright-black -14| " /redraw — Invalidate components and redraw the terminal " - style 1-55 fg=bright-black -15| " /reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " - style 1-88 fg=bright-black -16| " /resume — List this workspace's resumable sessions " - style 1-50 fg=bright-black -17| " /status — Show detailed session diagnostics " - style 1-43 fg=bright-black -18| " /tools — Expand or collapse all tool cards " - style 1-42 fg=bright-black -19| " /skill: [instructions] — load a skill into the conversation " - style 1-65 fg=bright-black -20| -21| " provider stream failed after partial output " - style 1-43 fg=red -22| -23| " The previous process ended during this turn. " - style 1-44 fg=yellow +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s · Completed 2026-07-21 15:05:00 " + style 0-46 dim +6| +7| "Keyboard shortcuts " + style 0-17 fg=bright-blue bold +8| "Enter send • Shift/Alt+Enter newline • Up/Down prompt history " + style 0-60 fg=bright-black +9| "Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning " + style 0-74 fg=bright-black +10| "Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " + style 0-72 fg=bright-black +11| " " +12| "/clear — Clear the transcript view (session history is unchanged) " + style 0-64 fg=bright-black +13| "/exit — Exit after the active turn reaches idle " + style 0-46 fg=bright-black +14| "/help — Show keyboard shortcuts and commands " + style 0-43 fg=bright-black +15| "/model [[provider/]model] — Show or switch this session's model " + style 0-62 fg=bright-black +16| "/quit — Exit after the active turn reaches idle " + style 0-46 fg=bright-black +17| "/reasoning — Toggle reasoning blocks " + style 0-35 fg=bright-black +18| "/redraw — Invalidate components and redraw the terminal " + style 0-54 fg=bright-black +19| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " + style 0-87 fg=bright-black +20| "/resume — List this workspace's resumable sessions " + style 0-49 fg=bright-black +21| "/status — Show session diagnostics, system prompt, and registered tools " + style 0-70 fg=bright-black +22| "/tools — Expand or collapse all tool cards " + style 0-41 fg=bright-black +23| "/skill: [instructions] — load a skill into the conversation " + style 0-64 fg=bright-black 24| -25| " Unknown command: /unknown-advanced-command " - style 1-42 fg=yellow -26| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -27| " " - style 1-1 inverse -28| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -29| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 65-91 dim -30-31| +25| "provider stream failed after partial output " + style 0-42 fg=red +26| +27| "The previous process ended during this turn. " + style 0-43 fg=yellow +28| +29| "Turn stopped: the agent was disposed. " + style 0-36 fg=yellow +30| +31| "Turn ended: plugin-policy. " + style 0-25 fg=yellow +32| +33| "Unknown command: /unknown-advanced-command " + style 0-41 fg=yellow +34| +35| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +36| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +37| diff --git a/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.expected.txt b/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.expected.txt index ace55782e9..9826588b32 100644 --- a/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.expected.txt +++ b/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.expected.txt @@ -1,46 +1,40 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=15 bufferRow=15 +cursor hidden column=7 viewportRow=17 bufferRow=17 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=yellow -5| "▌ ◌ workflow: tui-matrix " - style 0-0 fg=yellow - style 2-2 fg=yellow bold - style 3-23 bold -6| "▌ phase('Inspect') " - style 0-0 fg=yellow -7| "▌ const reports = await parallel([ " - style 0-0 fg=yellow -8| "▌ () => agent('Audit layout', { label: 'layout', phase: 'Inspect' }), " - style 0-0 fg=yellow -9| "▌ … +1 lines (Ctrl+O to expand) " - style 0-0 fg=yellow - style 2-30 dim -10| "▌ ]) " - style 0-0 fg=yellow -11| "▌ phase('Verify') " - style 0-0 fg=yellow -12| "▌ return { reports, verdict: 'covered' } " - style 0-0 fg=yellow -13| "▌ " - style 0-0 fg=yellow -14| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -15| " " - style 1-1 inverse -16| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -17| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| +6| "○ Tool / workflow" + style 0-16 fg=yellow +7| "workflow: tui-matrix " +8| "phase('Inspect') " +9| "const reports = await parallel([ " +10| "… +2 lines (Ctrl+O to expand) " + style 0-28 dim +11| "]) " +12| "phase('Verify') " +13| "return { reports, verdict: 'covered' } " +14| "Model wait 0.0s " + style 0-14 dim +15| +16| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +17| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse 18-35| diff --git a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt index 05c35e32e1..f93a4b47da 100644 --- a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt +++ b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt @@ -1,63 +1,77 @@ -terminal 92x32 buffer=normal length=32 base=0 viewport=0 +terminal 92x32 buffer=normal length=37 base=5 viewport=5 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=27 bufferRow=27 +cursor hidden column=7 viewportRow=31 bufferRow=36 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| " Keyboard shortcuts " - style 1-18 fg=bright-blue bold -5| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history " - style 1-61 fg=bright-black -6| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning " - style 1-75 fg=bright-black -7| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " - style 1-73 fg=bright-black -8| " " -9| " /clear — Clear the transcript view (session history is unchanged) " - style 1-65 fg=bright-black -10| " /exit — Exit after the active turn reaches idle " - style 1-47 fg=bright-black -11| " /help — Show keyboard shortcuts and commands " - style 1-44 fg=bright-black -12| " /model [[provider/]model] — Show or switch this session's model " - style 1-63 fg=bright-black -13| " /reasoning — Toggle reasoning blocks " - style 1-36 fg=bright-black -14| " /redraw — Invalidate components and redraw the terminal " - style 1-55 fg=bright-black -15| " /reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " - style 1-88 fg=bright-black -16| " /resume — List this workspace's resumable sessions " - style 1-50 fg=bright-black -17| " /status — Show detailed session diagnostics " - style 1-43 fg=bright-black -18| " /tools — Expand or collapse all tool cards " - style 1-42 fg=bright-black -19| " /skill: [instructions] — load a skill into the conversation " - style 1-65 fg=bright-black -20| -21| " provider stream failed after partial output " - style 1-43 fg=red -22| -23| " The previous process ended during this turn. " - style 1-44 fg=yellow +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s · Completed 2026-07-21 15:05:00 " + style 0-46 dim +6| +7| "Keyboard shortcuts " + style 0-17 fg=bright-blue bold +8| "Enter send • Shift/Alt+Enter newline • Up/Down prompt history " + style 0-60 fg=bright-black +9| "Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning " + style 0-74 fg=bright-black +10| "Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " + style 0-72 fg=bright-black +11| " " +12| "/clear — Clear the transcript view (session history is unchanged) " + style 0-64 fg=bright-black +13| "/exit — Exit after the active turn reaches idle " + style 0-46 fg=bright-black +14| "/help — Show keyboard shortcuts and commands " + style 0-43 fg=bright-black +15| "/model [[provider/]model] — Show or switch this session's model " + style 0-62 fg=bright-black +16| "/quit — Exit after the active turn reaches idle " + style 0-46 fg=bright-black +17| "/reasoning — Toggle reasoning blocks " + style 0-35 fg=bright-black +18| "/redraw — Invalidate components and redraw the terminal " + style 0-54 fg=bright-black +19| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " + style 0-87 fg=bright-black +20| "/resume — List this workspace's resumable sessions " + style 0-49 fg=bright-black +21| "/status — Show session diagnostics, system prompt, and registered tools " + style 0-70 fg=bright-black +22| "/tools — Expand or collapse all tool cards " + style 0-41 fg=bright-black +23| "/skill: [instructions] — load a skill into the conversation " + style 0-64 fg=bright-black 24| -25| " Unknown command: /unknown-advanced-command " - style 1-42 fg=yellow -26| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -27| " " - style 1-1 inverse -28| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -29| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 65-91 dim -30-31| +25| "provider stream failed after partial output " + style 0-42 fg=red +26| +27| "The previous process ended during this turn. " + style 0-43 fg=yellow +28| +29| "Turn stopped: the agent was disposed. " + style 0-36 fg=yellow +30| +31| "Turn ended: plugin-policy. " + style 0-25 fg=yellow +32| +33| "Unknown command: /unknown-advanced-command " + style 0-41 fg=yellow +34| +35| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +36| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/file-autocomplete.expected.txt b/packages/ui/tui/tests/snapshots/file-autocomplete.expected.txt index 174f21a0f4..73b7c494c5 100644 --- a/packages/ui/tui/tests/snapshots/file-autocomplete.expected.txt +++ b/packages/ui/tui/tests/snapshots/file-autocomplete.expected.txt @@ -1,24 +1,31 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=5 viewportRow=4 bufferRow=4 +cursor hidden column=11 viewportRow=8 bufferRow=8 viewport 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim -3| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -4| " @tsc " - style 5-5 inverse -5| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -6| " → File · terminal-special-case.t src/terminal-special-case.ts " - style 1-32 fg=bright-blue -7| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim -8-35| +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +8| " dsh > @tsc " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 11-11 inverse +9| " → File · terminal-special-case.t src/terminal-special-case.ts " + style 7-38 fg=bright-blue +10-35| diff --git a/packages/ui/tui/tests/snapshots/model-effort-switching.expected.txt b/packages/ui/tui/tests/snapshots/model-effort-switching.expected.txt deleted file mode 100644 index cef660ff49..0000000000 --- a/packages/ui/tui/tests/snapshots/model-effort-switching.expected.txt +++ /dev/null @@ -1,42 +0,0 @@ -terminal 92x32 buffer=normal length=32 base=0 viewport=0 -lifecycle started=1 stopped=0 progress=inactive -title "DSH snapshot" -cursor hidden column=92 viewportRow=15 bufferRow=15 -buffer -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-blue bold - style 10-16 bold -1| " Snapshot agent ready." - style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim -3| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -4| " " - style 1-1 inverse -5| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 65-91 dim -7-12| -13| " ╭ Select model ────────────────────────────────────────────────────────────╮ " - style 8-83 fg=bright-blue -14| " │ deepseek/deepseek-v4-flash DeepSeek V4 Flash — High — current │ " - style 8-8 fg=bright-blue - style 38-77 fg=bright-black - style 83-83 fg=bright-blue -15| " │ → deepseek/deepseek-v4-pro DeepSeek V4 Pro — provider default │ " - style 8-8 fg=bright-blue - style 10-77 fg=bright-blue inverse - style 83-83 fg=bright-blue -16| " │ │ " - style 8-8 fg=bright-blue - style 83-83 fg=bright-blue -17| " │ ↑/↓ navigate • Shift+Tab reasoning • Enter select • Esc cancel │ " - style 8-8 fg=bright-blue - style 10-71 dim - style 83-83 fg=bright-blue -18| " ╰──────────────────────────────────────────────────────────────────────────╯ " - style 8-83 fg=bright-blue -19-31| diff --git a/packages/ui/tui/tests/snapshots/model-selector.expected.txt b/packages/ui/tui/tests/snapshots/model-selector.expected.txt index c86b1f1f3d..df2dbf5e33 100644 --- a/packages/ui/tui/tests/snapshots/model-selector.expected.txt +++ b/packages/ui/tui/tests/snapshots/model-selector.expected.txt @@ -8,27 +8,34 @@ buffer style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim -3| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -4| " " - style 1-1 inverse -5| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 65-91 dim -7-12| +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +8| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +9-12| 13| " ╭ Select model ────────────────────────────────────────────────────────────╮ " style 8-83 fg=bright-blue -14| " │ → deepseek/deepseek-v4-flash DeepSeek V4 Flash — High — current │ " +14| " │ → deepseek/deepseek-v4-flash DeepSeek V4 Flash — current │ " style 8-8 fg=bright-blue - style 10-77 fg=bright-blue inverse + style 10-70 fg=bright-blue inverse style 83-83 fg=bright-blue -15| " │ deepseek/deepseek-v4-pro DeepSeek V4 Pro — provider default │ " +15| " │ deepseek/deepseek-v4-pro DeepSeek V4 Pro │ " style 8-8 fg=bright-blue - style 36-77 fg=bright-black + style 36-58 fg=bright-black style 83-83 fg=bright-blue 16| " │ │ " style 8-8 fg=bright-blue diff --git a/packages/ui/tui/tests/snapshots/model-switching.expected.txt b/packages/ui/tui/tests/snapshots/model-switching.expected.txt index 4e711e37c9..9bba7a9b14 100644 --- a/packages/ui/tui/tests/snapshots/model-switching.expected.txt +++ b/packages/ui/tui/tests/snapshots/model-switching.expected.txt @@ -1,27 +1,32 @@ 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=7 bufferRow=7 +cursor hidden column=7 viewportRow=10 bufferRow=10 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-pro • main-session" - style 1-32 dim +2| " main-session" + style 1-12 dim 3| -4| " Model selected: deepseek/deepseek-v4-pro. Reasoning effort: provider default. New steps " - style 1-91 fg=bright-black -5| " will use it. " - style 1-12 fg=bright-black -6| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -7| " " - style 1-1 inverse -8| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -9| "deepseek-v4-pro /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-41 dim - style 65-91 dim -10-31| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "Model selected: deepseek/deepseek-v4-pro. New steps will use it. " + style 0-63 fg=bright-black +8| +9| "/workspace/project (tui-staging) deepseek-v4-pro ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-48 fg=bright-black + style 51-55 fg=bright-black + style 58-67 fg=bright-black +10| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +11-31| diff --git a/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt new file mode 100644 index 0000000000..02d1ff0c97 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt @@ -0,0 +1,40 @@ +terminal 56x20 buffer=normal length=20 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=0 viewportRow=19 bufferRow=19 +viewport +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 fg=bright-black +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 " + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-55 fg=bright-black +8| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +9-11| +12| " " +13| " Question 1/1 (1 unanswered) · Confirm " + style 2-38 fg=bright-black +14| " Continue with this change? " +15| " " +16| " › 1. Proceed Apply the proposed change " + style 2-13 fg=bright-blue bold + style 16-40 fg=bright-black +17| " Tab custom answer • Enter submit • Esc interrupt " + style 2-49 dim +18| " " +19| diff --git a/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt index 4e17a0e652..c5787378e0 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt @@ -8,12 +8,11 @@ viewport style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim -3| "────────────────────────────────────────────────────────" - style 0-55 dim -4| " " - style 1-1 inverse +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline 5| " " 6| " Question 1/3 (3 unanswered) · Coverage " style 2-39 fg=bright-black diff --git a/packages/ui/tui/tests/snapshots/question-dialog.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog.expected.txt index 220f5dc1c7..611b8262d4 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog.expected.txt @@ -8,17 +8,14 @@ viewport style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim -3| "────────────────────────────────────────────────────────" - style 0-55 dim -4| " " - style 1-1 inverse -5| "────────────────────────────────────────────────────────" - style 0-55 dim -6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context" - style 0-43 dim - style 46-55 dim +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s " + style 0-14 dim +6| 7| " " 8| " Question 1/3 (3 unanswered) · Coverage " style 2-39 fg=bright-black diff --git a/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt b/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt index accef4fffc..c6a3abac21 100644 --- a/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt +++ b/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt @@ -1,38 +1,34 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=13 bufferRow=13 +cursor hidden column=7 viewportRow=12 bufferRow=12 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Start then cancel. " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Start then cancel. " +6| +7| "Retrying model request (1/2) in 1000ms: temporary transport failure " + style 0-66 fg=yellow 8| -9| " Retrying model request (1/2) in 1000ms: temporary transport failure " - style 1-67 fg=yellow +9| "Turn cancelled. " + style 0-14 fg=yellow 10| -11| " Turn cancelled. " - style 1-15 fg=yellow -12| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -13| " " - style 1-1 inverse -14| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -15| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim -16-35| +11| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +12| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +13-35| diff --git a/packages/ui/tui/tests/snapshots/retry-exhausted.expected.txt b/packages/ui/tui/tests/snapshots/retry-exhausted.expected.txt index 1b038d5a83..c642694132 100644 --- a/packages/ui/tui/tests/snapshots/retry-exhausted.expected.txt +++ b/packages/ui/tui/tests/snapshots/retry-exhausted.expected.txt @@ -1,35 +1,31 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=11 bufferRow=11 +cursor hidden column=7 viewportRow=10 bufferRow=10 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Let the bounded policy exhaust. " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Let the bounded policy exhaust. " +6| +7| "provider still unavailable " + style 0-25 fg=red 8| -9| " provider still unavailable " - style 1-26 fg=red -10| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -11| " " - style 1-1 inverse -12| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -13| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim -14-35| +9| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +10| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +11-35| diff --git a/packages/ui/tui/tests/snapshots/retry-recovered.expected.txt b/packages/ui/tui/tests/snapshots/retry-recovered.expected.txt index c0ae86361c..4144bac651 100644 --- a/packages/ui/tui/tests/snapshots/retry-recovered.expected.txt +++ b/packages/ui/tui/tests/snapshots/retry-recovered.expected.txt @@ -1,39 +1,31 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=14 bufferRow=14 +cursor hidden column=7 viewportRow=10 bufferRow=10 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Recover this request. " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Recover this request. " +6| +7| "Retrying model request (1/2) in 500ms: provider rate limit " + style 0-57 fg=yellow 8| -9| " Retrying model request (1/2) in 500ms: provider rate limit " - style 1-58 fg=yellow -10| -11| " Assistant " - style 1-9 fg=bright-magenta bold -12| " Recovered on the next bounded attempt. " -13| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -14| " " - style 1-1 inverse -15| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -16| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim -17-35| +9| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +10| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +11-35| diff --git a/packages/ui/tui/tests/snapshots/retry-scheduled.expected.txt b/packages/ui/tui/tests/snapshots/retry-scheduled.expected.txt index 16dee8242a..4144bac651 100644 --- a/packages/ui/tui/tests/snapshots/retry-scheduled.expected.txt +++ b/packages/ui/tui/tests/snapshots/retry-scheduled.expected.txt @@ -1,35 +1,31 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=11 bufferRow=11 +cursor hidden column=7 viewportRow=10 bufferRow=10 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Recover this request. " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Recover this request. " +6| +7| "Retrying model request (1/2) in 500ms: provider rate limit " + style 0-57 fg=yellow 8| -9| " Retrying model request (1/2) in 500ms: provider rate limit " - style 1-58 fg=yellow -10| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -11| " " - style 1-1 inverse -12| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -13| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim -14-35| +9| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +10| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +11-35| diff --git a/packages/ui/tui/tests/snapshots/session-reference.expected.txt b/packages/ui/tui/tests/snapshots/session-reference.expected.txt index 3cd62dd243..3e0d12f045 100644 --- a/packages/ui/tui/tests/snapshots/session-reference.expected.txt +++ b/packages/ui/tui/tests/snapshots/session-reference.expected.txt @@ -1,39 +1,35 @@ terminal 96x24 buffer=normal length=24 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH session reference" -cursor hidden column=1 viewportRow=14 bufferRow=14 +cursor hidden column=7 viewportRow=14 bufferRow=14 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Session reference snapshot." style 1-27 fg=bright-black -2| " mock • target-session" - style 1-23 dim +2| " target-session" + style 1-14 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Use @Source session " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Use @Source session " +6| +7| "Referenced sessions · Source session (source-session) " + style 0-52 dim 8| -9| " Referenced sessions · Source session (source-session) " - style 1-53 dim -10| -11| " Assistant " - style 1-9 fg=bright-magenta bold -12| " Combined reference request accepted. " -13| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -14| " " - style 1-1 inverse -15| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -16| "mock /workspace/project ↑0 ↓0 tools:collapsed" - style 0-30 dim - style 81-95 dim -17-23| +9| "Assistant " + style 0-8 fg=bright-magenta bold underline +10| "Combined reference request accepted. " +11| "Model wait 0.0s · Completed 2026-07-21 12:30:00 " + style 0-46 dim +12| +13| "/workspace/project mock ↑0 ↓0" + style 0-17 fg=bright-blue bold + style 20-23 fg=bright-black + style 26-30 fg=bright-black +14| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +15-23| diff --git a/packages/ui/tui/tests/snapshots/shell-prompt-multiline.expected.txt b/packages/ui/tui/tests/snapshots/shell-prompt-multiline.expected.txt new file mode 100644 index 0000000000..3b47364308 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/shell-prompt-multiline.expected.txt @@ -0,0 +1,31 @@ +terminal 44x18 buffer=normal length=18 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=38 viewportRow=13 bufferRow=13 +viewport +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 fg=bright-black +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-43 fg=bright-black +8| " ↑ 1 more " + style 1-14 dim +9| " enough detail to wrap across multiple " +10| " full-width continuation rows without " +11| " leaving a prompt-sized gap at the right " +12| " edge. " +13| " Then suggest a simpler version. " + style 38-38 inverse +14-17| diff --git a/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt b/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt index 4937592cc7..a22787ea49 100644 --- a/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt +++ b/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt @@ -1,110 +1,120 @@ -terminal 56x36 buffer=normal length=36 base=0 viewport=0 +terminal 56x36 buffer=normal length=44 base=8 viewport=8 lifecycle started=1 stopped=0 progress=inactive title "Inspect session diagnostics — DSH snapshot" -cursor hidden column=1 viewportRow=32 bufferRow=32 +cursor hidden column=7 viewportRow=35 bufferRow=43 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Inspect session diagnostics" style 1-27 fg=bright-black -2| " deepseek-v4-pro • main-session" - style 1-32 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ inspect this session " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue -8| -9| " Assistant " - style 1-9 fg=bright-magenta bold -10| " Session inspected. " -11| -12| "╭─ Session status ─────────────────────────────────────╮" +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Session inspected. " +6| "Model wait 0.0s " + style 0-14 dim +7| +8| "You " + style 0-2 fg=bright-blue bold underline +9| "inspect this session " +10| +11| "╭─ Session status ─────────────────────────────────────╮" style 0-2 dim style 3-16 fg=bright-blue bold style 17-55 dim -13| "│ Session: main-session │" +12| "│ Session: main-session │" style 0-0 dim style 3-12 fg=bright-black style 55-55 dim -14| "│ Title: Inspect session diagnostics │" +13| "│ Title: Inspect session diagnostics │" style 0-0 dim style 3-12 fg=bright-black style 55-55 dim -15| "│ Directory: /workspace/project │" +14| "│ Directory: /workspace/project │" style 0-0 dim style 3-12 fg=bright-black style 55-55 dim -16| "│ Model: deepseek/deepseek-v4-pro (effort │" +15| "│ Model: deepseek/deepseek-v4-pro (effort │" style 0-0 dim style 3-12 fg=bright-black style 40-55 dim -17| "│ default; reasoning blocks shown) │" +16| "│ default; reasoning blocks shown) │" style 0-0 dim style 15-46 dim style 55-55 dim -18| "│ │" +17| "│ │" style 0-0 dim style 55-55 dim -19| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 │" +18| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 │" style 0-0 dim style 3-12 fg=bright-black style 55-55 dim -20| "│ tool call │" +19| "│ tool call │" style 0-0 dim style 55-55 dim -21| "│ │" +20| "│ │" style 0-0 dim style 55-55 dim -22| "│ Tokens: 1,250 input + 340 output │" +21| "│ Tokens: 1,250 input + 340 output │" style 0-0 dim style 3-12 fg=bright-black style 55-55 dim -23| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read │" +22| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read │" style 0-0 dim style 3-12 fg=bright-black style 15-15 dim style 16-26 fg=bright-blue style 27-32 dim style 55-55 dim -24| "│ + 250 write) │" +23| "│ + 250 write) │" style 0-0 dim style 55-55 dim -25| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / │" +24| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / │" style 0-0 dim style 3-12 fg=bright-black style 15-15 dim style 16-20 fg=bright-blue style 21-32 dim style 55-55 dim -26| "│ 128,000) │" +25| "│ 128,000) │" style 0-0 dim style 55-55 dim -27| "│ │" +26| "│ │" style 0-0 dim style 55-55 dim -28| "│ Created: 2026-07-22 09:10:11 UTC │" +27| "│ Created: 2026-07-22 09:10:11 UTC │" style 0-0 dim style 3-12 fg=bright-black style 55-55 dim -29| "│ Active: 2026-07-22 09:10:11 UTC │" +28| "│ Active: 2026-07-22 09:10:11 UTC │" style 0-0 dim style 3-12 fg=bright-black style 55-55 dim -30| "╰──────────────────────────────────────────────────────╯" +29| "╰──────────────────────────────────────────────────────╯" style 0-55 dim -31| "────────────────────────────────────────────────────────" - style 0-55 dim -32| " " - style 1-1 inverse -33| "────────────────────────────────────────────────────────" - style 0-55 dim -34| "deepseek-v4-pro /workspace/project ↑1.3k ↓340 cache 6" - style 0-55 dim -35| +30| +31| "System prompt " + style 0-12 fg=bright-blue bold +32| "You are an AI agent powered by the DeepSeek Harness SDK." +33| " " +34| "Paths prefixed with @ are files explicitly referenced by" +35| "the user. Use the read tool when their contents are " +36| "needed; do not claim to have inspected a file before " +37| "reading it. " +38| +39| "Registered tools " + style 0-15 fg=bright-blue bold +40| "read, write " +41| +42| "/workspace/project (tui-staging) deepseek-v4-pro ↑1.3k" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-48 fg=bright-black + style 51-55 fg=bright-black +43| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt b/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt index 915d4e58ef..d8fb37bac4 100644 --- a/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt +++ b/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt @@ -1,99 +1,107 @@ -terminal 92x32 buffer=normal length=32 base=0 viewport=0 +terminal 92x32 buffer=normal length=38 base=6 viewport=6 lifecycle started=1 stopped=0 progress=inactive title "Inspect session diagnostics — DSH snapshot" -cursor hidden column=1 viewportRow=28 bufferRow=28 +cursor hidden column=7 viewportRow=31 bufferRow=37 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Inspect session diagnostics" style 1-27 fg=bright-black -2| " deepseek-v4-pro • main-session" - style 1-32 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ inspect this session " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue -8| -9| " Assistant " - style 1-9 fg=bright-magenta bold -10| " Session inspected. " -11| -12| "╭─ Session status ───────────────────────────────────────────────────────────────╮" +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Session inspected. " +6| "Model wait 0.0s " + style 0-14 dim +7| +8| "You " + style 0-2 fg=bright-blue bold underline +9| "inspect this session " +10| +11| "╭─ Session status ───────────────────────────────────────────────────────────────╮" style 0-2 dim style 3-16 fg=bright-blue bold style 17-81 dim -13| "│ Session: main-session │" +12| "│ Session: main-session │" style 0-0 dim style 3-12 fg=bright-black style 81-81 dim -14| "│ Title: Inspect session diagnostics │" +13| "│ Title: Inspect session diagnostics │" style 0-0 dim style 3-12 fg=bright-black style 81-81 dim -15| "│ Directory: /workspace/project │" +14| "│ Directory: /workspace/project │" style 0-0 dim style 3-12 fg=bright-black style 81-81 dim -16| "│ Model: deepseek/deepseek-v4-pro (effort default; reasoning blocks shown) │" +15| "│ Model: deepseek/deepseek-v4-pro (effort default; reasoning blocks shown) │" style 0-0 dim style 3-12 fg=bright-black style 40-79 dim style 81-81 dim -17| "│ │" +16| "│ │" style 0-0 dim style 81-81 dim -18| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 tool call │" +17| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 tool call │" style 0-0 dim style 3-12 fg=bright-black style 81-81 dim -19| "│ │" +18| "│ │" style 0-0 dim style 81-81 dim -20| "│ Tokens: 1,250 input + 340 output │" +19| "│ Tokens: 1,250 input + 340 output │" style 0-0 dim style 3-12 fg=bright-black style 81-81 dim -21| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read + 250 write) │" +20| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read + 250 write) │" style 0-0 dim style 3-12 fg=bright-black style 15-15 dim style 16-26 fg=bright-blue style 27-32 dim style 81-81 dim -22| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / 128,000) │" +21| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / 128,000) │" style 0-0 dim style 3-12 fg=bright-black style 15-15 dim style 16-20 fg=bright-blue style 21-32 dim style 81-81 dim -23| "│ │" +22| "│ │" style 0-0 dim style 81-81 dim -24| "│ Created: 2026-07-22 09:10:11 UTC │" +23| "│ Created: 2026-07-22 09:10:11 UTC │" style 0-0 dim style 3-12 fg=bright-black style 81-81 dim -25| "│ Active: 2026-07-22 09:10:11 UTC │" +24| "│ Active: 2026-07-22 09:10:11 UTC │" style 0-0 dim style 3-12 fg=bright-black style 81-81 dim -26| "╰────────────────────────────────────────────────────────────────────────────────╯" +25| "╰────────────────────────────────────────────────────────────────────────────────╯" style 0-81 dim -27| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -28| " " - style 1-1 inverse -29| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -30| "deepseek-v4-pro /workspace/project ↑1.3k ↓340 cache 67% 33% context tools:collapsed" - style 0-57 dim - style 64-91 dim -31| +26| +27| "System prompt " + style 0-12 fg=bright-blue bold +28| "You are an AI agent powered by the DeepSeek Harness SDK. " +29| " " +30| "Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when " +31| "their contents are needed; do not claim to have inspected a file before reading it. " +32| +33| "Registered tools " + style 0-15 fg=bright-blue bold +34| "read, write " +35| +36| "/workspace/project (tui-staging) deepseek-v4-pro ↑1.3k ↓340 cache 67% 33% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-48 fg=bright-black + style 51-71 fg=bright-black + style 74-84 fg=bright-black +37| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/step-timing-completed.expected.txt b/packages/ui/tui/tests/snapshots/step-timing-completed.expected.txt new file mode 100644 index 0000000000..728d7116b5 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/step-timing-completed.expected.txt @@ -0,0 +1,34 @@ +terminal 96x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=7 viewportRow=11 bufferRow=11 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 fg=bright-black +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Reasoning " + style 0-8 fg=bright-black italic +6| "Checking the result. " + style 0-19 fg=bright-black italic +7| "The result is ready. " +8| "Model wait 1.0s · Thinking 2.0s · Response 3.0s · Completed 2026-07-21 14:32:12 " + style 0-78 dim +9| +10| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +11| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +12-35| diff --git a/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt b/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt index cb629bbccd..4eeb12de71 100644 --- a/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt +++ b/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt @@ -1,30 +1,36 @@ terminal 44x18 buffer=normal length=18 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=9 bufferRow=9 +cursor hidden column=7 viewportRow=15 bufferRow=15 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| " Context · compact " - style 1-17 dim -5| " Compacted summary: the prior command " - style 1-43 fg=bright-black -6| " completed and its details were retired " - style 1-43 fg=bright-black -7| " from the active surface. " - style 1-24 fg=bright-black -8| "────────────────────────────────────────────" - style 0-43 dim -9| " " - style 1-1 inverse -10| "────────────────────────────────────────────" - style 0-43 dim -11| "deepseek-v4-flash /workspace/project ↑0 ↓0" - style 0-43 dim -12-17| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "Context · workspace-context " + style 0-26 dim +8| "system-reminder " + style 0-14 fg=bright-black +9| " Additional instructions from: " +10| "nested/AGENTS.md " +11| " " +12| " Render workspace context XML clearly. " +13| +14| "/workspace/project (tui-staging) deepseek-v" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-43 fg=bright-black +15| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +16-17| diff --git a/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt b/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt index 6acf1e0483..74ca972da9 100644 --- a/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt +++ b/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt @@ -1,27 +1,37 @@ terminal 104x30 buffer=normal length=30 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=7 bufferRow=7 +cursor hidden column=7 viewportRow=14 bufferRow=14 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| " Context · compact " - style 1-17 dim -5| " Compacted summary: the prior command completed and its details were retired from the active surface. " - style 1-100 fg=bright-black -6| "────────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-103 dim -7| " " - style 1-1 inverse -8| "────────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-103 dim -9| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 77-103 dim -10-29| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "Context · workspace-context " + style 0-26 dim +8| "system-reminder " + style 0-14 fg=bright-black +9| " Additional instructions from: nested/AGENTS.md " +10| " " +11| " Render workspace context XML clearly. " +12| +13| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +14| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +15-29| diff --git a/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt b/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt index e69fb478b0..54f04ef787 100644 --- a/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt +++ b/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt @@ -1,59 +1,47 @@ terminal 80x24 buffer=normal length=24 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=20 bufferRow=20 +cursor hidden column=7 viewportRow=20 bufferRow=20 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Old prompt with a long line that exercises wrapping before compaction. " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| +6| "You " + style 0-2 fg=bright-blue bold underline +7| "Old prompt with a long line that exercises wrapping before compaction. " 8| -9| "▌ " - style 0-0 fg=green -10| "▌ ✓ pnpm run test:coverage " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-25 bold -11| "▌ Run the coverage gate " - style 0-0 fg=green - style 2-22 fg=bright-black -12| "▌ /workspace/project " - style 0-0 fg=green - style 2-19 dim -13| "▌ packages/ui/tui 100% " - style 0-0 fg=green -14| "▌ … +1 lines (Ctrl+O to expand) " - style 0-0 fg=green - style 2-30 dim -15| "▌ 1 test skipped " - style 0-0 fg=green -16| "▌ coverage complete " - style 0-0 fg=green -17| "▌ [exit 0] " - style 0-0 fg=green - style 2-9 dim -18| "▌ " - style 0-0 fg=green -19| "────────────────────────────────────────────────────────────────────────────────" - style 0-79 dim -20| " " - style 1-1 inverse -21| "────────────────────────────────────────────────────────────────────────────────" - style 0-79 dim -22| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 53-79 dim -23| +9| "● Tool / bash / Run the coverage gate" + style 0-36 fg=green +10| "$ pnpm run test:coverage " + style 0-23 fg=cyan +11| "/workspace/project " + style 0-17 dim +12| "packages/ui/tui 100% " +13| "… +1 lines (Ctrl+O to expand) " + style 0-28 dim +14| "1 test skipped " +15| "coverage complete " +16| "[exit 0] " + style 0-7 dim +17| "Model wait 0.0s " + style 0-14 dim +18| +19| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +20| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +21-23| diff --git a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt index fd2437a492..e8df1c98db 100644 --- a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt +++ b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt @@ -1,77 +1,59 @@ -terminal 100x34 buffer=normal length=38 base=4 viewport=4 +terminal 100x34 buffer=normal length=34 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "Unsafe terminal title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" -cursor hidden column=100 viewportRow=33 bufferRow=37 +cursor hidden column=0 viewportRow=33 bufferRow=33 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Unsafe welcome \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" style 1-60 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| +6| "You " + style 0-2 fg=bright-blue bold underline +7| "Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " 8| -9| " Reasoning " - style 1-9 fg=bright-black italic -10| " Unsafe reasoning \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 1-62 fg=bright-black italic -11| -12| " Assistant " - style 1-9 fg=bright-magenta bold -13| " Unsafe assistant \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " -14| -15| "▌ " - style 0-0 fg=green -16| "▌ ✓ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-61 bold -17| "▌ Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 0-0 fg=green - style 2-65 fg=bright-black -18| "▌ /unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 0-0 fg=green - style 2-54 dim -19| "▌ Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 0-0 fg=green -20| "▌ [signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] " - style 0-0 fg=green - style 2-58 fg=red -21| "▌ " - style 0-0 fg=green -22| -23| " Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 1-62 dim -24| " Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 1-60 fg=bright-black -25| -26| " Prompt blocked: Unsafe policy \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 1-75 fg=yellow -27| -28| " Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 1-63 fg=red -29| -30| " " -31| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +9| "● Tool / unsafe / Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" + style 0-81 fg=green +10| "$ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 0-59 fg=cyan +11| "/unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 0-52 dim +12| "Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +13| "[signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] " + style 0-56 fg=red +14| "Model wait 0.0s · Completed 2026-07-21 15:00:00 " + style 0-46 dim +15| +16| "Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 0-61 dim +17| "Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 0-59 fg=bright-black +18| +19| "Prompt blocked: Unsafe policy \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 0-74 fg=yellow +20| +21| "Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 0-62 fg=red +22-23| +24| "Plan" + style 0-3 fg=bright-blue bold +25| " ● Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" + style 2-2 fg=yellow +26| " " +27| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 2-90 fg=bright-black -32| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " -33| " " -34| " › 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m Unsafe detail \\x1b]2;snapshot-c " +28| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +29| " " +30| " › 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 -35| " Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt " - style 2-64 dim -36| " " -37| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 73-99 dim +31| " Tab custom answer • Enter submit • Esc interrupt " + style 2-49 dim +32| " " +33| diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index a507db017a..f349b0113c 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it, vi } from 'vitest' import type { Context } from 'cordis' import { agentEvents } from '@deepseek-ai/dsh-agent' -import { CallId, ReasoningEffortId, type ContentBlock } from '@deepseek-ai/dsh-llm' +import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-llm-retry' import { SessionId, type JsonValue, type Session } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -27,6 +27,8 @@ const REFRESHING = process.env.DSH_SNAPSHOT === 'refresh' const CHECKPOINTS = [ 'conversation-streaming', + 'shell-prompt-multiline', + 'step-timing-completed', 'retry-scheduled', 'retry-recovered', 'retry-cancelled', @@ -40,12 +42,12 @@ const CHECKPOINTS = [ 'advanced-cards-expanded', 'untrusted-controls', 'question-dialog', + 'question-dialog-single-option', 'question-dialog-validation', 'surface-before-compaction', 'surface-after-compaction-narrow', 'surface-after-compaction-wide', 'model-selector', - 'model-effort-switching', 'model-switching', 'errors-and-help', 'disposed-terminal', @@ -104,7 +106,7 @@ async function setupSnapshot( cwd: options.cwd === undefined ? '/workspace/project' : options.cwd, config: Object.assign({ welcome: 'Snapshot agent ready.', - color: true, + theme: { color: true }, title: 'DSH snapshot', }, options.config), }) @@ -195,13 +197,12 @@ const ADVANCED_CARD_TOOLS: Record = { ), edit: visualTool( 'edit', - () => ({ card: 'diff', title: 'Edit renderer', diffs: [{ path: 'src/view.ts', oldText: 'old line', newText: 'new line' }] }), + () => ({ card: 'diff', title: 'Edit src/view.ts', diffs: [{ path: 'src/view.ts', oldText: 'old line', newText: 'new line' }] }), + // The real edit/write tools produce exactly one diff whose path the title + // already names, so the card omits the redundant per-file header. (): ToolResultView => ({ card: 'diff', - diffs: [ - { path: 'src/view.ts', oldText: 'old line\nkeep', newText: 'new line\nkeep' }, - { path: 'tests/view.spec.ts', oldText: null, newText: 'expect(screen).toMatchSnapshot()' }, - ], + diffs: [{ path: 'src/view.ts', oldText: 'old line\nkeep', newText: 'new line\nkeep' }], }), ), subagent: visualTool('subagent', args => ({ @@ -209,12 +210,19 @@ const ADVANCED_CARD_TOOLS: Record = { title: 'Delegate renderer audit', rawInput: (args as { prompt: string }).prompt, })), - task_output: visualTool('task_output', args => ({ - card: 'generic', - kind: 'read', - title: `Read output from background task ${(args as { task_id: string }).task_id}`, - rawInput: (args as { task_id: string }).task_id, - })), + task_output: visualTool( + 'task_output', + args => ({ + card: 'generic', + kind: 'read', + title: `Read output from background task ${(args as { task_id: string }).task_id}`, + rawInput: (args as { task_id: string }).task_id, + }), + () => ({ + card: 'generic', + content: [{ type: 'text', text: '```console\nstarted background task bash-5\n```' }], + }), + ), skill: visualTool('skill', args => ({ card: 'generic', kind: 'read', @@ -228,46 +236,64 @@ const DISPLAYED_CONTROL_PROBE = String.raw`\x1b]2;snapshot-controlled\x07\x09\x7 describe('TUI terminal-state snapshots', () => { it('pins an in-flight reasoning and Markdown stream', async () => { + let clock = new Date(2026, 6, 21, 14, 30, 0).getTime() + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock) const harness = await setupSnapshot() - // Freeze the loader's first animation interval so this semantic snapshot - // cannot select a different spinner frame under scheduler contention. - const frozenLoaderTimer = setInterval(() => {}, 60_000) - const intervals = vi.spyOn(globalThis, 'setInterval').mockImplementationOnce(() => frozenLoaderTimer) - try { - 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: 1, - step: 1, - chunk: { type: 'block-start', index: 0, blockType: 'reasoning' }, - }) - harness.session.append('assistant/chunk', { - turn: 1, - step: 1, - chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' }, - }) - harness.session.append('assistant/chunk', { - turn: 1, - step: 1, - chunk: { type: 'block-start', index: 1, blockType: 'text' }, - }) - harness.session.append('assistant/chunk', { - turn: 1, - step: 1, - chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…' }, - }) + await renderAfter(harness, () => { + harness.agent.status = 'running' + harness.ctx.emit('agent/status', harness.agent, 'running') + appendUser(harness.session, 'Show the live update.') + clock += 1_000 + harness.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'block-start', index: 0, blockType: 'reasoning' }, }) - const loaderIntervalMs = intervals.mock.calls[0]?.[1] - if (typeof loaderIntervalMs !== 'number') throw new Error('TUI loader did not register an animation interval') - await new Promise(resolve => setTimeout(resolve, loaderIntervalMs + 5)) - await checkpoint('conversation-streaming', harness.terminal) - } finally { - intervals.mockRestore() - clearInterval(frozenLoaderTimer) - await disposeSnapshot(harness) - } + harness.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' }, + }) + clock += 2_000 + harness.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'block-start', index: 1, blockType: 'text' }, + }) + harness.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…\n\n```ts\nconst visible = true\n```' }, + }) + }) + await checkpoint('conversation-streaming', harness.terminal) + await disposeSnapshot(harness) + nowSpy.mockRestore() + }) + + it('pins a completed step timing summary', async () => { + let clock = new Date(2026, 6, 21, 14, 32, 6).getTime() + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock) + const harness = await setupSnapshot() + await renderAfter(harness, () => { + clock += 1_000 + harness.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'reasoning-delta', index: 0, text: 'Checking the result.' }, + }) + clock += 2_000 + harness.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 1, text: 'The result is ready.' }, + }) + clock += 3_000 + harness.session.append('step/end', { turn: 1, step: 1 }) + }) + await checkpoint('step-timing-completed', harness.terminal, { includeScrollback: true }) + nowSpy.mockRestore() + await disposeSnapshot(harness) }) it('pins failed-stream retraction, scheduled retry, and eventual success', async () => { @@ -290,15 +316,13 @@ describe('TUI terminal-state snapshots', () => { }) await checkpoint('retry-scheduled', harness.terminal, { includeScrollback: true }) - await renderAfter(harness, () => { - harness.session.append('assistant/message', { - turn: 1, - step: 2, - provenance: { provider: 'mock', model: 'deepseek-v4-flash' }, - content: [{ type: 'text', text: 'Recovered on the next bounded attempt.' }], - }, { surfaceOp: 'append' }) - harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - }) + harness.session.append('assistant/message', { + turn: 1, + step: 2, + provenance: { provider: 'mock', model: 'deepseek-v4-flash' }, + content: [{ type: 'text', text: 'Recovered on the next bounded attempt.' }], + }, { surfaceOp: 'append' }) + harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await checkpoint('retry-recovered', harness.terminal, { includeScrollback: true }) await disposeSnapshot(harness) }) @@ -347,7 +371,7 @@ describe('TUI terminal-state snapshots', () => { }) it('paints the startup banner product name in the DeepSeek brand gradient on truecolor terminals', async () => { - const harness = await setupSnapshot({ config: { truecolor: true } }) + const harness = await setupSnapshot({ config: { theme: { truecolor: true } } }) await checkpoint('banner-gradient', harness.terminal, {}, true) await disposeSnapshot(harness) }) @@ -452,6 +476,7 @@ describe('TUI terminal-state snapshots', () => { }) it('renders terminal controls as inert text across transcripts, tools, dialogs, diagnostics, and title', async () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 15, 0, 0).getTime()) const tools = { unsafe: visualTool( 'unsafe', @@ -518,14 +543,13 @@ describe('TUI terminal-state snapshots', () => { }) const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) await harness.terminal.waitForFrame(beforeQuestion) - await renderAfter(harness, () => { - agentEvents(harness.ctx, harness.agent).emit('agent/error', 8, 3, new Error(`Unsafe live error ${CONTROL_PROBE}`)) - }) + agentEvents(harness.ctx, harness.agent).emit('agent/error', 8, 3, new Error(`Unsafe live error ${CONTROL_PROBE}`)) await checkpoint('untrusted-controls', harness.terminal, { includeScrollback: true }) controller.abort() await rejected await disposeSnapshot(harness) + nowSpy.mockRestore() }) it('pins a constrained multi-select question and its validation state', async () => { @@ -568,7 +592,37 @@ describe('TUI terminal-state snapshots', () => { await disposeSnapshot(harness) }) + it('pins a single-option question', async () => { + const harness = await setupSnapshot({ + config: { + 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: 'confirm', + header: 'Confirm', + question: 'Continue with this change?', + options: [{ label: 'Proceed', description: 'Apply the proposed change' }], + }], + signal: controller.signal, + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await harness.terminal.waitForFrame(beforeQuestion) + await checkpoint('question-dialog-single-option', harness.terminal) + controller.abort() + await rejected + await disposeSnapshot(harness) + }) + it('pins compaction surface replacement and narrow-to-wide reflow', async () => { + // Freeze the clock: the timing header hides zero-duration buckets, so a + // real-clock millisecond tick between the fixture appends and the render + // would flip `Tools 0.0s` in and out of the pinned header. + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 14, 40, 0).getTime()) let replacementStart = 0 let replacementEnd = 0 let replacementSources: number[] = [] @@ -602,8 +656,11 @@ describe('TUI terminal-state snapshots', () => { await renderAfter(harness, () => { harness.session.append('user/message', { - content: [{ type: 'text', text: 'Compacted summary: the prior command completed and its details were retired from the active surface.' }], - source: { kind: 'plugin', plugin: 'compact' }, + content: [{ + type: 'text', + text: '\nAdditional instructions from: nested/AGENTS.md\n\nRender workspace context XML clearly.\n', + }], + source: { kind: 'plugin', plugin: 'workspace-context' }, }, { surfaceOp: { op: 'replace', start: replacementStart, end: replacementEnd }, sourceEventSeqs: replacementSources, @@ -615,9 +672,22 @@ describe('TUI terminal-state snapshots', () => { await renderAfter(harness, () => { harness.terminal.resize(104, 30) }) await checkpoint('surface-after-compaction-wide', harness.terminal, { includeScrollback: true }) await disposeSnapshot(harness) + nowSpy.mockRestore() + }) + + it('pins wrapped and explicit multiline shell-prompt input', async () => { + const harness = await setupSnapshot({}, { columns: 44, rows: 18 }) + await renderAfter(harness, () => { + harness.terminal.send('Explain this implementation with enough detail to wrap across multiple full-width continuation rows without leaving a prompt-sized gap at the right edge.') + harness.terminal.send('\x1b[13;2u') + harness.terminal.send('Then suggest a simpler version.') + }) + await checkpoint('shell-prompt-multiline', harness.terminal) + await disposeSnapshot(harness) }) it('pins help, unknown commands, live errors, turn failures, and terminal restoration', async () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 15, 5, 0).getTime()) const harness = await setupSnapshot({}, { columns: 92, rows: 32 }) await renderAfter(harness, () => { harness.terminal.send('/help') @@ -635,6 +705,12 @@ describe('TUI terminal-state snapshots', () => { turn: 2, reason: { kind: 'interrupted' }, }) + harness.session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }) + harness.session.append('turn/end', { turn: 3, reason: { kind: 'disposed' } }) + harness.session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } }) + // A merge-extensible turn-end kind unknown to the TUI still surfaces its + // name so the agent never stops without a visible reason. + harness.session.append('turn/end', { turn: 4, reason: { kind: 'plugin-policy' } as never }) }) await checkpoint('errors-and-help', harness.terminal, { includeScrollback: true }) @@ -643,31 +719,11 @@ describe('TUI terminal-state snapshots', () => { await checkpoint('disposed-terminal', harness.terminal, { includeScrollback: true }) await harness.ctx.fiber.dispose() await harness.terminal.dispose() + nowSpy.mockRestore() }) - it('pins the model selector, effort cycling, and provider-default selection', async () => { - const harness = await setupSnapshot({ - 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' }, - ], - resolveModelInfo: (_provider, model) => Promise.resolve({ - context: { contextWindow: 128_000 }, - reasoning: { - efforts: [ - { id: ReasoningEffortId('off'), name: 'Off' }, - { id: ReasoningEffortId('high'), name: 'High' }, - { id: ReasoningEffortId('max'), name: 'Max' }, - ], - ...model === 'deepseek-v4-flash' - ? { defaultEffort: ReasoningEffortId('high') } - : {}, - }, - }), - }, - }, { columns: 92, rows: 32 }) + 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') @@ -675,13 +731,6 @@ describe('TUI terminal-state snapshots', () => { await checkpoint('model-selector', harness.terminal, { includeScrollback: true }) await renderAfter(harness, () => { harness.terminal.send('\x1b[B') - harness.terminal.send('\x1b[Z') - harness.terminal.send('\x1b[Z') - harness.terminal.send('\x1b[Z') - harness.terminal.send('\x1b[Z') - }) - await checkpoint('model-effort-switching', harness.terminal, { includeScrollback: true }) - await renderAfter(harness, () => { harness.terminal.send('\r') }) await checkpoint('model-switching', harness.terminal, { includeScrollback: true }) @@ -727,6 +776,22 @@ describe('TUI terminal-state snapshots', () => { contextWindow: 128_000, contextTokens: 42_000, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-pro' }, + tools: { + read: { + name: 'read', + description: 'Read a file', + parameters: {}, + output: { schema: { type: 'null' }, render: () => [] }, + execute: async () => null, + }, + write: { + name: 'write', + description: 'Write a file', + parameters: {}, + output: { schema: { type: 'null' }, render: () => [] }, + execute: async () => null, + }, + }, beforeMount(session) { appendUser(session, 'inspect this session') appendAssistant(session, [{ type: 'text', text: 'Session inspected.' }], { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index b59054659f..827bc0e6d8 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -3,14 +3,13 @@ import { homedir, tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { CombinedAutocompleteProvider, type Terminal } from '@earendil-works/pi-tui' -import AgentRegistry, { agentEvents, assembleContextFor, AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent' +import { CombinedAutocompleteProvider, visibleWidth, type Terminal } from '@earendil-works/pi-tui' +import AgentRegistry, { agentEvents, AgentMessageId, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' import { ReasoningEffortId, type LlmCallConfig, type LlmModelReasoningInfo, } from '@deepseek-ai/dsh-llm' -import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal' import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId, type JsonValue, type SessionEvent, type SessionHeader, type TurnEndReason } from '@deepseek-ai/dsh-session' import type { SessionRecord } from '@deepseek-ai/dsh-session-query' @@ -25,6 +24,7 @@ import { FILE_REFERENCE_PROMPT, mountTui, renderSkillInvocation, + TuiPromptService, resolveTuiConfig, type TuiOverlayHost, type TuiOverlaySession, @@ -171,8 +171,14 @@ describe('TUI config', () => { fileSearchMaxEntries: 10_000, fileSearchExcludedDirectories: ['.git', 'node_modules'], showHardwareCursor: false, - color: true, - truecolor: false, + theme: { + color: true, + truecolor: false, + leftPrompt: '${cwd}${git/worktree}${model}${token_meter/cache_hit_rate}${context}', + rightPrompt: '${timing}', + inputPrompt: '${symbol} ${indicator}', + inputPlaceholder: 'press enter to steer and esc to cancel', + }, title: 'DeepSeek Harness', }) expect(resolveTuiConfig({ @@ -189,8 +195,7 @@ describe('TUI config', () => { fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], showHardwareCursor: true, - color: false, - truecolor: true, + theme: { color: false, truecolor: true }, title: 'DSH', })).toEqual({ showReasoning: false, @@ -206,8 +211,14 @@ describe('TUI config', () => { fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], showHardwareCursor: true, - color: false, - truecolor: true, + theme: { + color: false, + truecolor: true, + leftPrompt: '${cwd}${git/worktree}${model}${token_meter/cache_hit_rate}${context}', + rightPrompt: '${timing}', + inputPrompt: '${symbol} ${indicator}', + inputPlaceholder: 'press enter to steer and esc to cancel', + }, title: 'DSH', }) }) @@ -1065,40 +1076,6 @@ describe('resume command and /resume', () => { }) describe('pi-tui chat lifecycle and transcript', () => { - it('restores durable goal phase without implying automatic continuation', async () => { - const change: GoalSnapshotChangeMeta = { - kind: 'goal/change', - version: GOAL_CHANGE_VERSION, - operation: 'create', - goal: { - id: GoalId('restored-goal'), - revision: 1, - objective: 'Resume only with human confirmation', - phase: 'active', - maxGoalRounds: 4, - }, - roundsStarted: 0, - createdAt: 10, - updatedAt: 10, - } - const result = await setup({ - beforeMount(session) { - session.append('user/message', { - content: renderGoalChange(change), - source: { kind: 'goal', goalId: change.goal.id, revision: change.goal.revision, round: 0 }, - meta: change as unknown as JsonValue, - }, { surfaceOp: 'append' }) - }, - }) - expect(result.terminal.output).toContain('Goal restored (active) with automatic continuation disarmed') - expect(result.terminal.output).toContain('/goal resume') - result.terminal.send('/resume') - result.terminal.send('\r') - await tick(); await tick() - expect(result.terminal.output).toContain('goal active') - await dispose(result) - }) - it('uses the latest log-backed title for the header subtitle and terminal window', async () => { const result = await setup({ // A fixed short cwd keeps the footer's token counters inside the 88-column @@ -1164,10 +1141,13 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('restored thought') expect(result.terminal.output).toContain('restored answer') expect(result.terminal.output).toContain('write tests') - expect(result.terminal.output).toContain('↑1.3k ↓42') - // Exact model resolution is async; settle before reading. + expect(result.terminal.output).toContain('/opt (tui-staging) deepseek-v4-flash ↑1.3k ↓42') + expect(result.terminal.output).toContain('dsh > ') + expect(result.terminal.output).not.toContain('main-session deepseek-v4-flash') + // Context resolution is async (resolveModelContext); settle before reading. await tick() - expect(result.terminal.output).toContain('42% context tools:collapsed') + expect(result.terminal.output).toContain('42% context') + expect(result.terminal.output).not.toContain('tools:collapsed') // Narrow terminals clip the right-hand context/tools segment first; the // model-led left segment stays. result.terminal.resize(52) @@ -1182,11 +1162,16 @@ describe('pi-tui chat lifecycle and transcript', () => { 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' }) - result.session.append('user/message', { content: [{ type: 'text', text: 'user context' }], source: { kind: 'plugin', plugin: 'ctx' } }, { surfaceOp: 'append' }) - result.session.append('user/message', { content: [{ type: 'text', text: '' }], source: { kind: 'plugin', plugin: 'ctx' } }, { surfaceOp: 'append' }) - // A non-plugin injected source (goal) has no `plugin` field, so its context - // card label falls back to the source kind. - result.session.append('user/message', { content: [{ type: 'text', text: 'goal context' }], source: { kind: 'goal', goalId: 'g1', revision: 1, round: 0 } as never }, { surfaceOp: 'append' }) + result.session.append('user/message', { content: [{ type: 'text', text: 'user context' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + result.session.append('user/message', { + content: [{ type: 'text', text: '\nAdditional instructions from: nested/AGENTS.md\n\nRender XML context clearly.\n' }], + source: { kind: 'plugin', plugin: 'workspace-context' }, + }, { surfaceOp: 'append' }) + result.session.append('user/message', { + content: [{ type: 'text', text: '' }], + source: { kind: 'plugin', plugin: 'workspace-control-context' }, + }, { surfaceOp: 'append' }) + result.session.append('user/message', { content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) result.session.append('prompt/blocked', { content: [{ type: 'text', text: 'blocked' }], source: { kind: 'user' }, reason: 'test policy' }) appendAssistant(result.session, []) result.session.append('step/end', { turn: 1, step: 1 }) @@ -1264,10 +1249,14 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('final live answer') }) - expect(result.terminal.output).toContain('Enter sends steering, Esc cancels') + expect(result.terminal.output).toContain('press enter to steer and esc to cancel') expect(result.terminal.output).toContain('Steering') expect(result.terminal.output).toContain('user context') - expect(result.terminal.output).toContain('Context · goal') // goal-sourced injected context labels by kind + expect(result.terminal.output).toContain('Context · workspace-context') + expect(result.terminal.output).toContain('system-reminder') + expect(result.terminal.output).toContain('Additional instructions from: nested/AGENTS.md') + expect(result.terminal.output).not.toContain('') + expect(result.terminal.output).toContain('\\x9b') expect(result.terminal.output).toContain('Prompt blocked') expect(result.terminal.output).toContain('Turn cancelled') expect(result.terminal.progress).toContain(true) @@ -1333,6 +1322,7 @@ describe('pi-tui chat lifecycle and transcript', () => { step: 1, chunk: { type: 'text-delta', index: 0, text: 'discarded partial answer' }, }) + result.session.append('step/end', { turn: 1, step: 1 }) result.session.append('llm/retry', { turn: 1, step: 1, @@ -1353,16 +1343,28 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('Retrying model request (1/2) in 500ms: rate limited') expect(result.terminal.output).toContain('Retrying model request (2/2) in 1000ms: failed before chunks') + expect(result.terminal.output).not.toContain('discarded partial answer') await dispose(result) }) - it('badges queued steering on the running status line and clears it as each drains', async () => { - // Pin a cwd free of the substring under test; the footer renders the path. + it('badges queued steering on the prompt context timing and clears it as each drains', async () => { + // Pin a cwd free of the substring under test; the prompt context renders the path. const result = await setup({ status: 'running', cwd: '/workspace' }) - // Running with nothing queued: the plain steering hint, no badge. - expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels') + // Running with nothing queued: timing appears once in the prompt context and the editor keeps its hint. + expect(result.terminal.output).toContain('Assistant') + expect(result.terminal.output).toContain('Model wait 0.0s') + expect(result.terminal.output).toContain('press enter to steer and esc to cancel') + expect(result.terminal.output).not.toContain('│') expect(result.terminal.output).not.toContain('queued') + result.terminal.output = '' + result.terminal.send('x') + await tick() + expect(result.terminal.output).not.toContain('press enter to steer and esc to cancel') + result.terminal.send('\x7f') + await tick() + expect(result.terminal.output).toContain('press enter to steer and esc to cancel') + const queueSteering = (text: string): void => { result.ctx.emit('agent/inbox/enqueue', result.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) } @@ -1371,7 +1373,7 @@ describe('pi-tui chat lifecycle and transcript', () => { } // A steering queue for a different agent never touches this status line. - const other = { ...result.agent, id: SessionId('other') } as unknown as Agent + const other = { ...result.agent, id: SessionId('other') } as Agent result.terminal.output = '' result.ctx.emit('agent/inbox/enqueue', other, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'elsewhere' }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) await tick() @@ -1382,7 +1384,7 @@ describe('pi-tui chat lifecycle and transcript', () => { result.terminal.output = '' queueSteering('second') await tick() - expect(result.terminal.output).toContain('2 queued · Enter sends steering, Esc cancels') + expect(result.terminal.output).toContain('2 queued') // A non-steering queue (an idle-style send) leaves the badge untouched. result.terminal.output = '' @@ -1396,7 +1398,8 @@ describe('pi-tui chat lifecycle and transcript', () => { result.terminal.output = '' drainSteering('second') await tick() - expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels') + expect(result.terminal.output).toContain('press enter to steer and esc to cancel') + expect(result.terminal.output).not.toContain('│') expect(result.terminal.output).not.toContain('queued') // A drain with no matching queued entry is ignored rather than underflowing. @@ -1406,9 +1409,8 @@ describe('pi-tui chat lifecycle and transcript', () => { await tick() expect(result.terminal.output).toContain('1 queued') - // A steering/message whose source matches no pending badge entry (here a - // plugin source with no tracked enqueue) pops nothing, so it cannot consume - // a pending user slot even when it drains first. + // A loop-authored steering event (plugin source, no matching agent/queued) + // cannot consume a pending user slot, even when it drains first. result.terminal.output = '' result.session.append('steering/message', { turn: 1, @@ -1429,104 +1431,402 @@ describe('pi-tui chat lifecycle and transcript', () => { result.terminal.output = '' result.ctx.emit('agent/status', result.agent, 'running') await tick() - expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels') + expect(result.terminal.output).not.toContain('│') expect(result.terminal.output).not.toContain('queued') await dispose(result) }) - it('derives the fine-grained turn phase from session lifecycle events', async () => { - // A live event before the turn runs has no status controller to move. - const idle = await setup() - // A steering queue arriving while idle has no status line to badge, so the - // refresh is a no-op beyond requesting a render. - idle.ctx.emit('agent/inbox/enqueue', idle.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'early' }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) - idle.session.append('tool/call', { turn: 1, step: 0, callId: 'pre' as never, name: 'bash', arguments: '{}' }) - await tick() - expect(idle.terminal.output).not.toContain('Executing tools') - expect(idle.terminal.output).not.toContain('queued') - await dispose(idle) - + it('accumulates exclusive timing buckets across a multi-step turn', async () => { + let clock = 1_700_000_000_000 + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock) const result = await setup({ status: 'running' }) - expect(result.terminal.output).toContain('Waiting for the first token') + clock += 1_000 + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } }) + clock += 2_000 + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'answering' } }) + clock += 1_000 + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'reconsidering' } }) + clock += 2_000 + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'revised' } }) + clock += 3_000 + result.session.append('tool/call', { turn: 1, step: 1, callId: 'c1' as never, name: 'bash', arguments: '{}' }) + clock += 4_000 + result.session.append('step/end', { turn: 1, step: 1 }) + result.session.append('step/start', { turn: 1, step: 2 }) + clock += 1_000 + result.session.append('assistant/chunk', { turn: 1, step: 2, chunk: { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } } }) + clock += 2_000 + result.session.append('assistant/chunk', { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'done' } }) + clock += 3_000 result.terminal.output = '' - result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } }) - result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'reasoning-delta', index: 0, text: 'mull it over' } }) + result.session.append('step/end', { turn: 1, step: 2 }) await tick() - expect(result.terminal.output).toContain('Thinking') - result.terminal.output = '' - result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'block-start', index: 1, blockType: 'text' } }) - result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'text-delta', index: 1, text: 'answering' } }) - await tick() - expect(result.terminal.output).toContain('Responding') - - result.terminal.output = '' - result.session.append('tool/call', { turn: 1, step: 0, callId: 'c1' as never, name: 'bash', arguments: '{}' }) - await tick() - expect(result.terminal.output).toContain('Executing tools') - - // The next step reopens the wait window and resets the executing label. - result.terminal.output = '' - result.session.append('step/start', { turn: 1, step: 1 }) - await tick() - expect(result.terminal.output).toContain('Waiting for the first token') - expect(result.terminal.output).not.toContain('Executing tools') - - await dispose(result) - }) - - it('refreshes the running status elapsed time on its own timer', async () => { - let now = 0 - const intervals = vi.spyOn(globalThis, 'setInterval') - let result: Awaited> | undefined - try { - result = await setup({ status: 'running', now: () => now }) - const refresh = intervals.mock.calls.find(([, interval]) => interval === 1_000)?.[0] - if (typeof refresh !== 'function') throw new Error('TUI did not register its elapsed-status refresh interval') - result.terminal.output = '' - // The loader repaints "0s" until the controller's own interval fires; a - // non-zero elapsed proves the refresh, not just the loader's animation. - now = 1_000 - refresh() - await tick() - expect(result.terminal.output).toContain('Waiting for the first token 1s') - } finally { - if (result !== undefined) await dispose(result) - intervals.mockRestore() - } - }) - - it('shows minutes and seconds once a step passes a minute', async () => { - const result = await setup({ status: 'running' }) - const base = Date.now() - const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(base + 95_000) - result.terminal.output = '' - result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'text-delta', index: 0, text: 'hi' } }) - await tick() - expect(result.terminal.output).toContain('total 1m') + expect(result.terminal.output).toContain('Model wait 1.0s · Thinking 4.0s · Response 4.0s · Tools 4.0s') + expect(result.terminal.output).toContain('Model wait 1.0s · Response 3.0s · Completed') + expect(result.terminal.output).not.toContain('Thinking 0s') nowSpy.mockRestore() await dispose(result) }) - it('preserves the turn phase and elapsed time across a mid-turn color-scheme change', async () => { - const result = await setup({ status: 'running' }) - const base = Date.now() - const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(base) - // Advance into `responding`, anchoring the phase clock at `base`. - result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'text-delta', index: 0, text: 'answering' } }) + it('rebuilds used subsecond buckets and the durable local completion time', async () => { + let clock = new Date(2026, 6, 21, 14, 32, 6).getTime() + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock) + let result: Awaited> | undefined + try { + result = await setup({ + beforeMount(session) { + clock += 250 + session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'fast' } }) + clock += 500 + session.append('step/end', { turn: 1, step: 1 }) + clock += 86_400_000 + }, + }) + const completed = 'Model wait 0.2s · Response 0.5s · Completed 2026-07-21 14:32:06' + expect(result.terminal.output).toContain(completed) + + result.terminal.output = '' + appendUser(result.session, 'rebuild the transcript') + result.terminal.resize(result.terminal.columns + 1) + await tick() + expect(result.terminal.output).toContain(completed) + } finally { + if (result !== undefined) await dispose(result) + nowSpy.mockRestore() + } + }) + + it('renders completion for a step whose opening event is unavailable', async () => { + const result = await setup({ omitInitialLifecycle: true }) + result.session.append('step/end', { turn: 1, step: 1 }) + await tick() + expect(result.terminal.output).toContain('Completed ') + expect(result.terminal.output).toContain('Assistant') + expect(result.terminal.output).toContain('Model wait 0.0s · Completed') + await dispose(result) + }) + + it('does not reuse a completed turn before the next turn starts', async () => { + let clock = 1_700_000_000_000 + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock) + let result: Awaited> | undefined + try { + result = await setup({ + beforeMount(session) { + clock += 2_000 + session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'done' } }) + clock += 1_000 + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }, + }) + result.agent.status = 'running' + result.terminal.output = '' + result.ctx.emit('agent/status', result.agent, 'running') + await tick() + expect(result.terminal.output).not.toContain('Model wait') + expect(result.terminal.output).toContain('press enter to steer and esc to cancel') + expect(result.terminal.output).not.toContain('│') + expect(result.terminal.output).not.toContain('Response 1s') + + result.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + result.session.append('step/start', { turn: 2, step: 1 }) + clock += 1_000 + result.terminal.output = '' + result.session.append('assistant/chunk', { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'next' } }) + await tick() + expect(result.terminal.output).toContain('Model wait 1.0s') + expect(result.terminal.output).not.toContain('Model wait 3.0s') + } finally { + if (result !== undefined) await dispose(result) + nowSpy.mockRestore() + } + }) + + it('starts a running status before lifecycle events arrive', async () => { + const result = await setup({ status: 'running', omitInitialLifecycle: true }) + expect(result.terminal.output).not.toContain('Model wait') + await dispose(result) + }) + + it('replaces the prompt caret with a phase-specific status glyph while running', async () => { + // Hold the clock past the fade-in so the glyph is at full opacity; with + // color off the settled glyph renders as its bare character. + let clock = 0 + const result = await setup({ status: 'running', now: () => clock }) + clock = 1_000 + + // A space separates `dsh` from the caret slot: the prompt reads + // `dsh ` with the same visible width as the idle `dsh > `, so the + // cursor never shifts. Assert both the glyph slot and that constant width + // (color is off in this harness, so output carries no ANSI to strip). + const promptWidth = (): number => { + const row = result.terminal.output.split('\n').find(line => line.includes('dsh')) + if (row === undefined) throw new Error('prompt row not rendered') + return visibleWidth(row.slice(row.indexOf('dsh'), row.indexOf('dsh') + 6)) + } + + // Each phase swaps only the glyph character in the same slot at equal width. + const phaseGlyph: [() => void, string][] = [ + [() => result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'weighing' } }), 'dsh ✻ '], + [() => result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'answer' } }), 'dsh ● '], + [() => result.session.append('tool/call', { turn: 1, step: 1, callId: 'c1' as never, name: 'bash', arguments: '{}' }), 'dsh ⚙ '], + ] + let runningWidth: number | undefined + for (const [drive, expected] of phaseGlyph) { + result.terminal.output = '' + drive() + await tick() + expect(result.terminal.output).toContain(expected) + runningWidth ??= promptWidth() + expect(promptWidth()).toBe(runningWidth) + } + + // Idle begins a fade-out; once it settles (clock past the fade window) the + // plain `>` caret returns at the same width — no horizontal shift. The + // fade-out timer emits intermediate frames, so read the terminal's final + // rendered prompt row rather than the accumulated stream. + result.agent.status = 'idle' + result.ctx.emit('agent/status', result.agent, 'idle') + clock = 2_000 + await new Promise(resolve => setTimeout(resolve, 150)) + await tick() + const promptRow = (): string => { + const rows = result.terminal.output.split(/\r?\n|\x1b\[[0-9;]*[A-Za-z]/u).filter(r => r.includes('dsh')) + return rows.at(-1) ?? '' + } + expect(promptRow()).toContain('dsh > ') + expect(promptRow()).not.toMatch(/dsh(?:\x1b\[[0-9;]*m| )*[◍✻●⚙]/u) + expect(promptWidth()).toBe(runningWidth) + + await dispose(result) + }) + + // Extract the running glyph's interpolated gray channel from a rendered frame. + const glyphGray = (frame: string): number => { + const m = /\x1b\[38;2;(\d+);(\d+);(\d+)m●/u.exec(frame) + if (m === null) throw new Error('frame did not paint a truecolor glyph') + const [r, g, b] = [Number(m[1]), Number(m[2]), Number(m[3])] + // Pure gray: equal channels, never the blue-dominant accent. + expect(r).toBe(g) + expect(g).toBe(b) + return r + } + + it('throbs the running glyph in dim gray, swelling from invisible to full, never accent', async () => { + let clock = 0 + let chunkIndex = 0 + const result = await setup({ status: 'running', config: { theme: { color: true, truecolor: true } }, now: () => clock }) + // A fresh chunk index each frame changes the streamed line, forcing the + // diffing terminal to repaint the prompt row and re-emit the glyph slot. + const frameAt = async (t: number): Promise => { + clock = t + chunkIndex += 1 + result.terminal.output = '' + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: chunkIndex, text: '.' } }) + await tick() + return result.terminal.output + } + + // The pulse swells from fully invisible at its trough to the settled peak + // and back. At phase 0 (t=1400, pulse level 0, past fade-in) the glyph is + // hidden — the slot carries no ● — so the dimmest breath truly disappears. + const trough = await frameAt(1_400) + expect(trough).not.toMatch(/●/u) + // Half a period later (t=2100, pulse peak) it paints the brightest gray. + const peak = await frameAt(2_100) + expect(glyphGray(peak)).toBe(136) + // A frame partway up the swell paints a gray strictly between trough and + // full, and the glyph is never the accent color. + const rising = await frameAt(1_680) + const grey = glyphGray(rising) + expect(grey).toBeGreaterThan(43) + expect(grey).toBeLessThan(136) + expect(rising).not.toMatch(/\x1b\[94m●/u) + + await dispose(result) + }) + + it('fades the running glyph out to the plain caret after the turn ends', async () => { + let clock = 0 + const result = await setup({ status: 'running', config: { theme: { color: true, truecolor: true } }, now: () => clock }) + clock = 1_000 + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '.' } }) await tick() - // Four seconds later the terminal reports a light color scheme, rebuilding - // the status loader; the phase and its elapsed time must survive the rebuild. - nowSpy.mockReturnValue(base + 4_000) + // End the turn: the last glyph fades out over 300 ms rather than vanishing. + result.agent.status = 'idle' + result.ctx.emit('agent/status', result.agent, 'idle') + await tick() + // Just after the end the glyph still paints a gray, not `>`. + expect(result.terminal.output).toMatch(/\x1b\[38;2;\d+;\d+;\d+m●/u) + expect(result.terminal.output).not.toContain('dsh \x1b[90m>') + + // While the clock stays within the fade window the timer keeps ticking + // without clearing the fade (the not-yet-elapsed branch): the last frame is + // still the fading glyph, and the plain caret has not returned. + result.terminal.output = '' + await new Promise(resolve => setTimeout(resolve, 120)) + const lastPromptRow = result.terminal.output.split(/\x1b\[[0-9;]*[A-Za-z]/u).filter(r => r.includes('dsh')).at(-1) ?? '' + expect(lastPromptRow).not.toContain('dsh \x1b[90m>') + + // Past the fade window the fade timer clears and the plain caret returns. + clock = 2_000 + result.terminal.output = '' + await new Promise(resolve => setTimeout(resolve, 120)) + await tick() + expect(result.terminal.output).not.toMatch(/dsh(?:\x1b\[[0-9;]*m| )*●/u) + expect(result.terminal.output).toContain('>') + + await dispose(result) + }) + + it('appears past the fade midpoint and disappears without truecolor, still dim not accent', async () => { + let clock = 0 + const result = await setup({ status: 'running', config: { theme: { color: true } }, now: () => clock }) + const frameAt = async (t: number): Promise => { + clock = t + result.terminal.output = '' + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '.' } }) + await tick() + return result.terminal.output + } + + // Without truecolor there is no per-frame gray: below the fade midpoint the + // glyph slot is blank; past it the glyph shows in the palette muted role + // (ANSI 90), never the accent (SGR 94). + const early = await frameAt(60) + expect(early).not.toMatch(/dsh(?:\x1b\[[0-9;]*m| )*●/u) + const shown = await frameAt(300) + expect(shown).toMatch(/\x1b\[90m●/u) + expect(shown).not.toMatch(/\x1b\[94m●/u) + + await dispose(result) + }) + + it('shows the plain prompt caret while idle', async () => { + const result = await setup({ now: () => 0 }) + expect(result.terminal.output).toContain('dsh > ') + expect(result.terminal.output).not.toMatch(/dsh [◍✻●⚙]/u) + await dispose(result) + }) + + it('escapes configured prompt controls while preserving registry-owned styling', async () => { + const result = await setup({ config: { theme: { leftPrompt: 'LEFT\u001B]2;unsafe\u0007 ${custom}' } } }) + result.ctx.tuiPrompt.register('custom', '\u001B[1mTRUSTED\u001B[22m') + await tick() + expect(result.terminal.output).toContain('LEFT\\x1b]2;unsafe\\x07') + expect(result.terminal.output).toContain('\u001B[1mTRUSTED\u001B[22m') + expect(result.terminal.output).not.toContain('\u001B]2;unsafe\u0007') + await dispose(result) + }) + + it('redraws when an out-of-band prompt value changes on its own schedule', async () => { + // A plugin-owned value that changes without any other UI event must still + // repaint: the registry notifies the renderer through its subscription. + const result = await setup({ config: { theme: { leftPrompt: '${custom}${model}' } } }) + const handle = result.ctx.tuiPrompt.register('custom', 'BEFORE ') + await tick() + expect(result.terminal.output).toContain('BEFORE ') + + result.terminal.output = '' + handle.set('AFTER ') + // The coalesced notification lands on a microtask; no session/agent event fires. + await tick() + expect(result.terminal.output).toContain('AFTER ') + expect(result.terminal.output).not.toContain('BEFORE ') + await dispose(result) + }) + + it('tracks steering drains without a running status line', async () => { + const result = await setup() + const source = { kind: 'user' as const } + result.ctx.emit('agent/inbox/enqueue', result.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'early' }], source, contexts: [], steering: true, wakeup: true }) + result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'early' }], source }, { surfaceOp: 'append' }) + await tick() + expect(result.terminal.output).not.toContain('queued') + await dispose(result) + }) + + it('refreshes the running turn timing on its own timer', async () => { + let now = 1_700_000_000_000 + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now) + const intervals = vi.spyOn(globalThis, 'setInterval') + let result: Awaited> | undefined + try { + result = await setup({ status: 'running', now: () => now }) + // The running prompt animates at ~20 fps (50 ms); the same tick keeps the + // elapsed timing text current, so no separate timing-only timer exists. + const refresh = intervals.mock.calls.find(([, interval]) => interval === 50)?.[0] + if (typeof refresh !== 'function') throw new Error('TUI did not register its running-status refresh interval') + result.terminal.output = '' + now += 1_000 + refresh() + await tick() + expect(result.terminal.output).toContain('Model wait 1.0s') + } finally { + if (result !== undefined) await dispose(result) + intervals.mockRestore() + nowSpy.mockRestore() + } + }) + + it('shows minutes and seconds in accumulated timing', async () => { + let clock = 1_700_000_000_000 + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock) + const result = await setup({ status: 'running' }) + clock += 95_000 + result.terminal.output = '' + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } }) + await tick() + expect(result.terminal.output).toContain('Model wait 1m35.0s') + nowSpy.mockRestore() + await dispose(result) + }) + + it('trails the completed step timing below the step tool cards, not above them', async () => { + const clock = new Date(2026, 6, 21, 12, 0, 0).getTime() + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(clock) + const result = await setup({ status: 'running' }) + // A step whose assistant message drives a tool call: the tool card is + // appended after the assistant text, so the timing footer must follow the + // tool output rather than sit above it (its first message). + appendAssistant(result.session, [ + { type: 'text', text: 'Running a command' }, + { type: 'tool-call', id: 'c1' as never, name: 'bash', arguments: '{}' }, + ]) + result.session.append('tool/call', { turn: 1, step: 1, callId: 'c1' as never, name: 'bash', arguments: '{}' }) + result.session.append('tool/result', { + turn: 1, step: 1, callId: 'c1' as never, content: [{ type: 'text', text: 'command output' }], isError: false, + }, { surfaceOp: 'append' }) + result.terminal.output = '' + result.session.append('step/end', { turn: 1, step: 1 }) + await tick() + + const frame = result.terminal.output + const toolAt = frame.indexOf('command output') + const timingAt = frame.indexOf('Completed 2026-07-21 12:00:00') + expect(toolAt).toBeGreaterThanOrEqual(0) + expect(timingAt).toBeGreaterThan(toolAt) + nowSpy.mockRestore() + await dispose(result) + }) + + it('preserves accumulated timing across a mid-turn color-scheme change', async () => { + let clock = 1_700_000_000_000 + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock) + const result = await setup({ status: 'running' }) + clock += 1_000 + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'answering' } }) + clock += 4_000 result.terminal.output = '' result.terminal.send('\x1b[?997;2n') await tick() await tick() - expect(result.terminal.output).toContain('Responding 4s') - expect(result.terminal.output).not.toContain('Waiting for the first token') + expect(result.terminal.output).toContain('Model wait 1.0s · Response 4.0s') nowSpy.mockRestore() await dispose(result) @@ -1535,7 +1835,7 @@ describe('pi-tui chat lifecycle and transcript', () => { it('renders the ANSI palette and every markdown/content style', async () => { const result = await setup({ cwd: '/workspace', - config: { color: true }, + config: { theme: { color: true } }, beforeMount(session) { session.append('user/message', { content: [ @@ -1549,7 +1849,7 @@ describe('pi-tui chat lifecycle and transcript', () => { }, { surfaceOp: 'append' }) appendAssistant(session, [ { type: 'reasoning', text: 'styled reasoning' }, - { type: 'text', text: 'styled answer' }, + { type: 'text', text: 'styled answer\n\n```ts\nconst answer = 42\n```' }, ], { inputTokens: 2_000_000, outputTokens: 1_500_000 }) session.append('todo/write', { todos: [ { content: 'done', status: 'completed' }, @@ -1571,6 +1871,8 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('nested result') expect(result.terminal.output).toContain('[future-block]') expect(result.terminal.output).toContain('[content]') + expect(result.terminal.output).toContain('\x1b[36mconst answer = 42\x1b[39m') + expect(result.terminal.output).not.toContain('```') expect(result.terminal.output).toContain('↑2.0m ↓1.5m') await dispose(result) }) @@ -1608,7 +1910,7 @@ describe('pi-tui chat lifecycle and transcript', () => { }, }) await vi.waitFor(() => { - expect(homeResult.terminal.output).toContain('~ ↑25k ↓10k') + expect(homeResult.terminal.output).toContain('~ (tui-staging) deepseek-v4-flash ↑25k ↓10k') }) await dispose(homeResult) @@ -1692,6 +1994,16 @@ describe('pi-tui chat lifecycle and transcript', () => { contextTokens: 42_000, config: { showReasoning: false }, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-pro' }, + tools: { + read: { + name: 'read', description: 'Read a file', parameters: {}, + output: { schema: { type: 'null' }, render: () => [] }, execute: async () => null, + }, + write: { + name: 'write', description: 'Write a file', parameters: {}, + output: { schema: { type: 'null' }, render: () => [] }, execute: async () => null, + }, + }, beforeMount(session) { session.append('session/title', { title: 'Inspect status \u001B]2;unsafe\u0007', @@ -1712,11 +2024,18 @@ describe('pi-tui chat lifecycle and transcript', () => { }) }, }) + result.ctx.systemPrompt.section({ + name: 'test:status', + order: 1, + text: 'Current instructions \u001B]2;prompt-unsafe\u0007', + }) result.agent.status = 'running' agentEvents(result.ctx, result.agent).emit('agent/status', 'running') result.terminal.send('/status') result.terminal.send('\r') - await tick() + await vi.waitFor(() => { + expect(result.terminal.output).toContain('Session status') + }) expect(result.terminal.output).toContain('Session status') expect(result.terminal.output).toContain('main-session') @@ -1729,6 +2048,11 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('[███████████░░░░░] 67% hit (3,000 read + 250 write)') expect(result.terminal.output).toContain('[█████░░░░░░░░░░░] 33% used (42,000 / 128,000)') expect(result.terminal.output).toContain('2026-07-22 09:10:11 UTC') + expect(result.terminal.output).toContain('System prompt') + expect(result.terminal.output).toContain('You are an AI agent powered by the DeepSeek Harness SDK.') + expect(result.terminal.output).toContain('Current instructions \\x1b]2;prompt-unsafe\\x07') + expect(result.terminal.output).toContain('Registered tools') + expect(result.terminal.output).toContain('read, write') expect(result.terminal.output).not.toContain('\u001B]2;unsafe\u0007') result.terminal.resize(56) @@ -1764,10 +2088,22 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('n/a (0 read + 0 write)') expect(result.terminal.output).toContain('7 used · capacity unknown') expect(result.terminal.output).toContain('2026-07-22 10:11:12 UTC') + expect(result.terminal.output).toContain('You are an AI agent powered by the DeepSeek Harness SDK.') + expect(result.terminal.output).toContain('(none)') await dispose(result) dateNow.mockRestore() }) + it('/quit exits while idle', async () => { + const result = await setup() + result.terminal.send('/quit') + result.terminal.send('\r') + await tick() + + expect(result.exit).toHaveBeenCalledWith(0) + await dispose(result) + }) + it('sends, steers, handles commands, global keys, and disposed-agent input', async () => { const result = await setup() @@ -1888,7 +2224,7 @@ describe('pi-tui chat lifecycle and transcript', () => { await mkdir(join(cwd, 'docs'), { recursive: true }) await writeFile(join(cwd, 'src', 'source-file.ts'), 'export const source = true\n') await writeFile(join(cwd, 'docs', 'design notes.md'), '# Design\n') - await writeFile(join(cwd, 'unsafe\u007ffile.ts'), 'unsafe name\n') + await writeFile(join(cwd, 'unsafe\nfile.ts'), 'unsafe name\n') const result = await setup({ cwd, tools: { @@ -1925,12 +2261,13 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('Folder · docs/') }) result.terminal.send('\t') - result.terminal.output = '' + await vi.waitFor(() => { + expect(result.terminal.output).toContain('File · design notes.md') + }) result.terminal.send('\t') await vi.waitFor(() => { expect(result.terminal.output).toContain('@"docs/design notes.md"') }) - await tick() result.terminal.send('\r') await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) }) expect(result.agent.sent[1]).toEqual([{ type: 'text', text: '@"docs/design notes.md"' }]) @@ -2359,14 +2696,12 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('advertised by multiple providers') expect(result.terminal.output).toContain('already alpha/a1') - const firstSelectorOutput = result.terminal.output.length result.terminal.send('/model') result.terminal.send('\r') result.terminal.send('/model') result.terminal.send('\r') - await vi.waitFor(() => { - expect(result.terminal.output.slice(firstSelectorOutput)).toContain('Select model') - }) + await tick() + expect(result.terminal.output).toContain('Select model') result.terminal.send('\x1b') await tick() @@ -2457,16 +2792,13 @@ describe('pi-tui chat lifecycle and transcript', () => { )).resolves.toEqual({ provider: 'beta', model: 'shared' }) result.agent.status = 'running' - const runningSelectorOutput = result.terminal.output.length result.terminal.send('/model') result.terminal.send('\r') - await vi.waitFor(() => { - const output = result.terminal.output.slice(runningSelectorOutput) - expect(output).toContain('Select model') - expect(output).toContain('alpha/a1') - expect(output).toContain('Alpha One — Fast — Low — current') - expect(output).toContain('Beta One — High') - }) + await tick() + expect(result.terminal.output).toContain('Select model') + expect(result.terminal.output).toContain('alpha/a1') + expect(result.terminal.output).toContain('Alpha One — Fast — Low — current') + expect(result.terminal.output).toContain('Beta One — High') result.terminal.send('\x1b[B') result.terminal.send('\x1b[B') result.terminal.send('\x1b[Z') @@ -2480,14 +2812,11 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.agent.steered).toEqual([]) initialContext.resolve({ contextWindow: 100 }) await tick() - expect(result.terminal.output).not.toContain('50% context tools:collapsed') + expect(result.terminal.output).not.toContain('50% context') - const cancelledSelectorOutput = result.terminal.output.length result.terminal.send('/model') result.terminal.send('\r') - await vi.waitFor(() => { - expect(result.terminal.output.slice(cancelledSelectorOutput)).toContain('Select model') - }) + await tick() result.terminal.send('\x1b') await tick() expect(result.agent.cancelled).not.toContain('cancelled from terminal') @@ -2495,7 +2824,8 @@ describe('pi-tui chat lifecycle and transcript', () => { result.ctx.emit('agent/status', result.agent, 'idle') await tick() expect(result.terminal.output).toContain('b1 max ') - expect(result.terminal.output).toContain('25% context tools:collapsed') + expect(result.terminal.output).toContain('25% context') + expect(result.terminal.output).not.toContain('tools:collapsed') result.terminal.send('/status') result.terminal.send('\r') await tick() @@ -2559,7 +2889,7 @@ describe('pi-tui chat lifecycle and transcript', () => { }) }, }) - expect(resumedDefault.terminal.output).toContain('default • main-session') + expect(resumedDefault.terminal.output).toContain('default ↑0 ↓0') await dispose(resumedDefault) const unset = await setup({ @@ -2791,9 +3121,9 @@ describe('pi-tui chat lifecycle and transcript', () => { await result.ctx.fiber.dispose() }) - it('cancels before /exit while running and handles agent errors/disposal', async () => { + it('cancels before /quit while running and handles agent errors/disposal', async () => { const result = await setup({ status: 'running' }) - result.terminal.send('/exit') + result.terminal.send('/quit') result.terminal.send('\r') await tick() expect(result.agent.cancelled).toContainEqual({ kind: 'user' }) @@ -2801,7 +3131,7 @@ describe('pi-tui chat lifecycle and transcript', () => { const events = await setup() const unrelatedSession = events.ctx.sessions.create(SessionId('unrelated-session')) - const unrelatedAgent = { ...events.agent, id: unrelatedSession.id, session: unrelatedSession } as unknown as Agent + const unrelatedAgent = { ...events.agent, id: unrelatedSession.id, session: unrelatedSession } unrelatedSession.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) unrelatedSession.append('todo/write', { todos: [{ content: 'hidden', status: 'pending' }] }) agentEvents(events.ctx, unrelatedAgent).emit('agent/status', 'running') @@ -2825,6 +3155,11 @@ describe('pi-tui chat lifecycle and transcript', () => { turn: 7, reason: { kind: 'error', step: 1, failure: { message: 'structured provider failure', code: 'SERVER' } }, }) + events.session.append('turn/start', { turn: 8, trigger: { kind: 'message', source: { kind: 'user' } } }) + events.session.append('turn/end', { turn: 8, reason: { kind: 'disposed' } }) + events.session.append('turn/start', { turn: 9, trigger: { kind: 'message', source: { kind: 'user' } } }) + // Merge-extensible reason kind unknown to the TUI still names the stop. + events.session.append('turn/end', { turn: 9, reason: { kind: 'plugin-policy' } as never }) agentEvents(events.ctx, events.agent).emit('agent/disposed') await tick() expect(events.terminal.output).toContain('live failure') @@ -2834,6 +3169,8 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(events.terminal.output).toContain('output-token limit') expect(events.terminal.output).toContain('Turn rejected') expect(events.terminal.output).toContain('previous process ended') + expect(events.terminal.output).toContain('Turn stopped: the agent was disposed') + expect(events.terminal.output).toContain('Turn ended: plugin-policy') expect(events.terminal.output).toContain('was disposed') await dispose(events) }) @@ -2846,14 +3183,19 @@ describe('skill slash command', () => { const skills = ctx.get('skills') if (skills === undefined) throw new Error('skills service not mounted') skills.register({ name: 'demo-skill', description: 'Demo skill for tests', source: 'runtime', provider: 'runtime', content: 'Demo instructions body.' }) + skills.register({ name: 'project-skill', description: 'Project skill for tests', source: 'project-dsh', provider: 'runtime', content: 'Project instructions body.' }) skills.register({ name: 'hidden-skill', description: 'Model-hidden skill', source: 'runtime', provider: 'runtime', content: 'Hidden instructions body.', disableModelInvocation: true }) } - it('offers non-hidden skills as slash completions and hides model-disabled ones', async () => { + it('labels slash completions by scope and hides model-disabled skills', async () => { const result = await setup({ configureContext: withSkills }) result.terminal.send('/skill') await tick() expect(result.terminal.output).toContain('demo-skill') + expect(result.terminal.output).toContain('(user)') + expect(result.terminal.output).toContain('project-skill') + expect(result.terminal.output).toContain('(project)') + expect(result.terminal.output).not.toContain('[instructions]') expect(result.terminal.output).not.toContain('hidden-skill') await dispose(result) }) @@ -3010,10 +3352,22 @@ describe('tool cards and surface replay', () => { }), presentResult: () => ({ card: 'diff', diffs: [{ path: 'a.txt', oldText: null, newText: 'created' }] }), }, + singleDiff: { + name: 'singleDiff', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], + presentCall: () => ({ + card: 'diff', + title: 'Edit src/only.ts', + diffs: [{ path: 'src/only.ts', oldText: 'old', newText: 'new' }], + }), + }, generic: { name: 'generic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], presentCall: () => ({ card: 'generic', title: 'Inspect value', rawInput: { alpha: 1 } }), - presentResult: () => ({ card: 'generic', title: 'Inspected', content: [{ type: 'text', text: 'result text' }] }), + presentResult: () => ({ + card: 'generic', + title: 'Inspected', + content: [{ type: 'text', text: 'result **text**\n\n```console\nstarted background task bash-5\n```' }], + }), }, throwing: { name: 'throwing', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], @@ -3024,6 +3378,24 @@ describe('tool cards and surface replay', () => { name: 'rawTerminal', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], presentCall: () => ({ card: 'terminal', title: 'raw command' }), }, + // An empty-string description is treated as no description: the header omits + // the ` / ` segment, exactly as an absent description does. + emptyDescTerminal: { + name: 'emptyDescTerminal', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], + presentCall: () => ({ card: 'terminal', title: 'blank desc command', description: '' }), + }, + // A generic card whose title only repeats the tool name and carries no + // content or rawInput renders a header with an empty body block. + emptyBody: { + name: 'emptyBody', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], + presentCall: () => ({ card: 'generic', title: 'emptyBody' }), + }, + multilineTerminal: { + name: 'multilineTerminal', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], + // A multi-line bash command as the title/description: the card title and the + // meta rows are single logical lines and must render inline, not break rows. + presentCall: () => ({ card: 'terminal', title: 'S=/tmp\necho "$S"', description: 'set\nand echo' }), + }, undefinedViews: { name: 'undefinedViews', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], presentCall: () => undefined, @@ -3042,6 +3414,10 @@ describe('tool cards and surface replay', () => { name: 'symbolic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], presentCall: () => ({ card: 'generic', title: 'Symbol input', rawInput: Symbol('input') }), }, + knownXml: { + name: 'knownXml', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], + presentCall: () => ({ card: 'generic', title: 'Known XML' }), + }, } it('uses terminal, diff, generic, fallback, and collapsed tool presentations', async () => { @@ -3054,10 +3430,14 @@ describe('tool cards and surface replay', () => { ['c5', 'throwing', '{}'], ['c6', 'unknown', 'not-json'], ['c7', 'rawTerminal', '{"value":"raw"}'], + ['c14', 'emptyDescTerminal', '{}'], + ['c15', 'emptyBody', '{}'], + ['c9', 'multilineTerminal', '{}'], ['c8', 'undefinedViews', '{"value":8}'], ['c10', 'empty', '{}'], ['c11', 'terminalResult', '{}'], ['c12', 'symbolic', '{}'], + ['c13', 'knownXml', '{}'], ] as const appendAssistant(result.session, [ { type: 'text', text: 'Calling tools' }, @@ -3106,11 +3486,16 @@ describe('tool cards and surface replay', () => { result.session.append('tool/result', { turn: 1, step: 1, callId: 'c11' as never, content: [{ type: 'text', text: '\nconverted terminal\n\nfinished\n' }], isError: false, }, { surfaceOp: 'append' }) + result.session.append('tool/result', { + turn: 1, step: 1, callId: 'c13' as never, + content: [{ type: 'text', text: 'literal' }], + isError: false, + }, { surfaceOp: 'append' }) result.session.append('tool/result', { turn: 1, step: 1, callId: 'orphan' as never, - content: [{ type: 'text', text: 'orphan result' }], + content: [{ type: 'text', text: '/tmp/a.txthelloworld' }], isError: true, error: { name: 'InterruptedError', code: 'interrupted' }, }, { surfaceOp: 'append' }) @@ -3119,11 +3504,32 @@ describe('tool cards and surface replay', () => { const output = result.terminal.output expect(output).toContain('Run command') expect(output).toContain('printf hello') + // A multi-line terminal title and description render inline (newline escaped + // to `\x0a`), so they cannot break onto extra rows and collide with the body. + expect(output).toContain('S=/tmp\\x0aecho "$S"') + expect(output).toContain('set\\x0aand echo') expect(output).toContain('lines (Ctrl+O to expand)') expect(output).toContain('SIGTERM') - expect(output).toContain('Edit files') + // The header is a fixed `Tool / ` frame; the tool name shows there. + expect(output).toContain('Tool / bash') + expect(output).toContain('Tool / edit') + // An empty-string terminal description contributes no ` / ` segment; + // the header ends at the tool name, and the command shows as the body $-line. + expect(output).toContain('Tool / emptyDescTerminal') + expect(output).not.toContain('Tool / emptyDescTerminal /') + expect(output).toContain('$ blank desc command') + // A card whose title only repeats the name renders header-only (empty body). + expect(output).toContain('Tool / emptyBody') + // A diff card drops its title (the paths + change footer carry the meaning). + // The first file's path is head-visible; the second file and the change + // footer sit past this card's 4-line budget and appear only when expanded. + expect(output).not.toContain('Edit files') + expect(output).toContain('a.txt') + // A generic card's presenter title moves from the header into the body. expect(output).toContain('Inspected') expect(output).toContain('result text') + expect(output).toContain('started background task bash-5') + expect(output).not.toContain('```console') expect(output).toContain('Presenter failed') expect(output).toContain('not-json') expect(output).toContain('nested output') @@ -3131,7 +3537,10 @@ describe('tool cards and surface replay', () => { expect(output).toContain('undefined presenter output') expect(output).toContain('Empty card') expect(output).toContain('converted terminal') - expect(output).toContain('orphan result') + expect(output).toContain('literal') + expect(output).toContain('path: /tmp/a.txt') + expect(output).toContain('line (number="1"): hello') + expect(output).not.toContain('') result.terminal.send('/redraw') result.terminal.send('\r') @@ -3140,11 +3549,41 @@ describe('tool cards and surface replay', () => { expect(collapsed).toContain('Run command') expect(collapsed).toContain('[exit 0]') expect(collapsed).not.toContain('▌ hello') - expect(collapsed).not.toContain('world') + expect(collapsed).not.toContain('▌ world') result.terminal.send('\x0f') await tick() expect(result.terminal.output).toContain('world') + expect(result.terminal.output).toContain('Tool cards expanded.') + expect(result.terminal.output).not.toContain('tools:expanded') expect(result.terminal.output).toContain('+ created') + expect(result.terminal.output).toContain('console') + // The multi-file diff's second-file change and its footer surface once + // expanded (`+ after` is b.txt's new text; the footer counts both files). + expect(result.terminal.output).toContain('+ after') + expect(result.terminal.output).toContain('· 2 files') + await dispose(result) + }) + + it('names a single-file diff in the body once, under a fixed Tool header', async () => { + const result = await setup({ tools }) + appendUser(result.session, 'edit one file') + appendAssistant(result.session, [ + { type: 'text', text: 'Editing' }, + { type: 'tool-call', id: 'single' as never, name: 'singleDiff', arguments: '{}' }, + ]) + result.session.append('tool/call', { + turn: 1, step: 1, callId: 'single' as never, name: 'singleDiff', arguments: '{}', + }) + await tick() + const output = result.terminal.output + // The header is a fixed `Tool / ` frame; the diff title is dropped and + // the file path shows once in the body, above the change footer. + expect(output).toContain('Tool / singleDiff') + expect(output).not.toContain('Edit src/only.ts') + expect(output.split('src/only.ts').length - 1).toBe(1) + expect(output).toContain('- old') + expect(output).toContain('+ new') + expect(output).toContain('· 1 file') await dispose(result) }) @@ -3215,6 +3654,8 @@ describe('TUI user-interaction dialogs', () => { questions: [{ id: 'other', question: 'Choose or type', options: [{ label: 'Default' }] }], }) await tick() + const singleOptionRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('Choose or type')) + expect(singleOptionRender).not.toContain('↑/↓ navigate') result.terminal.send('\t') result.terminal.send('my choice') result.terminal.send('\r') @@ -3232,7 +3673,7 @@ describe('TUI user-interaction dialogs', () => { }) it('handles option wrapping, deselection errors, and returning from custom input', async () => { - const result = await setup({ config: { color: true } }) + const result = await setup({ config: { theme: { color: true } } }) const single = result.ctx.userInteraction.ask({ questions: [{ id: 'single', question: 'Single options', options: [{ label: 'One' }, { label: 'Two' }] }], }) @@ -3456,7 +3897,7 @@ describe('TUI extension service', () => { const secondTerminal = new FakeTerminal() const secondController = createTuiChat(result.ctx, { sessionId: result.agent.id, - color: false, + theme: { color: false }, welcome: 'Mounted again.', }, { terminal: secondTerminal, @@ -3481,6 +3922,7 @@ describe('terminal mounting', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) + await ctx.plugin(TuiPromptService) ctx.provide('tools', { get: () => undefined } as never) const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ @@ -3488,7 +3930,7 @@ describe('terminal mounting', () => { followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() - mountTui(ctx, { color: false }, { terminal, exit: vi.fn() }) + mountTui(ctx, { theme: { color: false } }, { terminal, exit: vi.fn() }) await tick() expect(terminal.started).toBe(1) await ctx.fiber.dispose() @@ -3505,6 +3947,7 @@ describe('terminal mounting', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) + await ctx.plugin(TuiPromptService) ctx.provide('tools', { get: () => undefined } as never) const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ @@ -3514,9 +3957,9 @@ describe('terminal mounting', () => { const terminal = new FakeTerminal() // Mirror dsh-tui's own inject (minus loader, the absence under test). await ctx.plugin({ - inject: ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'tokenMeter'], + inject: ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'tokenMeter', 'tuiPrompt'], apply: (pluginCtx: Context) => { - mountTui(pluginCtx, { color: false }, { terminal, exit: vi.fn() }) + mountTui(pluginCtx, { theme: { color: false } }, { terminal, exit: vi.fn() }) }, }) await tick() @@ -3535,9 +3978,10 @@ describe('terminal mounting', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) + await ctx.plugin(TuiPromptService) ctx.provide('tools', { get: () => undefined } as never) const terminal = new FakeTerminal() - mountTui(ctx, { sessionId: 'late-session', color: false }, { terminal, exit: vi.fn() }) + mountTui(ctx, { sessionId: 'late-session', theme: { color: false } }, { terminal, exit: vi.fn() }) expect(terminal.started).toBe(0) const otherSession = ctx.sessions.create(SessionId('other-session')) @@ -3565,10 +4009,11 @@ describe('terminal mounting', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) + await ctx.plugin(TuiPromptService) ctx.provide('tools', { get: () => undefined } as never) const terminal = new FakeTerminal() const exit = vi.fn() - mountTui(ctx, { sessionId: 'main-session', color: false }, { terminal, exit }) + mountTui(ctx, { sessionId: 'main-session', theme: { color: false } }, { terminal, exit }) ctx.emit('agent-loop/config-start-failed', SessionId('other-session'), new Error('other failed')) expect(terminal.output).toBe('') @@ -3594,11 +4039,12 @@ describe('terminal mounting', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) + await ctx.plugin(TuiPromptService) ctx.provide('tools', { get: () => undefined } as never) const terminal = new FakeTerminal() const exit = vi.fn() - mountTui(ctx, { sessionId: 'main-session', color: false }, { terminal, exit }) + mountTui(ctx, { sessionId: 'main-session', theme: { color: false } }, { terminal, exit }) ctx.emit('agent-loop/config-start-failed', SessionId('main-session'), { toString(): string { throw new Error('coercion failed') }, }) @@ -3616,6 +4062,7 @@ describe('terminal mounting', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) + await ctx.plugin(TuiPromptService) ctx.provide('tools', { get: () => undefined } as never) const session = ctx.sessions.create(SessionId('failed-start-session')) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -3627,7 +4074,7 @@ describe('terminal mounting', () => { const terminal = new FakeTerminal() terminal.start = () => { throw new Error('terminal startup failed') } - expect(() => createTuiChat(ctx, { sessionId: 'failed-start-session', color: false }, { terminal, exit: vi.fn() })) + expect(() => createTuiChat(ctx, { sessionId: 'failed-start-session', theme: { color: false } }, { terminal, exit: vi.fn() })) .toThrow('terminal startup failed') await tick() expect(ctx.commands.list(ctx.agents.get(SessionId('failed-start-session'))!)).toEqual([]) @@ -3652,6 +4099,7 @@ describe('terminal mounting', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) + await ctx.plugin(TuiPromptService) ctx.provide('tools', { get: () => undefined } as never) const runtime: TuiRuntime = { terminal: new FakeTerminal(), exit: vi.fn() } expect(() => createTuiChat(ctx, { sessionId: 'missing' }, runtime)).toThrow('is not running') @@ -3659,9 +4107,9 @@ describe('terminal mounting', () => { }) it('detects a light terminal color scheme and switches from dark- to light-optimised ANSI codes', async () => { - const result = await setup({ config: { color: true } }) + const result = await setup({ config: { theme: { color: true } } }) // Initial render uses dark-optimised palette: SGR 2 (dim) for dim text. - expect(result.terminal.output).toContain('\x1b[2mdeepseek-v4-flash') + expect(result.terminal.output).toContain('\x1b[90mdeepseek-v4-flash') // A report matching the current scheme is a no-op: no palette rebuild or // re-render (ESC [?997;1n = dark, the startup default). @@ -3670,7 +4118,8 @@ describe('terminal mounting', () => { await tick() expect(result.terminal.output.length).toBe(beforeSameScheme) - // ESC [?997;2n reports light; ESC [?997;1n reports dark. + // Simulate the terminal responding with a light color scheme report + // (ESC [?997;2n = light, ESC [?997;1n = dark). result.terminal.send('\x1b[?997;2n') await tick() await tick() @@ -3682,10 +4131,12 @@ describe('terminal mounting', () => { // uses ANSI 90 for the same header text. expect(result.terminal.output).toContain('\x1b[90mdeepseek-v4-flash') + // Switch back to dark scheme. result.terminal.send('\x1b[?997;1n') await tick() await tick() - expect(result.terminal.output).toContain('\x1b[2mdeepseek-v4-flash') + // After switching back, a new write uses SGR 2 for the header detail. + expect(result.terminal.output).toContain('\x1b[90mdeepseek-v4-flash') await dispose(result) }) @@ -3700,11 +4151,12 @@ describe('terminal mounting', () => { } const terminal = new QueryFailTerminal() const result = await createTuiTestHarness(terminal, vi.fn(), { - config: { color: true }, + config: { theme: { color: true } }, cwd: process.cwd(), }) await tick() - expect(terminal.output).toContain('\x1b[2mdeepseek-v4-flash') + expect(terminal.output).toContain('\x1b[94m~/') + expect(terminal.output).toContain('\x1b[90m (tui-staging)') await disposeTuiTestHarness(result) }) it('runs /reload against every file-backed loader subtree, reports completion, and rejects re-entry while in flight', async () => { @@ -3801,7 +4253,7 @@ describe('banner sweep reveal', () => { // The product name carries a per-letter 24-bit gradient from the brand // indigo to light blue; the per-letter layout is pinned by the // `banner-gradient` terminal snapshot. - const result = await setup({ config: { color: true, truecolor: true } }) + const result = await setup({ config: { theme: { color: true, truecolor: true } } }) expect(result.terminal.output).toContain('\x1b[38;2;77;107;254m') expect(result.terminal.output).toContain('\x1b[38;2;36;152;255m') expect(result.terminal.output).toContain('HARNESS') diff --git a/packages/ui/tui/tests/xml-tool-output.spec.ts b/packages/ui/tui/tests/xml-tool-output.spec.ts new file mode 100644 index 0000000000..df270eed20 --- /dev/null +++ b/packages/ui/tui/tests/xml-tool-output.spec.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { renderUnknownXml } from '../src/xml-tool-output.ts' + +const render = (source: string, limit = 4, expanded = false): string[] | undefined => renderUnknownXml( + source, + limit, + expanded, + text => text.replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/gu, control => + `\\x${control.charCodeAt(0).toString(16).padStart(2, '0')}`), + text => `[label]${text}[/label]`, + count => ` … +${count} lines`, +) + +describe('unknown-tool XML rendering', () => { + it('renders nested elements and attributes as an indented tree', () => { + expect(render(` + /tmp/a.txt + file + + hello + world + +`)).toEqual([ + '[label]result[/label]', + ' [label]path:[/label] /tmp/a.txt', + ' [label]type:[/label] file', + ' [label]content[/label]', + ' [label]line (number="1"):[/label] hello', + ' [label]line (number="2"):[/label] world', + ]) + }) + + it('renders root text, CDATA, empty elements, and multiline nested text', () => { + expect(render(' \nfirst\nsecond\n ')).toEqual([ + '[label]result[/label]', + ' first', + ' second', + ]) + expect(render('\nfirst\nsecond\n', 1, true)).toEqual([ + '[label]result[/label]', + ' first', + ' second', + ]) + expect(render(']]>')).toEqual([ + '[label]result[/label]', + ' [label]value:[/label] literal ', + ' [label]empty[/label]', + ]) + }) + + it('previews each top-level child independently and expands all rows', () => { + const xml = '\na\nb\nc\nd\ne\nf\n\ng\nh\ni\nj\nk\nl\n' + expect(render(xml, 3)).toEqual([ + '[label]result[/label]', + ' [label]first[/label]', + ' a', + ' … +4 lines', + ' f', + ' [label]second[/label]', + ' g', + ' … +4 lines', + ' l', + ]) + expect(render(xml, 3, true)).toHaveLength(15) + }) + + it('bounds the collapsed child count and counts the hidden lines', () => { + const xml = `${Array.from({ length: 8 }, (_, index) => `${index}`).join('')}` + expect(render(xml, 3)).toEqual([ + '[label]result[/label]', + ' [label]item:[/label] 0', + ' [label]item:[/label] 1', + ' … +5 lines', + ' [label]item:[/label] 7', + ]) + expect(render(xml, 3, true)).toHaveLength(9) + }) + + it('escapes control characters expanded from character references', () => { + expect(render('tab csi›')).toEqual([ + '[label]result (attr="a\\\\x9bb")[/label]', + ' tab\\x09csi\\x9b', + ]) + expect(render('')).toEqual([ + '[label]result[/label]', + ' [label]value:[/label] del\\x7f', + ]) + }) + + it.each([ + 'missing close', + '', + ' ', + 'prefix /tmp/a', + '/tmp/a suffix', + '', + '', + '', + '', + '', + ' \n ', + ])('declines malformed or mixed text: %s', (source) => { + expect(render(source)).toBeUndefined() + }) +}) diff --git a/packages/ui/tui/tsdown.config.ts b/packages/ui/tui/tsdown.config.ts new file mode 100644 index 0000000000..4d985b3971 --- /dev/null +++ b/packages/ui/tui/tsdown.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'tsdown' +import baseConfig from '../../../tsdown.config.ts' + +export default defineConfig({ + ...baseConfig, + entry: ['lib/types/index.js', 'lib/types/invariant.js', 'lib/types/prompt.js'], +}) diff --git a/patches/@earendil-works__pi-tui@0.80.7.patch b/patches/@earendil-works__pi-tui@0.80.7.patch new file mode 100644 index 0000000000..c14edb5f7e --- /dev/null +++ b/patches/@earendil-works__pi-tui@0.80.7.patch @@ -0,0 +1,346 @@ +diff --git a/dist/components/editor.d.ts b/dist/components/editor.d.ts +index a6fedc9e3b36d066e34860d040db6df47d88c432..f0b20eb87686215d1d1a54274a7b1ebdf4030ef1 100644 +--- a/dist/components/editor.d.ts ++++ b/dist/components/editor.d.ts +@@ -21,7 +21,7 @@ export interface TextChunk { + * When omitted the default Intl.Segmenter is used. + * @returns Array of chunks with text and position information + */ +-export declare function wordWrapLine(line: string, maxWidth: number, preSegmented?: Intl.SegmentData[]): TextChunk[]; ++export declare function wordWrapLine(line: string, maxWidth: number, preSegmented?: Intl.SegmentData[], continuationWidth?: number): TextChunk[]; + export interface EditorTheme { + borderColor: (str: string) => string; + selectList: SelectListTheme; +@@ -29,6 +29,13 @@ export interface EditorTheme { + export interface EditorOptions { + paddingX?: number; + autocompleteMaxVisible?: number; ++ /** Omit the editor's horizontal frame. */ ++ frame?: "horizontal" | "none"; ++ /** Fixed-width prefixes for the first input row and explicit newlines. Wrapped rows start at the editor edge. */ ++ prompt?: { ++ first: string; ++ continuation: string; ++ }; + } + export declare class Editor implements Component, Focusable { + private state; +@@ -79,6 +86,11 @@ export declare class Editor implements Component, Focusable { + getAutocompleteMaxVisible(): number; + setAutocompleteMaxVisible(maxVisible: number): void; + setAutocompleteProvider(provider: AutocompleteProvider): void; ++ /** Replace fixed-width first and continuation input prefixes. */ ++ setPrompt(prompt: { ++ first: string; ++ continuation: string; ++ }): void; + /** + * Add a prompt to history for up/down arrow navigation. + * Called after successful submission. +diff --git a/dist/components/editor.js b/dist/components/editor.js +index 6c03aeec4148571558713e885ac7f7df18a511bc..0111317ccab31a1e973b92272d8a668b75a29174 100644 +--- a/dist/components/editor.js ++++ b/dist/components/editor.js +@@ -79,7 +79,7 @@ function segmentWithMarkers(text, baseSegmenter, validIds) { + * When omitted the default Intl.Segmenter is used. + * @returns Array of chunks with text and position information + */ +-export function wordWrapLine(line, maxWidth, preSegmented) { ++export function wordWrapLine(line, maxWidth, preSegmented, continuationWidth = maxWidth) { + if (!line || maxWidth <= 0) { + return [{ text: "", startIndex: 0, endIndex: 0 }]; + } +@@ -90,6 +90,7 @@ export function wordWrapLine(line, maxWidth, preSegmented) { + const chunks = []; + const segments = preSegmented ?? [...graphemeSegmenter.segment(line)]; + let currentWidth = 0; ++ let currentMaxWidth = maxWidth; + let chunkStart = 0; + // Wrap opportunity: the position after the last whitespace before a non-whitespace + // grapheme, i.e. where a line break is allowed. +@@ -102,11 +103,12 @@ export function wordWrapLine(line, maxWidth, preSegmented) { + const charIndex = seg.index; + const isWs = !isPasteMarker(grapheme) && isWhitespaceChar(grapheme); + // Overflow check before advancing. +- if (currentWidth + gWidth > maxWidth) { +- if (wrapOppIndex >= 0 && currentWidth - wrapOppWidth + gWidth <= maxWidth) { ++ if (currentWidth + gWidth > currentMaxWidth) { ++ if (wrapOppIndex >= 0 && currentWidth - wrapOppWidth + gWidth <= continuationWidth) { + // Backtrack to last wrap opportunity (the remaining content + // plus the current grapheme still fits within maxWidth). + chunks.push({ text: line.slice(chunkStart, wrapOppIndex), startIndex: chunkStart, endIndex: wrapOppIndex }); ++ currentMaxWidth = continuationWidth; + chunkStart = wrapOppIndex; + currentWidth -= wrapOppWidth; + } +@@ -117,22 +119,29 @@ export function wordWrapLine(line, maxWidth, preSegmented) { + // the current grapheme (e.g. a wide character) still exceeds + // maxWidth. + chunks.push({ text: line.slice(chunkStart, charIndex), startIndex: chunkStart, endIndex: charIndex }); ++ currentMaxWidth = continuationWidth; + chunkStart = charIndex; + currentWidth = 0; + } + wrapOppIndex = -1; + } +- if (gWidth > maxWidth) { +- // Single atomic segment wider than maxWidth (e.g. paste marker ++ if (gWidth > currentMaxWidth) { ++ if (segments.length === 1) { ++ chunks.push({ text: grapheme, startIndex: charIndex, endIndex: charIndex + grapheme.length }); ++ return chunks; ++ } ++ // Single atomic segment wider than the current line width (e.g. paste marker + // in a narrow terminal). Re-wrap it at grapheme granularity. + // The segment remains logically atomic for cursor + // movement / editing — the split is purely visual for word-wrap layout. +- const subChunks = wordWrapLine(grapheme, maxWidth); ++ const subChunks = wordWrapLine(grapheme, currentMaxWidth, undefined, continuationWidth); + for (let j = 0; j < subChunks.length - 1; j++) { + const sc = subChunks[j]; + chunks.push({ text: sc.text, startIndex: charIndex + sc.startIndex, endIndex: charIndex + sc.endIndex }); + } + const last = subChunks[subChunks.length - 1]; ++ if (subChunks.length > 1) ++ currentMaxWidth = continuationWidth; + chunkStart = charIndex + last.startIndex; + currentWidth = visibleWidth(last.text); + wrapOppIndex = -1; +@@ -189,8 +198,12 @@ export class Editor { + tui; + theme; + paddingX = 0; ++ frame = "horizontal"; ++ prompt; ++ promptWidth = 0; + // Store last render width for cursor navigation + lastWidth = 80; ++ lastContinuationWidth = 80; + // Vertical scrolling support + scrollOffset = 0; + // Border color (can be changed dynamically) +@@ -243,9 +256,29 @@ export class Editor { + this.borderColor = theme.borderColor; + const paddingX = options.paddingX ?? 0; + this.paddingX = Number.isFinite(paddingX) ? Math.max(0, Math.floor(paddingX)) : 0; ++ this.frame = options.frame ?? "horizontal"; ++ this.prompt = options.prompt; ++ if (this.prompt) { ++ const firstWidth = visibleWidth(this.prompt.first); ++ const continuationWidth = visibleWidth(this.prompt.continuation); ++ if (firstWidth !== continuationWidth) { ++ throw new Error("Editor prompt prefixes must have equal visible widths"); ++ } ++ this.promptWidth = firstWidth; ++ } + const maxVisible = options.autocompleteMaxVisible ?? 5; + this.autocompleteMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5; + } ++ setPrompt(prompt) { ++ const firstWidth = visibleWidth(prompt.first); ++ const continuationWidth = visibleWidth(prompt.continuation); ++ if (firstWidth !== continuationWidth) { ++ throw new Error("Editor prompt prefixes must have equal visible widths"); ++ } ++ this.prompt = prompt; ++ this.promptWidth = firstWidth; ++ this.invalidate(); ++ } + /** Set of currently valid paste IDs, for marker-aware segmentation. */ + validPasteIds() { + return new Set(this.pastes.keys()); +@@ -364,14 +397,17 @@ export class Editor { + const maxPadding = Math.max(0, Math.floor((width - 1) / 2)); + const paddingX = Math.min(this.paddingX, maxPadding); + const contentWidth = Math.max(1, width - paddingX * 2); ++ const inputWidth = Math.max(1, contentWidth - this.promptWidth); + // Layout width: with padding the cursor can overflow into it, + // without padding we reserve 1 column for the cursor. +- const layoutWidth = Math.max(1, contentWidth - (paddingX ? 0 : 1)); +- // Store for cursor navigation (must match wrapping width) ++ const layoutWidth = Math.max(1, inputWidth - (paddingX ? 0 : 1)); ++ const continuationLayoutWidth = Math.max(1, contentWidth - (paddingX ? 0 : 1)); ++ // Store for cursor navigation (must match wrapping widths) + this.lastWidth = layoutWidth; ++ this.lastContinuationWidth = continuationLayoutWidth; + const horizontal = this.borderColor("─"); + // Layout the text +- const layoutLines = this.layoutText(layoutWidth); ++ const layoutLines = this.layoutText(layoutWidth, continuationLayoutWidth); + // Calculate max visible lines: 30% of terminal height, minimum 5 lines + const terminalRows = this.tui.terminal.rows; + const maxVisibleLines = Math.max(5, Math.floor(terminalRows * 0.3)); +@@ -396,16 +432,22 @@ export class Editor { + const rightPadding = leftPadding; + // Render top border (with scroll indicator if scrolled down) + if (this.scrollOffset > 0) { +- const indicator = `─── ↑ ${this.scrollOffset} more `; +- const remaining = width - visibleWidth(indicator); +- if (remaining >= 0) { +- result.push(this.borderColor(indicator + "─".repeat(remaining))); ++ if (this.frame === "none") { ++ const indicator = `${" ".repeat(this.promptWidth)}↑ ${this.scrollOffset} more`; ++ result.push(`${leftPadding}${this.borderColor(indicator)}${" ".repeat(Math.max(0, contentWidth - visibleWidth(indicator)))}${rightPadding}`); + } + else { +- result.push(this.borderColor(truncateToWidth(indicator, width))); ++ const indicator = `─── ↑ ${this.scrollOffset} more `; ++ const remaining = width - visibleWidth(indicator); ++ if (remaining >= 0) { ++ result.push(this.borderColor(indicator + "─".repeat(remaining))); ++ } ++ else { ++ result.push(this.borderColor(truncateToWidth(indicator, width))); ++ } + } + } +- else { ++ else if (this.frame === "horizontal") { + result.push(horizontal.repeat(width)); + } + // Render each visible layout line +@@ -413,7 +455,19 @@ export class Editor { + // hardware cursor for IME candidate-window placement even while + // autocomplete (e.g. slash-command menu) is visible. + const emitCursorMarker = this.focused; +- for (const layoutLine of visibleLines) { ++ for (let visibleIndex = 0; visibleIndex < visibleLines.length; visibleIndex++) { ++ const layoutLine = visibleLines[visibleIndex]; ++ if (!layoutLine) ++ continue; ++ const absoluteIndex = this.scrollOffset + visibleIndex; ++ const prefix = this.prompt ++ ? (absoluteIndex === 0 ++ ? this.prompt.first ++ : layoutLine.isContinuation ++ ? "" ++ : this.prompt.continuation) ++ : ""; ++ const lineContentWidth = inputWidth + (layoutLine.isContinuation ? this.promptWidth : 0); + let displayText = layoutLine.text; + let lineVisibleWidth = visibleWidth(layoutLine.text); + let cursorInPadding = false; +@@ -439,34 +493,41 @@ export class Editor { + displayText = before + marker + cursor; + lineVisibleWidth = lineVisibleWidth + 1; + // If cursor overflows content width into the padding, flag it +- if (lineVisibleWidth > contentWidth && paddingX > 0) { ++ if (lineVisibleWidth > lineContentWidth && paddingX > 0) { + cursorInPadding = true; + } + } + } + // Calculate padding based on actual visible width +- const padding = " ".repeat(Math.max(0, contentWidth - lineVisibleWidth)); ++ const padding = " ".repeat(Math.max(0, lineContentWidth - lineVisibleWidth)); + const lineRightPadding = cursorInPadding ? rightPadding.slice(1) : rightPadding; + // Render the line (no side borders, just horizontal lines above and below) +- result.push(`${leftPadding}${displayText}${padding}${lineRightPadding}`); ++ result.push(`${leftPadding}${prefix}${displayText}${padding}${lineRightPadding}`); + } + // Render bottom border (with scroll indicator if more content below) + const linesBelow = layoutLines.length - (this.scrollOffset + visibleLines.length); + if (linesBelow > 0) { +- const indicator = `─── ↓ ${linesBelow} more `; +- const remaining = width - visibleWidth(indicator); +- result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining)))); ++ if (this.frame === "none") { ++ const indicator = `${" ".repeat(this.promptWidth)}↓ ${linesBelow} more`; ++ result.push(`${leftPadding}${this.borderColor(indicator)}${" ".repeat(Math.max(0, contentWidth - visibleWidth(indicator)))}${rightPadding}`); ++ } ++ else { ++ const indicator = `─── ↓ ${linesBelow} more `; ++ const remaining = width - visibleWidth(indicator); ++ result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining)))); ++ } + } +- else { ++ else if (this.frame === "horizontal") { + result.push(horizontal.repeat(width)); + } + // Add autocomplete list if active + if (this.autocompleteState && this.autocompleteList) { +- const autocompleteResult = this.autocompleteList.render(contentWidth); ++ const autocompleteResult = this.autocompleteList.render(inputWidth); ++ const autocompletePrefix = " ".repeat(this.promptWidth); + for (const line of autocompleteResult) { + const lineWidth = visibleWidth(line); +- const linePadding = " ".repeat(Math.max(0, contentWidth - lineWidth)); +- result.push(`${leftPadding}${line}${linePadding}${rightPadding}`); ++ const linePadding = " ".repeat(Math.max(0, inputWidth - lineWidth)); ++ result.push(`${leftPadding}${autocompletePrefix}${line}${linePadding}${rightPadding}`); + } + } + return result; +@@ -726,7 +787,7 @@ export class Editor { + this.insertCharacter(data); + } + } +- layoutText(contentWidth) { ++ layoutText(contentWidth, continuationWidth) { + const layoutLines = []; + if (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === "")) { + // Empty editor +@@ -734,6 +795,7 @@ export class Editor { + text: "", + hasCursor: true, + cursorPos: 0, ++ isContinuation: false, + }); + return layoutLines; + } +@@ -749,18 +811,20 @@ export class Editor { + text: line, + hasCursor: true, + cursorPos: this.state.cursorCol, ++ isContinuation: false, + }); + } + else { + layoutLines.push({ + text: line, + hasCursor: false, ++ isContinuation: false, + }); + } + } + else { + // Line needs wrapping - use word-aware wrapping +- const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")]); ++ const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")], continuationWidth); + for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { + const chunk = chunks[chunkIndex]; + if (!chunk) +@@ -796,12 +860,14 @@ export class Editor { + text: chunk.text, + hasCursor: true, + cursorPos: adjustedCursorPos, ++ isContinuation: chunkIndex > 0, + }); + } + else { + layoutLines.push({ + text: chunk.text, + hasCursor: false, ++ isContinuation: chunkIndex > 0, + }); + } + } +@@ -1439,7 +1505,7 @@ export class Editor { + * - startCol: starting column in the logical line + * - length: length of this visual line segment + */ +- buildVisualLineMap(width) { ++ buildVisualLineMap(width, continuationWidth = this.lastContinuationWidth) { + const visualLines = []; + for (let i = 0; i < this.state.lines.length; i++) { + const line = this.state.lines[i] || ""; +@@ -1453,7 +1519,7 @@ export class Editor { + } + else { + // Line needs wrapping - use word-aware wrapping +- const chunks = wordWrapLine(line, width, [...this.segment(line, "grapheme")]); ++ const chunks = wordWrapLine(line, width, [...this.segment(line, "grapheme")], continuationWidth); + for (const chunk of chunks) { + visualLines.push({ + logicalLine: i, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f82de24032..ceec11afc2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + '@earendil-works/pi-tui@0.80.7': 6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946 + importers: .: @@ -11,6 +14,9 @@ importers: '@agentclientprotocol/sdk': specifier: 0.25.1 version: 0.25.1(zod@4.4.3) + '@deepseek-ai/dsh-tool-session-query': + specifier: workspace:^ + version: link:packages/session-query/tool-session-query '@stylistic/eslint-plugin': specifier: ^5.10.0 version: 5.10.0(eslint@10.5.0(jiti@2.7.0)) @@ -4291,7 +4297,10 @@ importers: dependencies: '@earendil-works/pi-tui': specifier: 0.80.7 - version: 0.80.7 + version: 0.80.7(patch_hash=6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946) + saxes: + specifier: 6.0.0 + version: 6.0.0 schemastery: specifier: ^3.18.0 version: 3.18.0 @@ -10830,7 +10839,7 @@ snapshots: - ws - zod - '@earendil-works/pi-tui@0.80.7': + '@earendil-works/pi-tui@0.80.7(patch_hash=6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946)': dependencies: get-east-asian-width: 1.6.0 marked: 18.0.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8da07afcf0..d0b328aa8b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -54,3 +54,6 @@ minimumReleaseAgeExclude: # Fresh pi-ai releases carry the model catalog updates that are the whole # point of bumping it; waiting out the release age would defeat that. - '@earendil-works/pi-ai@0.81.1' + +patchedDependencies: + '@earendil-works/pi-tui@0.80.7': patches/@earendil-works__pi-tui@0.80.7.patch diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 0a664ca988..a30475d683 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -97,6 +97,7 @@ function workspaceManifests(): WorkspaceManifest[] { const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-helper': ['lib/assets'], + '@deepseek-ai/dsh-tui': ['lib/prompt.js'], '@deepseek-ai/dsh-scripts': [ 'lib/dev/tsdown-config.js', 'lib/local-plugin-loader-hooks.js',