Merge remote-tracking branch 'origin/master' into perf/tui-resume-scan
# Conflicts: # packages/cordis/tool-cordis/src/api-catalog.ts # packages/ui/tui/README.i18n.yaml # packages/ui/tui/tests/tui.spec.ts
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: 74dbf0dc26f99d0d7a6586fe6caec1f0ccdb2f36
|
||||
README.zh.md: d0ec1acbecf6e82ef35027a72268ba16fee65d6f
|
||||
README.md: b1eb150bd7ba27a63038d2557782d6603ec9168f
|
||||
README.zh.md: 476153f9aca58a0248193a45f4c5f16e400e3cf0
|
||||
|
||||
@@ -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`, `/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. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
|
||||
|
||||
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: 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.
|
||||
|
||||
@@ -40,7 +40,7 @@ Selection repeats those checks and requires the current agent to be idle before
|
||||
|
||||
The exit line is launcher-owned, not configurable. A launcher provides `TUI_GOODBYE_MESSAGE_KEY` on the boot context — for the shipped `dsh`, the command that resumes this session — and exiting prints it verbatim after the terminal is released; absent, exiting prints nothing. Only the launcher knows how it was invoked, so only it can name a command that works. The TUI escapes terminal controls before rendering and never executes the text. A launcher that also supplies `MAIN_SESSION_ID_KEY` fixes which session the mounted app binds to, so resume survives any config-level patch.
|
||||
|
||||
A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY` (the skill name) on the boot context; the TUI auto-invokes it exactly as a typed `/skill:<name>`, once the chat is live. The shipped `dsh migrate`/`dsh upgrade` set it and only for a fresh session, so a resumed session never re-invokes the skill; an unknown name is reported as a notice.
|
||||
A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY` (the skill name) on the boot context; the TUI auto-invokes it exactly as a typed `/skill:<name>`, once the chat is live. The shipped `dsh migrate`/`dsh experimental-upgrade` set it and only for a fresh session, so a resumed session never re-invokes the skill; an unknown name is reported as a notice.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -79,9 +79,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's `+`/`-` lines and a `[signal …]` marker stay colored, because there the color is the meaning rather than emphasis. The question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling.
|
||||
|
||||
|
||||
@@ -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`、`/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 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。
|
||||
|
||||
`/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 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。
|
||||
|
||||
@@ -40,7 +40,7 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
|
||||
|
||||
退出时打印的行由启动器拥有,不可通过配置指定。启动器在启动上下文上提供 `TUI_GOODBYE_MESSAGE_KEY`(对于随附的 `dsh`,即恢复本会话的命令),释放终端后退出会原样打印它;未提供时退出不打印任何内容。只有启动器知道自己是如何被调用的,因此只有它能给出可用的命令。TUI 在渲染前会转义终端控制字符,且绝不执行该文本。若启动器同时提供 `MAIN_SESSION_ID_KEY`,则会固定已挂载应用绑定的会话,因此恢复功能不受配置层修补影响。
|
||||
|
||||
启动器可通过在启动上下文上提供 `INITIAL_SKILL_KEY`(skill 名称)来播种全新会话的首轮;聊天就绪后,TUI 会像用户手动键入 `/skill:<name>` 一样自动调用它。随附的 `dsh migrate`/`dsh upgrade` 会设置该键,且仅对全新会话设置,因此恢复的会话绝不会重复调用该 skill;未知名称会以通知形式报告。
|
||||
启动器可通过在启动上下文上提供 `INITIAL_SKILL_KEY`(skill 名称)来播种全新会话的首轮;聊天就绪后,TUI 会像用户手动键入 `/skill:<name>` 一样自动调用它。随附的 `dsh migrate`/`dsh experimental-upgrade` 会设置该键,且仅对全新会话设置,因此恢复的会话绝不会重复调用该 skill;未知名称会以通知形式报告。
|
||||
|
||||
## 配置
|
||||
|
||||
@@ -79,9 +79,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 卡片的 `+`/`-` 行与 `[signal …]` 标记保留颜色,因为那里的颜色本身就是语义,而非强调。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
@@ -122,8 +122,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 experimental-upgrade`); absent
|
||||
* leaves the first turn to the user.
|
||||
*/
|
||||
initialSkill?: string
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -226,9 +227,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 experimental-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,6 +330,7 @@ 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.
|
||||
@@ -337,6 +339,14 @@ export function createTuiChat(
|
||||
let completedStreaming: StreamingAssistantComponent | undefined
|
||||
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 +410,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 +424,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 +457,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 +472,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 +506,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 +560,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 +570,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 +603,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') {
|
||||
@@ -1498,6 +1530,32 @@ export function createTuiChat(
|
||||
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)) {
|
||||
@@ -1541,6 +1599,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
|
||||
@@ -1629,11 +1690,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 experimental-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}`,
|
||||
|
||||
@@ -235,6 +235,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
injectedOptions.push(input)
|
||||
return input.id
|
||||
},
|
||||
reserveTurnAdmission: () => undefined,
|
||||
cancel(cause) {
|
||||
cancelled.push(cause)
|
||||
},
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
} from '../src/index.ts'
|
||||
import { WorkspaceFileSearch } from '../src/chat/file-autocomplete.ts'
|
||||
import { ResumePicker } from '../src/components/dialogs.ts'
|
||||
import { ATTRIBUTE_ROLES, COLOR_ROLES, createPalette, paletteSpec } from '../src/components/theme.ts'
|
||||
import { ATTRIBUTE_ROLES, brandText, COLOR_ROLES, createPalette, paletteSpec } from '../src/components/theme.ts'
|
||||
import {
|
||||
appendAssistant,
|
||||
appendUser,
|
||||
@@ -139,6 +139,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()
|
||||
@@ -2102,12 +2108,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 ✻ '],
|
||||
@@ -2120,8 +2120,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
|
||||
@@ -2138,12 +2138,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)
|
||||
@@ -2251,7 +2428,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)
|
||||
})
|
||||
|
||||
@@ -4175,8 +4352,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()
|
||||
@@ -5333,6 +5511,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'),
|
||||
@@ -5500,7 +5679,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: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
const terminal = new FakeTerminal()
|
||||
mountTui(ctx, { theme: { color: false } }, { terminal, exit: vi.fn() })
|
||||
@@ -5525,7 +5704,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: () => {}, 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).
|
||||
@@ -5560,14 +5739,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: () => {}, 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: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
} as Agent
|
||||
ctx.agents.register(agent)
|
||||
await tick()
|
||||
@@ -5598,7 +5777,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: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
await tick()
|
||||
expect(terminal.started).toBe(0)
|
||||
@@ -5642,7 +5821,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: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
const terminal = new FakeTerminal()
|
||||
terminal.start = () => { throw new Error('terminal startup failed') }
|
||||
@@ -5710,6 +5889,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