feat(tui): add transcript compaction progress marker

This commit is contained in:
Hypatia May
2026-07-31 11:46:43 +08:00
parent afe8d49691
commit 949b9fba8d
11 changed files with 115 additions and 26 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md
2026-07-30-compaction-progress-visibility.md: fdd927f41b967ebd118b2934b300bca60ad99a87
2026-07-30-compaction-progress-visibility.zh.md: 95059a8e12dc2378728a5c89a2f571536ac51ded
2026-07-30-compaction-progress-visibility.md: e16bc550e9be896b0940f8537178ff4be2ca3220
2026-07-30-compaction-progress-visibility.zh.md: e8170e8408c7a59e830a8739f0a048f1522b937e

View File

@@ -6,19 +6,21 @@ English | [中文](2026-07-30-compaction-progress-visibility.zh.md)
## Problem
A standalone manual compaction runs between turns while the agent remains idle. The TUI's turn-phase indicator therefore kept its plain `>` caret throughout the slow summary operation, and a failed attempt produced no transcript row because no replacement checkpoint landed.
A standalone manual compaction runs between turns while the agent remains idle. The TUI's turn-phase indicator therefore kept its plain `>` caret throughout the slow summary operation, and a failed attempt produced no transcript row because no replacement checkpoint landed. Replacing only that caret with a one-cell glyph made the operation technically visible but still easy to miss while attention remained on the transcript.
The durable log can retain an unmatched `compact/start` after a process dies. That orphan is useful recovery evidence, but it is not proof that work is running in the current process; replaying it as progress would leave resumed sessions with a permanent phantom indicator.
## Decision
The TUI treats the live standalone `compact/start { turn: null }` to matching `compact/end` bracket as the source of in-flight compaction presentation. A module-local `compacting` cell records the render-clock start and owns one animation timer. The existing one-cell indicator renders `⊙` through the same fade and throb path as turn-phase glyphs, and the terminal progress bit remains active until the bracket closes.
The TUI treats the live standalone `compact/start { turn: null }` to matching `compact/end` bracket as the source of in-flight compaction presentation. A module-local `compacting` cell records the render-clock start, owns one animation timer, and retains a terminal-only progress component. The existing one-cell indicator renders `⊙` through the same fade and throb path as turn-phase glyphs, while the component renders a `◐` / `◓` / `◑` / `◒ Compaction in progress…` row; the terminal progress bit remains active until the bracket closes.
The progress component owns its leading blank line and is re-pinned to the transcript tail on every compaction render. New session content, `/clear`, and a transcript rebuild therefore cannot strand or erase the live marker. The compaction timer refreshes both the prompt glyph and the row; the row does not own another timer.
Turn-phase glyphs take precedence over `⊙`. Numbered compaction brackets are ignored because they are enclosed by a running turn whose phase already lights the indicator. The compaction cell does not change the idle editor border, hint, or steering badge, so prompts remain visibly accepted while standalone compaction reserves turn admission.
The cell is live-only. Mount and transcript replay never scan history for an unmatched start; only a `session/event` notification observed by the mounted TUI can open it. Turn-status transitions preserve the cell, while terminal teardown clears its timer and progress bit.
The cell and row are live-only. Mount and transcript replay never scan history for an unmatched start; only a `session/event` notification observed by the mounted TUI can open them. Turn-status transitions preserve the cell, while terminal teardown removes the row and clears its timer and progress bit.
On `compact/end`, the TUI clears the live cell before starting the ordinary glyph fade-out. An end carrying `error` adds `Compaction failed: <error>` as a warning. Successful completion remains represented by the landed replacement's transcript marker, and duration remains derivable from the matching durable start and end timestamps without another visible row.
On `compact/end`, the TUI removes the live row and clears the cell before starting the ordinary glyph fade-out. An end carrying `error` adds `Compaction failed: <error>` as a warning. Successful completion remains represented by the landed replacement's transcript marker, and duration remains derivable from the matching durable start and end timestamps without another settled row.
This decision partially supersedes only the progress-related deferred clauses in the [terminal transcript decision](../bug-fix/2026-07-29-human-transcript-append-origin.md) and [browser transcript decision](../bug-fix/2026-07-30-web-transcript-log-ordered-projection.md): progress does not require marker scale or a replacement-rendering refactor. Both notes remain active and continue to own append-origin transcript projection and landed checkpoint markers. The [queued manual compaction decision](2026-07-30-queued-manual-compaction.md) remains the owner of bracket ordering, locking, and stale-orphan classification.
@@ -36,12 +38,14 @@ This decision partially supersedes only the progress-related deferred clauses in
**Use a generic command-running indicator.** Rejected for this behavior because the compaction bracket is the more precise source and also covers non-command paths. A future generic command indicator belongs to the `command/run` / `command/done` lifecycle.
**Keep only the prompt-caret glyph.** Rejected because the caret is a single peripheral cell, while a slow compaction primarily leaves the user's attention on the transcript. A live transcript row makes the same bracket visible without creating a durable session event.
**Print a success notice with duration.** Rejected because the landed replacement already supplies the completion marker. The bracket timestamps preserve duration for a future presentation that justifies another transcript row.
## Consequences
Manual compaction now has visible liveness while the agent is idle, failure has a direct warning, and a resumed orphan never looks active. The indicator remains one terminal cell wide and reuses the existing animation, color, and terminal-progress behavior.
Manual compaction now has visible liveness in both the transcript and prompt while the agent is idle, failure has a direct warning, and a resumed orphan never looks active. The prompt indicator remains one terminal cell wide, and both presentations share the existing compaction timer, semantic palette, and terminal-progress lifecycle.
The live cell and its timer are additional process-local state, cleared on both bracket close and TUI teardown. This is intentionally not reconstructible presentation state: durable history supplies the successful marker and timing facts, while current-process observation alone supplies liveness.
The live cell, row, and timer are additional process-local state, cleared on both bracket close and TUI teardown. This is intentionally not reconstructible presentation state: durable history supplies the successful marker and timing facts, while current-process observation alone supplies liveness.
The package-level TUI tests pin standalone start, numbered-start exclusion, fade-out, failure warning, idle-status preservation, running-turn precedence, orphaned resume, and timer disposal. The assembled `queued-manual-compact` terminal scenario also observes `dsh ⊙` while the real summary boundary is held.
The package-level TUI tests pin standalone start, spinner animation, transcript-tail reattachment, numbered-start exclusion, fade-out, failure warning, idle-status preservation, running-turn precedence, orphaned resume, and timer disposal. The assembled `queued-manual-compact` terminal scenario observes both `dsh ⊙` and `Compaction in progress…` while the real summary boundary is held.

View File

@@ -6,19 +6,21 @@ Status: implemented
## 问题
独立手动压缩compaction在轮次之间运行此时 agent智能体保持空闲。因此在缓慢的摘要操作期间TUI 的轮次阶段指示器始终显示普通的 `>` 光标;尝试失败时,由于没有替换检查点落地,也不会产生 transcript文本记录行。
独立手动压缩compaction在轮次之间运行此时 agent智能体保持空闲。因此在缓慢的摘要操作期间TUI 的轮次阶段指示器始终显示普通的 `>` 光标;尝试失败时,由于没有替换检查点落地,也不会产生 transcript文本记录行。仅把该光标替换成单格字形后,操作虽然在技术上可见,但当注意力仍停留在 transcript 时依然很容易错过。
进程终止后,持久日志中可能保留未匹配的 `compact/start`。该未匹配标记是有用的恢复证据,但无法证明当前进程中有工作正在运行;若将其回放为进度,恢复后的会话便会永久显示虚假的进度指示。
## 决策
TUI 将实时独立的 `compact/start { turn: null }` 与匹配的 `compact/end` 组成的标记对,作为显示进行中压缩状态的真源。模块局部的 `compacting` 状态记录渲染时钟的起始时间,独占一个动画定时器。现有的单格指示器通过与轮次阶段字形相同的明暗渐变和呼吸律动路径渲染 `⊙`,终端进度标志位会保持活跃,直至标记对闭合。
TUI 将实时独立的 `compact/start { turn: null }` 与匹配的 `compact/end` 组成的标记对,作为显示进行中压缩状态的真源。模块局部的 `compacting` 状态记录渲染时钟的起始时间,独占一个动画定时器,并保存一个仅存在于终端的进度组件。现有的单格指示器通过与轮次阶段字形相同的明暗渐变和呼吸律动路径渲染 `⊙`组件则渲染一行 `◐` / `◓` / `◑` / `◒ Compaction in progress…`终端进度标志位会保持活跃,直至标记对闭合。
进度组件拥有其前导空行,并会在每次压缩渲染时重新固定到 transcript 尾部。因此,新增会话内容、`/clear` 和 transcript 重建都无法让实时标记滞留在旧位置或消失。压缩定时器会同时刷新提示符字形与该行;该行不独占另一个定时器。
轮次阶段字形的优先级高于 `⊙`。带编号的压缩标记对会被忽略,因为它们处于运行中的轮次内,该轮次的阶段已经激活指示器。压缩状态不会改变空闲编辑器边框、提示或 steering中途引导徽标因此在独立压缩预留轮次准入期间界面仍会明确显示提示词已获接纳。
该状态只反映实时事件。挂载和 transcript 回放绝不会扫描历史以查找未匹配的 start只有已挂载的 TUI 观察到 `session/event` 通知,才能开启该状态。轮次状态转换会保留该状态,而终端清理会清除其定时器和进度标志位。
该状态与该行只反映实时事件。挂载和 transcript 回放绝不会扫描历史以查找未匹配的 start只有已挂载的 TUI 观察到 `session/event` 通知,才能开启它们。轮次状态转换会保留该状态,而终端清理会移除该行,并清除其定时器和进度标志位。
收到 `compact/end`TUI 会先除实时状态,再启动普通字形的淡出。携带 `error` 的结束事件会以警告形式添加 `Compaction failed: <error>`。成功完成仍由已落地替换项的 transcript 标记呈现;无需再添加可见行,也可从匹配且已持久记录的开始与结束时间戳推导持续时间。
收到 `compact/end`TUI 会先除实时行并清除状态,再启动普通字形的淡出。携带 `error` 的结束事件会以警告形式添加 `Compaction failed: <error>`。成功完成仍由已落地替换项的 transcript 标记呈现;无需再添加已结算的行,也可从匹配且已持久记录的开始与结束时间戳推导持续时间。
本决策仅部分取代[终端 transcript 决策](../bug-fix/2026-07-29-human-transcript-append-origin.md)和[浏览器 transcript 决策](../bug-fix/2026-07-30-web-transcript-log-ordered-projection.md)中与进度相关的延期条款:进度显示不要求标记携带规模信息,也不要求重构替换项渲染。两份记录均保持活动状态,并继续负责基于追加来源的 transcript 投影和已落地检查点标记。[排队式手动压缩决策](2026-07-30-queued-manual-compaction.md)继续负责标记对顺序、锁定机制和陈旧未匹配标记分类。
@@ -36,12 +38,14 @@ TUI 将实时独立的 `compact/start { turn: null }` 与匹配的 `compact/end`
**使用通用的命令运行指示器。** 本行为不采用该方案,因为压缩标记对是更精确的真源,并且还覆盖非命令路径。未来若实现通用命令指示器,应归属于 `command/run``command/done` 生命周期。
**仅保留提示符光标字形。** 不予采用:该光标只是外围的单个字符单元,而缓慢压缩期间,用户的注意力主要仍停留在 transcript 上。实时 transcript 行无需创建持久会话事件,也能让同一个标记对清晰可见。
**打印包含持续时间的成功通知。** 不予采用:已落地的替换项已经提供完成标记。标记对的时间戳保留了持续时间,可供未来能够证明新增 transcript 行合理的展示方式使用。
## 后果
手动压缩在 agent 空闲时会显示正在运行,失败会直接产生警告,恢复会话时的陈旧未匹配标记绝不会显示为活动状态。指示器保持一个终端字符单元宽,并复用现有的动画、颜色和终端进度行为
手动压缩在 agent 空闲时会同时在 transcript 与提示符中显示正在运行,失败会直接产生警告,恢复会话时的陈旧未匹配标记绝不会显示为活动状态。提示符指示器保持一个终端字符单元宽,两种显示共享现有的压缩定时器、语义调色板和终端进度生命周期
实时状态及其定时器是额外的进程局部状态,在标记对闭合和 TUI 清理这两种情况下都会清除。按设计,这种显示状态不可重建:持久历史提供成功标记与计时事实,只有当前进程的观察才能提供运行中状态。
实时状态、实时行及其定时器是额外的进程局部状态,在标记对闭合和 TUI 清理这两种情况下都会清除。按设计,这种显示状态不可重建:持久历史提供成功标记与计时事实,只有当前进程的观察才能提供运行中状态。
package级 TUI 测试固定了以下行为:独立开始事件、排除带编号的开始事件、淡出、失败警告、保留空闲状态、运行轮次优先级、存在未匹配标记时的恢复,以及定时器释放。组装后的 `queued-manual-compact` 终端场景会在真实摘要边界保持开放期间观察到 `dsh ⊙`
package级 TUI 测试固定了以下行为:独立开始事件、旋转动画、重新固定到 transcript 尾部、排除带编号的开始事件、淡出、失败警告、保留空闲状态、运行轮次优先级、存在未匹配标记时的恢复,以及定时器释放。组装后的 `queued-manual-compact` 终端场景会在真实摘要边界保持开放期间同时观察到 `dsh ⊙``Compaction in progress…`

View File

@@ -547,6 +547,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
clock.mockReturnValue(snapshotTime + 1_000)
await settleTerminal(terminal)
await expect.poll(() => terminal.snapshot()).toContain('dsh ⊙')
await expect.poll(() => terminal.snapshot()).toContain('Compaction in progress…')
clock.mockReturnValue(snapshotTime)
// Real keystrokes: the prompt keeps its ordinary queue identity while

View File

@@ -2356,7 +2356,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi
abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession
```
Source: [`packages/ui/tui/src/index.ts:241`](../../packages/ui/tui/src/index.ts)
Source: [`packages/ui/tui/src/index.ts:242`](../../packages/ui/tui/src/index.ts)
## `ctx.typert` — `TypertRegistry`

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/tui/README.md
README.md: ec21bfa7dc18acead439fd409e60a29391d4daf3
README.zh.md: b4b49fc3dad77b740800351c87bfdfcdc545d208
README.md: 712a7420c802a86a326d9dbcb8921a85fa226c04
README.zh.md: 138f97facc7c3fc8f99d9e02c353e51e0dbc81b5

View File

@@ -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. During a live standalone compaction bracket, the idle prompt caret becomes a one-cell throbbing `⊙` and terminal progress stays active until close; a failed close adds `Compaction failed: <error>`, 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.
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 terminal-only `◐` / `◓` / `◑` / `◒ Compaction in progress…` row stays at the transcript tail, the idle prompt caret becomes a one-cell throbbing `⊙`, and terminal progress stays active until close. The live row survives transcript clears and rebuilds but is never logged or replayed; a failed close replaces it with `Compaction failed: <error>`, while a resumed orphaned start never activates either 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.

View File

@@ -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 ·` 徽标每条消息排空后随即清除。在实时独立压缩compaction标记对处于开启状态期间空闲提示符光标会变成占一个终端字符单元并呈呼吸律动的 `⊙`,终端进度状态会保持活跃,直至标记对闭合;闭合失败时会追加 `Compaction failed: <error>`,而恢复会话时遇到的陈旧未匹配 start 绝不会激活指示器([决策](../../../.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md)。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉而上下文卡片保持预览因为注入的指令不属于工具流量。注入上下文卡片把消息渲染为文本并去掉生产方的外层提醒外框因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoningCtrl+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标记对处于开启状态期间一行仅存在于终端的 `◐` / `◓` / `◑` / `◒ Compaction in progress…` 状态会固定在 transcript 尾部,空闲提示符光标会变成占一个终端字符单元并呈呼吸律动的 `⊙`,终端进度状态会保持活跃,直至标记对闭合。该实时行会在 transcript 清空或重建后继续存在,但绝不会记录或回放;闭合失败时它会被 `Compaction failed: <error>` 取代,而恢复会话时遇到的陈旧未匹配 start 绝不会激活这两个指示器([决策](../../../.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md)。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉而上下文卡片保持预览因为注入的指令不属于工具流量。注入上下文卡片把消息渲染为文本并去掉生产方的外层提醒外框因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoningCtrl+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 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。

View File

@@ -32,10 +32,14 @@ import { contentText, type ParsedArguments } from './content.ts'
import {
formatCompletionTime,
formatTimingTotals,
STATUS_ANIMATION_INTERVAL_MS,
stepTimingAt,
type StepPosition,
} from '../chat/timing.ts'
const COMPACTION_PROGRESS_FRAMES = ['◐', '◓', '◑', '◒'] as const
const COMPACTION_PROGRESS_LABEL = 'Compaction in progress…'
/** Concatenate the text of every block of one type, separated by blank lines. */
function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'): string {
return content
@@ -120,6 +124,29 @@ export class HeaderComponent implements Component {
}
}
/**
* Process-local transcript tail announcing a live standalone compaction.
* The leading blank belongs to the component so removing it leaves no gap.
*/
export class CompactionProgressComponent implements Component {
constructor(
private readonly startedAt: number,
private readonly now: () => number,
private readonly palette: Palette,
) {}
invalidate(): void {}
render(width: number): string[] {
const elapsed = Math.max(0, this.now() - this.startedAt)
const frameIndex = Math.floor(elapsed / STATUS_ANIMATION_INTERVAL_MS)
% COMPACTION_PROGRESS_FRAMES.length
const frame = COMPACTION_PROGRESS_FRAMES[frameIndex] as string
const marker = `${this.palette.accent(frame)} ${this.palette.dim(COMPACTION_PROGRESS_LABEL)}`
return ['', truncateToWidth(marker, Math.max(1, width), '')]
}
}
/**
* A user or steering prompt in the transcript. An underlined accent role header
* plus blank-line spacing separate it from surrounding blocks; body lines carry

View File

@@ -94,6 +94,7 @@ import {
type Config,
} from './config.ts'
import {
CompactionProgressComponent,
ContextCardComponent,
type ToolCardVisibility,
HeaderComponent,
@@ -347,7 +348,11 @@ export function createTuiChat(
* 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
let compacting: {
startedAt: number
timer: ReturnType<typeof setInterval>
progress: CompactionProgressComponent
} | 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.
@@ -478,6 +483,11 @@ export function createTuiChat(
const requestRender = (): void => {
if (disposed) return
if (compacting !== undefined) {
compacting.progress.invalidate()
chat.removeChild(compacting.progress)
chat.addChild(compacting.progress)
}
updatePromptValues()
const inputPrompt = renderInputPrompt()
editor.setPrompt({ first: inputPrompt, continuation: ' '.repeat(visibleWidth(inputPrompt)) })
@@ -567,6 +577,7 @@ export function createTuiChat(
const clearStatus = (): void => {
if (compacting !== undefined) {
clearInterval(compacting.timer)
chat.removeChild(compacting.progress)
compacting = undefined
}
clearTurnStatus()
@@ -1525,9 +1536,11 @@ export function createTuiChat(
// a stale resumed orphan for current work.
if (event.type === 'compact/start' && event.data.turn === null) {
if (compacting === undefined) {
const startedAt = now()
compacting = {
startedAt: now(),
startedAt,
timer: setInterval(renderStatus, STATUS_ANIMATION_INTERVAL_MS),
progress: new CompactionProgressComponent(startedAt, now, palette),
}
runtime.terminal.setProgress(true)
}
@@ -1536,6 +1549,7 @@ export function createTuiChat(
}
if (event.type === 'compact/end' && event.data.turn === null && compacting !== undefined) {
clearInterval(compacting.timer)
chat.removeChild(compacting.progress)
compacting = undefined
if (event.data.error !== undefined) {
appendNotice(`Compaction failed: ${event.data.error}`, 'warning')

View File

@@ -1992,12 +1992,15 @@ describe('pi-tui chat lifecycle and transcript', () => {
await dispose(result)
})
it('shows one compaction glyph cell for a live standalone bracket while idle', async () => {
it('shows a dynamic transcript marker and one glyph cell for a live standalone bracket while idle', 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 })
await tick()
expect(result.terminal.output).toContain('◐ Compaction in progress…')
clock = 1_000
result.terminal.output = ''
await new Promise(resolve => setTimeout(resolve, 75))
@@ -2005,6 +2008,12 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('dsh ⊙ ')
expect(promptWidth(result.terminal.output)).toBe(idleWidth)
expect(result.terminal.progress.at(-1)).toBe(true)
clock = 1_050
result.terminal.output = ''
await new Promise(resolve => setTimeout(resolve, 75))
expect(result.terminal.output).toContain('◓')
await dispose(result)
})
@@ -2015,6 +2024,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('dsh > ')
expect(result.terminal.output).not.toContain('dsh ⊙ ')
expect(result.terminal.output).not.toContain('Compaction in progress…')
expect(result.terminal.progress.at(-1)).toBe(false)
await dispose(result)
})
@@ -2029,12 +2039,14 @@ describe('pi-tui chat lifecycle and transcript', () => {
await tick()
clock = 2_000
result.terminal.output = ''
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('Compaction in progress…')
expect(result.terminal.progress.at(-1)).toBe(false)
await dispose(result)
})
@@ -2047,6 +2059,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
await tick()
expect(result.terminal.output).toContain('Compaction failed: summary failed')
expect(result.terminal.output).not.toContain('Compaction in progress…')
expect(result.terminal.progress.at(-1)).toBe(false)
await dispose(result)
})
@@ -2058,9 +2071,11 @@ describe('pi-tui chat lifecycle and transcript', () => {
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.output).toContain('Compaction in progress…')
expect(result.terminal.progress.at(-1)).toBe(true)
await dispose(result)
})
@@ -2075,6 +2090,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('dsh ◍ ')
expect(result.terminal.output).not.toContain('dsh ⊙ ')
expect(result.terminal.output).toContain('Compaction in progress…')
result.session.append('compact/end', { turn: null })
await tick()
result.terminal.output = ''
@@ -2099,12 +2115,12 @@ describe('pi-tui chat lifecycle and transcript', () => {
clearIntervalSpy.mockClear()
result.session.append('compact/start', { turn: null })
clock = 1_000
result.terminal.output = ''
result.session.append('compact/start', { turn: null })
await tick()
expect(intervalSpy).toHaveBeenCalledOnce()
expect(result.terminal.output).toContain('dsh ⊙ ')
expect(result.terminal.output).toContain('Compaction in progress…')
expect(result.terminal.progress.at(-1)).toBe(true)
result.session.append('compact/end', { turn: null })
@@ -2132,10 +2148,33 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('dsh > ')
expect(result.terminal.output).not.toContain('dsh ⊙ ')
expect(result.terminal.output).not.toContain('Compaction in progress…')
expect(result.terminal.progress.at(-1)).toBe(false)
await dispose(result)
})
it('keeps the live compaction marker at the transcript tail across new content and clear', async () => {
const result = await setup({ omitInitialLifecycle: true, now: () => 1_000 })
result.session.append('compact/start', { turn: null })
result.terminal.output = ''
appendUser(result.session, 'arrived during compaction')
await tick()
expect(result.terminal.output).toContain('arrived during compaction')
expect(result.terminal.output).toContain('Compaction in progress…')
result.terminal.output = ''
result.terminal.send('/clear')
result.terminal.send('\r')
await tick()
result.terminal.resize(result.terminal.columns + 1)
await tick()
expect(result.terminal.output).toContain('Compaction in progress…')
result.session.append('compact/end', { turn: null })
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')