Merge remote-tracking branch 'origin/master' into codex/pr-1037-resolution
# Conflicts: # packages/ui/tui/README.i18n.yaml
This commit is contained in:
@@ -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: 837e072ec63752d5f0f1b93bcff16871d32ad615
|
||||
README.zh.md: 15f3ad49f2d5b7cdd438532d7443b066a1eac87d
|
||||
README.md: 60e42a64305931a6bf93a470f851f662be33e2bb
|
||||
README.zh.md: fc12b75a1e320103a1d4cc70cc00adfeb639a3d9
|
||||
|
||||
@@ -10,7 +10,7 @@ Interactive terminals on macOS, Linux, and Windows are supported. Windows uses p
|
||||
|
||||
This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `llm`, `systemPrompt`, `tokenMeter`, `tools`, and `userInteraction`, optionally reads a `skills` service (present only when one is mounted), then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
|
||||
|
||||
After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme, display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives.
|
||||
After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme (including terminal-safe DeepSeek `brand` treatment), display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives.
|
||||
|
||||
The TUI rebuilds resumed history from the append-origin session events, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. A surface replacement never rewrites the rendered transcript: the conversation it shadows stays readable, and a landed compaction checkpoint adds one dim `… earlier context was compacted …` marker at its log position, so the terminal reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies — a pruned tool result, a regenerated assistant message — render nothing.
|
||||
|
||||
@@ -22,7 +22,7 @@ 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:<payload>)`, 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`, `/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.
|
||||
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`, `/details`, `/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. During a live standalone compaction bracket, a fixed `Context being compacted <elapsed>` row appears above the prompt, the idle prompt caret becomes a one-cell throbbing `⊙`, and terminal progress stays active until close; the row and glyph share the bracket's one refresh timer. This live state is never reconstructed from the log; a failed close adds `Compaction failed: <error>` to the transcript, while a resumed orphaned start never activates the indicator ([decision](../../../.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md)). 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. The hidden phase also folds each turn's assistant steps into one message: the first step with visible text or reasoning keeps the turn's single `Assistant` header, later steps render as headerless continuations, and a step without a visible body renders nothing; leaving the hidden phase restores the per-step headers. 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. `/details` names the same state those two shortcuts cycle: bare it opens a centered keyboard toggle with one entry per dimension — `Tool cards` and `Reasoning` — showing the live values, where Tab cycles the highlighted entry and applies the change immediately (the transcript behind the dialog is the preview), and Enter, Esc, or Ctrl+C closes; `/details collapsed|expanded|hidden` jumps tool cards to that phase directly, and `/details reasoning [on|off]` sets — or bare `reasoning` toggles — reasoning-block display; arguments combine in one invocation, an unknown argument fails with the usage line, and a combined invocation applies reasoning first so its transcript rebuild never drops the card notice.
|
||||
|
||||
`/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 `Default`, which clears an explicit selection and preserves the provider default; 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 <model>` still selects an unambiguous model id directly, while `/model <provider>/<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.
|
||||
|
||||
@@ -58,6 +58,7 @@ A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY
|
||||
| `questionDialogMaxHeight` | `20` | Question-panel maximum rows |
|
||||
| `modelDialogWidth` | `76` | Model-selector width in columns |
|
||||
| `modelDialogMaxHeight` | `20` | Model-selector maximum rows |
|
||||
| `detailsDialogWidth` | `72` | Transcript-details selector width in columns |
|
||||
| `fileSearchMaxResults` | `20` | Maximum file and directory candidates shown for one `@` query |
|
||||
| `fileSearchMaxEntries` | `10000` | Maximum paths retained in the bounded workspace index used by bare fuzzy queries |
|
||||
| `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | Directory basenames omitted from traversal and direct completion |
|
||||
@@ -81,9 +82,9 @@ Startup fails before mounting when either process stream is not a TTY. The compo
|
||||
|
||||
## Color
|
||||
|
||||
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.
|
||||
Every general-purpose 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 gradient and the official mark's exact `#4D6BFE` ink are the two deliberate truecolor brand exceptions. 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.
|
||||
There is one role per visual meaning: `dim` is the single recessed tone, `accent` the single interaction emphasis, and `brand` the DeepSeek mark's standard-ANSI fallback, 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 with both sides available colors and counts exact added `+` and removed `-` lines, while unchanged context stays dim and uncounted. If exact comparison exceeds `maxDiffEditLength`, the card renders each old-side row as removed and each new-side row as added, marks the footer approximate, and caches that fallback for later redraws. When `oldText` is unavailable, including pending writes and replay fallbacks as well as creates, every non-empty new-side row is shown and counted as added; that count does not prove the rows were absent from an existing file. Empty new content produces no synthetic `+ ` row. A `[signal …]` marker remains 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.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ DeepSeek Harness agent(智能体)的交互式终端入口,基于 [`@earend
|
||||
|
||||
本包(package)只持有交互式终端展示和输入。它注入 `agents`、[`commands`](../commands/README.md)、`llm`、`systemPrompt`、`tokenMeter`、`tools` 和 `userInteraction`,可选读取 `skills` 服务(仅在已挂载时存在),然后驱动由 app 或开发者代码创建或恢复的 agent。Agent 生命周期、持久化与模型侧 [`ask_user_question`](../tool-ask-user/README.md) 工具仍是独立组合项。
|
||||
|
||||
终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题、显示文本转义、重绘、关闭和生命周期信号,但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect,因此卸载会移除排队工作,或在清理结算前关闭可见工作;终端关闭会先卸载依赖项,再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。
|
||||
终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题(包括终端安全的 DeepSeek `brand` 样式)、显示文本转义、重绘、关闭和生命周期信号,但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect,因此卸载会移除排队工作,或在清理结算前关闭可见工作;终端关闭会先卸载依赖项,再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。
|
||||
|
||||
TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 `<session title> — <configured title>`。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换从不重写已渲染的 transcript:被它遮蔽的对话仍可阅读,而已落地的压缩(compaction)检查点会在其日志位置添加一行暗色 `… earlier context was compacted …` 标记,因此终端报告的是模型从何处起不再看到那段历史,而不是把它抹掉。仅供模型使用的替换副本——被裁剪的工具结果、重新生成的 assistant 消息——不渲染任何内容。
|
||||
|
||||
@@ -22,7 +22,7 @@ TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应
|
||||
|
||||
挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:<payload>)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。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`、`/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 在空闲时退出。
|
||||
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`、`/details`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。在实时独立压缩(compaction)标记对处于开启状态期间,提示词上方会显示固定的 `Context being compacted <elapsed>` 状态行,空闲提示符光标会变成占一个终端字符单元并呈呼吸律动的 `⊙`,终端进度状态则会保持活跃,直至标记对闭合;该状态行和字形共用标记对的同一个刷新定时器。该实时状态绝不会从日志中重建;闭合失败时会向 transcript 添加 `Compaction failed: <error>`,而恢复会话时遇到的陈旧未匹配 start 绝不会激活该指示器([决策](../../../.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md))。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息:第一个有可见文本或 reasoning 的步骤保留该轮次唯一的 `Assistant` 标题,之后的步骤渲染为无标题的续段,没有可见正文的步骤则不渲染任何内容;离开隐藏阶段会恢复每步各自的标题。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。`/details` 命名的正是这两个快捷键循环的同一份状态:不带参数时打开一个居中的键盘开关,每个维度一个条目——`Tool cards` 与 `Reasoning`——显示实时值,Tab 循环高亮条目并立即应用变更(对话框背后的 transcript 即是预览),Enter、Esc 或 Ctrl+C 关闭;`/details collapsed|expanded|hidden` 让工具卡片直接跳到该阶段,`/details reasoning [on|off]` 设置——或裸 `reasoning` 切换——reasoning 块显示;参数可在一次调用中组合,未知参数会以用法行报错,组合调用先应用 reasoning,使其 transcript 重建不会丢掉卡片通知。
|
||||
|
||||
`/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:列表上方设有一个过滤框,按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在高亮行仍通过过滤时保持其选中状态;Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 会先清除非空过滤内容,再次按下才关闭选择器。适配器未公布默认推理强度时,循环还会包含 `Default`,该项会清除显式选择并保留提供方默认行为;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model <model>` 仍可直接选择无歧义的模型 id,`/model <provider>/<model>` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。
|
||||
|
||||
@@ -58,6 +58,7 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
|
||||
| `questionDialogMaxHeight` | `20` | 问题面板最大行数 |
|
||||
| `modelDialogWidth` | `76` | 模型选择器宽度(列数) |
|
||||
| `modelDialogMaxHeight` | `20` | 模型选择器最大行数 |
|
||||
| `detailsDialogWidth` | `72` | transcript 细节选择器宽度(列数) |
|
||||
| `fileSearchMaxResults` | `20` | 一次 `@` 查询显示的最大文件和目录候选数 |
|
||||
| `fileSearchMaxEntries` | `10000` | 无路径模糊查询使用的有界工作区索引最多保留的路径数 |
|
||||
| `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | 遍历和直接补全时忽略的目录 basename |
|
||||
@@ -81,9 +82,9 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
|
||||
|
||||
## 颜色
|
||||
|
||||
TUI 发出的所有 SGR 代码都集中在一个表中,即 `components/theme.ts` 内的 `paletteSpec`;`createPalette` 从该表派生包装层,`/palette` 则打印该表,任何组件都不会自行写入转义序列。该表仅包含标准 16 色 ANSI 前景色和 SGR 属性;每个终端都会将它们重新映射到当前配色方案,因此 TUI 在浅色与深色背景下都保持可读——启动 banner 的品牌渐变是唯一一个有意保留的例外。正文使用终端默认前景色,而非固定色调。
|
||||
TUI 发出的所有通用 SGR 代码都集中在一个表中,即 `components/theme.ts` 内的 `paletteSpec`;`createPalette` 从该表派生包装层,`/palette` 则打印该表,任何组件都不会自行写入转义序列。该表仅包含标准 16 色 ANSI 前景色和 SGR 属性;每个终端都会将它们重新映射到当前配色方案,因此 TUI 在浅色与深色背景下都保持可读。启动 banner 渐变与官方标志使用的精确 `#4D6BFE` 色值是两处有意保留的真彩色品牌例外。正文使用终端默认前景色,而非固定色调。
|
||||
|
||||
每种视觉语义只对应一个角色:`dim` 是唯一的弱化色调,`accent` 是唯一的强调色,`success` 和 `error` 还分别充当 diff 的新增行与删除行。颜色和属性分属不同类型,因此 `bold(accent(x))` 可以通过编译,`accent(error(x))` 则不行——SGR 没有颜色栈;在一种颜色内嵌套另一种颜色时,内层颜色闭合时会静默丢弃外层颜色。各属性占用彼此独立的 SGR 组,可以按任一顺序与任何颜色组合。运行 `/palette` 可查看每个角色在你的终端上的实际渲染效果及其 SGR 码对。
|
||||
每种视觉语义只对应一个角色:`dim` 是唯一的弱化色调,`accent` 是唯一的交互强调色,`brand` 是 DeepSeek 标志的标准 ANSI 回退色,`success` 和 `error` 还分别充当 diff 的新增行与删除行。颜色和属性分属不同类型,因此 `bold(accent(x))` 可以通过编译,`accent(error(x))` 则不行——SGR 没有颜色栈;在一种颜色内嵌套另一种颜色时,内层颜色闭合时会静默丢弃外层颜色。各属性占用彼此独立的 SGR 组,可以按任一顺序与任何颜色组合。运行 `/palette` 可查看每个角色在你的终端上的实际渲染效果及其 SGR 码对。
|
||||
|
||||
成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。在工具卡片内部,整个正文——presenter 标题、终端 `$` 命令与 cwd,以及工具自身的输出——统一以同一种暗色渲染,因此只有带状态色的表头携带颜色,正文读作一个整体弱化的区块,而不是一串互相竞争的色调;注入上下文卡片的正文与其表头也是同一种色调。当前后两侧文本均可用时,diff 卡片会为精确识别出的新增 `+` 行和删除 `-` 行着色并计数;未变更的上下文保持暗色且不纳入计数。如果精确比较超出 `maxDiffEditLength`,卡片会把旧侧每一行渲染为删除行、把新侧每一行渲染为新增行,将页脚标记为近似结果,并缓存该回退结果供后续重绘使用。当 `oldText` 不可用时(包括待处理写入、回放回退以及文件创建),新侧的每个非空行都会显示并计作新增行;该计数不能证明这些行原先不存在于已有文件中。新内容为空时,不会补出虚构的 `+ ` 行。`[signal …]` 标记仍保留颜色,因为那里的颜色本身就是语义,而非强调。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import type { AgentLlmTarget, AgentLlmTargetRef } from '@deepseek-ai/dsh-agent'
|
||||
import { errorChain, type ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { errorChain, LlmError, type ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { TuiOverlaySession } from '../extension/types.ts'
|
||||
import { displayText } from '../components/text.ts'
|
||||
import {
|
||||
@@ -37,6 +37,8 @@ export interface ModelController {
|
||||
resetContextResolution(): void
|
||||
/** Forget the tracked selector overlay (shutdown). */
|
||||
clearOverlay(): void
|
||||
/** Remove the adapter-registration listener (channel detach). */
|
||||
detach(): void
|
||||
}
|
||||
|
||||
type ContextResolution =
|
||||
@@ -55,8 +57,15 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
|
||||
let modelOverlay: TuiOverlaySession | undefined
|
||||
let modelCommands = Promise.resolve()
|
||||
|
||||
// A route whose adapter has not registered yet. Loader activation order is
|
||||
// service-driven, so the TUI can mount before a configured adapter plugin
|
||||
// activates; that transient NO_ADAPTER is not an error — the resolution
|
||||
// waits for the next `llm/adapters-updated` commit instead of surfacing it.
|
||||
let awaitingAdapter = false
|
||||
|
||||
const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => {
|
||||
contextWindow = undefined
|
||||
awaitingAdapter = false
|
||||
const resolution: Promise<ContextResolution> = selected === undefined
|
||||
? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const)
|
||||
: ctx.llm.resolveModelInfo(selected.provider, selected.model).then(
|
||||
@@ -67,6 +76,10 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
|
||||
void resolution.then((result) => {
|
||||
if (contextResolution !== resolution) return
|
||||
if (result.kind === 'error') {
|
||||
if (selected !== undefined && result.error instanceof LlmError && result.error.code === 'NO_ADAPTER') {
|
||||
awaitingAdapter = true
|
||||
return
|
||||
}
|
||||
deps.appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error')
|
||||
return
|
||||
}
|
||||
@@ -74,6 +87,15 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
|
||||
deps.requestRender()
|
||||
})
|
||||
}
|
||||
// The wait cannot go stale against `target.current`: every target change
|
||||
// re-enters resolveContextWindow, which clears it. A commit that still
|
||||
// lacks the route parks the resolution again rather than erroring, so
|
||||
// unrelated topology changes stay silent. The disposer rides the channel's
|
||||
// detachListeners() through detach(), matching the sibling listeners.
|
||||
const disposeAdapterListener = ctx.on('llm/adapters-updated', () => {
|
||||
if (deps.isDisposed() || !awaitingAdapter) return
|
||||
resolveContextWindow(target.current)
|
||||
})
|
||||
resolveContextWindow(target.current)
|
||||
|
||||
const selectModel = (
|
||||
@@ -187,5 +209,8 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
|
||||
clearOverlay(): void {
|
||||
modelOverlay = undefined
|
||||
},
|
||||
detach(): void {
|
||||
disposeAdapterListener()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Per-step timing model and running-status glyph animation for the terminal
|
||||
* Per-step timing model and prompt-status glyph animation for the terminal
|
||||
* front door. Timing buckets are replayed from the session event stream; the
|
||||
* running glyph fades in on turn start, throbs while the turn runs, and fades
|
||||
* out on turn end.
|
||||
* active glyph fades in when work starts, throbs while work runs, and fades out
|
||||
* when it ends.
|
||||
* @module @deepseek-ai/dsh-tui/chat/timing
|
||||
*/
|
||||
|
||||
@@ -10,25 +10,25 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Palette } from '../components/theme.ts'
|
||||
|
||||
/**
|
||||
* Render cadence of the running prompt while active, and while the glyph fades
|
||||
* out after a turn ends. ~20 fps so the truecolor glyph fade reads smoothly;
|
||||
* Render cadence of the status prompt while active, and while the glyph fades
|
||||
* out after work ends. ~20 fps so the truecolor glyph fade reads smoothly;
|
||||
* the same tick keeps the elapsed-time text (0.1 s resolution) current. Only
|
||||
* changed terminal cells are re-emitted, so the faster tick stays cheap.
|
||||
*/
|
||||
export const STATUS_ANIMATION_INTERVAL_MS = 50
|
||||
|
||||
/**
|
||||
* Milliseconds over which the running glyph fades in when a turn starts and
|
||||
* fades out after it ends. The fade is an envelope over the running pulse:
|
||||
* Milliseconds over which the status glyph fades in when work starts and fades
|
||||
* out after it ends. The fade is an envelope over the active pulse:
|
||||
* inside it the glyph throbs (see {@link STATUS_PULSE_PERIOD_MS}).
|
||||
*/
|
||||
export const STATUS_FADE_MS = 300
|
||||
|
||||
/** Milliseconds for one full brightness throb of the running glyph. */
|
||||
/** Milliseconds for one full brightness throb of the active status glyph. */
|
||||
export const STATUS_PULSE_PERIOD_MS = 1400
|
||||
|
||||
/**
|
||||
* Brightness floor of the running throb, as a fraction of the settled gray. At
|
||||
* Brightness floor of the status throb, as a fraction of the settled gray. At
|
||||
* 0 the pulse swells from the near-background trough up to full and back. The
|
||||
* trough is still rendered as the dimmest gray, not clipped to a blank, so the
|
||||
* cosine breathes symmetrically bold→dim→bold.
|
||||
@@ -36,7 +36,7 @@ export const STATUS_PULSE_PERIOD_MS = 1400
|
||||
export const STATUS_PULSE_FLOOR = 0
|
||||
|
||||
/**
|
||||
* Muted-gray foreground the truecolor running glyph fades through, from the
|
||||
* Muted-gray foreground the truecolor status glyph fades through, from the
|
||||
* near-background trough (opacity 0) to the settled dim gray (opacity 1). Same
|
||||
* hue-free gray as the idle caret, so the glyph reads as the caret dimly
|
||||
* appearing rather than a colored indicator. Foreground-only, matching the
|
||||
@@ -185,6 +185,9 @@ export const TIMING_BUCKET_GLYPHS: Record<TimingBucket, string> = {
|
||||
tools: '⚙',
|
||||
}
|
||||
|
||||
/** Status glyph for a live standalone compaction bracket. */
|
||||
const COMPACTING_GLYPH = '⊙'
|
||||
|
||||
/**
|
||||
* Derive the currently open step's active timing bucket, or `undefined` when no
|
||||
* step is open. The open step is the last `step/start` with no later matching
|
||||
@@ -219,25 +222,32 @@ export function openStepPhase(events: readonly SessionEvent[]): TimingBucket | u
|
||||
}
|
||||
|
||||
/**
|
||||
* The running agent's phase glyph, or `undefined` when idle. A running turn
|
||||
* with no open step falls back to the pre-first-token wait so a glyph is always
|
||||
* available while the agent works; it fades in on turn start, throbs while the
|
||||
* turn runs, and fades out on turn end (see {@link fadeGlyph}).
|
||||
* The active status glyph, or `undefined` when idle. A running turn takes
|
||||
* precedence over standalone compaction and falls back to the pre-first-token
|
||||
* wait when no step is open. The caller applies the shared fade and throb
|
||||
* animation (see {@link fadeGlyph}).
|
||||
* @param events - Session events to derive the phase from.
|
||||
* @param running - Whether the agent is currently running.
|
||||
* @returns The phase glyph, or `undefined` when idle.
|
||||
* @param compacting - Whether a live standalone compaction bracket is open.
|
||||
* @returns The active status glyph, or `undefined` when idle.
|
||||
*/
|
||||
export function runningPhaseGlyph(events: readonly SessionEvent[], running: boolean): string | undefined {
|
||||
if (!running) return undefined
|
||||
const bucket = openStepPhase(events) ?? 'ttft'
|
||||
return TIMING_BUCKET_GLYPHS[bucket]
|
||||
export function runningPhaseGlyph(
|
||||
events: readonly SessionEvent[],
|
||||
running: boolean,
|
||||
compacting: boolean,
|
||||
): string | undefined {
|
||||
if (running) {
|
||||
const bucket = openStepPhase(events) ?? 'ttft'
|
||||
return TIMING_BUCKET_GLYPHS[bucket]
|
||||
}
|
||||
return compacting ? COMPACTING_GLYPH : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The running throb's brightness at continuous clock `nowMs`: a cosine between
|
||||
* The status throb's brightness at continuous clock `nowMs`: a cosine between
|
||||
* {@link STATUS_PULSE_FLOOR} and 1 over {@link STATUS_PULSE_PERIOD_MS}, so the
|
||||
* dim glyph breathes bold→dim→bold without ever blinking off. Multiplied by the
|
||||
* fade envelope, which alone drives appear/disappear at turn boundaries.
|
||||
* fade envelope, which alone drives appear/disappear at work boundaries.
|
||||
*
|
||||
* @param nowMs - Monotonic render clock in milliseconds.
|
||||
* @returns Brightness fraction in [{@link STATUS_PULSE_FLOOR}, 1].
|
||||
@@ -249,14 +259,14 @@ export function pulseLevel(nowMs: number): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* One frame of the running glyph at fade `opacity` (0 = near-background trough
|
||||
* One frame of the status glyph at fade `opacity` (0 = near-background trough
|
||||
* gray, 1 = settled dim gray). The character and its width never change — only
|
||||
* the gray fades — so the prompt caret column stays fixed and the glyph reads as
|
||||
* the caret dimly breathing, never a colored indicator.
|
||||
*
|
||||
* With truecolor the glyph's 24-bit gray foreground interpolates continuously
|
||||
* between {@link STATUS_FADE_GRAY}'s trough and settled stops, so both the fade
|
||||
* and the running throb render as a smooth, symmetric brightness swing with no
|
||||
* and the status throb render as a smooth, symmetric brightness swing with no
|
||||
* hard cutoff to clip the trough into a blank. Without truecolor there is no
|
||||
* per-frame gray, so `visible` (driven by the fade envelope, not the opacity)
|
||||
* shows the glyph in the palette's muted role or leaves a blank column — a
|
||||
@@ -264,7 +274,7 @@ export function pulseLevel(nowMs: number): number {
|
||||
* no throb-driven blink. With color off entirely a visible glyph is bare,
|
||||
* holding the caret column on a monochrome terminal.
|
||||
*
|
||||
* @param glyph - The phase glyph to paint.
|
||||
* @param glyph - The status glyph to paint.
|
||||
* @param palette - Active palette supplying the muted (dim gray) role.
|
||||
* @param colorEnabled - Whether ANSI is emitted at all.
|
||||
* @param truecolor - Whether the terminal accepts 24-bit foreground codes.
|
||||
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction'
|
||||
import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, displayText, sanitizePastedText } from './text.ts'
|
||||
import { dialogSelectTheme, type Palette } from './theme.ts'
|
||||
import type { ToolCardVisibility } from './transcript.ts'
|
||||
import {
|
||||
renderTuiPromptTemplate,
|
||||
type TuiPromptTemplateToken,
|
||||
@@ -432,6 +433,79 @@ export class ModelDialog implements Component {
|
||||
}
|
||||
}
|
||||
|
||||
/** Both transcript-detail dimensions, applied immediately on each Tab. */
|
||||
export interface DetailsSelection {
|
||||
readonly visibility: ToolCardVisibility
|
||||
readonly showReasoning: boolean
|
||||
}
|
||||
|
||||
const TOOL_CARD_PHASES: readonly ToolCardVisibility[] = ['collapsed', 'expanded', 'hidden']
|
||||
|
||||
/**
|
||||
* Keyboard toggle over the two transcript-detail entries — tool-card
|
||||
* visibility and reasoning display. Tab cycles the highlighted entry's value
|
||||
* and applies it immediately, so the transcript behind the dialog is the live
|
||||
* preview; Enter, Esc, or Ctrl+C closes.
|
||||
*/
|
||||
export class DetailsDialog implements Component {
|
||||
private readonly list: SelectList
|
||||
private readonly toolsItem: SelectItem
|
||||
private readonly reasoningItem: SelectItem
|
||||
|
||||
constructor(
|
||||
private visibility: ToolCardVisibility,
|
||||
private showReasoning: boolean,
|
||||
private readonly palette: Palette,
|
||||
private readonly apply: (selection: DetailsSelection) => void,
|
||||
private readonly close: () => void,
|
||||
) {
|
||||
this.toolsItem = { value: 'tools', label: 'Tool cards', description: visibility }
|
||||
this.reasoningItem = { value: 'reasoning', label: 'Reasoning', description: this.reasoningLabel() }
|
||||
this.list = new SelectList([this.toolsItem, this.reasoningItem], 2, dialogSelectTheme(palette))
|
||||
this.list.onSelect = close
|
||||
}
|
||||
|
||||
private reasoningLabel(): string {
|
||||
return this.showReasoning ? 'shown' : 'hidden'
|
||||
}
|
||||
|
||||
/** Cycle the highlighted entry one step and apply the new state. */
|
||||
private cycle(): void {
|
||||
const selected = this.list.getSelectedItem()
|
||||
/* v8 ignore next -- the two-entry list always has a selection. */
|
||||
if (selected === null) return
|
||||
if (selected.value === 'tools') {
|
||||
const index = TOOL_CARD_PHASES.indexOf(this.visibility)
|
||||
this.visibility = TOOL_CARD_PHASES[(index + 1) % TOOL_CARD_PHASES.length] as ToolCardVisibility
|
||||
this.toolsItem.description = this.visibility
|
||||
} else {
|
||||
this.showReasoning = !this.showReasoning
|
||||
this.reasoningItem.description = this.reasoningLabel()
|
||||
}
|
||||
this.apply({ visibility: this.visibility, showReasoning: this.showReasoning })
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.list.invalidate()
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) this.close()
|
||||
else if (matchesKey(data, Key.tab)) this.cycle()
|
||||
else this.list.handleInput(data)
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const innerWidth = Math.max(1, width - 4)
|
||||
return renderDialog('Transcript details', [
|
||||
...this.list.render(innerWidth),
|
||||
'',
|
||||
this.palette.dim('↑/↓ move • Tab toggle • Enter/Esc close'),
|
||||
], width, this.palette)
|
||||
}
|
||||
}
|
||||
|
||||
/** The provider/model route recovered from a resume candidate's log. */
|
||||
export interface ResumeRoute {
|
||||
provider: string
|
||||
|
||||
@@ -46,6 +46,8 @@ export type AttributeRole = <T extends string>(text: T) => T
|
||||
*/
|
||||
export interface Palette {
|
||||
accent: ColorRole
|
||||
/** DeepSeek brand ink; exact gradient callers may override it on truecolor terminals. */
|
||||
brand: 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. */
|
||||
@@ -63,7 +65,7 @@ export interface Palette {
|
||||
}
|
||||
|
||||
/** 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
|
||||
export const COLOR_ROLES = ['text', 'dim', 'accent', 'brand', '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
|
||||
@@ -86,8 +88,9 @@ export interface RoleSpec {
|
||||
*
|
||||
* 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}).
|
||||
* a fixed 24-bit color would not. The startup gradient and exact official mark
|
||||
* color are the two deliberate brand exceptions ({@link gradientText},
|
||||
* {@link brandText}).
|
||||
*
|
||||
* @param scheme - Active terminal color scheme; only `code` differs between them.
|
||||
* @returns The SGR spec for every color and attribute role.
|
||||
@@ -109,6 +112,7 @@ export function paletteSpec(scheme: TerminalColorScheme): {
|
||||
// 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' },
|
||||
brand: { open: '34', close: '39', purpose: 'DeepSeek brand art when truecolor is unavailable' },
|
||||
// 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'
|
||||
@@ -168,6 +172,19 @@ const BRAND_GRADIENT = [
|
||||
[36, 152, 255], // #2498FF
|
||||
] as const
|
||||
|
||||
/** Official DeepSeek icon ink from the shipped 24x24 SVG. */
|
||||
const DEEPSEEK_BRAND_RGB = BRAND_GRADIENT[0]
|
||||
|
||||
/**
|
||||
* Paint trusted static DeepSeek brand art with the official `#4D6BFE` ink.
|
||||
* @param text - Static brand text or raster cells.
|
||||
* @returns text wrapped in the official truecolor foreground and a foreground reset.
|
||||
*/
|
||||
export function brandText(text: string): string {
|
||||
const [r, g, b] = DEEPSEEK_BRAND_RGB
|
||||
return `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample {@link BRAND_GRADIENT} at fraction `t` via piecewise-linear
|
||||
* interpolation across its stops.
|
||||
@@ -199,13 +216,12 @@ function brandColorAt(t: number): readonly [number, number, number] {
|
||||
* @returns `text` wrapped in truecolor SGR foreground codes.
|
||||
*/
|
||||
export function gradientText(text: string): string {
|
||||
// The sole caller passes the ASCII product name, so UTF-16 unit iteration
|
||||
// samples exactly one color per visible letter.
|
||||
const last = Math.max(1, text.length - 1)
|
||||
const glyphs = Array.from(text)
|
||||
const last = Math.max(1, glyphs.length - 1)
|
||||
let painted = ''
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
for (let index = 0; index < glyphs.length; index += 1) {
|
||||
const [r, g, b] = brandColorAt(index / last)
|
||||
painted += `\x1b[38;2;${r};${g};${b}m${text.charAt(index)}`
|
||||
painted += `\x1b[38;2;${r};${g};${b}m${glyphs[index]}`
|
||||
}
|
||||
return `${painted}\x1b[39m`
|
||||
}
|
||||
|
||||
@@ -186,20 +186,28 @@ export class UserMessageComponent extends Container {
|
||||
}
|
||||
}
|
||||
|
||||
/** Children of a settled assistant message: optional reasoning block then the response text. */
|
||||
/**
|
||||
* Children of a settled assistant message: optional reasoning block then the
|
||||
* response text. A folded continuation (a later step of a turn while tool cards
|
||||
* are hidden) drops the `Assistant` header and renders nothing when it has no
|
||||
* visible body, so tool-only steps leave no blank segment behind.
|
||||
*/
|
||||
function assistantMessageChildren(
|
||||
content: readonly ContentBlock[],
|
||||
showReasoning: boolean,
|
||||
foldedContinuation: boolean,
|
||||
palette: Palette,
|
||||
mdTheme: MarkdownTheme,
|
||||
): Component[] {
|
||||
const reasoning = displayText(textBlocks(content, 'reasoning').trim())
|
||||
const text = displayText(textBlocks(content, 'text').trim())
|
||||
const children: Component[] = [
|
||||
new Spacer(1),
|
||||
new Text(messageHeader('Assistant', palette.accent, palette), 0, 0),
|
||||
]
|
||||
if (reasoning && showReasoning) {
|
||||
const showsReasoning = reasoning !== '' && showReasoning
|
||||
if (foldedContinuation && !showsReasoning && text === '') return []
|
||||
const children: Component[] = [new Spacer(1)]
|
||||
if (!foldedContinuation) {
|
||||
children.push(new Text(messageHeader('Assistant', palette.accent, palette), 0, 0))
|
||||
}
|
||||
if (showsReasoning) {
|
||||
children.push(
|
||||
new Text(palette.italic(palette.dim('Reasoning')), 0, 0),
|
||||
new Markdown(reasoning, 0, 0, mdTheme, { color: value => palette.dim(value), italic: true }),
|
||||
@@ -257,6 +265,7 @@ interface StreamingBlock {
|
||||
export class StreamingAssistantComponent extends Container {
|
||||
private readonly blocks = new Map<number, StreamingBlock>()
|
||||
private settledContent: readonly ContentBlock[] | undefined
|
||||
private foldedContinuation = false
|
||||
/**
|
||||
* The step's timing footer. The renderer keeps it at the tail of the chat so
|
||||
* it trails any tool cards the step appends after this assistant message; it
|
||||
@@ -265,7 +274,8 @@ export class StreamingAssistantComponent extends Container {
|
||||
readonly timing: StepTimingComponent
|
||||
|
||||
constructor(
|
||||
position: StepPosition,
|
||||
/** The step's turn/step coordinates, used to group steps into their turn. */
|
||||
readonly position: StepPosition,
|
||||
events: () => readonly SessionEvent[],
|
||||
now: () => number,
|
||||
private showReasoning: boolean,
|
||||
@@ -336,18 +346,49 @@ export class StreamingAssistantComponent extends Container {
|
||||
this.rebuild()
|
||||
}
|
||||
|
||||
private rebuild(): void {
|
||||
this.clear()
|
||||
const content: readonly ContentBlock[] = this.settledContent ?? [...this.blocks.entries()]
|
||||
/**
|
||||
* Mark this step as a folded continuation of its turn: no `Assistant` header,
|
||||
* and no output at all while the step has no visible body. Used while tool
|
||||
* cards are hidden so a turn reads as one assistant message.
|
||||
* @param folded - Whether to render as a headerless continuation.
|
||||
*/
|
||||
setFoldedContinuation(folded: boolean): void {
|
||||
if (this.foldedContinuation === folded) return
|
||||
this.foldedContinuation = folded
|
||||
this.rebuild()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the step currently renders visible reasoning or text.
|
||||
* @returns `true` when a header-owning render would show a body.
|
||||
*/
|
||||
hasVisibleBody(): boolean {
|
||||
const content = this.presentedContent()
|
||||
return textBlocks(content, 'text').trim() !== ''
|
||||
|| (this.showReasoning && textBlocks(content, 'reasoning').trim() !== '')
|
||||
}
|
||||
|
||||
/** The settled content when available, otherwise the streamed blocks in model order. */
|
||||
private presentedContent(): readonly ContentBlock[] {
|
||||
return this.settledContent ?? [...this.blocks.entries()]
|
||||
.sort(([left], [right]) => left - right)
|
||||
.flatMap<ContentBlock>(([, block]) => {
|
||||
if (block.type === 'text') return [{ type: 'text', text: block.text }]
|
||||
if (block.type === 'reasoning') return [{ type: 'reasoning', text: block.text }]
|
||||
return []
|
||||
})
|
||||
for (const child of assistantMessageChildren(content, this.showReasoning, this.palette, this.mdTheme)) {
|
||||
this.addChild(child)
|
||||
}
|
||||
}
|
||||
|
||||
private rebuild(): void {
|
||||
this.clear()
|
||||
const children = assistantMessageChildren(
|
||||
this.presentedContent(),
|
||||
this.showReasoning,
|
||||
this.foldedContinuation,
|
||||
this.palette,
|
||||
this.mdTheme,
|
||||
)
|
||||
for (const child of children) this.addChild(child)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,6 +446,7 @@ export class ToolCardComponent implements Component {
|
||||
* @param event - The `tool/result` event payload.
|
||||
*/
|
||||
updateResult(event: Extract<SessionEvent, { type: 'tool/result' }>['data']): void {
|
||||
this.diffBodyCache = undefined
|
||||
const result = event.message.content[0]
|
||||
this.result = {
|
||||
content: [...result.content],
|
||||
@@ -441,23 +483,26 @@ export class ToolCardComponent implements Component {
|
||||
const glyph = this.result === undefined ? '○' : '●'
|
||||
const rawBody = this.renderBody()
|
||||
const view = this.resultView ?? this.callView
|
||||
// A generic card's own content, or a read card's `content` fallback (the
|
||||
// A generic card's own content, a read card's `content` fallback (the
|
||||
// envelope-stripped file text — the TUI has no dedicated read rendering, so a
|
||||
// read renders exactly as before the read card existed), or a web card's
|
||||
// fallback to the raw result content (the `web` view carries no `content`
|
||||
// copy), all render as one dim Markdown block below, so links/lists/headings
|
||||
// keep the unified dim styling rather than reading as bare text. Terminal and
|
||||
// diff cards own their body styling, so they are excluded (mirrors
|
||||
// renderBody's post-terminal/diff fallback).
|
||||
// read renders exactly as before the read card existed), or a search/web
|
||||
// card's fallback to the raw result content (neither the `search` nor the
|
||||
// `web` view carries a `content` copy), all render as one dim Markdown block
|
||||
// below, so links/lists/headings keep the unified dim styling rather than
|
||||
// reading as bare text. A search card thus stays byte-identical to the
|
||||
// pre-search-card generic fallback. Terminal and diff cards own their body
|
||||
// styling, so they are excluded (mirrors renderBody's post-terminal/diff fallback).
|
||||
const markdownContent = view.card === 'generic' || view.card === 'read'
|
||||
? view.content ?? this.result?.content
|
||||
: view.card === 'web'
|
||||
// A web resultView is only assigned alongside this.result (the result
|
||||
// handler sets both) and the pending callView is never a web card, so
|
||||
// the optional-chain undefined side is unreachable here.
|
||||
/* v8 ignore next */
|
||||
: view.card === 'search'
|
||||
? this.result?.content
|
||||
: undefined
|
||||
: view.card === 'web'
|
||||
// A web resultView is only assigned alongside this.result (the result
|
||||
// handler sets both) and the pending callView is never a web card, so
|
||||
// the optional-chain undefined side is unreachable here.
|
||||
/* v8 ignore next */
|
||||
? this.result?.content
|
||||
: undefined
|
||||
const unknownXml = this.definition === undefined && markdownContent !== undefined
|
||||
? renderUnknownXml(
|
||||
displayText(contentText(markdownContent)),
|
||||
@@ -578,11 +623,12 @@ export class ToolCardComponent implements Component {
|
||||
this.diffBodyCache = { view, body }
|
||||
return body
|
||||
}
|
||||
// A generic or read card carries its own envelope-stripped `content`; a `web`
|
||||
// card carries no `content` copy and falls back to the raw result content
|
||||
// here. (Mirrors the `markdownContent` selection in render(); a read card has
|
||||
// no dedicated TUI rendering, so its `content` takes the same body path,
|
||||
// keeping read output as it was before the read card existed.)
|
||||
// A generic or read card carries its own envelope-stripped `content`; a
|
||||
// search or web card carries no `content` copy and falls back to the raw
|
||||
// result content here. (Mirrors the `markdownContent` selection in render();
|
||||
// a read card has no dedicated TUI rendering, so its `content` takes the same
|
||||
// body path, keeping read output as it was before the read card existed, and
|
||||
// a search card stays byte-identical to the pre-search-card fallback.)
|
||||
const content = (view.card === 'generic' || view.card === 'read' ? view.content : undefined) ?? this.result?.content
|
||||
const prelude: string[] = []
|
||||
const lines: string[] = []
|
||||
|
||||
@@ -50,6 +50,8 @@ export interface TuiConfig {
|
||||
modelDialogWidth?: number
|
||||
/** Model-selector maximum height in terminal rows. */
|
||||
modelDialogMaxHeight?: number
|
||||
/** Transcript-details selector width in terminal columns. */
|
||||
detailsDialogWidth?: number
|
||||
/** Maximum fuzzy file candidates displayed for one `@` query. */
|
||||
fileSearchMaxResults?: number
|
||||
/** Maximum paths retained in one `@` workspace index. */
|
||||
@@ -74,6 +76,7 @@ const questionDialogWidthSchema = z.number().step(1).min(20).default(200)
|
||||
const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
|
||||
const modelDialogWidthSchema = z.number().step(1).min(20).default(76)
|
||||
const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
|
||||
const detailsDialogWidthSchema = z.number().step(1).min(20).default(72)
|
||||
const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS)
|
||||
const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES)
|
||||
const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES])
|
||||
@@ -106,6 +109,7 @@ const tuiConfigSchemaFields = {
|
||||
questionDialogMaxHeight: questionDialogMaxHeightSchema,
|
||||
modelDialogWidth: modelDialogWidthSchema,
|
||||
modelDialogMaxHeight: modelDialogMaxHeightSchema,
|
||||
detailsDialogWidth: detailsDialogWidthSchema,
|
||||
fileSearchMaxResults: fileSearchMaxResultsSchema,
|
||||
fileSearchMaxEntries: fileSearchMaxEntriesSchema,
|
||||
fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema,
|
||||
@@ -126,8 +130,8 @@ export interface Config extends TuiConfig {
|
||||
/**
|
||||
* Skill name auto-invoked as this session's first user turn, exactly as if
|
||||
* the user typed `/skill:<name>`. Set only by a launcher for a fresh
|
||||
* skill-guided session (`dsh migrate`/`dsh upgrade`); absent leaves the first
|
||||
* turn to the user.
|
||||
* skill-guided session (`dsh migrate`/`dsh upgrade`); absent
|
||||
* leaves the first turn to the user.
|
||||
*/
|
||||
initialSkill?: string
|
||||
}
|
||||
@@ -147,6 +151,7 @@ export const Config: z<Config> = z.object({
|
||||
questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight,
|
||||
modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth,
|
||||
modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight,
|
||||
detailsDialogWidth: tuiConfigSchemaFields.detailsDialogWidth,
|
||||
fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults,
|
||||
fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries,
|
||||
fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories,
|
||||
@@ -177,6 +182,7 @@ export interface ResolvedTuiConfig {
|
||||
questionDialogMaxHeight: number
|
||||
modelDialogWidth: number
|
||||
modelDialogMaxHeight: number
|
||||
detailsDialogWidth: number
|
||||
fileSearchMaxResults: number
|
||||
fileSearchMaxEntries: number
|
||||
fileSearchExcludedDirectories: string[]
|
||||
@@ -203,6 +209,7 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf
|
||||
questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20,
|
||||
modelDialogWidth: config?.modelDialogWidth ?? 76,
|
||||
modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20,
|
||||
detailsDialogWidth: config?.detailsDialogWidth ?? 72,
|
||||
fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS,
|
||||
fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES,
|
||||
fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)],
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface TuiFocusable {
|
||||
export interface TuiTheme {
|
||||
/** Render ordinary foreground text. */
|
||||
readonly text: (value: string) => string
|
||||
/** Render trusted static brand art with the host's configured brand treatment. */
|
||||
readonly brand: (value: string) => string
|
||||
/** Render secondary information and low-emphasis hints, the one tone below `text`. */
|
||||
readonly dim: (value: string) => string
|
||||
/** Render the active accent role. */
|
||||
|
||||
@@ -69,7 +69,7 @@ import type {
|
||||
TuiTheme,
|
||||
} from './extension/types.ts'
|
||||
import { displayInlineText, displayText } from './components/text.ts'
|
||||
import { createPalette, markdownTheme, renderPalette, selectTheme } from './components/theme.ts'
|
||||
import { brandText, createPalette, markdownTheme, renderPalette, selectTheme } from './components/theme.ts'
|
||||
import { contentText, parseArguments } from './components/content.ts'
|
||||
import {
|
||||
cacheHitRate,
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
import {
|
||||
fadeGlyph,
|
||||
formatQueuedStatus,
|
||||
formatStatusDuration,
|
||||
openStepPhase,
|
||||
openTurn,
|
||||
pulseLevel,
|
||||
@@ -104,6 +105,7 @@ import {
|
||||
} from './components/transcript.ts'
|
||||
import {
|
||||
compactTargetLabel,
|
||||
DetailsDialog,
|
||||
diagnosticMeter,
|
||||
formatDiagnosticCount,
|
||||
formatDiagnosticNumber,
|
||||
@@ -112,6 +114,7 @@ import {
|
||||
StatusCardComponent,
|
||||
PromptContextComponent,
|
||||
targetLabel,
|
||||
type DetailsSelection,
|
||||
type StatusCardRow,
|
||||
} from './components/dialogs.ts'
|
||||
import {
|
||||
@@ -226,9 +229,9 @@ export const TUI_GOODBYE_MESSAGE_KEY = 'tuiGoodbyeMessage'
|
||||
/**
|
||||
* Context key a launcher sets before any Loader entry mounts
|
||||
* (`ctx.provide(INITIAL_SKILL_KEY, name)`) to seed a fresh session's first user
|
||||
* turn with `/skill:<name>` — the `dsh migrate`/`dsh upgrade` guided-session
|
||||
* entry. The launcher sets it only when minting a fresh session, so it never
|
||||
* re-fires on a resumed one. Absent leaves the first turn to the user.
|
||||
* turn with `/skill:<name>` — the `dsh migrate`/`dsh upgrade`
|
||||
* guided-session entry. The launcher sets it only when minting a fresh session,
|
||||
* so it never re-fires on a resumed one. Absent leaves the first turn to the user.
|
||||
*/
|
||||
export const INITIAL_SKILL_KEY = 'tuiInitialSkill'
|
||||
|
||||
@@ -329,14 +332,27 @@ export function createTuiChat(
|
||||
})
|
||||
editor.hintPrefix = initialInputPrompt
|
||||
const todo = new TodoComponent(palette)
|
||||
const compactionStatusLine = new Text('', 0, 0)
|
||||
let showReasoning = resolved.showReasoning
|
||||
// Ctrl+O cycles collapsed -> expanded -> hidden. Codex-style: hidden drops
|
||||
// tool cards entirely, collapsed previews, expanded shows full bodies.
|
||||
let toolsVisibility: ToolCardVisibility = 'collapsed'
|
||||
let streaming: StreamingAssistantComponent | undefined
|
||||
let completedStreaming: StreamingAssistantComponent | undefined
|
||||
// Assistant step components in model order per turn, for hidden-mode folding:
|
||||
// with tool cards hidden, a turn keeps one Assistant header and later steps
|
||||
// render as headerless continuations (see applyTurnFolding).
|
||||
const assistantSteps = new Map<number, StreamingAssistantComponent[]>()
|
||||
let runningStatus: RunningStatus | undefined
|
||||
let fadingStatus: FadingStatus | undefined
|
||||
/**
|
||||
* Live standalone compaction observed by this process. Never derive this
|
||||
* state from history: a resumed log may contain a stale orphaned start.
|
||||
*/
|
||||
let compacting: {
|
||||
startedAt: number
|
||||
timer: ReturnType<typeof setInterval>
|
||||
} | undefined
|
||||
// TUI steering submissions that the inbox has not yet claimed or discarded.
|
||||
// Correlation ids avoid guessing whether a running-state submission actually
|
||||
// joined steering or fell back to the queued-turn FIFO during turn close.
|
||||
@@ -400,6 +416,7 @@ export function createTuiChat(
|
||||
throw new Error('TUI prompt built-ins failed to initialize')
|
||||
}
|
||||
const updatePromptValues = (): void => {
|
||||
const renderTime = now()
|
||||
cwdValue.set(palette.bold(palette.accent(formattedCwd)))
|
||||
gitValue.set(branch === undefined ? undefined : palette.dim(` (${displayText(branch)})`))
|
||||
const rate = cacheHitRate(tokens)
|
||||
@@ -413,23 +430,31 @@ export function createTuiChat(
|
||||
const queued = runningStatus === undefined ? undefined : formatQueuedStatus(pendingSteering.size)
|
||||
queuedValue.set(queued === undefined ? undefined : palette.dim(queued))
|
||||
symbolValue.set(palette.bold(palette.accent('dsh')))
|
||||
compactionStatusLine.setText(compacting === undefined
|
||||
? ''
|
||||
: palette.dim(`Context being compacted ${formatStatusDuration(renderTime - compacting.startedAt)}`))
|
||||
// `${indicator}` owns the caret column and its trailing gap before the
|
||||
// cursor. The phase glyph replaces the `>` caret in place — same width
|
||||
// every frame — fading in as a turn starts, throbbing while it runs, and
|
||||
// fading out after it ends before the plain `>` returns. Only the gray
|
||||
// cursor. The active status glyph replaces the `>` caret in place — same
|
||||
// width every frame — fading in when work starts, throbbing while it runs,
|
||||
// and fading out after it ends before the plain `>` returns. Only the gray
|
||||
// brightness changes, so the cursor never shifts.
|
||||
const runningGlyph = runningPhaseGlyph(agent.session.events, runningStatus !== undefined)
|
||||
const statusGlyph = runningPhaseGlyph(
|
||||
agent.session.events,
|
||||
runningStatus !== undefined,
|
||||
compacting !== undefined,
|
||||
)
|
||||
// Remember the live phase glyph so the fade-out shows it, not the ttft
|
||||
// fallback the derivation returns once the closing turn's step has ended.
|
||||
if (runningStatus !== undefined && runningGlyph !== undefined) runningStatus.lastGlyph = runningGlyph
|
||||
// The fade envelope gates appear/disappear; the running throb breathes the
|
||||
// glyph the whole turn. Truecolor opacity is envelope × throb; the
|
||||
if (runningStatus !== undefined && statusGlyph !== undefined) runningStatus.lastGlyph = statusGlyph
|
||||
// The fade envelope gates appear/disappear; the active throb breathes the
|
||||
// glyph throughout the operation. Truecolor opacity is envelope × throb; the
|
||||
// non-truecolor fallback keys visibility off the envelope alone, so the
|
||||
// throb never blinks it. `envelope` clamps to [0, 1].
|
||||
const envelope = runningStatus !== undefined && runningGlyph !== undefined
|
||||
? { glyph: runningGlyph, level: Math.min(1, (now() - runningStatus.startedAt) / STATUS_FADE_MS) }
|
||||
const activeSince = runningStatus?.startedAt ?? compacting?.startedAt
|
||||
const envelope = activeSince !== undefined && statusGlyph !== undefined
|
||||
? { glyph: statusGlyph, level: Math.min(1, (renderTime - activeSince) / STATUS_FADE_MS) }
|
||||
: fadingStatus !== undefined
|
||||
? { glyph: fadingStatus.glyph, level: Math.max(0, 1 - (now() - fadingStatus.endedAt) / STATUS_FADE_MS) }
|
||||
? { glyph: fadingStatus.glyph, level: Math.max(0, 1 - (renderTime - fadingStatus.endedAt) / STATUS_FADE_MS) }
|
||||
: undefined
|
||||
const caret = envelope === undefined
|
||||
? palette.dim('>')
|
||||
@@ -438,7 +463,7 @@ export function createTuiChat(
|
||||
palette,
|
||||
resolved.theme.color,
|
||||
resolved.theme.color && resolved.theme.truecolor,
|
||||
envelope.level * pulseLevel(now()),
|
||||
envelope.level * pulseLevel(renderTime),
|
||||
envelope.level >= 0.5,
|
||||
)
|
||||
indicatorValue.set(`${caret}${palette.dim(' ')}`)
|
||||
@@ -453,6 +478,7 @@ export function createTuiChat(
|
||||
ui.addChild(new Spacer(1))
|
||||
todoContainer.addChild(todo)
|
||||
ui.addChild(todoContainer)
|
||||
ui.addChild(compactionStatusLine)
|
||||
ui.addChild(promptContext)
|
||||
ui.addChild(editor)
|
||||
ui.setFocus(editor)
|
||||
@@ -486,6 +512,9 @@ export function createTuiChat(
|
||||
|
||||
const extensionTheme: TuiTheme = Object.freeze({
|
||||
text: (value: string) => palette.text(value),
|
||||
brand: (value: string) => resolved.theme.color
|
||||
? resolved.theme.truecolor ? brandText(value) : palette.brand(value)
|
||||
: value,
|
||||
dim: (value: string) => palette.dim(value),
|
||||
accent: (value: string) => palette.accent(value),
|
||||
success: (value: string) => palette.success(value),
|
||||
@@ -537,8 +566,8 @@ export function createTuiChat(
|
||||
requestRender()
|
||||
}
|
||||
|
||||
/** Stop the running and fade-out timers and drop both states at once. */
|
||||
const clearStatus = (): void => {
|
||||
/** Stop the turn-phase running and fade-out timers and drop both states. */
|
||||
const clearTurnStatus = (): void => {
|
||||
if (runningStatus !== undefined) {
|
||||
clearInterval(runningStatus.timer)
|
||||
runningStatus = undefined
|
||||
@@ -547,21 +576,30 @@ export function createTuiChat(
|
||||
clearInterval(fadingStatus.timer)
|
||||
fadingStatus = undefined
|
||||
}
|
||||
runtime.terminal.setProgress(false)
|
||||
runtime.terminal.setProgress(compacting !== undefined)
|
||||
}
|
||||
|
||||
/** Hard clear: drop every indicator, including a live compaction bracket. */
|
||||
const clearStatus = (): void => {
|
||||
if (compacting !== undefined) {
|
||||
clearInterval(compacting.timer)
|
||||
compacting = undefined
|
||||
}
|
||||
clearTurnStatus()
|
||||
}
|
||||
|
||||
/**
|
||||
* On the running → non-running edge, hand the last rendered glyph to a
|
||||
* fade-out that re-renders until it settles on the `>` caret, then stops its
|
||||
* own timer. A hard clear (teardown) skips this via {@link clearStatus}.
|
||||
* Hand the last active glyph to a fade-out that re-renders until it settles
|
||||
* on the `>` caret, then stops its own timer. A hard clear (teardown) skips
|
||||
* this via {@link clearStatus}.
|
||||
*/
|
||||
const beginFadeOut = (glyph: string): void => {
|
||||
clearStatus()
|
||||
clearTurnStatus()
|
||||
const fading: FadingStatus = {
|
||||
glyph,
|
||||
endedAt: now(),
|
||||
timer: setInterval(() => {
|
||||
if (now() - fading.endedAt >= STATUS_FADE_MS) clearStatus()
|
||||
if (now() - fading.endedAt >= STATUS_FADE_MS) clearTurnStatus()
|
||||
renderStatus()
|
||||
}, STATUS_ANIMATION_INTERVAL_MS),
|
||||
}
|
||||
@@ -571,9 +609,9 @@ export function createTuiChat(
|
||||
const setStatus = (status: AgentStatus): void => {
|
||||
const priorTurn = runningStatus?.turn
|
||||
const fadeOutGlyph = status !== 'running' ? runningStatus?.lastGlyph : undefined
|
||||
if (status === 'running') clearStatus()
|
||||
if (status === 'running') clearTurnStatus()
|
||||
else if (fadeOutGlyph !== undefined) beginFadeOut(fadeOutGlyph)
|
||||
else clearStatus()
|
||||
else clearTurnStatus()
|
||||
editor.borderColor = status === 'running' ? text => palette.accent(text) : text => palette.dim(text)
|
||||
editor.hint = status === 'running' ? palette.dim(displayInlineText(resolved.theme.inputPlaceholder)) : undefined
|
||||
if (status === 'running') {
|
||||
@@ -615,6 +653,35 @@ export function createTuiChat(
|
||||
return card
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derive hidden-mode folding for one turn: the first step with a visible
|
||||
* body owns the turn's single Assistant header, every other step renders as a
|
||||
* headerless continuation (empty ones render nothing). Any other visibility
|
||||
* restores the per-step headers.
|
||||
*/
|
||||
const applyTurnFolding = (turn: number): void => {
|
||||
const steps = assistantSteps.get(turn)
|
||||
if (steps === undefined) return
|
||||
let headerSeen = false
|
||||
for (const step of steps) {
|
||||
if (toolsVisibility !== 'hidden') {
|
||||
step.setFoldedContinuation(false)
|
||||
} else if (!headerSeen && step.hasVisibleBody()) {
|
||||
headerSeen = true
|
||||
step.setFoldedContinuation(false)
|
||||
} else {
|
||||
step.setFoldedContinuation(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const registerAssistantStep = (component: StreamingAssistantComponent): void => {
|
||||
const steps = assistantSteps.get(component.position.turn) ?? []
|
||||
steps.push(component)
|
||||
assistantSteps.set(component.position.turn, steps)
|
||||
applyTurnFolding(component.position.turn)
|
||||
}
|
||||
|
||||
const removeStreaming = (current: StreamingAssistantComponent | undefined): void => {
|
||||
if (current === undefined) return
|
||||
for (const child of [current, current.timing]) {
|
||||
@@ -622,6 +689,15 @@ export function createTuiChat(
|
||||
/* v8 ignore next -- streaming components and their timing footers are retained only while attached to the chat. */
|
||||
if (index >= 0) chat.children.splice(index, 1)
|
||||
}
|
||||
const steps = assistantSteps.get(current.position.turn)
|
||||
/* v8 ignore next -- every attached streaming component is registered in the fold map. */
|
||||
if (steps === undefined) return
|
||||
const index = steps.indexOf(current)
|
||||
/* v8 ignore next -- registration precedes attachment, so the component is present until this removal. */
|
||||
if (index < 0) return
|
||||
steps.splice(index, 1)
|
||||
// A retracted step may have owned the turn's hidden-mode header.
|
||||
applyTurnFolding(current.position.turn)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -660,6 +736,7 @@ export function createTuiChat(
|
||||
palette,
|
||||
mdTheme,
|
||||
)
|
||||
registerAssistantStep(streaming)
|
||||
chat.addChild(streaming)
|
||||
chat.addChild(streaming.timing)
|
||||
}
|
||||
@@ -723,12 +800,22 @@ export function createTuiChat(
|
||||
startAssistantStep(event.data)
|
||||
break
|
||||
case 'assistant/chunk':
|
||||
if (options.renderChunks) streaming?.update(event.data.chunk)
|
||||
if (options.renderChunks && streaming !== undefined) {
|
||||
streaming.update(event.data.chunk)
|
||||
// The first streamed text/reasoning may make this step the turn's
|
||||
// hidden-mode header owner (or a continuation with a visible body).
|
||||
applyTurnFolding(streaming.position.turn)
|
||||
}
|
||||
break
|
||||
case 'assistant/message':
|
||||
completedStreaming = undefined
|
||||
if (streaming === undefined || !chat.children.includes(streaming)) startAssistantStep(event.data)
|
||||
streaming?.settle(event.data.message.content)
|
||||
// A settled component stays attached but never absorbs a later message
|
||||
// of the same step; both the live and replay paths start a new one.
|
||||
if (streaming === undefined || streaming.isSettled() || !chat.children.includes(streaming)) startAssistantStep(event.data)
|
||||
if (streaming !== undefined) {
|
||||
streaming.settle(event.data.message.content)
|
||||
applyTurnFolding(streaming.position.turn)
|
||||
}
|
||||
break
|
||||
case 'llm/retry': {
|
||||
retractFailedStreaming()
|
||||
@@ -847,6 +934,7 @@ export function createTuiChat(
|
||||
toolCards.clear()
|
||||
allToolCards.clear()
|
||||
contextCards.clear()
|
||||
assistantSteps.clear()
|
||||
streaming = undefined
|
||||
todo.update([])
|
||||
const transcriptCalls = transcriptToolCallIds(agent.session)
|
||||
@@ -956,32 +1044,99 @@ export function createTuiChat(
|
||||
// same reason.
|
||||
ui.queryTerminalColorScheme({ timeoutMs: 2000 }).catch(() => {})
|
||||
|
||||
const toggleTools = (): void => {
|
||||
// The cycle order puts the two common reading modes adjacent: preview ->
|
||||
// full detail -> conversation-only, then back to the preview default.
|
||||
toolsVisibility = toolsVisibility === 'collapsed' ? 'expanded'
|
||||
: toolsVisibility === 'expanded' ? 'hidden' : 'collapsed'
|
||||
const setToolsVisibility = (next: ToolCardVisibility): void => {
|
||||
toolsVisibility = next
|
||||
for (const card of allToolCards) card.setVisibility(toolsVisibility)
|
||||
// Context cards carry injected instructions rather than tool traffic, so
|
||||
// they never hide: the hidden phase reads as their collapsed preview.
|
||||
for (const card of contextCards) card.setExpanded(toolsVisibility === 'expanded')
|
||||
// Hidden mode folds each turn's steps into one assistant message; other
|
||||
// modes restore the per-step Assistant headers.
|
||||
for (const turn of assistantSteps.keys()) applyTurnFolding(turn)
|
||||
appendNotice(toolsVisibility === 'hidden' ? 'Tool cards hidden.' : `Tool and context cards ${toolsVisibility}.`)
|
||||
}
|
||||
|
||||
const toggleReasoning = (): void => {
|
||||
showReasoning = !showReasoning
|
||||
const toggleTools = (): void => {
|
||||
// The cycle order puts the two common reading modes adjacent: preview ->
|
||||
// full detail -> conversation-only, then back to the preview default.
|
||||
setToolsVisibility(toolsVisibility === 'collapsed' ? 'expanded'
|
||||
: toolsVisibility === 'expanded' ? 'hidden' : 'collapsed')
|
||||
}
|
||||
|
||||
const setReasoning = (show: boolean): void => {
|
||||
showReasoning = show
|
||||
const activeStreaming = streaming
|
||||
rebuildTranscript(false)
|
||||
/* v8 ignore next -- the non-streaming command path is covered; this branch preserves an active stream across rebuild. */
|
||||
if (activeStreaming !== undefined) {
|
||||
streaming = activeStreaming
|
||||
streaming.setShowReasoning(showReasoning)
|
||||
registerAssistantStep(activeStreaming)
|
||||
chat.addChild(activeStreaming)
|
||||
chat.addChild(activeStreaming.timing)
|
||||
}
|
||||
appendNotice(`Reasoning blocks ${showReasoning ? 'shown' : 'hidden'}.`)
|
||||
}
|
||||
|
||||
const toggleReasoning = (): void => { setReasoning(!showReasoning) }
|
||||
|
||||
// The selector and the argument grammar mutate the same closure state the
|
||||
// Ctrl+O cycle and Ctrl+R toggle drive, so every entry converges.
|
||||
let detailsOverlay: TuiOverlaySession | undefined
|
||||
const showDetailsSelector = (): void => {
|
||||
void detailsOverlay?.close()
|
||||
const session = overlayManager.open({
|
||||
create: () => new DetailsDialog(
|
||||
toolsVisibility,
|
||||
showReasoning,
|
||||
palette,
|
||||
// Each Tab applies immediately; one dimension changes per call.
|
||||
(selection: DetailsSelection) => {
|
||||
if (selection.showReasoning !== showReasoning) setReasoning(selection.showReasoning)
|
||||
if (selection.visibility !== toolsVisibility) setToolsVisibility(selection.visibility)
|
||||
},
|
||||
() => { void session.close() },
|
||||
),
|
||||
options: { width: resolved.detailsDialogWidth, anchor: 'center', margin: 1 },
|
||||
})
|
||||
detailsOverlay = session
|
||||
void session.closed.then(() => {
|
||||
if (detailsOverlay === session) detailsOverlay = undefined
|
||||
})
|
||||
requestRender()
|
||||
}
|
||||
|
||||
// `/details` names the same transcript-detail state the Ctrl+O cycle and
|
||||
// Ctrl+R toggle mutate, so a user can jump to a mode without cycling.
|
||||
const runDetails = (rawInput: string): CommandResult => {
|
||||
const tokens = rawInput.split(/\s+/u).filter(token => token !== '')
|
||||
if (tokens.length === 0) {
|
||||
showDetailsSelector()
|
||||
return { kind: 'success' }
|
||||
}
|
||||
let visibility: ToolCardVisibility | undefined
|
||||
let reasoning: boolean | undefined
|
||||
for (let token = tokens.shift(); token !== undefined; token = tokens.shift()) {
|
||||
if (token === 'collapsed' || token === 'expanded' || token === 'hidden') {
|
||||
visibility = token
|
||||
} else if (token === 'reasoning') {
|
||||
const value = tokens[0]
|
||||
if (value === 'on' || value === 'off') {
|
||||
tokens.shift()
|
||||
reasoning = value === 'on'
|
||||
} else {
|
||||
reasoning = !showReasoning
|
||||
}
|
||||
} else {
|
||||
return { kind: 'error', text: `Unknown /details argument "${token}". Usage: /details [collapsed|expanded|hidden] [reasoning [on|off]]` }
|
||||
}
|
||||
}
|
||||
// Reasoning first: its transcript rebuild would drop the visibility notice.
|
||||
if (reasoning !== undefined) setReasoning(reasoning)
|
||||
if (visibility !== undefined) setToolsVisibility(visibility)
|
||||
return { kind: 'success' }
|
||||
}
|
||||
|
||||
const showHelp = (): void => {
|
||||
const commandLines = ctx.commands.list(agent).map((command) => {
|
||||
const input = command.input === undefined ? '' : ` ${command.input.hint}`
|
||||
@@ -1167,6 +1322,12 @@ export function createTuiChat(
|
||||
description: 'Clear the transcript view (session history is unchanged)',
|
||||
handler: () => { chat.clear(); requestRender(); return { kind: 'success' } },
|
||||
})
|
||||
commandCtx.commands.register({
|
||||
name: 'details',
|
||||
description: 'Select tool-card visibility and reasoning display',
|
||||
input: { hint: '[collapsed|expanded|hidden] [reasoning [on|off]]' },
|
||||
handler: ({ rawInput }) => runDetails(rawInput),
|
||||
})
|
||||
commandCtx.commands.register({
|
||||
name: 'palette',
|
||||
description: 'Show every color and attribute role this terminal renders',
|
||||
@@ -1506,7 +1667,32 @@ export function createTuiChat(
|
||||
if (event.type === 'tool/result') fileSearch.invalidate()
|
||||
recordEventUsage(tokens, event)
|
||||
if (event.type === 'turn/start' && runningStatus !== undefined) runningStatus.turn = event.data.turn
|
||||
if (event.type === 'assistant/message' && streaming?.isSettled()) streaming = undefined
|
||||
// Track live standalone compaction state.
|
||||
if (event.type === 'compact/start' && event.data.turn === null) {
|
||||
if (compacting === undefined) {
|
||||
const startedAt = now()
|
||||
compacting = {
|
||||
startedAt,
|
||||
timer: setInterval(renderStatus, STATUS_ANIMATION_INTERVAL_MS),
|
||||
}
|
||||
runtime.terminal.setProgress(true)
|
||||
}
|
||||
requestRender()
|
||||
return
|
||||
}
|
||||
if (event.type === 'compact/end' && event.data.turn === null && compacting !== undefined) {
|
||||
const fadeOutGlyph = runningPhaseGlyph(agent.session.events, false, true)
|
||||
clearInterval(compacting.timer)
|
||||
compacting = undefined
|
||||
if (event.data.error !== undefined) {
|
||||
appendNotice(`Compaction failed: ${event.data.error}`, 'warning')
|
||||
}
|
||||
// A concurrently running turn owns the indicator. Keep its timer and
|
||||
// progress bit instead of letting the compaction fade clear that state.
|
||||
if (runningStatus === undefined && fadeOutGlyph !== undefined) beginFadeOut(fadeOutGlyph)
|
||||
requestRender()
|
||||
return
|
||||
}
|
||||
// A replacement mutates only the model surface, so the rendered transcript
|
||||
// keeps what it already showed; a landed summary checkpoint adds its marker.
|
||||
if (isReplacementSurfaceEvent(event)) {
|
||||
@@ -1550,6 +1736,9 @@ export function createTuiChat(
|
||||
// TUI stays mounted. Retained agents accept deliveries after detachment, so
|
||||
// without this a later send would drive a zombie agent/session; mark
|
||||
// disposed so dispatchMessage reports it instead.
|
||||
// The hard clear also retires live compaction. A later compact/end is
|
||||
// intentionally presentation-silent: this disposal notice owns the
|
||||
// terminal outcome, and no animation may survive agent detachment.
|
||||
clearStatus()
|
||||
appendNotice(`Agent "${agent.id}" was disposed.`, 'warning')
|
||||
disposed = true
|
||||
@@ -1572,6 +1761,7 @@ export function createTuiChat(
|
||||
disposeAgent()
|
||||
disposeSchemeListener()
|
||||
disposeTargetListeners()
|
||||
modelController.detach()
|
||||
}
|
||||
|
||||
// Sweep reveal of the whole banner: the header wipes in left-to-right over
|
||||
@@ -1637,11 +1827,11 @@ export function createTuiChat(
|
||||
})
|
||||
startBannerReveal()
|
||||
|
||||
// A launcher-seeded first turn (`dsh migrate`/`dsh upgrade`): invoke the
|
||||
// named skill exactly as a typed `/skill:<name>` would, once the chat is live
|
||||
// and the agent is idle. The launcher sets this only for a fresh session, so
|
||||
// there is no prior turn to collide with; invokeSkill reports an unknown skill
|
||||
// as a notice.
|
||||
// A launcher-seeded first turn (`dsh migrate`/`dsh upgrade`):
|
||||
// invoke the named skill exactly as a typed `/skill:<name>` would, once the
|
||||
// chat is live and the agent is idle. The launcher sets this only for a fresh
|
||||
// session, so there is no prior turn to collide with; invokeSkill reports an
|
||||
// unknown skill as a notice.
|
||||
if (config.initialSkill !== undefined) invokeSkill(config.initialSkill, '')
|
||||
|
||||
return {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
|
||||
const theme: TuiTheme = Object.freeze({
|
||||
text: (value: string) => `text:${value}`,
|
||||
brand: (value: string) => `brand:${value}`,
|
||||
muted: (value: string) => `muted:${value}`,
|
||||
dim: (value: string) => `dim:${value}`,
|
||||
accent: (value: string) => `accent:${value}`,
|
||||
|
||||
@@ -228,13 +228,14 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
steeredOptions.push(input)
|
||||
const id = input.id
|
||||
steeredIds.push(id)
|
||||
return id
|
||||
return { outcome: Promise.resolve({ status: 'admitted' as const, turn: 1, step: 1 }) }
|
||||
},
|
||||
inject(input) {
|
||||
injected.push(input.content)
|
||||
injectedOptions.push(input)
|
||||
return input.id
|
||||
},
|
||||
reserveTurnAdmission: () => undefined,
|
||||
cancel(cause) {
|
||||
cancelled.push(cause)
|
||||
},
|
||||
|
||||
42
packages/ui/tui/tests/snapshots/details-command.expected.txt
Normal file
42
packages/ui/tui/tests/snapshots/details-command.expected.txt
Normal file
@@ -0,0 +1,42 @@
|
||||
terminal 100x40 buffer=normal length=40 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=17 bufferRow=17
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Running the check now. "
|
||||
6| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
7| <blank>
|
||||
8| "You "
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
9| "Inspect the renderer. "
|
||||
10| "Model wait 0.0s · Completed 2026-07-30 18:00:00 "
|
||||
style 0-46 dim
|
||||
11| <blank>
|
||||
12| "Reasoning blocks hidden. "
|
||||
style 0-23 dim
|
||||
13| <blank>
|
||||
14| "Tool cards hidden. "
|
||||
style 0-17 dim
|
||||
15| <blank>
|
||||
16| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
17| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
18-39| <blank>
|
||||
@@ -0,0 +1,72 @@
|
||||
terminal 100x40 buffer=normal length=40 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=0 viewportRow=39 bufferRow=39
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Running the check now. "
|
||||
6| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
7| <blank>
|
||||
8| "You "
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
9| "Inspect the renderer. "
|
||||
10| <blank>
|
||||
11| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
12| <blank>
|
||||
13| "● Tool / bash / Run the coverage gate"
|
||||
style 0-36 fg=green
|
||||
14| "$ pnpm run test:coverage "
|
||||
style 0-23 dim
|
||||
15| "/workspace/project "
|
||||
style 0-17 dim
|
||||
16| "… +4 lines (Ctrl+O to expand) "
|
||||
style 0-28 dim
|
||||
17| "[exit 0] ╭ Transcript details ──────────────────────────────────────────────────╮ "
|
||||
style 0-7 dim
|
||||
style 14-85 fg=bright-magenta
|
||||
18| "Model wait 0.0│ → Tool cards collapsed │ "
|
||||
style 0-13 dim
|
||||
style 14-14 fg=bright-magenta
|
||||
style 16-58 fg=bright-magenta inverse
|
||||
style 85-85 fg=bright-magenta
|
||||
19| " │ Reasoning hidden │ "
|
||||
style 14-14 fg=bright-magenta
|
||||
style 27-55 dim
|
||||
style 85-85 fg=bright-magenta
|
||||
20| "Reasoning bloc│ │ "
|
||||
style 0-13 dim
|
||||
style 14-14 fg=bright-magenta
|
||||
style 85-85 fg=bright-magenta
|
||||
21| " │ ↑/↓ move • Tab toggle • Enter/Esc close │ "
|
||||
style 14-14 fg=bright-magenta
|
||||
style 16-54 dim
|
||||
style 85-85 fg=bright-magenta
|
||||
22| "Tool cards hid╰──────────────────────────────────────────────────────────────────────╯ "
|
||||
style 0-13 dim
|
||||
style 14-85 fg=bright-magenta
|
||||
23| <blank>
|
||||
24| "Tool and context cards collapsed. "
|
||||
style 0-32 dim
|
||||
25| <blank>
|
||||
26| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
27| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
28-39| <blank>
|
||||
@@ -1,7 +1,7 @@
|
||||
terminal 92x32 buffer=normal length=37 base=5 viewport=5
|
||||
terminal 92x32 buffer=normal length=39 base=7 viewport=7
|
||||
lifecycle started=1 stopped=1 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor visible column=0 viewportRow=31 bufferRow=36
|
||||
cursor visible column=0 viewportRow=31 bufferRow=38
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
@@ -29,48 +29,52 @@ buffer
|
||||
12| " "
|
||||
13| "/clear — Clear the transcript view (session history is unchanged) "
|
||||
style 0-64 dim
|
||||
14| "/exit — Exit after the active turn reaches idle "
|
||||
14| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Select tool-card visibility and "
|
||||
style 0-91 dim
|
||||
15| "reasoning display "
|
||||
style 0-16 dim
|
||||
16| "/exit — Exit after the active turn reaches idle "
|
||||
style 0-46 dim
|
||||
15| "/help — Show keyboard shortcuts and commands "
|
||||
17| "/help — Show keyboard shortcuts and commands "
|
||||
style 0-43 dim
|
||||
16| "/model [[provider/]model] — Show or switch this session's model "
|
||||
18| "/model [[provider/]model] — Show or switch this session's model "
|
||||
style 0-62 dim
|
||||
17| "/palette — Show every color and attribute role this terminal renders "
|
||||
19| "/palette — Show every color and attribute role this terminal renders "
|
||||
style 0-67 dim
|
||||
18| "/quit — Exit after the active turn reaches idle "
|
||||
20| "/quit — Exit after the active turn reaches idle "
|
||||
style 0-46 dim
|
||||
19| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
|
||||
21| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
|
||||
style 0-87 dim
|
||||
20| "/resume — List this workspace's resumable sessions "
|
||||
22| "/resume — List this workspace's resumable sessions "
|
||||
style 0-49 dim
|
||||
21| "/status — Show session diagnostics, system prompt, and registered tools "
|
||||
23| "/status — Show session diagnostics, system prompt, and registered tools "
|
||||
style 0-70 dim
|
||||
22| "/skill:<name> [instructions] — load a skill into the conversation "
|
||||
24| "/skill:<name> [instructions] — load a skill into the conversation "
|
||||
style 0-64 dim
|
||||
23| <blank>
|
||||
24| "provider stream failed after partial output "
|
||||
style 0-42 fg=red
|
||||
25| <blank>
|
||||
26| "The previous process ended during this turn. "
|
||||
style 0-43 fg=yellow
|
||||
26| "provider stream failed after partial output "
|
||||
style 0-42 fg=red
|
||||
27| <blank>
|
||||
28| "Turn stopped: the agent was disposed. "
|
||||
style 0-36 fg=yellow
|
||||
28| "The previous process ended during this turn. "
|
||||
style 0-43 fg=yellow
|
||||
29| <blank>
|
||||
30| "Turn ended: plugin-policy. "
|
||||
style 0-25 fg=yellow
|
||||
30| "Turn stopped: the agent was disposed. "
|
||||
style 0-36 fg=yellow
|
||||
31| <blank>
|
||||
32| "Unknown command: /unknown-advanced-command "
|
||||
style 0-41 fg=yellow
|
||||
32| "Turn ended: plugin-policy. "
|
||||
style 0-25 fg=yellow
|
||||
33| <blank>
|
||||
34| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
34| "Unknown command: /unknown-advanced-command "
|
||||
style 0-41 fg=yellow
|
||||
35| <blank>
|
||||
36| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
35| " dsh > "
|
||||
37| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
36| <blank>
|
||||
38| <blank>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
terminal 92x32 buffer=normal length=36 base=4 viewport=4
|
||||
terminal 92x32 buffer=normal length=38 base=6 viewport=6
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=31 bufferRow=35
|
||||
cursor hidden column=7 viewportRow=31 bufferRow=37
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
@@ -29,47 +29,51 @@ buffer
|
||||
12| " "
|
||||
13| "/clear — Clear the transcript view (session history is unchanged) "
|
||||
style 0-64 dim
|
||||
14| "/exit — Exit after the active turn reaches idle "
|
||||
14| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Select tool-card visibility and "
|
||||
style 0-91 dim
|
||||
15| "reasoning display "
|
||||
style 0-16 dim
|
||||
16| "/exit — Exit after the active turn reaches idle "
|
||||
style 0-46 dim
|
||||
15| "/help — Show keyboard shortcuts and commands "
|
||||
17| "/help — Show keyboard shortcuts and commands "
|
||||
style 0-43 dim
|
||||
16| "/model [[provider/]model] — Show or switch this session's model "
|
||||
18| "/model [[provider/]model] — Show or switch this session's model "
|
||||
style 0-62 dim
|
||||
17| "/palette — Show every color and attribute role this terminal renders "
|
||||
19| "/palette — Show every color and attribute role this terminal renders "
|
||||
style 0-67 dim
|
||||
18| "/quit — Exit after the active turn reaches idle "
|
||||
20| "/quit — Exit after the active turn reaches idle "
|
||||
style 0-46 dim
|
||||
19| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
|
||||
21| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
|
||||
style 0-87 dim
|
||||
20| "/resume — List this workspace's resumable sessions "
|
||||
22| "/resume — List this workspace's resumable sessions "
|
||||
style 0-49 dim
|
||||
21| "/status — Show session diagnostics, system prompt, and registered tools "
|
||||
23| "/status — Show session diagnostics, system prompt, and registered tools "
|
||||
style 0-70 dim
|
||||
22| "/skill:<name> [instructions] — load a skill into the conversation "
|
||||
24| "/skill:<name> [instructions] — load a skill into the conversation "
|
||||
style 0-64 dim
|
||||
23| <blank>
|
||||
24| "provider stream failed after partial output "
|
||||
style 0-42 fg=red
|
||||
25| <blank>
|
||||
26| "The previous process ended during this turn. "
|
||||
style 0-43 fg=yellow
|
||||
26| "provider stream failed after partial output "
|
||||
style 0-42 fg=red
|
||||
27| <blank>
|
||||
28| "Turn stopped: the agent was disposed. "
|
||||
style 0-36 fg=yellow
|
||||
28| "The previous process ended during this turn. "
|
||||
style 0-43 fg=yellow
|
||||
29| <blank>
|
||||
30| "Turn ended: plugin-policy. "
|
||||
style 0-25 fg=yellow
|
||||
30| "Turn stopped: the agent was disposed. "
|
||||
style 0-36 fg=yellow
|
||||
31| <blank>
|
||||
32| "Unknown command: /unknown-advanced-command "
|
||||
style 0-41 fg=yellow
|
||||
32| "Turn ended: plugin-policy. "
|
||||
style 0-25 fg=yellow
|
||||
33| <blank>
|
||||
34| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
34| "Unknown command: /unknown-advanced-command "
|
||||
style 0-41 fg=yellow
|
||||
35| <blank>
|
||||
36| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
35| " dsh > "
|
||||
37| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
terminal 100x40 buffer=normal length=40 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=20 bufferRow=20
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Inspecting the renderer first. "
|
||||
6| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
7| <blank>
|
||||
8| "You "
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
9| "Refactor the renderer. "
|
||||
10| "Model wait 0.0s · Completed 2026-07-29 22:30:00 "
|
||||
style 0-46 dim
|
||||
11| <blank>
|
||||
12| "The renderer is sound; no refactor needed. "
|
||||
13| "Model wait 0.0s · Completed 2026-07-29 22:30:00 "
|
||||
style 0-46 dim
|
||||
14| <blank>
|
||||
15| "Tool and context cards expanded. "
|
||||
style 0-31 dim
|
||||
16| <blank>
|
||||
17| "Tool cards hidden. "
|
||||
style 0-17 dim
|
||||
18| <blank>
|
||||
19| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
20| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
21-39| <blank>
|
||||
@@ -13,46 +13,41 @@ buffer
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| <blank>
|
||||
6| "You "
|
||||
5| "Reasoning "
|
||||
style 0-8 dim italic
|
||||
6| "Unsafe reasoning \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 0-61 dim italic
|
||||
7| "Unsafe assistant \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
8| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
9| <blank>
|
||||
10| "You "
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
7| "Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
8| <blank>
|
||||
9| "● Tool / unsafe / Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
|
||||
11| "Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
12| <blank>
|
||||
13| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
14| <blank>
|
||||
15| "● Tool / unsafe / Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
|
||||
style 0-81 fg=green
|
||||
10| "$ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
16| "$ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 0-59 dim
|
||||
11| "/unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
17| "/unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 0-52 dim
|
||||
12| "Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
18| "Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 0-58 dim
|
||||
13| "[signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] "
|
||||
19| "[signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] "
|
||||
style 0-56 fg=red
|
||||
14| "Model wait 0.0s · Completed 2026-07-21 15:00:00 "
|
||||
20| "Model wait 0.0s · Completed 2026-07-21 15:00:00 "
|
||||
style 0-46 dim
|
||||
15| <blank>
|
||||
16| "Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
|
||||
21| <blank>
|
||||
22| "Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
|
||||
style 0-61 dim
|
||||
17| "Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
23| "Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 0-59 dim
|
||||
18| <blank>
|
||||
19| "Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
24| <blank>
|
||||
25| "Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 0-62 fg=red
|
||||
20-21| <blank>
|
||||
22| "Plan"
|
||||
style 0-3 fg=bright-magenta bold
|
||||
23| " ● Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
|
||||
style 2-2 fg=yellow
|
||||
24| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
25| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
26| " "
|
||||
27| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 2-90 dim
|
||||
|
||||
@@ -44,6 +44,9 @@ const CHECKPOINTS = [
|
||||
'cordis-tools-pending',
|
||||
'advanced-cards-collapsed',
|
||||
'advanced-cards-expanded',
|
||||
'tool-cards-hidden-folded',
|
||||
'details-command',
|
||||
'details-selector',
|
||||
'untrusted-controls',
|
||||
'question-dialog',
|
||||
'question-dialog-single-option',
|
||||
@@ -630,6 +633,71 @@ describe('TUI terminal-state snapshots', () => {
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('pins the hidden phase folding a multi-step turn into one assistant message', async () => {
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 29, 22, 30, 0).getTime())
|
||||
const harness = await setupSnapshot({
|
||||
tools: ADVANCED_CARD_TOOLS,
|
||||
config: { maxToolOutputLines: 3 },
|
||||
}, { columns: 100, rows: 40 })
|
||||
await renderAfter(harness, () => {
|
||||
appendUser(harness.session, 'Refactor the renderer.')
|
||||
appendAssistant(harness.session, [{ type: 'text', text: 'Inspecting the renderer first.' }])
|
||||
appendToolCalls(harness.session, [
|
||||
{ id: 'fold-1', name: 'bash', arguments: { command: 'pnpm run test' } },
|
||||
])
|
||||
appendToolResult(harness.session, 'fold-1', [{ type: 'text', text: 'all tests pass' }])
|
||||
harness.session.append('step/end', { turn: 1, step: 1 })
|
||||
harness.session.append('step/start', { turn: 1, step: 2 })
|
||||
appendAssistant(harness.session, [{ type: 'text', text: 'The renderer is sound; no refactor needed.' }], undefined, { turn: 1, step: 2 })
|
||||
harness.session.append('step/end', { turn: 1, step: 2 })
|
||||
harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
})
|
||||
// collapsed -> expanded -> hidden: one Assistant header, no tool card.
|
||||
await renderAfter(harness, () => { harness.terminal.send('\x0f') })
|
||||
await renderAfter(harness, () => { harness.terminal.send('\x0f') })
|
||||
await checkpoint('tool-cards-hidden-folded', harness.terminal, { includeScrollback: true })
|
||||
nowSpy.mockRestore()
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('pins /details jumping card visibility and reasoning display to named states', async () => {
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 30, 18, 0, 0).getTime())
|
||||
const harness = await setupSnapshot({
|
||||
tools: ADVANCED_CARD_TOOLS,
|
||||
config: { maxToolOutputLines: 3 },
|
||||
}, { columns: 100, rows: 40 })
|
||||
await renderAfter(harness, () => {
|
||||
appendUser(harness.session, 'Inspect the renderer.')
|
||||
appendAssistant(harness.session, [
|
||||
{ type: 'reasoning', text: 'The tool card and this block vanish under /details hidden reasoning off.' },
|
||||
{ type: 'text', text: 'Running the check now.' },
|
||||
])
|
||||
appendToolCalls(harness.session, [
|
||||
{ id: 'details-1', name: 'bash', arguments: { command: 'pnpm run test' } },
|
||||
])
|
||||
appendToolResult(harness.session, 'details-1', [{ type: 'text', text: 'all tests pass' }])
|
||||
harness.session.append('step/end', { turn: 1, step: 1 })
|
||||
harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
})
|
||||
await renderAfter(harness, () => {
|
||||
harness.terminal.send('/details hidden reasoning off')
|
||||
harness.terminal.send('\r')
|
||||
})
|
||||
await checkpoint('details-command', harness.terminal, { includeScrollback: true })
|
||||
// Bare /details opens the two-entry toggle seeded with the current
|
||||
// hidden/reasoning-off state; one Tab immediately cycles tool cards
|
||||
// hidden -> collapsed, so the frame pins the applied notice, the restored
|
||||
// tool card behind the dialog, and the updated entry value together.
|
||||
await renderAfter(harness, () => {
|
||||
harness.terminal.send('/details')
|
||||
harness.terminal.send('\r')
|
||||
harness.terminal.send('\t')
|
||||
})
|
||||
await checkpoint('details-selector', harness.terminal, { includeScrollback: true })
|
||||
nowSpy.mockRestore()
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('renders terminal controls as inert text across transcripts, tools, dialogs, diagnostics, and title', async () => {
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 15, 0, 0).getTime())
|
||||
const tools = {
|
||||
|
||||
@@ -10,6 +10,7 @@ import AgentRegistry, {
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage,
|
||||
createToolResultMessage,
|
||||
LlmError,
|
||||
ReasoningEffortId,
|
||||
type LlmCallConfig,
|
||||
type LlmModelReasoningInfo,
|
||||
@@ -41,7 +42,7 @@ import {
|
||||
type TuiRuntime,
|
||||
} from '../src/index.ts'
|
||||
import { WorkspaceFileSearch } from '../src/chat/file-autocomplete.ts'
|
||||
import { ATTRIBUTE_ROLES, COLOR_ROLES, paletteSpec } from '../src/components/theme.ts'
|
||||
import { ATTRIBUTE_ROLES, brandText, COLOR_ROLES, paletteSpec } from '../src/components/theme.ts'
|
||||
import {
|
||||
appendAssistant,
|
||||
appendUser,
|
||||
@@ -137,6 +138,12 @@ async function tick(): Promise<void> {
|
||||
await new Promise(resolve => setTimeout(resolve, 25))
|
||||
}
|
||||
|
||||
function promptWidth(output: string): number {
|
||||
const row = output.split('\n').find(line => line.includes('dsh'))
|
||||
if (row === undefined) throw new Error('prompt row not rendered')
|
||||
return visibleWidth(row.slice(row.indexOf('dsh'), row.indexOf('dsh') + 6))
|
||||
}
|
||||
|
||||
async function setup(options: TuiHarnessOptions = {}) {
|
||||
const terminal = new FakeTerminal()
|
||||
const exit = vi.fn()
|
||||
@@ -186,6 +193,7 @@ describe('TUI config', () => {
|
||||
questionDialogMaxHeight: 20,
|
||||
modelDialogWidth: 76,
|
||||
modelDialogMaxHeight: 20,
|
||||
detailsDialogWidth: 72,
|
||||
fileSearchMaxResults: 20,
|
||||
fileSearchMaxEntries: 10_000,
|
||||
fileSearchExcludedDirectories: ['.git', 'node_modules'],
|
||||
@@ -211,6 +219,7 @@ describe('TUI config', () => {
|
||||
questionDialogMaxHeight: 14,
|
||||
modelDialogWidth: 64,
|
||||
modelDialogMaxHeight: 16,
|
||||
detailsDialogWidth: 44,
|
||||
fileSearchMaxResults: 7,
|
||||
fileSearchMaxEntries: 123,
|
||||
fileSearchExcludedDirectories: ['.git', 'generated'],
|
||||
@@ -228,6 +237,7 @@ describe('TUI config', () => {
|
||||
questionDialogMaxHeight: 14,
|
||||
modelDialogWidth: 64,
|
||||
modelDialogMaxHeight: 16,
|
||||
detailsDialogWidth: 44,
|
||||
fileSearchMaxResults: 7,
|
||||
fileSearchMaxEntries: 123,
|
||||
fileSearchExcludedDirectories: ['.git', 'generated'],
|
||||
@@ -1953,12 +1963,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
// `dsh <glyph> ` with the same visible width as the idle `dsh > `, so the
|
||||
// cursor never shifts. Assert both the glyph slot and that constant width
|
||||
// (color is off in this harness, so output carries no ANSI to strip).
|
||||
const promptWidth = (): number => {
|
||||
const row = result.terminal.output.split('\n').find(line => line.includes('dsh'))
|
||||
if (row === undefined) throw new Error('prompt row not rendered')
|
||||
return visibleWidth(row.slice(row.indexOf('dsh'), row.indexOf('dsh') + 6))
|
||||
}
|
||||
|
||||
// Each phase swaps only the glyph character in the same slot at equal width.
|
||||
const phaseGlyph: [() => void, string][] = [
|
||||
[() => result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'weighing' } }), 'dsh ✻ '],
|
||||
@@ -1971,8 +1975,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
drive()
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain(expected)
|
||||
runningWidth ??= promptWidth()
|
||||
expect(promptWidth()).toBe(runningWidth)
|
||||
runningWidth ??= promptWidth(result.terminal.output)
|
||||
expect(promptWidth(result.terminal.output)).toBe(runningWidth)
|
||||
}
|
||||
|
||||
// Idle begins a fade-out; once it settles (clock past the fade window) the
|
||||
@@ -1989,12 +1993,189 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
return rows.at(-1) ?? ''
|
||||
}
|
||||
expect(promptRow()).toContain('dsh > ')
|
||||
expect(promptRow()).not.toMatch(/dsh(?:\x1b\[[0-9;]*m| )*[◍✻●⚙]/u)
|
||||
expect(promptWidth()).toBe(runningWidth)
|
||||
expect(promptRow()).not.toMatch(/dsh(?:\x1b\[[0-9;]*m| )*[◍✻●⚙⊙]/u)
|
||||
expect(promptWidth(result.terminal.output)).toBe(runningWidth)
|
||||
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('shows a live standalone compaction in the fixed status area', async () => {
|
||||
let clock = 0
|
||||
const result = await setup({ omitInitialLifecycle: true, now: () => clock })
|
||||
const idleWidth = promptWidth(result.terminal.output)
|
||||
|
||||
result.session.append('compact/start', { turn: null })
|
||||
clock = 1_000
|
||||
result.terminal.output = ''
|
||||
await new Promise(resolve => setTimeout(resolve, 75))
|
||||
|
||||
expect(result.terminal.output).toContain('dsh ⊙ ')
|
||||
expect(result.terminal.output).toContain('Context being compacted 1.0s')
|
||||
expect(promptWidth(result.terminal.output)).toBe(idleWidth)
|
||||
expect(result.terminal.progress.at(-1)).toBe(true)
|
||||
|
||||
clock = 1_450
|
||||
result.terminal.output = ''
|
||||
await new Promise(resolve => setTimeout(resolve, 75))
|
||||
expect(result.terminal.output).toContain('Context being compacted 1.4s')
|
||||
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('ignores a numbered compaction bracket while the status line is idle', async () => {
|
||||
const result = await setup({ now: () => 1_000 })
|
||||
result.session.append('compact/start', { turn: 1 })
|
||||
await tick()
|
||||
|
||||
expect(result.terminal.output).toContain('dsh > ')
|
||||
expect(result.terminal.output).not.toContain('dsh ⊙ ')
|
||||
expect(result.terminal.progress.at(-1)).toBe(false)
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('fades a closed standalone compaction back to the plain caret', async () => {
|
||||
let clock = 0
|
||||
const result = await setup({ omitInitialLifecycle: true, now: () => clock })
|
||||
clock = 1_000
|
||||
result.session.append('compact/start', { turn: null })
|
||||
await tick()
|
||||
result.session.append('compact/end', { turn: null })
|
||||
await tick()
|
||||
|
||||
clock = 2_000
|
||||
await new Promise(resolve => setTimeout(resolve, 120))
|
||||
result.terminal.output = ''
|
||||
result.terminal.resize(result.terminal.columns + 1)
|
||||
await tick()
|
||||
|
||||
expect(result.terminal.output).toContain('dsh > ')
|
||||
expect(result.terminal.output).not.toMatch(/dsh [◍✻●⚙⊙]/u)
|
||||
expect(result.terminal.output).not.toContain('Context being compacted')
|
||||
expect(result.terminal.progress.at(-1)).toBe(false)
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('reports a failed standalone compaction when its live bracket closes', async () => {
|
||||
const result = await setup({ omitInitialLifecycle: true, now: () => 1_000 })
|
||||
result.session.append('compact/start', { turn: null })
|
||||
result.terminal.output = ''
|
||||
result.session.append('compact/end', { turn: null, error: 'summary failed' })
|
||||
await tick()
|
||||
|
||||
expect(result.terminal.output).toContain('Compaction failed: summary failed')
|
||||
expect(result.terminal.progress.at(-1)).toBe(false)
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('preserves live compaction progress across an idle status edge', async () => {
|
||||
let clock = 0
|
||||
const result = await setup({ omitInitialLifecycle: true, now: () => clock })
|
||||
result.session.append('compact/start', { turn: null })
|
||||
clock = 1_000
|
||||
result.terminal.output = ''
|
||||
result.ctx.emit('agent/status', result.agent, 'idle')
|
||||
result.terminal.resize(result.terminal.columns + 1)
|
||||
await tick()
|
||||
|
||||
expect(result.terminal.output).toContain('dsh ⊙ ')
|
||||
expect(result.terminal.progress.at(-1)).toBe(true)
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('keeps a running turn phase glyph ahead of standalone compaction', async () => {
|
||||
let clock = 0
|
||||
const result = await setup({ status: 'running', now: () => clock })
|
||||
clock = 1_000
|
||||
result.terminal.output = ''
|
||||
result.session.append('compact/start', { turn: null })
|
||||
await tick()
|
||||
|
||||
expect(result.terminal.output).toContain('dsh ◍ ')
|
||||
expect(result.terminal.output).not.toContain('dsh ⊙ ')
|
||||
result.session.append('compact/end', { turn: null })
|
||||
await tick()
|
||||
result.terminal.output = ''
|
||||
result.terminal.resize(result.terminal.columns + 1)
|
||||
await tick()
|
||||
|
||||
expect(result.terminal.output).toContain('dsh ◍ ')
|
||||
expect(result.terminal.output).not.toContain('dsh ⊙ ')
|
||||
expect(result.terminal.progress.at(-1)).toBe(true)
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('treats duplicate live compaction starts as one owned bracket', async () => {
|
||||
const intervalSpy = vi.spyOn(globalThis, 'setInterval')
|
||||
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval')
|
||||
let result: Awaited<ReturnType<typeof setup>> | undefined
|
||||
let didDispose = false
|
||||
let clock = 0
|
||||
try {
|
||||
result = await setup({ omitInitialLifecycle: true, now: () => clock })
|
||||
intervalSpy.mockClear()
|
||||
clearIntervalSpy.mockClear()
|
||||
result.session.append('compact/start', { turn: null })
|
||||
clock = 1_000
|
||||
result.session.append('compact/start', { turn: null })
|
||||
await tick()
|
||||
|
||||
expect(intervalSpy).toHaveBeenCalledOnce()
|
||||
expect(result.terminal.output).toContain('dsh ⊙ ')
|
||||
expect(result.terminal.progress.at(-1)).toBe(true)
|
||||
|
||||
result.session.append('compact/end', { turn: null })
|
||||
await tick()
|
||||
expect(clearIntervalSpy).toHaveBeenCalledOnce()
|
||||
expect(result.terminal.progress.at(-1)).toBe(false)
|
||||
|
||||
await dispose(result)
|
||||
didDispose = true
|
||||
} finally {
|
||||
if (result !== undefined && !didDispose) await dispose(result)
|
||||
intervalSpy.mockRestore()
|
||||
clearIntervalSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not show compaction progress for a resumed orphaned start', async () => {
|
||||
const result = await setup({
|
||||
omitInitialLifecycle: true,
|
||||
now: () => 1_000,
|
||||
beforeMount(session) {
|
||||
session.append('compact/start', { turn: null })
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.terminal.output).toContain('dsh > ')
|
||||
expect(result.terminal.output).not.toContain('dsh ⊙ ')
|
||||
expect(result.terminal.output).not.toContain('Context being compacted')
|
||||
expect(result.terminal.progress.at(-1)).toBe(false)
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('releases the live compaction timer and progress bit on dispose', async () => {
|
||||
const intervalSpy = vi.spyOn(globalThis, 'setInterval')
|
||||
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval')
|
||||
let result: Awaited<ReturnType<typeof setup>> | undefined
|
||||
let didDispose = false
|
||||
try {
|
||||
result = await setup({ omitInitialLifecycle: true, now: () => 1_000 })
|
||||
intervalSpy.mockClear()
|
||||
clearIntervalSpy.mockClear()
|
||||
result.session.append('compact/start', { turn: null })
|
||||
expect(intervalSpy).toHaveBeenCalledOnce()
|
||||
|
||||
await dispose(result)
|
||||
didDispose = true
|
||||
expect(clearIntervalSpy).toHaveBeenCalledOnce()
|
||||
expect(result.terminal.progress.at(-1)).toBe(false)
|
||||
} finally {
|
||||
if (result !== undefined && !didDispose) await dispose(result)
|
||||
intervalSpy.mockRestore()
|
||||
clearIntervalSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
// Extract the running glyph's interpolated gray channel from a rendered frame.
|
||||
const glyphGray = (frame: string): number => {
|
||||
const m = /\x1b\[38;2;(\d+);(\d+);(\d+)m●/u.exec(frame)
|
||||
@@ -2102,7 +2283,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
it('shows the plain prompt caret while idle', async () => {
|
||||
const result = await setup({ now: () => 0 })
|
||||
expect(result.terminal.output).toContain('dsh > ')
|
||||
expect(result.terminal.output).not.toMatch(/dsh [◍✻●⚙]/u)
|
||||
expect(result.terminal.output).not.toMatch(/dsh [◍✻●⚙⊙]/u)
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
@@ -2512,6 +2693,94 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('/details sets card visibility and reasoning display from arguments', async () => {
|
||||
const result = await setup()
|
||||
const run = async (line: string): Promise<void> => {
|
||||
result.terminal.send(line)
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
}
|
||||
|
||||
await run('/details hidden')
|
||||
expect(result.terminal.output).toContain('Tool cards hidden.')
|
||||
|
||||
await run('/details expanded reasoning off')
|
||||
expect(result.terminal.output).toContain('Tool and context cards expanded.')
|
||||
expect(result.terminal.output).toContain('Reasoning blocks hidden.')
|
||||
|
||||
await run('/details reasoning on')
|
||||
expect(result.terminal.output).toContain('Reasoning blocks shown.')
|
||||
|
||||
// Bare `reasoning` toggles: shown -> hidden.
|
||||
const toggleOutput = result.terminal.output.length
|
||||
await run('/details reasoning')
|
||||
expect(result.terminal.output.slice(toggleOutput)).toContain('Reasoning blocks hidden.')
|
||||
await run('/details collapsed')
|
||||
expect(result.terminal.output.slice(toggleOutput)).toContain('Tool and context cards collapsed.')
|
||||
|
||||
await run('/details bogus')
|
||||
expect(result.terminal.output).toContain('Unknown /details argument "bogus"')
|
||||
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('bare /details opens the transcript-details toggle and Tab applies immediately', async () => {
|
||||
const result = await setup()
|
||||
const open = async (): Promise<number> => {
|
||||
const from = result.terminal.output.length
|
||||
result.terminal.send('/details')
|
||||
result.terminal.send('\r')
|
||||
await vi.waitFor(() => { expect(result.terminal.output.slice(from)).toContain('Transcript details') })
|
||||
return from
|
||||
}
|
||||
|
||||
const opened = await open()
|
||||
expect(result.terminal.output.slice(opened)).toContain('Tool cards')
|
||||
expect(result.terminal.output.slice(opened)).toContain('Reasoning')
|
||||
|
||||
// A second /details while the selector is open replaces the overlay
|
||||
// instead of stacking a second one behind it.
|
||||
await result.ctx.commands.execute(result.agent, '/details', new AbortController().signal)
|
||||
await tick()
|
||||
|
||||
// Each Tab applies one step immediately while the dialog stays open:
|
||||
// collapsed -> expanded -> hidden -> collapsed (wraparound).
|
||||
result.terminal.send('\t')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Tool and context cards expanded.')
|
||||
result.terminal.send('\t')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Tool cards hidden.')
|
||||
result.terminal.send('\t')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Tool and context cards collapsed.')
|
||||
|
||||
// The reasoning entry toggles the same way.
|
||||
result.terminal.send('\x1b[B')
|
||||
result.terminal.send('\t')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Reasoning blocks hidden.')
|
||||
|
||||
// Enter closes without further changes.
|
||||
const entered = result.terminal.output.length
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.terminal.output.slice(entered)).not.toContain('Reasoning blocks')
|
||||
|
||||
// Esc and Ctrl+C also close; the reopened dialog shows the live values.
|
||||
const reopened = await open()
|
||||
expect(result.terminal.output.slice(reopened)).toContain('collapsed')
|
||||
expect(result.terminal.output.slice(reopened)).toContain('hidden')
|
||||
result.terminal.send('\x1b')
|
||||
await tick()
|
||||
const ctrlCOutput = await open()
|
||||
result.terminal.send('\x03')
|
||||
await tick()
|
||||
expect(result.terminal.output.slice(ctrlCOutput)).not.toContain('Reasoning blocks')
|
||||
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('sends, steers, handles commands, global keys, and disposed-agent input', async () => {
|
||||
const result = await setup()
|
||||
|
||||
@@ -3633,6 +3902,96 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
await dispose(reasoningFailed)
|
||||
})
|
||||
|
||||
it('defers a NO_ADAPTER context resolution until the provider registers instead of surfacing an error', async () => {
|
||||
// Loader activation order is service-driven: the TUI can mount before a
|
||||
// configured adapter plugin activates, so the initial resolveModelInfo
|
||||
// fails with NO_ADAPTER. That transient state must not print an error;
|
||||
// the resolution retries on llm/adapters-updated.
|
||||
const adapters = new Set<string>()
|
||||
const result = await setup({
|
||||
agentOptions: { provider: 'openai-codex', model: 'gpt-x' },
|
||||
contextTokens: 50_000,
|
||||
catalog: {
|
||||
providers: [],
|
||||
models: [],
|
||||
resolveModelInfo: () => adapters.has('openai-codex')
|
||||
? Promise.resolve({ context: { contextWindow: 100_000 } })
|
||||
: Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER')),
|
||||
},
|
||||
})
|
||||
await tick()
|
||||
expect(result.terminal.output).not.toContain('Could not resolve model context')
|
||||
|
||||
// A topology commit that still lacks the route parks the wait again.
|
||||
result.ctx.emit('llm/adapters-updated')
|
||||
await tick()
|
||||
expect(result.terminal.output).not.toContain('% context')
|
||||
expect(result.terminal.output).not.toContain('Could not resolve model context')
|
||||
|
||||
adapters.add('openai-codex')
|
||||
result.ctx.emit('llm/adapters-updated')
|
||||
await vi.waitFor(() => {
|
||||
expect(result.terminal.output).toContain('% context')
|
||||
})
|
||||
expect(result.terminal.output).not.toContain('Could not resolve model context')
|
||||
|
||||
// A commit after satisfaction is a no-op for the resolved value.
|
||||
result.ctx.emit('llm/adapters-updated')
|
||||
await tick()
|
||||
expect(result.terminal.output).not.toContain('Could not resolve model context')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('stops listening for adapter registrations after channel detach', async () => {
|
||||
// The listener disposer rides detachListeners() through the controller's
|
||||
// detach(): after dispose, a registry commit must not re-enter resolution
|
||||
// at all (the isDisposed() guard is a fallback, not the removal).
|
||||
const calls: string[] = []
|
||||
const result = await setup({
|
||||
agentOptions: { provider: 'openai-codex', model: 'gpt-x' },
|
||||
catalog: {
|
||||
providers: [],
|
||||
models: [],
|
||||
resolveModelInfo: (provider) => {
|
||||
calls.push(provider)
|
||||
return Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER'))
|
||||
},
|
||||
},
|
||||
})
|
||||
await tick()
|
||||
const callsAtDetach = calls.length
|
||||
await result.controller.dispose()
|
||||
result.ctx.emit('llm/adapters-updated')
|
||||
await tick()
|
||||
expect(calls.length).toBe(callsAtDetach)
|
||||
await result.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('drops a deferred NO_ADAPTER resolution when the target moved before the adapter registered', async () => {
|
||||
const result = await setup({
|
||||
agentOptions: { provider: 'openai-codex', model: 'gpt-x' },
|
||||
catalog: {
|
||||
providers: [{ id: 'alpha', name: 'Alpha' }],
|
||||
models: [{ provider: 'alpha', id: 'a1', name: 'Alpha One' }],
|
||||
resolveModelInfo: provider => provider === 'alpha'
|
||||
? Promise.resolve({ context: { contextWindow: 64_000 } })
|
||||
: Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER')),
|
||||
},
|
||||
})
|
||||
await tick()
|
||||
// Switching the model re-resolves and clears the deferred wait, so the
|
||||
// stale route's adapter arriving afterwards must be a no-op.
|
||||
result.terminal.send('/model alpha/a1')
|
||||
result.terminal.send('\r')
|
||||
await vi.waitFor(() => {
|
||||
expect(result.terminal.output).toContain('Model selected: alpha/a1')
|
||||
})
|
||||
result.ctx.emit('llm/adapters-updated')
|
||||
await tick()
|
||||
expect(result.terminal.output).not.toContain('Could not resolve model context')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('does not render a model catalog that resolves after TUI disposal', async () => {
|
||||
const deferred = Promise.withResolvers<never[]>()
|
||||
const result = await setup({
|
||||
@@ -3936,8 +4295,9 @@ describe('skill slash command', () => {
|
||||
source: 'runtime',
|
||||
content: 'Dynamic body.',
|
||||
})
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('DYNAMIC_COMPLETION_MARKER')
|
||||
await vi.waitFor(() => {
|
||||
expect(result.terminal.output).toContain('DYNAMIC_COMPLETION_MARKER')
|
||||
})
|
||||
|
||||
result.terminal.send('\x03')
|
||||
disposeSkill()
|
||||
@@ -4394,6 +4754,20 @@ describe('tool cards and surface replay', () => {
|
||||
presentCall: () => ({ card: 'generic', title: 'Becomes terminal' }),
|
||||
presentResult: () => ({ card: 'terminal', output: 'converted terminal' }),
|
||||
},
|
||||
// A search card carries no result text of its own; the TUI has no dedicated
|
||||
// search arm and falls back to the raw result content, rendered as the same
|
||||
// dim generic body a pre-search-card grep/glob result showed.
|
||||
search: {
|
||||
name: 'search', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
|
||||
presentCall: () => ({ card: 'generic', title: 'Grep todo', kind: 'search' }),
|
||||
presentResult: () => ({
|
||||
card: 'search',
|
||||
shape: 'matches',
|
||||
files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'todo one' }] }],
|
||||
truncated: false,
|
||||
total: 1,
|
||||
}),
|
||||
},
|
||||
symbolic: {
|
||||
name: 'symbolic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
|
||||
presentCall: () => ({ card: 'generic', title: 'Symbol input', rawInput: Symbol('input') }),
|
||||
@@ -4431,6 +4805,7 @@ describe('tool cards and surface replay', () => {
|
||||
['c12', 'symbolic', '{}'],
|
||||
['c13', 'knownXml', '{}'],
|
||||
['c16', 'webCard', '{}'],
|
||||
['c17', 'search', '{"pattern":"todo"}'],
|
||||
] as const
|
||||
appendAssistant(result.session, [
|
||||
{ type: 'text', text: 'Calling tools' },
|
||||
@@ -4532,6 +4907,14 @@ describe('tool cards and surface replay', () => {
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: 'c17' as never,
|
||||
content: [{ type: 'text', text: 'Found 1 match\n\na.ts\nLine 1: todo one' }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
@@ -4563,6 +4946,11 @@ describe('tool cards and surface replay', () => {
|
||||
expect(output).toContain('$ blank desc command')
|
||||
// A card whose title only repeats the name renders header-only (empty body).
|
||||
expect(output).toContain('Tool / emptyBody')
|
||||
// A search result view carries no `content` of its own, so the card renders
|
||||
// the raw model-facing result text through the same dim generic body — the
|
||||
// TUI has no dedicated search arm.
|
||||
expect(output).toContain('Tool / search')
|
||||
expect(output).toContain('Line 1: todo one')
|
||||
// A diff card drops its title (the paths + change footer carry the meaning).
|
||||
// The first file's path is head-visible; the second file and the change
|
||||
// footer sit past this card's 4-line budget and appear only when expanded.
|
||||
@@ -4712,13 +5100,19 @@ describe('tool cards and surface replay', () => {
|
||||
|
||||
it('bounds and caches exact diff comparison before whole-side fallback', async () => {
|
||||
let oldTextReads = 0
|
||||
let newText = 'new one\nnew two'
|
||||
const boundedDiff = {
|
||||
path: 'bounded.txt',
|
||||
get oldText() {
|
||||
oldTextReads += 1
|
||||
return 'old one\nold two'
|
||||
},
|
||||
newText: 'new one\nnew two',
|
||||
get newText() { return newText },
|
||||
}
|
||||
const boundedView = {
|
||||
card: 'diff' as const,
|
||||
title: 'Edit bounded.txt',
|
||||
diffs: [boundedDiff],
|
||||
}
|
||||
const bounded: Record<string, ToolDefinition> = {
|
||||
bounded: {
|
||||
@@ -4727,11 +5121,11 @@ describe('tool cards and surface replay', () => {
|
||||
parameters: {},
|
||||
output: UNUSED_TOOL_OUTPUT,
|
||||
execute: async () => [],
|
||||
presentCall: () => ({
|
||||
card: 'diff',
|
||||
title: 'Edit bounded.txt',
|
||||
diffs: [boundedDiff],
|
||||
}),
|
||||
presentCall: () => boundedView,
|
||||
presentResult: () => {
|
||||
newText = 'settled one\nsettled two'
|
||||
return boundedView
|
||||
},
|
||||
},
|
||||
}
|
||||
const result = await setup({
|
||||
@@ -4759,9 +5153,22 @@ describe('tool cards and surface replay', () => {
|
||||
expect(result.terminal.output).toContain('└ +2 -2 · 1 file · approximate')
|
||||
const readsAfterFirstRender = oldTextReads
|
||||
expect(readsAfterFirstRender).toBeGreaterThan(0)
|
||||
result.session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: 'bounded-diff' as never,
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('+ settled one')
|
||||
expect(oldTextReads).toBeGreaterThan(readsAfterFirstRender)
|
||||
const readsAfterResult = oldTextReads
|
||||
result.terminal.resize(87)
|
||||
await tick()
|
||||
expect(oldTextReads).toBe(readsAfterFirstRender)
|
||||
expect(oldTextReads).toBe(readsAfterResult)
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
@@ -4972,6 +5379,134 @@ describe('tool cards and surface replay', () => {
|
||||
expect(mounted).not.toContain('stored model-only payload')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
/** The last repainted frame, with CSI/OSC escapes and carriage returns stripped. */
|
||||
const lastFrame = (terminal: FakeTerminal): string => terminal.output
|
||||
.slice(terminal.output.lastIndexOf('\x1b[2J'))
|
||||
.replaceAll(/\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07]*\x07|\r/g, '')
|
||||
|
||||
const countAssistantHeaders = (frame: string): number => frame.split('\n')
|
||||
.filter(row => row.trim() === 'Assistant').length
|
||||
|
||||
/** One turn with text -> tool call/result -> text across two steps. */
|
||||
const appendTwoStepTurn = (session: Awaited<ReturnType<typeof setup>>['session']): void => {
|
||||
appendUser(session, 'fold me')
|
||||
appendAssistant(session, [{ type: 'text', text: 'first step text' }])
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: 'fold-1' as never, name: 'bash', arguments: '{}' })
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: 'fold-1' as never, content: [{ type: 'text', text: 'tool body' }], isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('step/start', { turn: 1, step: 2 })
|
||||
appendAssistant(session, [{ type: 'text', text: 'second step text' }], undefined, { turn: 1, step: 2 })
|
||||
session.append('step/end', { turn: 1, step: 2 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
it('folds a turn to one Assistant header in hidden mode and restores headers on cycle', async () => {
|
||||
const result = await setup({ tools })
|
||||
appendTwoStepTurn(result.session)
|
||||
await tick()
|
||||
|
||||
// Collapsed (default): each step keeps its own header.
|
||||
result.terminal.send('\x0c')
|
||||
await tick()
|
||||
expect(countAssistantHeaders(lastFrame(result.terminal))).toBe(2)
|
||||
|
||||
// collapsed -> expanded -> hidden.
|
||||
result.terminal.send('\x0f')
|
||||
result.terminal.send('\x0f')
|
||||
await tick()
|
||||
result.terminal.send('\x0c')
|
||||
await tick()
|
||||
const hidden = lastFrame(result.terminal)
|
||||
expect(countAssistantHeaders(hidden)).toBe(1)
|
||||
expect(hidden).toContain('first step text')
|
||||
expect(hidden).toContain('second step text')
|
||||
expect(hidden).not.toContain('Tool / bash')
|
||||
// The fold keeps model order: header text precedes the continuation.
|
||||
expect(hidden.indexOf('first step text')).toBeLessThan(hidden.indexOf('second step text'))
|
||||
|
||||
// hidden -> collapsed restores per-step headers.
|
||||
result.terminal.send('\x0f')
|
||||
await tick()
|
||||
result.terminal.send('\x0c')
|
||||
await tick()
|
||||
expect(countAssistantHeaders(lastFrame(result.terminal))).toBe(2)
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('gives the hidden-mode header to the first step with a visible body and keeps turns separate', async () => {
|
||||
const result = await setup({ tools })
|
||||
// Turn 1, step 1 is tool-only; step 2 carries the turn's text.
|
||||
appendUser(result.session, 'tool-only first step')
|
||||
appendAssistant(result.session, [{ type: 'tool-call', id: 'only-1' as never, name: 'bash', arguments: '{}' }])
|
||||
result.session.append('tool/call', { turn: 1, step: 1, callId: 'only-1' as never, name: 'bash', arguments: '{}' })
|
||||
result.session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: 'only-1' as never, content: [{ type: 'text', text: 'tool body' }], isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('step/end', { turn: 1, step: 1 })
|
||||
result.session.append('step/start', { turn: 1, step: 2 })
|
||||
appendAssistant(result.session, [{ type: 'text', text: 'late turn-one text' }], undefined, { turn: 1, step: 2 })
|
||||
result.session.append('step/end', { turn: 1, step: 2 })
|
||||
result.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// Turn 2 keeps its own header.
|
||||
result.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
appendUser(result.session, 'next turn')
|
||||
result.session.append('step/start', { turn: 2, step: 1 })
|
||||
appendAssistant(result.session, [{ type: 'text', text: 'turn-two text' }], undefined, { turn: 2, step: 1 })
|
||||
result.session.append('step/end', { turn: 2, step: 1 })
|
||||
result.session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
await tick()
|
||||
|
||||
result.terminal.send('\x0f')
|
||||
result.terminal.send('\x0f')
|
||||
await tick()
|
||||
result.terminal.send('\x0c')
|
||||
await tick()
|
||||
const hidden = lastFrame(result.terminal)
|
||||
// One header per turn: the tool-only step neither renders a blank segment
|
||||
// nor consumes turn one's header, which the late text step owns.
|
||||
expect(countAssistantHeaders(hidden)).toBe(2)
|
||||
expect(hidden).toContain('late turn-one text')
|
||||
expect(hidden).toContain('turn-two text')
|
||||
const rows = hidden.split('\n').map(row => row.trim())
|
||||
const turnOneHeader = rows.indexOf('Assistant')
|
||||
expect(rows[turnOneHeader + 1]).toBe('late turn-one text')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('folds live hidden-mode streaming once a later step shows text', async () => {
|
||||
const result = await setup({ tools, status: 'running' })
|
||||
result.terminal.send('\x0f')
|
||||
result.terminal.send('\x0f')
|
||||
await tick()
|
||||
result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'live first' } })
|
||||
result.session.append('step/end', { turn: 1, step: 1 })
|
||||
result.session.append('step/start', { turn: 1, step: 2 })
|
||||
result.session.append('assistant/chunk', { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'live second' } })
|
||||
await tick()
|
||||
result.terminal.send('\x0c')
|
||||
await tick()
|
||||
const hidden = lastFrame(result.terminal)
|
||||
expect(countAssistantHeaders(hidden)).toBe(1)
|
||||
expect(hidden).toContain('live first')
|
||||
expect(hidden).toContain('live second')
|
||||
|
||||
// A transcript rebuild (resize) recomputes the same fold from the log.
|
||||
result.terminal.resize(89)
|
||||
await tick()
|
||||
const rebuilt = lastFrame(result.terminal)
|
||||
expect(countAssistantHeaders(rebuilt)).toBe(1)
|
||||
expect(rebuilt).toContain('live second')
|
||||
await dispose(result)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TUI user-interaction dialogs', () => {
|
||||
@@ -5165,6 +5700,7 @@ describe('TUI extension service', () => {
|
||||
host.theme.accent(`${label} plugin overlay`),
|
||||
[
|
||||
host.theme.text('text'),
|
||||
host.theme.brand('brand'),
|
||||
host.theme.dim('dim'),
|
||||
host.theme.success('success'),
|
||||
host.theme.warning('warning'),
|
||||
@@ -5332,7 +5868,7 @@ describe('terminal mounting', () => {
|
||||
const session = ctx.sessions.create(SessionId('main'))
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
const terminal = new FakeTerminal()
|
||||
mountTui(ctx, { theme: { color: false } }, { terminal, exit: vi.fn() })
|
||||
@@ -5357,7 +5893,7 @@ describe('terminal mounting', () => {
|
||||
const session = ctx.sessions.create(SessionId('main'))
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
const terminal = new FakeTerminal()
|
||||
// Mirror dsh-tui's own inject (minus loader, the absence under test).
|
||||
@@ -5392,14 +5928,14 @@ describe('terminal mounting', () => {
|
||||
const otherSession = ctx.sessions.create(SessionId('other-session'))
|
||||
ctx.agents.register({
|
||||
id: otherSession.id, options: {}, session: otherSession, status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
expect(terminal.started).toBe(0)
|
||||
|
||||
const session = ctx.sessions.create(SessionId('late-session'))
|
||||
const agent = {
|
||||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
} as Agent
|
||||
ctx.agents.register(agent)
|
||||
await tick()
|
||||
@@ -5430,7 +5966,7 @@ describe('terminal mounting', () => {
|
||||
const session = ctx.sessions.create(SessionId('main-session'))
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
await tick()
|
||||
expect(terminal.started).toBe(0)
|
||||
@@ -5474,7 +6010,7 @@ describe('terminal mounting', () => {
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'running', acceptsNextStep: true, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
const terminal = new FakeTerminal()
|
||||
terminal.start = () => { throw new Error('terminal startup failed') }
|
||||
@@ -5542,6 +6078,10 @@ describe('terminal mounting', () => {
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('uses the official DeepSeek SVG ink for truecolor brand art', () => {
|
||||
expect(brandText('mark')).toBe('\x1b[38;2;77;107;254mmark\x1b[39m')
|
||||
})
|
||||
|
||||
it('detects a light terminal color scheme and switches the scheme-dependent code role', async () => {
|
||||
const result = await setup({ config: { theme: { color: true } } })
|
||||
// `dim` is scheme-independent (SGR 2 over the default foreground), so the
|
||||
|
||||
Reference in New Issue
Block a user