diff --git a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml new file mode 100644 index 0000000000..629476cf8e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.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/architecture/2026-07-28-consolidated-tui-presentation.md +2026-07-28-consolidated-tui-presentation.md: f87d543a698d6e77abf9120c6579100df4b60b64 +2026-07-28-consolidated-tui-presentation.zh.md: 005e408f0e75207027315546942f9eab57d595d1 diff --git a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md new file mode 100644 index 0000000000..f87d543a69 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md @@ -0,0 +1,63 @@ +# Agent Note: Consolidated TUI presentation and navigation + +Status: implemented + +English | [中文](2026-07-28-consolidated-tui-presentation.zh.md) + +## Problem + +The terminal UI accumulated independent presentation rules that interacted poorly: palette roles aliased one another or inverted emphasis on light terminals; tool-card framing, output, and exit markers repeated or competed; injected context was parsed as XML and could not fold reliably; and `/resume` excluded sessions outside the current workspace even when the launcher could reach them. Each symptom appeared local, but the durable decision is one terminal-reading model: a small inspectable palette, status-led cards with recessed bodies, content-independent transcript folding, and workspace-aware navigation. + +## Decision + +### Palette + +`paletteSpec(scheme)` is the single table of SGR codes, close codes, and purposes. `createPalette` derives every wrapper from it and `/palette` prints the same table in the running terminal. Components do not emit their own SGR sequences except for the fixed startup brand gradient. Every close resets every SGR group its open sets. + +Duplicate roles are merged: `muted` into `dim`, `added` into `success`, `removed` into `error`, and the unused second accent is removed. `dim` uses `2;39` and closes with `22;39` on both schemes so recessed text stays relative to the terminal foreground rather than becoming a fixed heavy gray on light backgrounds. Colors and attributes are branded separately in TypeScript, allowing attribute/color composition while rejecting nested colors whose reset would discard the outer color. + +### Tool cards + +A tool card has one colored `Tool / ` status header over one dim body. Presenter titles, terminal commands and cwd rows, output, XML text, and fold markers use that body tone. Diff colors remain because red and green carry meaning, and signal markers remain errors. + +`renderUnknownXml` receives an explicit body styler for unknown tool results. Terminal presenters parse and remove the model-facing final exit or signal marker before returning `TerminalResultView.output`; the TUI renders the structured status once as its own pill. Truncation, timeout, and sandbox lines remain in the body because the pill does not represent them. + +### Injected context and folding + +Injected context renders as prose in `ContextCardComponent`, not through the XML tree renderer. Exact matched outer `` lines are stripped, but mismatched, unpaired, or inline tag-like text remains verbatim. Model-facing content is unchanged. Folding uses the shared `preview` helper after body assembly, so it depends only on row count, never parser success or payload characters. + +`Ctrl+O` cycles collapsed, expanded, and hidden. Tool cards disappear in the hidden state together with their card-owned leading gap. Context cards participate in collapsed and expanded states but fall back to collapsed while tools are hidden, because injected instructions are not disposable tool traffic. + +### Cross-workspace resume + +The resume picker summarizes all records and owns a current-workspace/all-workspaces scope toggled with Tab. It defaults to the current workspace, adds workspace labels only in the broader scope, and refuses records without a cwd because there is no directory to enter. + +`TuiResumeHost.handoff` receives the selected `SessionId` and the cwd re-read during preflight. The CLI changes directory before disposing the current app, so an unreachable directory fails while the terminal can still recover; `execve` then inherits the selected workspace. The launcher also supplies the exit message rather than asking the TUI to reconstruct launcher syntax. + +## Alternatives considered + +**Keep separate notes and local fixes for each visual symptom.** Rejected: the decisions share one reading hierarchy and repeatedly superseded each other. One owner makes the final palette, card, context, and navigation rules clear without requiring readers to reconstruct chronology. + +**Keep aliases and enforce presentation by convention.** Rejected: aliases imply distinctions that do not exist, and nested color resets or incomplete SGR closes fail silently. A single table plus types makes the contract inspectable and mechanically checked. + +**Retain framing/output color splits inside tool cards.** Rejected: real cards mixed default foreground, cyan commands, dim cwd, unstyled XML, and dim output. The status header already provides the scan anchor; one recessed body removes noise. Diff colors are the narrow semantic exception. + +**Parse or repair injected context as XML.** Rejected: reminder frames are prompting conventions around arbitrary prose containing raw ampersands, comparisons, and placeholder angle brackets. Repairing or escaping it would either guess structure or alter model-visible text. + +**Hide context cards with tool cards.** Rejected: context carries injected instructions, not recoverable execution detail. The hidden phase therefore removes only tool traffic. + +**Keep resume restricted to one workspace or infer cwd after boot.** Rejected: the restriction forces manual relaunch, while restored header cwd does not control filesystem and shell resolution. The target directory must cross the host seam before process replacement. + +**Drop the TUI exit pill or remove model-facing exit markers.** Rejected: the pill is the scannable UI status, while the text marker is the model's status signal. The presenter consumes the marker when constructing the structured view so both audiences receive one representation. + +## Consequences + +The transcript reads as colored status headers over recessed detail, context presentation is stable for arbitrary prose, and one shortcut controls transcript density. The public `TuiTheme.muted` role is removed; extensions use `dim`. The palette and `renderUnknownXml` contracts are stricter, adding small compile-time friction in exchange for preventing silent style loss. + +Cross-workspace resume can move every path-resolving tool to another directory. A missing or inaccessible cwd prevents handoff. The broader picker also makes concurrent access to a shared session store easier to reach; cross-process session locking remains separate work. + +The terminal presenter still treats a final output line exactly matching its exit-marker grammar as structured status, so a command that intentionally prints such a line can lose it from the card body. This residual is documented by `dsh-tool-bash`. + +## Testing + +TUI unit and keyless terminal snapshots cover palette enumeration, light/dark roles, legal and illegal style composition, uniformly dim card bodies, semantic diff colors, marker-free terminal output with one exit pill, prose-preserving context frames, content-independent folding, the three-state Ctrl+O cycle, model filtering, and both resume scopes. CLI handoff tests cover passing the re-read cwd and rejecting directory-entry failure before teardown. Tool-bash tests pin result-marker emission, parse, and stripping as one round trip. diff --git a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md new file mode 100644 index 0000000000..005e408f0e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md @@ -0,0 +1,63 @@ +# Agent Note: 统一的 TUI 呈现与导航 + +Status: implemented + +[English](2026-07-28-consolidated-tui-presentation.md) | 中文 + +## Problem + +终端 UI 逐步积累了多套彼此干扰的呈现规则:调色板角色互为别名,或在浅色终端中颠倒强调层级;工具卡片的框架、输出和退出标记重复或争夺注意力;注入上下文被当作 XML 解析,无法可靠折叠;`/resume` 即使能通过启动器访问其他工作区,也会排除不属于当前工作区的会话。每个症状看似局部,但持久决策只有一个终端阅读模型:精简且可检查的调色板、以状态为首且正文内收的卡片、与内容无关的记录折叠,以及感知工作区的导航。 + +## Decision + +### 调色板 + +`paletteSpec(scheme)` 是 SGR 开始码、结束码和用途的唯一表。`createPalette` 从该表派生所有包装器,`/palette` 在运行中的终端打印同一张表。除固定的启动品牌渐变外,组件不自行发出 SGR 序列。每个结束码都重置对应开始码设置的所有 SGR 组。 + +重复角色被合并:`muted` 并入 `dim`,`added` 并入 `success`,`removed` 并入 `error`,未使用的第二强调色被移除。`dim` 在两种配色方案中都使用 `2;39`,并以 `22;39` 结束,使内收文本相对于终端前景色变暗,而不会在浅色背景上变成固定的深灰色。TypeScript 分别标记颜色和属性,允许属性与颜色组合,同时拒绝会因重置而丢失外层颜色的嵌套颜色。 + +### 工具卡片 + +工具卡片由一行带颜色的 `Tool / ` 状态标题和一块统一的 dim 正文组成。呈现器标题、终端命令及 cwd 行、输出、XML 文本和折叠标记都使用正文色调。差异颜色继续保留,因为红绿承载语义;信号标记也继续作为错误显示。 + +`renderUnknownXml` 对未知工具结果显式接收正文样式器。终端呈现器在返回 `TerminalResultView.output` 前解析并移除面向模型的末尾退出或信号标记;TUI 只把结构化状态呈现一次。截断、超时和沙箱信息继续留在正文中,因为状态标记不表达这些事实。 + +### 注入上下文与折叠 + +注入上下文由 `ContextCardComponent` 按普通文本呈现,不经过 XML 树渲染器。仅移除精确配对的外层 `` 行;不匹配、单边或正文内类似标签的文本都原样保留。面向模型的内容不变。折叠在正文组装完成后使用共享 `preview` 辅助函数,因此只取决于行数,不依赖解析是否成功或载荷包含哪些字符。 + +`Ctrl+O` 在折叠、展开和隐藏之间循环。隐藏状态会连同卡片自有的前导间距一起移除工具卡片。上下文卡片参与折叠和展开状态,但工具隐藏时回到折叠状态,因为注入指令不是可丢弃的工具流量。 + +### 跨工作区恢复 + +恢复选择器汇总所有记录,并维护可用 Tab 切换的当前工作区/所有工作区范围。默认范围是当前工作区;只有更宽范围才显示工作区标签。没有 cwd 的记录会被拒绝,因为没有可进入的目录。 + +`TuiResumeHost.handoff` 接收选中的 `SessionId` 和预检时重新读取的 cwd。CLI 在释放当前应用前切换目录,因此无法访问的目录会在终端仍可恢复时失败;随后 `execve` 继承所选工作区。退出提示也由启动器提供,而不是让 TUI 反推启动器命令语法。 + +## Alternatives considered + +**为每个视觉症状保留独立 Agent Note 和局部修复。** 否决:这些决策共享同一阅读层级,而且彼此多次取代。由一份记录统一拥有最终的调色板、卡片、上下文和导航规则,读者无需重建变更顺序。 + +**保留别名,并依靠约定执行呈现规则。** 否决:别名暗示并不存在的差异;嵌套颜色重置或不完整的 SGR 结束会静默失败。单一表格加类型约束使契约可检查且可机械验证。 + +**保留工具卡片内部的框架/输出颜色分层。** 否决:真实卡片会混用默认前景、青色命令、dim cwd、无样式 XML 和 dim 输出。状态标题已经提供扫描锚点;统一内收正文能消除噪声。差异颜色是狭窄的语义例外。 + +**把注入上下文继续解析或修复成 XML。** 否决:提醒框架只是包裹任意普通文本的提示约定,其中会包含原始 `&`、比较表达式和尖括号占位符。修复或转义要么猜测结构,要么改变模型可见文本。 + +**随工具卡片一起隐藏上下文卡片。** 否决:上下文承载注入指令,不是可恢复的执行细节。因此隐藏阶段只移除工具流量。 + +**把恢复限制在一个工作区,或在启动后推断 cwd。** 否决:前者迫使用户手动重启;后者恢复的会话头 cwd 并不控制文件系统和 shell 的路径解析。目标目录必须在进程替换前跨过主机接口。 + +**移除 TUI 退出状态标记,或移除面向模型的退出标记。** 否决:前者是便于扫描的 UI 状态,后者是模型的状态信号。呈现器在构造结构化视图时消费文本标记,使两类受众各看到一种表示。 + +## Consequences + +记录现在表现为带颜色的状态标题和内收细节;上下文对任意普通文本都稳定呈现;一个快捷键控制记录密度。公共 `TuiTheme.muted` 角色被移除,扩展改用 `dim`。调色板和 `renderUnknownXml` 契约更严格,以少量编译期摩擦换取对静默样式丢失的防护。 + +跨工作区恢复会把所有依赖路径解析的工具移动到另一个目录。cwd 缺失或不可访问时不能交接。更宽的选择范围也使共享会话存储的并发访问更容易触达;跨进程会话锁仍是独立后续工作。 + +终端呈现器仍会把与退出标记语法完全一致的最后一行输出视为结构化状态,因此命令有意打印这种行时,卡片正文可能丢失该行。`dsh-tool-bash` 已记录这一残余限制。 + +## Testing + +TUI 单元测试和无密钥终端快照覆盖调色板枚举、浅色/深色角色、合法与非法样式组合、统一 dim 卡片正文、保留语义的差异颜色、仅有一个退出状态且正文无标记、普通文本上下文框架、与内容无关的折叠、Ctrl+O 三态循环、模型过滤和两种恢复范围。CLI 交接测试覆盖传递重新读取的 cwd,并在释放前拒绝目录切换失败。tool-bash 测试把结果标记的生成、解析和移除固定为同一轮往返契约。 diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index a87812ce00..0d402c1c51 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -24,7 +24,10 @@ import { } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-paths' import type { Context } from 'cordis' -import type { TuiResumeHost } from '@deepseek-ai/dsh-tui' +import { + TUI_GOODBYE_MESSAGE_KEY, + type TuiResumeHost, +} from '@deepseek-ai/dsh-tui' const NAME = 'dsh' @@ -70,8 +73,10 @@ export async function runTui(config: string | undefined, resumeSessionId: string const entry = process.argv[1] const execve = process.execve?.bind(process) const app: { current?: Context } = {} + const resumeCommand = (sessionId: string): string => + `${NAME} --resume=${sessionId}${config === undefined ? '' : ` --config ${config}`}` const resumeHost: TuiResumeHost | undefined = entry === undefined || execve === undefined ? undefined : { - async handoff(sessionId): Promise { + async handoff(sessionId, cwd): Promise { const current = app.current if (current === undefined) throw new Error(`${NAME}: app boot has not completed`) // Rebuild argv from the parsed config plus the selected id: TUI mode's @@ -83,6 +88,11 @@ export async function runTui(config: string | undefined, resumeSessionId: string `--resume=${sessionId}`, ...config !== undefined ? ['--config', config] : [], ] + try { + process.chdir(cwd) + } catch (error) { + throw new Error(`${NAME}: cannot resume in "${cwd}": ${String(error)}`) + } try { await current.fiber.dispose() execve(process.execPath, nextArgv, process.env) @@ -101,6 +111,9 @@ export async function runTui(config: string | undefined, resumeSessionId: string // Inject the resume id (or undefined) so the shipped config's `!!js` // reads it as a bare identifier; then offer the in-place handoff host. hostCtx.provide(RESUME_SESSION_ID_KEY, resumeSessionId) + if (resumeSessionId !== undefined) { + hostCtx.provide(TUI_GOODBYE_MESSAGE_KEY, `To resume this session: ${resumeCommand(resumeSessionId)}`) + } if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost) }, ) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f68519acd5..8f446ac386 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1866,12 +1866,12 @@ export interface Config extends TuiConfig { /** 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. + * Skill name auto-invoked as this session's first user turn, exactly as if + * the user typed `/skill:`. Set only by a launcher for a fresh + * skill-guided session (`dsh migrate`/`dsh upgrade`); absent leaves the first + * turn to the user. */ - resumeCommand?: string + initialSkill?: string } /** Interaction and presentation settings for the pi-tui terminal mode. */ diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 6f32d656af..0d80e4be57 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2152,7 +2152,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:187`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:247`](../../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 0d8f454204..2efc357338 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 @@ -4,29 +4,30 @@ title "Use the bash tool to — DSH TUI snapshot" cursor hidden column=7 viewportRow=24 bufferRow=24 buffer 0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-blue bold + style 1-8 fg=bright-magenta bold style 10-16 bold 1| " Use the bash tool to" - style 1-20 fg=bright-black + style 1-20 dim 2| " main-session" style 1-12 dim 3| 4| "You " - style 0-2 fg=bright-blue bold underline + style 0-2 fg=bright-magenta 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 + style 0-8 dim italic 9| "The user wants me to run a simple bash command and then reply with \"DONE\". " - style 0-73 fg=bright-black italic + style 0-73 dim 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 + style 0-17 dim 13| "TERMINAL_OK " + style 0-10 dim 14| "[exit 0] " style 0-7 dim 15| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " @@ -35,20 +36,20 @@ buffer 17| "Assistant " style 0-8 fg=bright-magenta bold underline 18| "Reasoning " - style 0-8 fg=bright-black italic + style 0-8 dim italic 19| "The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\". " - style 0-90 fg=bright-black italic + style 0-90 dim 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 + style 0-46 fg=bright-magenta bold + style 49-65 dim + style 68-88 dim + style 91-99 dim 24| " dsh ◍ " - style 1-3 fg=bright-blue bold - style 5-6 fg=bright-black + style 1-3 fg=bright-magenta bold + style 5-6 dim 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 248ea2f4e7..f4208bf650 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 @@ -4,15 +4,15 @@ title "Using ONE run_code program: call — DSH TUI snapshot" cursor hidden column=7 viewportRow=26 bufferRow=26 buffer 0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-blue bold + style 1-8 fg=bright-magenta bold style 10-16 bold 1| " Using ONE run_code program: call" - style 1-32 fg=bright-black + style 1-32 dim 2| " main-session" style 1-12 dim 3| 4| "You " - style 0-2 fg=bright-blue bold underline + style 0-2 fg=bright-magenta 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 " @@ -22,36 +22,38 @@ buffer 9| "Assistant " style 0-8 fg=bright-magenta bold underline 10| "Reasoning " - style 0-8 fg=bright-black italic + style 0-8 dim 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 + style 0-99 dim italic 12| "command, then returns only the number of lines in its output. " - style 0-60 fg=bright-black italic + style 0-60 dim italic 13| 14| "● Tool / run_code" style 0-16 fg=green 15| "Count lines in seq/awk output " + style 0-99 dim 16| "200 " + style 0-99 dim 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 + style 0-8 dim 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 + style 0-82 dim 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 + style 0-52 fg=bright-magenta bold + style 55-71 dim + style 74-93 dim + style 96-99 dim 26| " dsh ◍ " - style 1-3 fg=bright-blue bold - style 5-6 fg=bright-black + style 1-3 fg=bright-magenta bold + style 5-6 dim 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 45879f889f..03d65530b1 100644 --- a/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt @@ -4,15 +4,15 @@ title "Using ONE run_code program: call — DSH TUI snapshot" cursor hidden column=7 viewportRow=35 bufferRow=61 buffer 0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-blue bold + style 1-8 fg=bright-magenta bold style 10-16 bold 1| " Using ONE run_code program: call" - style 1-32 fg=bright-black + style 1-32 dim 2| " main-session" style 1-12 dim 3| 4| "You " - style 0-2 fg=bright-blue bold underline + style 0-2 fg=bright-magenta 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 @@ -24,37 +24,37 @@ buffer 9| "Assistant " style 0-8 fg=bright-magenta bold underline 10| "Reasoning " - style 0-8 fg=bright-black italic + style 0-8 dim italic 11| "The user wants me to write a single run_code program that: " - style 0-35 fg=bright-black italic + style 0-35 dim italic style 36-43 fg=cyan - style 44-57 fg=bright-black italic + style 44-57 dim 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 0-2 fg=bright-magenta + style 3-8 dim italic style 9-12 fg=cyan - style 13-37 fg=bright-black italic + style 13-37 dim italic style 38-50 fg=cyan - style 51-62 fg=bright-black italic + style 51-62 dim italic style 63-75 fg=cyan 13| "2. console.log exactly captured output " - style 0-2 fg=bright-blue + style 0-2 fg=bright-magenta style 3-13 fg=cyan - style 14-22 fg=bright-black italic + style 14-22 dim 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 + style 0-2 fg=bright-magenta + style 3-49 dim 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 0-37 dim italic style 38-41 fg=cyan - style 42-99 fg=bright-black italic + style 42-99 dim italic 17| "extract the stdout text from each call. " - style 0-38 fg=bright-black italic + style 0-38 dim italic 18| " " 19| "Looking at the bash output type: " - style 0-31 fg=bright-black italic + style 0-31 dim italic 20| " " 21| " " 22| " { " @@ -90,51 +90,54 @@ buffer 37| " " 38| " " 39| "So I need to access .stdout.text from each result. " - style 0-19 fg=bright-black italic + style 0-19 dim italic style 20-31 fg=cyan - style 32-49 fg=bright-black italic + style 32-49 dim italic 40| " " 41| "Let me write the program. " - style 0-24 fg=bright-black italic + style 0-24 dim italic 42| 43| "● Tool / run_code" style 0-16 fg=green 44| "Call bash twice, log, join outputs " + style 0-99 dim 45| "captured output " + style 0-99 dim 46| "CODE_ONE+CODE_TWO " + style 0-99 dim 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 + style 0-8 dim italic 51| "The program ran successfully. The output shows: " - style 0-46 fg=bright-black italic + style 0-46 dim italic 52| "- captured output (from console.log) " - style 0-1 fg=bright-blue + style 0-1 fg=bright-magenta style 2-16 fg=cyan - style 17-35 fg=bright-black italic + style 17-35 dim italic 53| "- CODE_ONE+CODE_TWO (the returned joined string) " - style 0-1 fg=bright-blue + style 0-1 fg=bright-magenta style 2-18 fg=cyan - style 19-47 fg=bright-black italic + style 19-47 dim italic 54| " " 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 + style 0-99 dim italic 56| "CODE_ONE+CODE_TWO. " style 0-16 fg=cyan - style 17-17 fg=bright-black italic + style 17-17 dim 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 + style 0-37 fg=bright-magenta bold + style 40-56 dim + style 59-78 dim + style 81-90 dim 61| " dsh ◍ " - style 1-3 fg=bright-blue bold - style 5-6 fg=bright-black + style 1-3 fg=bright-magenta bold + style 5-6 dim 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 b4add028d8..7d6a92ea77 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 @@ -4,15 +4,15 @@ title "Run this advanced flow exactly — DSH TUI snapshot" cursor hidden column=7 viewportRow=35 bufferRow=58 buffer 0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-blue bold + style 1-8 fg=bright-magenta bold style 10-16 bold 1| " Run this advanced flow exactly" - style 1-30 fg=bright-black + style 1-30 dim 2| " main-session" style 1-12 dim 3| 4| "You " - style 0-2 fg=bright-blue bold underline + style 0-2 fg=bright-magenta bold underline 5| "Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use " 6| "run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a " 7| "direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply " @@ -24,8 +24,11 @@ buffer 12| "● Tool / cordis_mount" style 0-20 fg=green 13| "Mount temporary Cordis Plugin " + style 0-99 dim 14| "Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH " + style 0-99 dim 15| "restarts). " + style 0-99 dim 16| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim 17| @@ -35,13 +38,16 @@ buffer 20| "● Tool / run_code" style 0-16 fg=green 21| "Verify the temporary marker Plugin " + style 0-99 dim 22| " " 23| "Temporary Plugins " - style 0-16 fg=bright-blue bold + style 0-16 fg=bright-magenta bold dim 24| " " 25| "- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: " - style 0-1 fg=bright-blue + style 0-1 fg=bright-magenta dim + style 2-99 dim 26| " until unmounted or DSH restarts " + style 0-99 dim 27| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim 28| @@ -51,6 +57,7 @@ buffer 31| "● Tool / subagent" style 0-16 fg=green 32| "DIRECT_CHILD_OK " + style 0-99 dim 33| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim 34| @@ -60,11 +67,17 @@ buffer 37| "● Tool / workflow" style 0-16 fg=green 38| "workflow: advanced-acp-snapshot " + style 0-99 dim 39| "workflow \"advanced-acp-snapshot\" completed (1 agent). " + style 0-99 dim 40| "Return value: " + style 0-99 dim 41| "{ " + style 0-99 dim 42| " \"reply\": \"WORKFLOW_CHILD_OK\" " + style 0-99 dim 43| "} " + style 0-99 dim 44| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim 45| @@ -74,7 +87,9 @@ buffer 48| "● Tool / cordis_unmount" style 0-22 fg=green 49| "Unmount temporary Cordis Plugin dyn-1 " + style 0-99 dim 50| "Temporary Plugin dyn-1 was unmounted and removed. " + style 0-99 dim 51| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim 52| @@ -85,11 +100,11 @@ buffer style 0-46 dim 56| 57| "/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 + style 0-52 fg=bright-magenta bold + style 55-71 dim + style 74-90 dim + style 93-99 dim 58| " dsh ◍ " - style 1-3 fg=bright-blue bold - style 5-6 fg=bright-black + style 1-3 fg=bright-magenta bold + style 5-6 dim 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 c704cb2399..ff8125c5ad 100644 --- a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt @@ -4,15 +4,15 @@ title "Use the workflow tool exactly — DSH TUI snapshot" cursor hidden column=7 viewportRow=35 bufferRow=42 buffer 0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-blue bold + style 1-8 fg=bright-magenta bold style 10-16 bold 1| " Use the workflow tool exactly" - style 1-29 fg=bright-black + style 1-29 dim 2| " main-session" style 1-12 dim 3| 4| "You " - style 0-2 fg=bright-blue bold underline + style 0-2 fg=bright-magenta 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') " @@ -24,57 +24,63 @@ buffer 13| "Assistant " style 0-8 fg=bright-magenta bold underline 14| "Reasoning " - style 0-8 fg=bright-black italic + style 0-8 dim 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 + style 0-99 dim italic 16| "follow the instructions: " - style 0-23 fg=bright-black italic + style 0-23 dim 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 + style 0-2 fg=bright-magenta + style 3-38 dim 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 + style 0-2 fg=bright-magenta + style 3-81 dim italic 20| "3. script = as given verbatim " - style 0-2 fg=bright-blue - style 3-28 fg=bright-black italic + style 0-2 fg=bright-magenta + style 3-28 dim italic 21| "4. After it returns, reply with \"WORKFLOW_DONE\" " - style 0-2 fg=bright-blue - style 3-46 fg=bright-black italic + style 0-2 fg=bright-magenta + style 3-46 dim italic 22| " " 23| "Let me do exactly that. " - style 0-22 fg=bright-black italic + style 0-22 dim italic 24| 25| "● Tool / workflow" style 0-16 fg=green 26| "workflow: snapshot-flow " + style 0-99 dim 27| "workflow \"snapshot-flow\" completed (1 agent). " + style 0-99 dim 28| "Return value: " + style 0-99 dim 29| "{ " + style 0-99 dim 30| " \"reply\": \"WF_CHILD_OK\" " + style 0-99 dim 31| "} " + style 0-99 dim 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 + style 0-8 dim 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 + style 0-99 dim italic 37| "\"WORKFLOW_DONE\" and stop. " - style 0-24 fg=bright-black italic + style 0-24 dim 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 + style 0-44 fg=bright-magenta bold + style 47-63 dim + style 66-86 dim + style 89-98 dim 42| " dsh ◍ " - style 1-3 fg=bright-blue bold - style 5-6 fg=bright-black + style 1-3 fg=bright-magenta bold + style 5-6 dim 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 09f3cb1fb4..c0c38592c3 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 @@ -4,59 +4,59 @@ title "Reply with exactly the word: — DSH TUI snapshot" cursor hidden column=7 viewportRow=30 bufferRow=30 buffer 0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-blue bold + style 1-8 fg=bright-magenta bold style 10-16 bold 1| " Reply with exactly the word:" - style 1-28 fg=bright-black + style 1-28 dim 2| " main-session" style 1-12 dim 3| 4| "You " - style 0-2 fg=bright-blue bold underline + style 0-2 fg=bright-magenta bold underline 5| "Reply with exactly the word: ONE. No tools. " 6| 7| "Plan mode on. Use /plan off to leave. " - style 0-36 fg=bright-black + style 0-36 dim 8| 9| "Assistant " style 0-8 fg=bright-magenta bold underline 10| "Reasoning " - style 0-8 fg=bright-black italic + style 0-8 dim italic 11| "The user wants me to reply with exactly the word \"ONE\" and use no tools. " - style 0-71 fg=bright-black italic + style 0-71 dim italic 12| "ONE " 13| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim 14| -15| "Context · plan-mode " +15| "Context · plan-mode" style 0-18 dim 16| "The user switched this session back to the default mode. " - style 0-55 fg=bright-black + style 0-55 dim 17| 18| "Plan mode off. " - style 0-13 fg=bright-black + style 0-13 dim 19| 20| "You " - style 0-2 fg=bright-blue bold underline + style 0-2 fg=bright-magenta 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 + style 0-8 dim italic 25| "The user wants me to reply with exactly the word \"TWO\" and no tools. " - style 0-67 fg=bright-black italic + style 0-67 dim 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 + style 0-51 fg=bright-magenta bold + style 54-70 dim + style 73-92 dim + style 95-99 dim 30| " dsh ◍ " - style 1-3 fg=bright-blue bold - style 5-6 fg=bright-black + style 1-3 fg=bright-magenta bold + style 5-6 dim 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 3d99c45758..82b3048bb4 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 @@ -4,15 +4,15 @@ title "Use the read tool twice — DSH TUI snapshot" cursor hidden column=7 viewportRow=27 bufferRow=27 buffer 0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-blue bold + style 1-8 fg=bright-magenta bold style 10-16 bold 1| " Use the read tool twice" - style 1-23 fg=bright-black + style 1-23 dim 2| " main-session" style 1-12 dim 3| 4| "You " - style 0-2 fg=bright-blue bold underline + style 0-2 fg=bright-magenta 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 " @@ -21,16 +21,22 @@ buffer 9| "● Tool / read" style 0-12 fg=green 10| "Read a.txt " + style 0-99 dim 11| "1: alpha " + style 0-99 dim 12| " " 13| "(End of file - total 1 lines) " + style 0-99 dim 14| 15| "● Tool / read" style 0-12 fg=green 16| "Read b.txt " + style 0-99 dim 17| "1: beta " + style 0-99 dim 18| " " 19| "(End of file - total 1 lines) " + style 0-99 dim 20| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim 21| @@ -41,12 +47,12 @@ buffer 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 + style 0-47 fg=bright-magenta bold + style 50-66 dim + style 69-84 dim + style 87-96 dim 27| " dsh ◍ " - style 1-3 fg=bright-blue bold - style 5-6 fg=bright-black + style 1-3 fg=bright-magenta bold + style 5-6 dim 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 9dafcf576d..a5d2ca07b3 100644 --- a/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt @@ -4,15 +4,15 @@ title "Use the todo_write tool to — DSH TUI snapshot" cursor hidden column=7 viewportRow=31 bufferRow=31 buffer 0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-blue bold + style 1-8 fg=bright-magenta bold style 10-16 bold 1| " Use the todo_write tool to" - style 1-26 fg=bright-black + style 1-26 dim 2| " main-session" style 1-12 dim 3| 4| "You " - style 0-2 fg=bright-blue bold underline + style 0-2 fg=bright-magenta 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. " @@ -20,31 +20,33 @@ buffer 9| "Assistant " style 0-8 fg=bright-magenta bold underline 10| "Reasoning " - style 0-8 fg=bright-black italic + style 0-8 dim 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 + style 0-99 dim italic 12| "specified statuses, then reply with \"DONE\". " - style 0-42 fg=bright-black italic + style 0-42 dim italic 13| 14| "● Tool / todo_write" style 0-18 fg=green 15| "Update todo list " + style 0-99 dim 16| "Updated todo list: 2 pending, 1 in progress, 0 completed. " + style 0-99 dim 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 + style 0-8 dim 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 + style 0-94 dim 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 + style 0-3 fg=bright-magenta bold 27| " ● read the code" style 2-2 fg=yellow 28| " ○ write the fix" @@ -52,12 +54,12 @@ buffer 29| " ○ run the tests" style 2-2 dim 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 + style 0-37 fg=bright-magenta bold + style 40-56 dim + style 59-79 dim + style 82-91 dim 31| " dsh ◍ " - style 1-3 fg=bright-blue bold - style 5-6 fg=bright-black + style 1-3 fg=bright-magenta bold + style 5-6 dim style 7-7 inverse 32-35| diff --git a/packages/bash/tool-bash/README.i18n.yaml b/packages/bash/tool-bash/README.i18n.yaml index a529b56b56..676c935cbd 100644 --- a/packages/bash/tool-bash/README.i18n.yaml +++ b/packages/bash/tool-bash/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 965ae25a5e29a4f767adfcb73e4a77f1060e4b46 -README.zh.md: 60be5c5ca5624719f5ca651a78b6ba56f3f3df06 +# pnpm run verify-translation-pairing --write packages/bash/tool-bash/README.md +README.md: deb6b899c81cb8c335b4c1cffdde4797e0a8be92 +README.zh.md: c2514308fb9f234e6d191a6b1a821ac3d195378b diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index 965ae25a5e..deb6b899c8 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -57,7 +57,7 @@ When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` bef ## UI presentation -The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a terminal card carrying command, description, cwd, raw output, and parsed exit status. A background start is a generic execute card because it returns only a task id; the generic `task_*` tools own their own cards. These presenters are pure and replay-safe. +The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a terminal card carrying command, description, cwd, output, and parsed exit status. Because the card shows the exit as its own pill, the `[exit code: N]` / `[killed by signal: …]` marker the parse consumes leaves the output; every other marker (truncation, timeout, sandbox) stays in it. A background start is a generic execute card because it returns only a task id; the generic `task_*` tools own their own cards. These presenters are pure and replay-safe. ## The tool builds its request from named args only @@ -153,6 +153,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **Replay exit pills parse from result text** — output whose final line happens to be exactly `[exit code: N]` / `[killed by signal: …]` shows a wrong pill on session replay; a display-only known residual. +- **Replay exit pills parse from result text** — output whose final line happens to be exactly `[exit code: N]` / `[killed by signal: …]` shows a wrong pill on session replay and loses that line from the card body, because the parse treats it as the marker it consumes; a display-only known residual. - **The `bash` tool opts out of `timeout-policy` budgets** — it keeps the executor-owned `BASH_TIMEOUT` path, per [the tool-call timeout-policy Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md). - **Background processes have no executor timeout** — callers must use `task_kill`, or rely on owner/service disposal, when work no longer matters. diff --git a/packages/bash/tool-bash/README.zh.md b/packages/bash/tool-bash/README.zh.md index 60be5c5ca5..c2514308fb 100644 --- a/packages/bash/tool-bash/README.zh.md +++ b/packages/bash/tool-bash/README.zh.md @@ -57,7 +57,7 @@ overlay 根据当前 `ToolExecution` 计算,并通过专用的 `BashExecReques ## UI 展示 -工具持有自己的 `presentCall`/`presentResult` 渲染意图。前台调用是终端卡片,包含命令、说明、cwd、原始输出和解析后的退出状态。后台启动只返回 task id,因此使用通用执行卡片;通用 `task_*` 工具持有各自的卡片。这些 presenter 是纯函数,可安全回放。 +工具持有自己的 `presentCall`/`presentResult` 渲染意图。前台调用是终端卡片,包含命令、说明、cwd、输出和解析后的退出状态。由于卡片以独立的 pill 展示退出状态,解析所消耗的 `[exit code: N]` / `[killed by signal: …]` 标记会从输出中移除;其他所有标记(截断、超时、沙箱)都保留在输出中。后台启动只返回 task id,因此使用通用执行卡片;通用 `task_*` 工具持有各自的卡片。这些 presenter 是纯函数,可安全回放。 ## 工具仅使用具名参数构建请求 @@ -153,6 +153,6 @@ renderer 先输出依数据而定的 stdout 尾部,再输出可选的 `[stderr ## 已知限制与延期工作 -- **回放退出状态 pill 从结果文本解析**:如果输出最后一行恰好精确为 `[exit code: N]` / `[killed by signal: …]`,会话回放将显示错误的 pill;这是仅影响展示的已知残留问题。 +- **回放退出状态 pill 从结果文本解析**:如果输出最后一行恰好精确为 `[exit code: N]` / `[killed by signal: …]`,会话回放将显示错误的 pill,并且该行会从卡片正文中丢失,因为解析会把它当作自己消耗的标记;这是仅影响展示的已知残留问题。 - **`bash` 工具不采用 `timeout-policy` 预算**:根据[工具调用 timeout-policy Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md),它保留由执行器持有的 `BASH_TIMEOUT` 路径。 - **后台进程没有执行器超时**:工作不再需要时,调用方必须使用 `task_kill`,或依赖持有者/服务的 dispose。 diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index b403c4414e..f8cece2862 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -296,7 +296,9 @@ function presentBashResult(args: unknown, result: ToolResult): ToolResultView | if (isBackground || result.isError) { return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }] } } - return { card: 'terminal', output: raw, ...parseExitStatus(raw) } + // The exit marker becomes the card's exit pill, so it leaves the output body. + const { body, ...exit } = parseExitStatus(raw) + return { card: 'terminal', output: body, ...exit } } /** diff --git a/packages/bash/tool-bash/src/render.ts b/packages/bash/tool-bash/src/render.ts index 77a88e28f3..eabe681c25 100644 --- a/packages/bash/tool-bash/src/render.ts +++ b/packages/bash/tool-bash/src/render.ts @@ -95,10 +95,23 @@ export function renderProcessRead( } /** - * Recover the structured exit status from a rendered {@link renderResult} - * string — the inverse of the status markers it appends. A killed marker - * yields `signal`; otherwise a non-zero marker yields `exitCode`; absent both - * means a clean exit 0. + * The exit status recovered from a rendered result, with the output body that + * status was split off from. + */ +export type ParsedExitStatus = + & { body: string } + & ({ exitCode: number } | { signal: string }) + +/** + * Split a rendered {@link renderResult} string into its output body and the + * structured exit status — the inverse of the status markers it appends. A + * killed marker yields `signal`; otherwise a non-zero marker yields `exitCode`; + * absent both means a clean exit 0. + * + * The consumed marker is removed from `body` because a terminal presentation + * shows the exit status as its own pill: leaving the marker in the output would + * render the exit twice. Other markers (timeout, sandbox denial) carry facts no + * pill shows, so they stay in the body. * * Replay only retains the rendered content text, not the original * `BashRunResult`, so terminal presentation must recover the exit pill here. @@ -106,12 +119,12 @@ export function renderProcessRead( * that merely ends with marker-like text from matching unless the final line * is indistinguishable from a real marker. * @param text - rendered model-facing bash result. - * @returns the recovered terminal exit code or signal. + * @returns the marker-free body plus the recovered terminal exit code or signal. */ -export function parseExitStatus(text: string): { exitCode: number } | { signal: string } { +export function parseExitStatus(text: string): ParsedExitStatus { const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text) - if (signal?.[1] !== undefined) return { signal: signal[1] } + if (signal?.[1] !== undefined) return { body: text.slice(0, signal.index), signal: signal[1] } const exit = /\n\[exit code: (\d+)\]$/.exec(text) - if (exit?.[1] !== undefined) return { exitCode: Number(exit[1]) } - return { exitCode: 0 } + if (exit?.[1] !== undefined) return { body: text.slice(0, exit.index), exitCode: Number(exit[1]) } + return { body: text, exitCode: 0 } } diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 15222ee647..a52b3f16f1 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -911,22 +911,32 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => { it('bash presentResult: a terminal result carries RAW output (newlines intact) + parsed exit code', async () => { const ctx = await setup() const present = ctx.tools.get('bash')!.presentResult!( - { command: 'echo hi', description: 'echo' }, - { content: [{ type: 'text', text: 'hi\n[exit code: 0]\n\n' }], isError: false }, + { command: 'printf "hi\\n\\n"', description: 'echo' }, + // A clean run renders no exit marker at all, so the body is the raw bytes. + { content: [{ type: 'text', text: 'hi\n\n' }], isError: false }, ) // A terminal result keeps the RAW bytes (newlines intact) a terminal renderer - // needs; the bridge derives the fenced fallback. exitCode is parsed back from - // the [exit code: N] marker. - expect(present).toEqual({ card: 'terminal', output: 'hi\n[exit code: 0]\n\n', exitCode: 0 }) + // needs; the bridge derives the fenced fallback. + expect(present).toEqual({ card: 'terminal', output: 'hi\n\n', exitCode: 0 }) }) it('bash presentResult: a non-zero exit and a signal kill parse into exitCode / signal', async () => { const ctx = await setup() const args = { command: 'x', description: 'x' } const nonzero = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'oops\n[exit code: 3]' }], isError: false }) - expect(nonzero).toEqual({ card: 'terminal', output: 'oops\n[exit code: 3]', exitCode: 3 }) + expect(nonzero).toEqual({ card: 'terminal', output: 'oops', exitCode: 3 }) const killed = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'gone\n[killed by signal: SIGKILL]' }], isError: false }) - expect(killed).toEqual({ card: 'terminal', output: 'gone\n[killed by signal: SIGKILL]', signal: 'SIGKILL' }) + expect(killed).toEqual({ card: 'terminal', output: 'gone', signal: 'SIGKILL' }) + }) + + it('bash presentResult: markers a pill CANNOT show (timeout, sandbox denial) stay in the terminal output', async () => { + const ctx = await setup() + const args = { command: 'x', description: 'x' } + const timedOut = ctx.tools.get('bash')!.presentResult!( + args, + { content: [{ type: 'text', text: 'slow\n[timed out after 100ms]\n[exit code: 143]' }], isError: false }, + ) + expect(timedOut).toEqual({ card: 'terminal', output: 'slow\n[timed out after 100ms]', exitCode: 143 }) }) it('bash presentResult exit parse is the inverse of renderResult markers (round-trip)', async () => { @@ -952,8 +962,11 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => { const rendered = renderResult(c.result) const out = present.presentResult!({ command: 'x', description: 'x' }, { content: [{ type: 'text', text: rendered }], isError: false }) // Drop card + output; the remaining fields are the parsed exit. - const { card: _c, output: _o, ...exit } = out as { card: string; output?: string; exitCode?: number; signal?: string } + const { card: _c, output, ...exit } = out as { card: string; output?: string; exitCode?: number; signal?: string } expect(exit).toEqual(c.expect) + // Whatever the parse consumed is gone from the body, so a card with an exit + // pill never shows the same status twice. + expect(output).not.toMatch(/\[exit code: \d+\]|\[killed by signal: /) } }) @@ -964,6 +977,7 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => { // newline; parsing requires the leading newline emitted for real markers, so this stays exit 0. const out = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[exit code: 5]' }], isError: false }) expect(out).toEqual({ card: 'terminal', output: '[exit code: 5]', exitCode: 0 }) + // Unparsed marker-like text is real output, so it is NOT stripped from the body. // Same for a fake signal marker with no leading newline. const sig = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[killed by signal: SIGKILL]' }], isError: false }) expect(sig).toEqual({ card: 'terminal', output: '[killed by signal: SIGKILL]', exitCode: 0 }) diff --git a/packages/ui/tui/AGENTS.md b/packages/ui/tui/AGENTS.md new file mode 100644 index 0000000000..2e73212a58 --- /dev/null +++ b/packages/ui/tui/AGENTS.md @@ -0,0 +1,5 @@ +# AGENTS.md — TUI package + +These rules supplement the package conventions in [packages/AGENTS.md](../../AGENTS.md). + +- **Present TUI designs in tmux, not in the session transcript.** When tmux is available, run the assembled TUI in a pane of the same window the session runs in and point the user at it; print a rendering into the transcript only as a fallback. diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index 4e0757170d..8ab63910fa 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: 5aafd6f5207320bf273c96a04f2d606577ca2da0 -README.zh.md: 1901faeb26c65126bc5475a991fedecd39a88ba5 +README.md: 0b358520b863f0b9ee7a128cf4807f582fc46d8d +README.zh.md: 7e89197bd82d16dfbabeb715e953275e2f6dd68b diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 5aafd6f520..0b358520b8 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -22,9 +22,9 @@ Typing `@` at a token boundary searches files and directories under the session When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation, so idle follow-ups still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook. -While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. +While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. -`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `provider default`, which clears an explicit selection; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model ` still selects an unambiguous model id directly, while `/model /` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. +`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: a filter box above the list narrows rows by a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the highlighted row selected when it survives the filter; Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape clears a non-empty filter before a second Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `provider default`, which clears an explicit selection; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model ` still selects an unambiguous model id directly, while `/model /` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. `/reload` (EXPERIMENTAL, dev-only) re-reads every file-backed loader config tree and applies the diff to the running app — the HMR watcher's config path, invoked manually; it needs the cordis Loader in the context and degrades to a warning without one, runs only while the agent is idle, and refuses re-entry while a reload is in flight. Module-source hot reload remains watcher-owned. When a `skills` service is mounted, `/skill: [instructions]` loads that skill's instructions into the conversation as a user turn; autocomplete lists the model-invocable skills, and any skill (including a model-disabled one) is loadable by its exact name. @@ -32,9 +32,15 @@ The footer sums the session's reported usage as `↑ `/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, selected reasoning effort or default behavior, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer. -`/resume` opens a full-viewport keyboard selector over the current workspace instead of a centered dialog. Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and replaces its process. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`. +`/resume` opens a full-viewport keyboard selector instead of a centered dialog. Two scopes cover the same candidate set: the current workspace, which it opens on, and all workspaces, which Tab toggles to. The scope line under the search field names the active scope and the count the other holds, and each row in the all-workspaces scope also reports its own workspace. Toggling clears the search and selection so the highlighted row always belongs to the visible list. -`resumeCommand` remains the deployment-owned fallback: exiting prints it only after the current session is durable, and a host without in-place handoff shows the selected session's command. `{session}` expands to the session id. TUI code never executes the template or arbitrary shell text. +Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Candidates are sorted by last logged activity and searchable by log-backed title or session id, and by workspace label in the all-workspaces scope; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, a session with no recorded workspace to run in, or a session whose logged provider has no current adapter remains visible but disabled; a workspace other than the current one is a scope rather than a disabled reason, because resume enters that directory. + +Selection repeats those checks and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume` with the selected id and the workspace re-read at preflight: process cwd, not the restored session header, is what filesystem and shell tools resolve against, so the host must enter that directory. Where `process.execve` is available, the shipped `dsh` host chdirs into it before disposing the app and replacing its process, and rejects an unreachable directory while the terminal can still be restored. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`. + +The exit line is launcher-owned, not configurable. A launcher provides `TUI_GOODBYE_MESSAGE_KEY` on the boot context — for the shipped `dsh`, the command that resumes this session — and exiting prints it verbatim after the terminal is released; absent, exiting prints nothing. Only the launcher knows how it was invoked, so only it can name a command that works. The TUI escapes terminal controls before rendering and never executes the text. A launcher that also supplies `MAIN_SESSION_ID_KEY` fixes which session the mounted app binds to, so resume survives any config-level patch. + +A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY` (the skill name) on the boot context; the TUI auto-invokes it exactly as a typed `/skill:`, once the chat is live. The shipped `dsh migrate`/`dsh upgrade` set it and only for a fresh session, so a resumed session never re-invokes the skill; an unknown name is reported as a notice. ## Config @@ -57,7 +63,6 @@ The footer sums the session's reported usage as `↑ | `showHardwareCursor` | `false` | Show the hardware cursor at pi-tui's IME marker | | `color` | `true` | Apply the built-in ANSI palette (see [Color](#color)) | | `title` | `DeepSeek Harness` | Product suffix for the terminal window title. | -| `resumeCommand` | — | Shell command template for the exit hint and hosts without in-place handoff, with `{session}` expanded to the session id | ```yaml - id: terminal @@ -74,7 +79,11 @@ 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, 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. +Every SGR code the TUI emits lives in one table, `paletteSpec` in `components/theme.ts`, which `createPalette` derives its wrappers from and `/palette` prints; no component writes an escape of its own. The table holds only the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so the TUI stays readable on light and dark backgrounds alike — the startup banner's brand gradient is the one deliberate exception. Body text keeps the terminal's default foreground rather than a fixed shade. + +There is one role per visual meaning: `dim` is the single recessed tone and `accent` the single emphasis color, while `success` and `error` double as a diff's added and removed lines. Colors and attributes are separately typed, so `bold(accent(x))` compiles and `accent(error(x))` does not — SGR has no color stack, so nesting one color inside another silently drops the outer color at the inner one's close. Attributes occupy independent SGR groups and compose with any color in either order. Run `/palette` to see every role as your terminal renders it, with its SGR pair. + +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. Inside a tool card, the whole body — presenter title, a terminal `$` command and cwd, and the tool's own output — renders in one dim tone, so only the status-colored header carries color and the body reads as one recessed block instead of a run of competing shades; an injected-context card's prose is the same tone as its header. A diff card's `+`/`-` lines and a `[signal …]` marker stay colored, because there the color is the meaning rather than emphasis. 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 @@ -156,7 +165,7 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **Resume has no cross-process session lock** — the selector rejects sessions known to be live in its own runtime, but another process can resume the same persisted id before or during handoff. Deployments that can run concurrent hosts must coordinate ownership outside the TUI. +- **Resume has no cross-process session lock** — the selector rejects sessions known to be live in its own runtime, but another process can resume the same persisted id before or during handoff. The all-workspaces scope makes this reachable in one step, since a session another host is driving in a different directory is now selectable. Deployments that can run concurrent hosts must coordinate ownership outside the TUI. - **One configured session owns the transcript and editor** — questions from other agents can still use the shared overlay provider, but session rendering and prompt input remain bound to `sessionId`. - **Tool cards are text terminal presentations** — terminal, diff, and generic cards use tool-owned titles/content, but session content currently has no image block for inline image rendering. - **Non-TTY operation is intentionally unsupported** — app bundles that need automation must compose a one-shot or server front door (`dsh-cli-demo`, `dsh-acp`) rather than expecting an internal fallback. diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 1901faeb26..7e89197bd8 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -22,9 +22,9 @@ TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reaso 挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()` 或 `agent.followup()`,因此空闲 followup 仍会分派 `agent/prompt-submit`,而轮次中的 steering 会在检查点加入且不触发该 hook。 -Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/reasoning`、`/tools`、`/redraw`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片把长主体折叠为可配置的头尾预览;Ctrl+O 在预览与完整输出之间切换所有卡片。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。 +Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。 -`/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 关闭。适配器未公布默认推理强度时,循环还会包含 `provider default`,该项会清除显式选择;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model ` 仍可直接选择无歧义的模型 id,`/model /` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。 +`/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:列表上方设有一个过滤框,按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在高亮行仍通过过滤时保持其选中状态;Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 会先清除非空过滤内容,再次按下才关闭选择器。适配器未公布默认推理强度时,循环还会包含 `provider default`,该项会清除显式选择;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model ` 仍可直接选择无歧义的模型 id,`/model /` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。 `/reload`(实验性,仅开发环境)会重新读取所有基于文件的 loader 配置树,并把 diff 应用到运行中 app:它手动调用 HMR(热模块替换)watcher 的配置路径;上下文中必须有 cordis Loader,否则退化为警告。它只在 agent 空闲时运行,并拒绝 reload 进行期间的再次进入。模块源代码热重载仍由 watcher 持有。挂载 `skills` 服务后,`/skill: [instructions]` 会把该 skill 的指令作为一个 user 轮次加载到会话中;自动补全列出模型可调用的 skill,任何 skill(包括模型禁用的 skill)都可通过精确名称加载。 @@ -32,9 +32,15 @@ Footer 将会话报告的用量汇总为 `↑`;任 `/status` 会向 transcript 添加一张时间点诊断卡片,并在 agent 运行时保持可用。它报告会话 id、标题、工作目录、所选提供方/模型、所选推理强度或默认行为、reasoning 块可见性、agent 状态、事件/轮次/步骤/工具调用计数、精确输入/输出/缓存 token bucket、KV-cache 命中率、token-meter 上下文用量与容量、创建时间和最新事件时间。缺失标题、模型、缓存输入或上下文容量时会明确标记,而非推断。该卡片只存在于终端,不会重复紧凑 footer。 -`/resume` 会针对当前工作区打开全 viewport 键盘选择器,而非居中对话框。获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。候选项按最近记录的活动排序,可按日志支持的标题或会话 id 搜索;每行报告 current/live/persisted 状态、上一轮次结果、近期提供方/模型,以及存在时的持久目标阶段。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志、cwd 不匹配或日志所记提供方没有当前适配器的会话仍会显示,但不可选择。选择时会重复这些检查,并要求当前 agent 空闲,随后 flush 当前会话。TUI 接着停止终端 UI,并调用由宿主持有的可选 `TuiRuntime.handoffResume`;存在 `process.execve` 时,发布的 `dsh` 宿主会对 app 执行 dispose(资源释放)并替换自身进程。恢复操作保留相同的 `SessionId`、transcript、标题、todo 和持久目标;目标激活仍保持解除,TUI 会要求用户确认或执行 `/goal resume`。 +`/resume` 会打开全 viewport 键盘选择器,而非居中对话框。两个作用域覆盖同一候选项集合:打开时所处的当前工作区,以及按 Tab 切换到的所有工作区。搜索字段下方的作用域行会给出当前作用域的名称以及另一个作用域包含的数量,且在所有工作区作用域中每行还会报告自身所属的工作区。切换会清除搜索与选择,使高亮行始终属于可见列表。 -`resumeCommand` 仍是部署持有的回退行为:只有当前会话已持久化后,退出才会打印它;不支持原地 handoff 的宿主会显示所选会话的命令。`{session}` 展开为会话 id。TUI 代码绝不会执行模板或任意 shell 文本。 +获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。候选项按最近记录的活动排序,可按日志支持的标题或会话 id 搜索,在所有工作区作用域中还可按工作区标签搜索;每行报告 current/live/persisted 状态、上一轮次结果、近期提供方/模型,以及存在时的持久目标阶段。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志、没有可运行的已记录工作区的会话,或日志所记提供方没有当前适配器的会话仍会显示,但不可选择;不同于当前工作区的工作区属于作用域而非禁用原因,因为恢复会进入该目录。 + +选择时会重复这些检查,并要求当前 agent 空闲,随后 flush 当前会话。TUI 接着停止终端 UI,并以所选 id 和在预检时重新读取的工作区调用由宿主持有的可选 `TuiRuntime.handoffResume`:文件系统与 shell 工具解析所依据的是进程 cwd,而非恢复出的会话头部,因此宿主必须进入该目录。存在 `process.execve` 时,发布的 `dsh` 宿主会先 chdir 进入该目录,再对 app 执行 dispose 并替换自身进程,并在终端仍可恢复时拒绝不可达的目录。恢复操作保留相同的 `SessionId`、transcript、标题、todo 和持久目标;目标激活仍保持解除,TUI 会要求用户确认或执行 `/goal resume`。 + +退出时打印的行由启动器拥有,不可通过配置指定。启动器在启动上下文上提供 `TUI_GOODBYE_MESSAGE_KEY`(对于随附的 `dsh`,即恢复本会话的命令),释放终端后退出会原样打印它;未提供时退出不打印任何内容。只有启动器知道自己是如何被调用的,因此只有它能给出可用的命令。TUI 在渲染前会转义终端控制字符,且绝不执行该文本。若启动器同时提供 `MAIN_SESSION_ID_KEY`,则会固定已挂载应用绑定的会话,因此恢复功能不受配置层修补影响。 + +启动器可通过在启动上下文上提供 `INITIAL_SKILL_KEY`(skill 名称)来播种全新会话的首轮;聊天就绪后,TUI 会像用户手动键入 `/skill:` 一样自动调用它。随附的 `dsh migrate`/`dsh upgrade` 会设置该键,且仅对全新会话设置,因此恢复的会话绝不会重复调用该 skill;未知名称会以通知形式报告。 ## 配置 @@ -57,7 +63,6 @@ Footer 将会话报告的用量汇总为 `↑`;任 | `showHardwareCursor` | `false` | 在 pi-tui 的 IME marker 处显示硬件 cursor | | `color` | `true` | 应用内置 ANSI palette(参见[颜色](#color)) | | `title` | `DeepSeek Harness` | 终端窗口标题的产品后缀。 | -| `resumeCommand` | 未设置 | 供退出提示和不支持原地 handoff 的宿主使用的 shell 命令模板,其中 `{session}` 会展开为会话 id | ```yaml - id: terminal @@ -70,11 +75,15 @@ Footer 将会话报告的用量汇总为 `↑`;任 fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist'] ``` -任一进程流不是 TTY 时,启动会在挂载前失败。组合 app 必须先挂载 TUI,再挂载由配置创建的 agent,使入口能够观察 `agent-loop/config-start-failed`;完全匹配会话的失败会在全屏模式启动前写出并以状态 1 退出,而不是留下空白终端。dispose 会停止接收扩展请求,卸载 `ctx.tui` 提供方及其依赖插件,中止运行中的命令,移除 TUI 定义,停止 loader,拒绝待处理问题,排空终端输入,恢复终端状态,注销事件 listener 和用户交互提供方,并且绝不会在 HMR 期间退出替换进程。 +任一进程流不是 TTY 时,启动会在挂载前失败。组合 app 必须先挂载 TUI,再挂载由配置创建的 agent,使入口能够观察 `agent-loop/config-start-failed`;完全匹配会话的失败会在全屏模式启动前写出并以状态 1 退出,而不是留下空白终端。dispose(资源释放)会停止接收扩展请求,卸载 `ctx.tui` 提供方及其依赖插件,中止运行中的命令,移除 TUI 定义,停止 loader,拒绝待处理问题,排空终端输入,恢复终端状态,注销事件 listener 和用户交互提供方,并且绝不会在 HMR 期间退出替换进程。 ## 颜色 -Palette 使用标准 16 色 ANSI 前景色和 SGR 属性,每个终端都会将其重新映射到当前配色方案,因此浅色与深色背景下都保持可读。正文使用终端默认前景色,而非固定色调。成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。 +TUI 发出的所有 SGR 代码都集中在一个表中,即 `components/theme.ts` 内的 `paletteSpec`;`createPalette` 从该表派生包装层,`/palette` 则打印该表,任何组件都不会自行写入转义序列。该表仅包含标准 16 色 ANSI 前景色和 SGR 属性;每个终端都会将它们重新映射到当前配色方案,因此 TUI 在浅色与深色背景下都保持可读——启动 banner 的品牌渐变是唯一一个有意保留的例外。正文使用终端默认前景色,而非固定色调。 + +每种视觉语义只对应一个角色:`dim` 是唯一的弱化色调,`accent` 是唯一的强调色,`success` 和 `error` 还分别充当 diff 的新增行与删除行。颜色和属性分属不同类型,因此 `bold(accent(x))` 可以通过编译,`accent(error(x))` 则不行——SGR 没有颜色栈;在一种颜色内嵌套另一种颜色时,内层颜色闭合时会静默丢弃外层颜色。各属性占用彼此独立的 SGR 组,可以按任一顺序与任何颜色组合。运行 `/palette` 可查看每个角色在你的终端上的实际渲染效果及其 SGR 码对。 + +成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。在工具卡片内部,整个正文——presenter 标题、终端 `$` 命令与 cwd,以及工具自身的输出——统一以同一种暗色渲染,因此只有带状态色的表头携带颜色,正文读作一个整体弱化的区块,而不是一串互相竞争的色调;注入上下文卡片的正文与其表头也是同一种色调。diff 卡片的 `+`/`-` 行与 `[signal …]` 标记保留颜色,因为那里的颜色本身就是语义,而非强调。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。 ## 模型体验 @@ -156,7 +165,7 @@ Paths prefixed with @ are files explicitly referenced by the user. Use the read ## 已知限制与延期工作 -- **恢复功能没有跨进程会话锁**:选择器会拒绝本运行时中已知处于活跃状态的会话,但另一个进程可以在 handoff 之前或期间恢复同一持久 id。能够运行并发宿主的部署必须在 TUI 外协调所有权。 +- **恢复功能没有跨进程会话锁**:选择器会拒绝本运行时中已知处于活跃状态的会话,但另一个进程可以在 handoff 之前或期间恢复同一持久 id。所有工作区作用域让这一情形一步即可触及,因为另一个宿主正在其他目录驱动的会话现在也可被选中。能够运行并发宿主的部署必须在 TUI 外协调所有权。 - **一个已配置会话持有 transcript 和编辑器**:其他 agent 的问题仍可使用共享 overlay 提供方,但会话渲染与提示词输入仍绑定到 `sessionId`。 - **工具卡片是文本终端展示**:终端、diff 与通用卡片使用工具持有的标题/内容,但会话内容目前没有用于内联图像渲染的图像块。 - **有意不支持非 TTY 运行**:需要自动化的 app bundle 必须组合单次执行或服务器入口(`dsh-cli-demo`、`dsh-acp`),而不能依赖内部回退。 diff --git a/packages/ui/tui/src/chat/resume.ts b/packages/ui/tui/src/chat/resume.ts index 2f0fdd423c..9521bd7368 100644 --- a/packages/ui/tui/src/chat/resume.ts +++ b/packages/ui/tui/src/chat/resume.ts @@ -1,26 +1,23 @@ /** * Session-resume sub-controller for the interactive chat channel: the * `/resume` selector, per-candidate summary reads that tolerate a corrupt - * neighbor, the pre-handoff preflight, the terminal handoff itself, and the - * durable resume-hint command printed on exit. + * neighbor, the pre-handoff preflight, and the terminal handoff itself. * @module @deepseek-ai/dsh-tui/chat/resume */ import type { TUI } from '@earendil-works/pi-tui' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import { errorChain } from '@deepseek-ai/dsh-llm' -import { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session' import type { SessionLogSnapshot, SessionQueryService, SessionRecord, } from '@deepseek-ai/dsh-session-query' -import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import type { HintEditor } from './helpers.ts' import { formatCwd } from './helpers.ts' import type { TuiOverlaySession } from '../extension/types.ts' import type { TuiRuntime } from '../runtime.ts' -import type { Config } from '../config.ts' import { ResumePicker, summarizeResumeCandidate, @@ -31,9 +28,7 @@ import type { ChannelNotice, ChatChannelDeps } from './channel.ts' /** Collaborators the resume controller needs from the chat channel. */ export interface ResumeControllerDeps extends ChatChannelDeps, ChannelNotice { readonly agent: Agent - readonly config: Config readonly runtime: TuiRuntime - readonly persistence: SessionPersistence | undefined readonly sessionQuery: SessionQueryService | undefined readonly ui: TUI readonly editor: HintEditor @@ -43,47 +38,27 @@ export interface ResumeControllerDeps extends ChatChannelDeps, ChannelNotice { /** Session-resume controller for one chat channel. */ export interface ResumeController { - /** Open the current-workspace searchable session selector. */ + /** Open the searchable session selector, scoped to this workspace until the user widens it. */ showResume(): void - /** - * The resume command for the current session — the configured template with - * every `{session}` filled — but only once the session is durably persisted; - * `undefined` otherwise. - */ - currentResumeCommand(): Promise } /** * Build the session-resume controller for one chat channel. * @param deps - channel collaborators, terminal handles, and optional services. - * @returns the controller wired to the `/resume` command and exit hint. + * @returns the controller wired to the `/resume` command. */ export function createResumeController(deps: ResumeControllerDeps): ResumeController { const { - ctx, agent, config, runtime, resolved, palette, overlayManager, - persistence, sessionQuery, ui, editor, + ctx, agent, runtime, resolved, palette, overlayManager, + sessionQuery, ui, editor, } = deps let resumeOverlay: TuiOverlaySession | undefined let resumeInFlight = false let resumeScan = 0 - /** - * Persisted sessions for this workspace, newest first. Empty when no - * persistence backend is mounted or a listing failure would otherwise block - * exit or crash `/resume`; the resume hint is best-effort convenience. - */ - const listWorkspaceSessions = async (): Promise => { - if (persistence === undefined) return [] - let all: readonly SessionHeader[] - try { - all = await persistence.list() - } catch { - // A listing failure must never block terminal exit or crash `/resume`. - return [] - } - return all - .filter(header => header.cwd === agent.session.header.cwd) - } + /** Label any session's own workspace the way the prompt labels the current one. */ + const workspaceLabel = (cwd: string | undefined): string => + runtime.formatCwd?.(cwd) ?? formatCwd(cwd) /** Build one display candidate without letting a corrupt neighbor abort the selector. */ const readResumeCandidate = async ( @@ -109,6 +84,7 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro agent.session.id, agent.session.header.cwd, providers, + workspaceLabel, ) } catch (error: unknown) { return { @@ -116,13 +92,18 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro title: 'Unreadable session', lastActivityAt: record.header.createdAt, lastTurn: 'log unavailable', + currentWorkspace: record.header.cwd === agent.session.header.cwd, + workspaceLabel: workspaceLabel(record.header.cwd), disabledReason: `session cannot be loaded: ${errorChain(error)}`, } } } - /** Re-read every mutable precondition immediately before terminal handoff. */ - const preflightResume = async (sessionId: SessionId): Promise => { + /** + * Re-read every mutable precondition immediately before terminal handoff and + * resolve the exact identity and workspace the host will re-exec into. + */ + const preflightResume = async (sessionId: SessionId): Promise<{ id: SessionId; cwd: string }> => { /* v8 ignore next -- only showResume can call this closure, after proving the optional service exists */ if (sessionQuery === undefined) throw new Error('Resume is unavailable: session query is not mounted.') const initialStatus = deps.agentStatus() @@ -134,9 +115,12 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro new Set(ctx.llm.listProviders().map(provider => provider.id)), ) if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason) + const cwd = candidate.record.header.cwd + /* v8 ignore next -- summarizeResumeCandidate disables a cwd-less record, so the check above already rejected it */ + if (cwd === undefined) throw new Error(`Session "${sessionId}" has no recorded workspace to resume in.`) const finalStatus = deps.agentStatus() if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`) - return candidate + return { id: candidate.record.header.id, cwd } } const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise => { @@ -147,13 +131,9 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro const checked = await preflightResume(candidate.record.header.id) const hostHandoff = runtime.handoffResume if (hostHandoff === undefined) { - const template = config.resumeCommand - const fallback = template?.replaceAll('{session}', checked.record.header.id) await overlay.close() resumeOverlay = undefined - deps.appendNotice(fallback === undefined - ? 'Session is resumable, but this host cannot hand it off in place.' - : `This host cannot hand off in place. Exit and run: ${fallback}`, 'warning') + deps.appendNotice('Session is resumable, but this host cannot hand it off in place.', 'warning') return } /* v8 ignore next -- shutdown during preflight invalidates an awaited service read or reaches this guard */ @@ -169,7 +149,10 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro if (deps.isDisposed()) return ui.stop() terminalReleased = true - await hostHandoff(checked.record.header.id) + // The host re-execs into the session's own workspace: process cwd, not the + // restored session header, is what the filesystem and shell tools resolve + // against. + await hostHandoff(checked.id, checked.cwd) throw new Error('resume host returned without replacing the process') } catch (error: unknown) { if (!deps.isDisposed()) { @@ -189,12 +172,6 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro } return { - currentResumeCommand: async (): Promise => { - if (config.resumeCommand === undefined) return undefined - const sessions = await listWorkspaceSessions() - if (!sessions.some(header => header.id === agent.session.id)) return undefined - return config.resumeCommand.replaceAll('{session}', agent.session.id) - }, showResume(): void { if (agent.status !== 'idle') { deps.appendNotice('Resume requires the current turn to finish or be cancelled first.', 'warning') @@ -208,9 +185,10 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro void resumeOverlay?.close() void sessionQuery.listSessions().then(async (records) => { if (deps.isDisposed() || scan !== resumeScan) return - const workspace = records.filter(record => record.header.cwd === agent.session.header.cwd) + // Every workspace in the store is summarized; the picker owns the + // current-workspace/all-workspaces scope split over the whole set. const providers = new Set(ctx.llm.listProviders().map(provider => provider.id)) - const candidates = await Promise.all(workspace.map(record => readResumeCandidate(record, providers))) + const candidates = await Promise.all(records.map(record => readResumeCandidate(record, providers))) candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt || a.record.header.id.localeCompare(b.record.header.id)) if (deps.isDisposed() || scan !== resumeScan) return @@ -218,7 +196,7 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro create: host => new ResumePicker( candidates, resolved.maxResumeOptions, - runtime.formatCwd?.(agent.session.header.cwd) ?? formatCwd(agent.session.header.cwd), + workspaceLabel(agent.session.header.cwd), () => host.viewport.rows, palette, (candidate) => { void handoffResume(candidate, session) }, diff --git a/packages/ui/tui/src/chat/timing.ts b/packages/ui/tui/src/chat/timing.ts index 13477adfa0..0aff3bc736 100644 --- a/packages/ui/tui/src/chat/timing.ts +++ b/packages/ui/tui/src/chat/timing.ts @@ -290,7 +290,7 @@ export function fadeGlyph( return `\x1b[38;2;${r};${g};${b}m${glyph}\x1b[39m` } if (!visible) return ' ' - return colorEnabled ? palette.muted(glyph) : glyph + return colorEnabled ? palette.dim(glyph) : glyph } /** diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index 370be088b1..5e9237574a 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -205,7 +205,7 @@ export class StatusCardComponent implements Component { 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 prefix = ` ${this.palette.dim(plainLabel.padEnd(labelWidth))} ` const continuation = ' '.repeat(1 + labelWidth + 2) const valueWidth = Math.max(1, innerWidth - visibleWidth(prefix)) const wrapped = wrapTextWithAnsi(value, valueWidth) @@ -281,9 +281,10 @@ export function renderDialog( return lines } -/** Keyboard model selector rendered as a bordered overlay, with per-model reasoning-effort cycling. */ +/** Keyboard model selector rendered as a bordered overlay, with a filter box and per-model reasoning-effort cycling. */ export class ModelDialog implements Component { - private readonly list: SelectList + private list: SelectList + private readonly filter = new Input() private readonly items: Map private readonly choices: Map private readonly efforts: Map @@ -292,10 +293,10 @@ export class ModelDialog implements Component { constructor( choices: readonly ModelChoice[], current: AgentLlmTarget | undefined, - maxVisible: number, + private readonly maxVisible: number, private readonly palette: Palette, - done: (selection: ModelDialogSelection) => void, - cancel: () => void, + private readonly done: (selection: ModelDialogSelection) => void, + private readonly cancel: () => void, ) { this.items = new Map() this.choices = new Map() @@ -317,18 +318,38 @@ export class ModelDialog implements Component { 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 + this.list = this.buildList(this.currentValue) + } + + /** Build a SelectList over the currently filtered items, selecting `selectValue` when present. */ + private buildList(selectValue: string | undefined): SelectList { + const items = this.filteredItems() + const list = new SelectList(items, this.maxVisible, dialogSelectTheme(this.palette)) + const index = selectValue === undefined ? 0 : items.findIndex(item => item.value === selectValue) + list.setSelectedIndex(Math.max(0, index)) + list.onSelect = (item) => { this.confirm(item) } + list.onCancel = this.cancel + return list + } + + /** Items matching the filter box, as a case-insensitive substring over the label, model name, and description. */ + private filteredItems(): SelectItem[] { + const query = this.filter.getValue().trim().toLocaleLowerCase() + if (query === '') return [...this.items.values()] + return [...this.items.values()].filter((item) => { + const choice = this.choices.get(item.value) + /* v8 ignore next -- items and choices share the same keys. */ + if (choice === undefined) return false + return [item.value, choice.modelName, choice.description ?? ''] + .some(field => field.toLocaleLowerCase().includes(query)) + }) + } + + private confirm(item: SelectItem): void { + const selected = this.choices.get(item.value) + /* v8 ignore next -- SelectList only returns values built from `choices`. */ + if (selected === undefined) return + this.done({ choice: selected, reasoningEffort: this.efforts.get(item.value) }) } private describeChoice(choice: ModelChoice, isCurrent: boolean): string { @@ -362,24 +383,50 @@ export class ModelDialog implements Component { } invalidate(): void { + this.filter.invalidate() this.list.invalidate() } handleInput(data: string): void { if (matchesKey(data, Key.shift(Key.tab))) { this.cycleReasoningEffort() - } else { + } else if (matchesKey(data, Key.escape)) { + if (this.filter.getValue() === '') this.cancel() + else { + this.filter.setValue('') + this.list = this.buildList(undefined) + } + } else if ( + matchesKey(data, Key.up) + || matchesKey(data, Key.down) + || matchesKey(data, Key.enter) + ) { this.list.handleInput(data) + } else { + const previous = this.filter.getValue() + this.filter.focused = true + this.filter.handleInput(data) + if (this.filter.getValue() !== previous) { + const selected = this.list.getSelectedItem() + this.list = this.buildList(selected?.value) + } } this.invalidate() } render(width: number): string[] { const innerWidth = Math.max(1, width - 4) + this.filter.focused = true + const results = this.filteredItems() + const filterContent = truncateToWidth(this.filter.render(innerWidth).join(''), innerWidth, '') return renderDialog('Select model', [ - ...this.list.render(innerWidth), + filterContent, '', - this.palette.dim('↑/↓ navigate • Shift+Tab reasoning • Enter select • Esc cancel'), + ...results.length === 0 + ? [this.palette.dim(' No models match the filter')] + : this.list.render(innerWidth), + '', + this.palette.dim('type to filter • ↑/↓ move • Shift+Tab reasoning • Enter select • Esc'), ], width, this.palette) } } @@ -396,6 +443,10 @@ export interface ResumeCandidate { title: string lastActivityAt: number lastTurn: string + /** Whether the session's workspace is the one the current session runs in, which selects the picker scope that lists it. */ + currentWorkspace: boolean + /** The session's own workspace as a prompt-style label; the all-workspaces scope shows it per row. */ + workspaceLabel: string route?: ResumeRoute goalPhase?: GoalPhase disabledReason?: string @@ -429,12 +480,15 @@ function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | 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. + * title, route, goal phase, workspace scope, and any reason the session cannot + * be resumed here. A workspace other than the current one is a scope, not a + * disabled reason: resuming it hands the process off into that directory. * @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 cwd - The CURRENT session's workspace, which decides the picker scope this row falls in. * @param availableProviders - Providers registered in this runtime. + * @param formatWorkspace - Renders THIS record's own cwd as its prompt-style label. * @returns The summarized resume candidate. */ export function summarizeResumeCandidate( @@ -443,6 +497,7 @@ export function summarizeResumeCandidate( currentId: SessionId, cwd: string | undefined, availableProviders: ReadonlySet, + formatWorkspace: (cwd: string | undefined) => string, ): ResumeCandidate { const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session' const route = resumeRoute(snapshot) @@ -450,7 +505,7 @@ export function summarizeResumeCandidate( 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 (record.header.cwd === undefined) disabledReason = 'session has no recorded workspace' else if (route !== undefined && !availableProviders.has(route.provider)) { disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})` } @@ -459,6 +514,8 @@ export function summarizeResumeCandidate( title, lastActivityAt: snapshot.events.at(-1)?.time ?? snapshot.session.createdAt, lastTurn: resumeTurnLabel(snapshot), + currentWorkspace: record.header.cwd === cwd, + workspaceLabel: formatWorkspace(record.header.cwd), ...route === undefined ? {} : { route }, /* v8 ignore next -- goal-bearing resume records are covered by the goal/session integration surface. */ ...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase }, @@ -466,12 +523,23 @@ export function summarizeResumeCandidate( } } -/** Full-viewport keyboard selector over detached, preflighted resume summaries. */ +/** Which workspaces the resume picker currently lists. */ +export type ResumeScope = 'workspace' | 'all' + +/** + * Full-viewport keyboard selector over detached, preflighted resume summaries. + * + * Two scopes over one candidate set: `workspace` (the default) lists only the + * current session's workspace, `all` lists every workspace and labels each row + * with its own. Tab toggles between them; the search query and selection reset + * on a scope change so the highlighted row always belongs to the visible list. + */ export class ResumePicker implements Component, Focusable { private readonly search = new Input() private pasteBuffer: string | undefined private selectedIndex = 0 private error = '' + private scope: ResumeScope = 'workspace' focused = false constructor( @@ -488,15 +556,29 @@ export class ResumePicker implements Component, Focusable { this.search.invalidate() } + /** Candidates in the active scope, before the search query narrows them. */ + private scoped(): ResumeCandidate[] { + return this.scope === 'all' + ? [...this.candidates] + : this.candidates.filter(candidate => candidate.currentWorkspace) + } + 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)) + const scoped = this.scoped() + if (query === '') return scoped + // The workspace label only distinguishes rows once it is on screen, so it + // joins the searchable text exactly in the scope that shows it. + return scoped.filter(candidate => candidate.title.toLocaleLowerCase().includes(query) + || candidate.record.header.id.toLocaleLowerCase().includes(query) + || (this.scope === 'all' && candidate.workspaceLabel.toLocaleLowerCase().includes(query))) } private visibleCandidateCount(): number { - const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / 4)) + // The all-workspaces scope adds a per-row workspace line, so a row costs + // one more terminal row there than in the single-workspace scope. + const rowHeight = this.scope === 'all' ? 5 : 4 + const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / rowHeight)) return Math.min(this.maxVisible, candidateBudget) } @@ -553,6 +635,11 @@ export class ResumePicker implements Component, Focusable { Math.max(0, filtered.length - 1), this.selectedIndex + this.visibleCandidateCount(), ) + } else if (matchesKey(data, Key.tab)) { + this.scope = this.scope === 'workspace' ? 'all' : 'workspace' + this.search.setValue('') + this.selectedIndex = 0 + this.error = '' } else if (matchesKey(data, Key.enter)) { const selected = filtered[this.selectedIndex] if (selected === undefined) this.error = 'No session matches this search.' @@ -570,6 +657,21 @@ export class ResumePicker implements Component, Focusable { this.invalidate() } + /** + * The scope line under the search box: the active scope with the current + * workspace it means, and the inactive scope with the count Tab would reveal. + */ + private renderScopeLine(): string { + const inWorkspace = this.candidates.filter(candidate => candidate.currentWorkspace).length + const active = this.scope === 'workspace' + ? `this workspace ${displayText(this.workspaceLabel)}` + : `all workspaces (${this.candidates.length})` + const other = this.scope === 'workspace' + ? `all workspaces (${this.candidates.length})` + : `this workspace (${inWorkspace})` + return `${this.palette.accent(active)}${this.palette.dim(` ⇥ ${other}`)}` + } + render(width: number): string[] { this.search.focused = this.focused const height = Math.max(1, this.viewportRows()) @@ -594,7 +696,7 @@ export class ResumePicker implements Component, Focusable { `${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))}`, + `${indent}${this.renderScopeLine()}`, '', ) @@ -620,8 +722,13 @@ export class ResumePicker implements Component, Focusable { 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(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`)) push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`)) + // Only the all-workspaces scope mixes directories, so the per-row + // workspace is redundant in the scope that already names one. + if (this.scope === 'all') { + push(this.palette.dim(` workspace ${displayText(candidate.workspaceLabel)}`)) + } if (candidate.disabledReason !== undefined) { push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`)) } @@ -632,7 +739,7 @@ export class ResumePicker implements Component, Focusable { push(this.palette.error(displayText(this.error))) } - const footer = `${indent}${this.palette.dim('Type to search • ↑/↓ navigate • Enter resume • Esc clear/cancel')}` + const footer = `${indent}${this.palette.dim('Type to search • ↑/↓ navigate • Tab scope • Enter resume • Esc clear/cancel')}` while (lines.length < height - 2) lines.push('') lines.push(footer, '') return lines.slice(0, height) @@ -720,7 +827,7 @@ export class QuestionDialog implements Component, Focusable { 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), + this.palette.dim(header), ...wrapTextWithAnsi(this.palette.text(displayText(this.question.question)), innerWidth), ] const push = (line: string): void => { lines.push(line) } @@ -764,7 +871,7 @@ export class QuestionDialog implements Component, Focusable { : left const description = option.description === undefined ? '' - : `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.muted(displayText(option.description))}` + : `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.dim(displayText(option.description))}` push(`${leftStyled}${description}`) } if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`)) diff --git a/packages/ui/tui/src/components/theme.ts b/packages/ui/tui/src/components/theme.ts index 1ea75b437f..269a7c28e8 100644 --- a/packages/ui/tui/src/components/theme.ts +++ b/packages/ui/tui/src/components/theme.ts @@ -11,67 +11,149 @@ import type { TerminalColorScheme, } from '@earendil-works/pi-tui' -/** Theme-agnostic role colors and SGR attribute wrappers. */ +/** + * Text carrying exactly one palette color. Branded so the compiler rejects + * wrapping it in a second color: SGR has no color stack, so an inner span's + * close reverts to the default foreground rather than the outer color, which + * silently drops the outer color for the remainder of the line. + */ +export type Colored = string & { readonly __coloredBy: unique symbol } + +/** + * Text a color may still be applied to: a bare string, or one already carrying + * SGR attributes. Attributes (bold, italic, underline, strike, reverse) occupy + * independent SGR groups from the foreground color, so they compose in either + * order without either side clobbering the other. + */ +export type Colorable = string & { readonly __coloredBy?: undefined } + +/** Applies one color role; rejects input that already carries a color. */ +export type ColorRole = (text: Colorable) => Colored + +/** Applies one SGR attribute; accepts colored or uncolored text and preserves its color. */ +export type AttributeRole = (text: T) => T + +/** + * Theme-agnostic role colors and SGR attribute wrappers. + * + * One role per visual meaning: `dim` is the single recessed tone, `accent` the + * single emphasis color, and `success`/`error` double as a diff's added/removed + * pair. Roles that resolved to the same escape were merged rather than kept as + * aliases, so a reader cannot pick a name that silently renders as another. + * + * Colors and attributes are separately typed: `bold(accent(x))` and + * `accent(bold(x))` both compile, while `accent(error(x))` does not. + */ 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 + accent: ColorRole + /** The terminal's own default foreground; still a color, so it does not stack. */ + text: ColorRole + /** The one recessed tone, below `text`: tool-card bodies, chrome, reasoning, footers. */ + dim: ColorRole + success: ColorRole + warning: ColorRole + error: ColorRole + code: ColorRole + bold: AttributeRole + italic: AttributeRole + underline: AttributeRole + strike: AttributeRole /** Reverse video for the active selection; swaps the theme's own fg/bg so it reads on any scheme. */ - selected: (text: string) => string + selected: AttributeRole } -function ansi(open: string, close: string, enabled: boolean): (text: string) => string { - return enabled ? text => `\x1b[${open}m${text}\x1b[${close}m` : text => text +/** Names of the palette's color roles, in the order `/palette` prints them. */ +export const COLOR_ROLES = ['text', 'dim', 'accent', 'code', 'success', 'warning', 'error'] as const + +/** Names of the palette's attribute roles, in the order `/palette` prints them. */ +export const ATTRIBUTE_ROLES = ['bold', 'italic', 'underline', 'strike', 'selected'] as const + +/** One role's SGR parameters and the reason it carries them. */ +export interface RoleSpec { + /** SGR parameters that open the span, without the `ESC [` prefix or `m` suffix. */ + readonly open: string + /** SGR parameters that close it; MUST reset every group `open` sets. */ + readonly close: string + /** What the role means, shown by `/palette`. */ + readonly purpose: string } /** - * 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. + * Every SGR code the TUI is allowed to emit, keyed by role. This table is the + * single source: {@link createPalette} derives the wrappers from it and + * `/palette` prints it, so a role cannot exist in one and not the other, and no + * component hand-writes an escape. + * + * Only the standard 16-color set and SGR attributes appear here. Terminals remap + * those to the user's active theme, so the TUI stays legible on any background; + * a fixed 24-bit color would not. The brand gradient is the one deliberate + * exception ({@link gradientText}). + * + * @param scheme - Active terminal color scheme; only `code` differs between them. + * @returns The SGR spec for every color and attribute role. + */ +export function paletteSpec(scheme: TerminalColorScheme): { + readonly colors: Readonly> + readonly attributes: Readonly> +} { + return { + colors: { + // The terminal's own foreground, emitted as no escape at all: ordinary body + // text must inherit whatever the user's theme uses. + text: { open: '', close: '', purpose: 'Body text, the terminal default foreground' }, + // SGR 2 over an explicit default foreground, closing both groups it sets. + // The attribute fades relative to whatever the terminal's own foreground is, + // which is the only way to land *below* `text` on both schemes: ANSI 90 + // (bright black) is a fixed hue that many light themes render heavier than + // their default foreground, which made every "dim" surface the most + // prominent text on screen. + dim: { open: '2;39', close: '22;39', purpose: 'The one recessed tone: tool bodies, chrome, footers' }, + accent: { open: '95', close: '39', purpose: 'The one emphasis color: role headers, prompt, borders' }, + // 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' + ? { open: '34', close: '39', purpose: 'Inline code and code blocks in prose' } + : { open: '36', close: '39', purpose: 'Inline code and code blocks in prose' }, + success: { open: '32', close: '39', purpose: 'Succeeded calls, and a diff\'s added lines' }, + warning: { open: '33', close: '39', purpose: 'Pending calls and warnings' }, + error: { open: '31', close: '39', purpose: 'Failures, signals, and a diff\'s removed lines' }, + }, + attributes: { + bold: { open: '1', close: '22', purpose: 'Emphasis; composes with any color' }, + italic: { open: '3', close: '23', purpose: 'Reasoning text' }, + underline: { open: '4', close: '24', purpose: 'Role-header banding' }, + strike: { open: '9', close: '29', purpose: 'Struck-through Markdown' }, + selected: { open: '7', close: '27', purpose: 'Reverse video for the active selection' }, + }, + } +} + +/** + * Wrap text in an SGR pair, or pass it through when color is disabled. + * An empty `open` emits nothing, so the `text` role costs no escape. + */ +function ansi(spec: RoleSpec, enabled: boolean): (text: string) => string { + if (!enabled || spec.open === '') return text => text + return text => `\x1b[${spec.open}m${text}\x1b[${spec.close}m` +} + +/** + * Theme-agnostic palette derived from {@link paletteSpec}. 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. + * @param scheme - Active terminal color scheme; adjusts the code role. * @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), - } + const spec = paletteSpec(scheme) + const roles = {} as Record + for (const name of COLOR_ROLES) roles[name] = ansi(spec.colors[name], enabled) + for (const name of ATTRIBUTE_ROLES) roles[name] = ansi(spec.attributes[name], enabled) + return roles as unknown as Palette } /** @@ -145,8 +227,8 @@ export function markdownTheme(palette: Palette): MarkdownTheme { // 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), + quote: text => palette.dim(text), + quoteBorder: text => palette.accent(text), hr: text => palette.dim(text), listBullet: text => palette.accent(text), bold: text => palette.bold(text), @@ -165,7 +247,7 @@ export function selectTheme(palette: Palette): SelectListTheme { return { selectedPrefix: palette.accent, selectedText: palette.accent, - description: palette.muted, + description: palette.dim, scrollInfo: palette.dim, noMatch: palette.warning, } @@ -182,3 +264,49 @@ export function dialogSelectTheme(palette: Palette): SelectListTheme { selectedText: text => palette.selected(palette.accent(text)), } } + +/** Sample text every `/palette` row renders, long enough to judge a tone against its neighbours. */ +const PALETTE_SAMPLE = 'The quick brown fox 0123' + +/** + * Render every palette role as a labelled sample row, each painted by the role + * it names, so a reader compares the actual tones their terminal produces rather + * than reading SGR numbers. Colors print first and attributes second because the + * two groups compose in that order; every row shows its SGR pair so a mismatch + * between the table and the screen is visible. + * + * @param palette - Active role palette, used to paint each sample. + * @param scheme - Active color scheme, reported in the heading and selecting the spec. + * @param colorEnabled - Whether ANSI is emitted; reported so an unstyled listing is not confusing. + * @returns The rendered rows, without a trailing blank. + */ +export function renderPalette( + palette: Palette, + scheme: TerminalColorScheme, + colorEnabled: boolean, +): string[] { + const spec = paletteSpec(scheme) + const width = Math.max(...[...COLOR_ROLES, ...ATTRIBUTE_ROLES].map(name => name.length)) + // Two rows per role: the painted sample beside its name and SGR pair, then the + // purpose indented under it. Splitting the purpose onto its own row keeps every + // sample on one visual line at the narrow widths a side-by-side pane gives. + const head = (name: string, role: RoleSpec, sample: string): string => { + const pair = role.open === '' ? 'no escape' : `ESC[${role.open}m ESC[${role.close}m` + return ` ${sample} ${palette.dim(`${name.padEnd(width)} ${pair}`)}` + } + const purpose = (role: RoleSpec): string => ` ${palette.dim(` ${role.purpose}`)}` + const rows = [ + palette.bold(palette.accent('Palette')), + palette.dim(`${scheme} scheme · color ${colorEnabled ? 'on' : 'off'}`), + '', + palette.dim('Colors — exactly one per span; they never nest inside each other.'), + ] + for (const name of COLOR_ROLES) { + rows.push(head(name, spec.colors[name], palette[name](PALETTE_SAMPLE)), purpose(spec.colors[name])) + } + rows.push('', palette.dim('Attributes — compose with any color, in either order.')) + for (const name of ATTRIBUTE_ROLES) { + rows.push(head(name, spec.attributes[name], palette[name](PALETTE_SAMPLE)), purpose(spec.attributes[name])) + } + return rows +} diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts index 1e79d94ce8..58d3d6a178 100644 --- a/packages/ui/tui/src/components/transcript.ts +++ b/packages/ui/tui/src/components/transcript.ts @@ -25,7 +25,7 @@ import type { ToolResultView, } from '@deepseek-ai/dsh-tools' import type { FileDiff } from '@deepseek-ai/dsh-tools' -import { renderUnknownXml } from './xml-tool-output.ts' +import { preview, 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' @@ -58,9 +58,9 @@ function diffLines(diff: FileDiff, palette: Palette): string[] { // 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.oldText).split('\n')) lines.push(palette.error(`- ${line}`)) } - for (const line of displayText(diff.newText).split('\n')) lines.push(palette.added(`+ ${line}`)) + for (const line of displayText(diff.newText).split('\n')) lines.push(palette.success(`+ ${line}`)) return lines } @@ -109,7 +109,7 @@ export class HeaderComponent implements Component { const subtitle = this.subtitle() const lines = [ title, - ...subtitle === undefined ? [] : [this.palette.muted(displayText(subtitle))], + ...subtitle === undefined ? [] : [this.palette.dim(displayText(subtitle))], this.palette.dim(detail), ] .flatMap(line => wrapTextWithAnsi(line, usable)) @@ -147,12 +147,12 @@ function assistantMessageChildren( const text = displayText(textBlocks(content, 'text').trim()) const children: Component[] = [ new Spacer(1), - new Text(messageHeader('Assistant', palette.accent2, palette), 0, 0), + new Text(messageHeader('Assistant', palette.accent, 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 }), + new Text(palette.italic(palette.dim('Reasoning')), 0, 0), + new Markdown(reasoning, 0, 0, mdTheme, { color: value => palette.dim(value), italic: true }), ) } if (text) children.push(new Markdown(text, 0, 0, mdTheme, { color: value => palette.text(value) })) @@ -301,10 +301,27 @@ export class StreamingAssistantComponent extends Container { } } +/** + * A tool card's body split at the Markdown boundary. `prelude` rows are already + * styled and render verbatim (a terminal `$` command, its cwd, a diff's hunks); + * `lines` is the tool's own text. A generic card renders both as one Markdown + * document under the dim body tone. + */ +interface CardBody { + readonly prelude: readonly string[] + readonly lines: readonly string[] +} + +/** + * Ctrl+O card-visibility cycle: `hidden` drops tool cards from the transcript, + * `collapsed` previews the first body lines, `expanded` shows everything. + */ +export type ToolCardVisibility = 'hidden' | 'collapsed' | 'expanded' + /** 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 visibility: ToolCardVisibility = 'collapsed' private callView: ToolCallView private resultView: ToolResultView | undefined @@ -353,16 +370,19 @@ export class ToolCardComponent implements Component { } /** - * Expand or collapse the card's body preview. - * @param expanded - Whether the full body is shown. + * Set the card's visibility state. + * @param visibility - Hidden, collapsed preview, or full body. */ - setExpanded(expanded: boolean): void { - this.expanded = expanded + setVisibility(visibility: ToolCardVisibility): void { + this.visibility = visibility } invalidate(): void {} render(width: number): string[] { + // Hidden renders nothing — not even the leading gap — so the transcript + // keeps only the conversation, the way Codex hides tool calls. + if (this.visibility === 'hidden') return [] 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. @@ -374,25 +394,23 @@ export class ToolCardComponent implements Component { ? renderUnknownXml( displayText(contentText(genericContent)), this.maxOutputLines, - this.expanded, + this.visibility === 'expanded', displayText, - text => this.palette.muted(text), + text => this.palette.dim(text), + text => this.palette.dim(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 + // A generic card renders title and result as one Markdown document, so the + // document's own block spacing is preserved, then dims every row — the whole + // card body reads as one dim block under the status-colored header. + const body = unknownXml ?? (genericContent !== undefined && rawBody.lines.length > 0 + ? this.dimBody(rawBody, width) + : [...rawBody.prelude, ...rawBody.lines]) + const visibleBody = unknownXml !== undefined || this.visibility === 'expanded' ? body - : [ - ...body.slice(0, headLines), - this.palette.dim(`… +${body.length - this.maxOutputLines} lines (Ctrl+O to expand)`), - ...body.slice(body.length - tailLines), - ] + : preview(body, this.maxOutputLines, count => this.palette.dim(`… +${count} lines (Ctrl+O to expand)`)) // 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 @@ -409,7 +427,9 @@ export class ToolCardComponent implements Component { 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)] + // The blank first row is the card's own paragraph gap (no external Spacer), + // so the hidden state removes the gap together with the card. + const lines: string[] = ['', statusColor(header)] if (visibleBody.length > 0) lines.push(...new Text(visibleBody.join('\n'), 0, 0).render(width)) return lines } @@ -438,10 +458,11 @@ export class ToolCardComponent implements Component { return this.resultView?.title ?? this.callView.title } - private renderBody(): string[] { + private renderBody(): CardBody { const view = this.resultView ?? this.callView if (view.card === 'terminal') { const pending = this.terminalPending() + const prelude: string[] = [] 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 @@ -452,18 +473,18 @@ export class ToolCardComponent implements Component { // 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 (commandInBody) prelude.push(this.palette.dim(`$ ${displayInlineText(pending.title)}`)) + if (pending?.cwd) prelude.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.output) lines.push(...this.dimOutput(this.resultView.output)) 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')) + lines.push(...this.dimOutput(contentText(this.result.content))) } - return lines.filter(Boolean) + return { prelude: prelude.filter(Boolean), lines: lines.filter(Boolean) } } if (view.card === 'diff') { // The header no longer names the file, so each diff keeps its own path @@ -477,22 +498,138 @@ export class ToolCardComponent implements Component { }) const files = view.diffs.length const footer = this.palette.dim(`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}`) - return [...hunks, footer] + // A diff's own `+`/`-` colors carry its meaning, so it renders verbatim + // rather than under the dim result-output color. + return { prelude: [...hunks, footer], lines: [] } } const content = view.content ?? this.result?.content + const prelude: string[] = [] 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 (bodyTitle !== displayText(this.name)) prelude.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)) + // Blank-line trimming spans the whole body, so the title counts as a row: + // interior blanks (a result's own paragraph break) survive while the body's + // leading and trailing ones are dropped. + const total = prelude.length + lines.length + return { + prelude, + lines: lines.filter((line, index) => { + const row = prelude.length + index + return line.length > 0 || (row > 0 && row < total - 1) + }), + } + } + + /** + * A tool's own output text as dim rows — the card's result-output color, which + * separates what the tool produced from the card's own framing. A blank row + * stays the empty string so the terminal branch's blank-row filter still reads + * it as blank instead of as an ANSI-wrapped value. + */ + private dimOutput(text: string): string[] { + return displayText(text).split('\n').map(line => line === '' ? line : this.palette.dim(line)) + } + + /** + * Render a generic card's prelude and result as one Markdown document under the + * dim body tone. Rendering both together preserves the document's own block + * spacing (Markdown's blank row before a heading); dimming every row keeps the + * card body one uniform tone, so only the status-colored header carries color. + */ + private dimBody(body: CardBody, width: number): string[] { + const rows = new Markdown([...body.prelude, ...body.lines].join('\n'), 0, 0, this.mdTheme, { + color: value => this.palette.text(value), + }).render(width) + // A whitespace-only row carries no output to dim; leaving it unwrapped keeps + // Markdown's padding out of the styled ranges. + return rows.map(row => row.trim() === '' ? row : this.palette.dim(row)) + } +} + +/** + * Matches a lone reminder-frame tag on its own line, capturing the element name. + * Producers emit the frame as whole lines (`workspace-context`, `dsh-tool-skill`), + * so anchoring the whole line keeps a tag mentioned inside prose from matching. + */ +const REMINDER_FRAME_LINE = /^<(\/?)([a-zA-Z][\w:.-]*)>$/u + +/** + * Drop a producer's outer reminder frame, keeping the instruction body verbatim. + * The card header already names the source, so the frame lines carry nothing. + * Only a matched open/close pair on the first and last lines is removed, so a + * body that merely starts with a tag-like line is left intact. + * @param text - Complete model-facing context text. + * @returns The body without its outer frame lines, trimmed of the blank lines they leave. + */ +function stripReminderFrame(text: string): string { + // A frame needs an open line and a distinct close line, so anything shorter than + // two lines is already frameless. + const [first = '', ...rest] = text.split('\n') + const last = rest.at(-1) + if (last === undefined) return text + const open = REMINDER_FRAME_LINE.exec(first.trim()) + const close = REMINDER_FRAME_LINE.exec(last.trim()) + if (open?.[1] !== '' || close?.[1] !== '/' || open[2] !== close[2]) return text + return rest.slice(0, -1).join('\n').replace(/^\n+|\n+$/gu, '') +} + +/** + * Injected context (plugin/goal source, e.g. `workspace-context`), rendered as a + * collapsible dim card that shares the tool-card `Ctrl+O` toggle. The header is + * `Context ·