Merge pull request #1026 from deepseek-harness/feat/compaction-progress-visibility

feat(tui): show live compaction progress
This commit is contained in:
hypatiamay
2026-07-31 19:17:43 +08:00
committed by GitHub
17 changed files with 427 additions and 70 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/bug-fix/2026-07-29-human-transcript-append-origin.md
2026-07-29-human-transcript-append-origin.md: dcc4a786c6f1926f06dce03124ec1d8ca805d7ae
2026-07-29-human-transcript-append-origin.zh.md: 0fefc52afa52e99cdec2bcea1a86b9c28711dd67
2026-07-29-human-transcript-append-origin.md: a47dd49dd831cdd32d520137417bf47d2c056a09
2026-07-29-human-transcript-append-origin.zh.md: 31639bd9aac5d6dace80392004f37c747bff2c36

View File

@@ -26,7 +26,7 @@ No persisted event, RPC envelope, compaction transaction, or model-visible surfa
The browser client is fixed separately, in [the web transcript projection note](2026-07-30-web-transcript-log-ordered-projection.md): it projects the same append-origin transcript in log order and renders a marker component, and it closes the pagination hole this change opened — because `session.history` no longer spends quota on the checkpoint, it never cuts on the checkpoint's provenance group, so a page can carry a checkpoint citing a `surfaceOp.start` outside the window, which the browser's surface fold rejected. That hole predates this change (counting could already run past a checkpoint into the range it shadows), but the old rule accidentally covered the case where the checkpoint was the oldest counted message and pulled the whole shadowed range onto its page.
Rendering compaction *progress* — a terminal indicator while a compaction runs — needs the bracket-first ordering that the queued manual `/compact` work introduces, and is out of scope here. The marker also carries no scale: the checkpoint's `sourceEventSeqs` already hold the shadowed count, so a count or range would tell a reader how much each row folded. That belongs with progress, where the reader meets the other half of the same information. Whoever takes it should fold the terminal's two replacement branches — replay and the live listener, textually identical and 600 lines apart — into one `renderReplacement(event)` first, so the marker's content has a single home.
The terminal's [live compaction progress decision](../feature/2026-07-30-compaction-progress-visibility.md) uses standalone bracket events to drive the existing one-cell indicator. It does not change the completion marker owned here or add scale: the checkpoint's `sourceEventSeqs` remain available for a separately justified count or range. Progress therefore needs neither marker-content changes nor a prerequisite `renderReplacement(event)` extraction.
## Alternatives considered

View File

@@ -26,7 +26,7 @@ Status: implemented
浏览器客户端在[Web 记录投影笔记](2026-07-30-web-transcript-log-ordered-projection.md)中单独修复:它按日志顺序投影同一份 append 来源记录并渲染一个标记组件,同时闭合本次变更打开的分页缺口——因为 `session.history` 不再为检查点消耗额度,它永远不会按检查点的溯源分组切分,于是一页可以携带一个引用了窗口之外 `surfaceOp.start` 的检查点,而浏览器的 surface fold 会拒绝该范围。这个缺口早于本次变更(此前计数就可能越过检查点进入它所遮蔽的范围),但旧规则恰好覆盖了这样一种情形:检查点是最旧的被计数消息,其溯源分组把整段被遮蔽的范围一起拉到该页。
渲染压缩*进度*——压缩运行期间的终端指示——需要排队式手动 `/compact` 工作引入的“先开括号”顺序,不在本次范围内。标记同样不携带规模信息:检查点的 `sourceEventSeqs` 已经包含被遮蔽的数量,因此一个计数或区间可以告诉读者每一行折叠了多少内容。这件事属于进度那一侧,读者正是在那里遇到同一份信息的另一半。接手者应当先把终端里两处替换分支——回放与实时监听器,文本完全相同却相隔 600 行——合并为一个 `renderReplacement(event)`,让标记的内容只有一个归处
终端的[实时压缩进度决策](../feature/2026-07-30-compaction-progress-visibility.md)使用独立标记对中的事件驱动现有的单格指示器。它既不改变本文所负责的完成标记,也不添加规模信息:检查点的 `sourceEventSeqs` 仍可供经另行论证的计数或区间使用。因此,进度显示既不需要修改标记内容,也不以提取 `renderReplacement(event)` 为前置条件
## Alternatives considered

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/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md
2026-07-30-web-transcript-log-ordered-projection.md: 9fb338643774efaeb9deab6f66920a9f4276ce67
2026-07-30-web-transcript-log-ordered-projection.zh.md: 7962dd432b8bbf115acde9dd480eba9c91f35bfc
2026-07-30-web-transcript-log-ordered-projection.md: 4bc629bfa619a2858ec0335a6136d3a5a24b025a
2026-07-30-web-transcript-log-ordered-projection.zh.md: d011ce9453bc1dbb9fdb62372aa1126c95ffb143

View File

@@ -69,4 +69,4 @@ The web e2e scenario now seeds a real compaction transaction over its recorded t
## Deferred
Compaction **progress** — an indicator while a compaction runs — needs the bracket-first ordering the queued manual-compaction work introduces, and stays out of scope here as it did in the terminal. The marker also carries no **scale**: the checkpoint's `sourceEventSeqs` already hold the shadowed count, so a count or range would tell a reader how much each row folded. Both belong together, where the reader meets the two halves of the same information.
The terminal's [compaction progress decision](../feature/2026-07-30-compaction-progress-visibility.md) uses the live standalone bracket to drive a one-cell indicator and does not change this browser projection. The marker still carries no **scale**: the checkpoint's `sourceEventSeqs` hold the shadowed count, so a separately justified count or range can be added without coupling it to progress.

View File

@@ -69,4 +69,4 @@ Web e2e 场景现在在它录制的那一轮之上播种一次真实的压缩事
## Deferred
压缩**进度**——压缩运行期间的指示——需要排队式手动压缩工作引入的“先开括号”顺序,与终端一样不在本次范围内。标记同样不携带**规模**信息:检查点的 `sourceEventSeqs` 已经包含被遮蔽的数量,因此一个计数或区间可以告诉读者每一行折叠了多少内容。两者应当放在一起,读者正是在那里遇到同一份信息的两半
终端的[压缩进度决策](../feature/2026-07-30-compaction-progress-visibility.md)使用实时独立标记对驱动单格指示器,并不改变此浏览器投影。标记不携带**规模**信息:检查点的 `sourceEventSeqs` 保存被遮蔽的数量,因此可以另行论证后添加计数或区间,而无须将其与进度耦合

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md
2026-07-30-compaction-progress-visibility.md: b4d95d4bc645924b96eab6a36ee7b8f36b76a2c6
2026-07-30-compaction-progress-visibility.zh.md: e444fbdfbf1c865a5bb3b6675a5ff318c5cef9e7

View File

@@ -0,0 +1,49 @@
# Agent Note: Live standalone compaction progress in the terminal
Status: implemented
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. The liveness presentation needs to reuse the existing status indicator without introducing a second animated status location.
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. A fixed row above the prompt renders `Context being compacted <elapsed>` from that clock, the existing one-cell running status indicator renders `⊙` through the same fade and throb path as turn-phase glyphs, and the terminal progress bit remains active until the bracket closes.
`runningPhaseGlyph` owns the choice among turn-phase glyphs, `⊙`, and the idle caret. Turn-phase glyphs take precedence because numbered compaction brackets are enclosed by a running turn whose phase already lights the indicator. The fixed row is outside the transcript and owns neither a spinner nor another timer; it collapses when empty. 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.
On `compact/end`, the TUI 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.
## Alternatives considered
**Add `progressLabel` to `CommandDefinition` and a second TUI status controller, as explored in PR #669.** Rejected because command metadata is not the compaction lifecycle authority, automatic compaction does not originate from a human command, and two status controllers can disagree about the same indicator.
**Add `compacting` to `TurnPhase`, as explored in PR #669.** Rejected because standalone compaction deliberately has no turn, while numbered compaction already has a visible running-turn phase.
**Add a fifth `TimingBucket`.** Rejected because timing buckets partition an open model step and feed its transcript footer. Standalone compaction has no step transition, and a new bucket would add a meaningless compaction column to every step total.
**Share one timer among running, fading, and compaction states.** Rejected because fade-out owns a self-terminating timer, while live compaction has an independent open/close lifetime. Sharing would restructure the reviewed animation state machine without removing an actual concurrent timer.
**Scan the log for an unmatched `compact/start`.** Rejected because a stale orphan from an earlier process lifecycle is expected durable history. Only the live notification proves current work.
**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.
**Add an animated compaction row to the transcript.** Rejected because it creates a second status animation point for the same lifecycle. The existing one-cell indicator owns liveness, while the landed marker and failure warning own settled transcript presentation.
**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 has a named elapsed-time display above the 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, while the row and indicator reuse the existing status animation, semantic palette, and terminal-progress lifecycle.
The live cell 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, elapsed-time refresh, single-indicator presentation, 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 `Context being compacted 1.0s` and `dsh ⊙` while the real summary boundary is held.

View File

@@ -0,0 +1,49 @@
# Agent Note: 终端中的实时独立压缩进度
Status: implemented
[English](2026-07-30-compaction-progress-visibility.md) | 中文
## 问题
独立手动压缩compaction在轮次之间运行此时 agent智能体保持空闲。因此在缓慢的摘要操作期间TUI 的轮次阶段指示器始终显示普通的 `>` 光标;尝试失败时,由于没有替换检查点落地,也不会产生 transcript文本记录行。运行状态显示需要复用现有的状态指示器不能再设置第二处带动画的状态显示。
进程终止后,持久日志中可能保留未匹配的 `compact/start`。该未匹配标记是有用的恢复证据,但无法证明当前进程中有工作正在运行;若将其回放为进度,恢复后的会话便会永久显示虚假的进度指示。
## 决策
TUI 将实时独立的 `compact/start { turn: null }` 与匹配的 `compact/end` 组成的标记对,作为显示进行中压缩状态的真源。模块局部的 `compacting` 状态记录渲染时钟的起始时间,并独占一个动画定时器。提示词上方的固定行根据该时钟渲染 `Context being compacted <elapsed>`,现有的单格运行状态指示器通过与轮次阶段字形相同的明暗渐变和呼吸律动路径渲染 `⊙`,终端进度标志位会保持活跃,直至标记对闭合。
`runningPhaseGlyph` 负责在轮次阶段字形、`⊙` 和空闲光标之间作出选择。轮次阶段字形的优先级更高,因为带编号的压缩标记对处于运行中的轮次内,该轮次的阶段已经激活指示器。该固定行位于 transcript 之外,不带 spinner也不另设定时器内容为空时会折叠。压缩状态不会改变空闲编辑器边框、提示或 steering中途引导徽标因此在独立压缩预留轮次准入期间界面仍会明确显示提示词已获接纳。
该状态只反映实时事件。挂载和 transcript 回放绝不会扫描历史以查找未匹配的 start只有已挂载的 TUI 观察到 `session/event` 通知,才能开启它。轮次状态转换会保留该状态,而终端清理会清除其定时器和进度标志位。
收到 `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)继续负责标记对顺序、锁定机制和陈旧未匹配标记分类。
## 曾考虑的替代方案
**按照 PRPull Request#669 中探索的方案,为 `CommandDefinition` 添加 `progressLabel` 和第二个 TUI 状态控制器。** 不予采用:命令元数据并非压缩生命周期的权威依据,自动压缩并非由人工命令发起,两个状态控制器也可能对同一个指示器给出不一致状态。
**按照 PR #669 中探索的方案,将 `compacting` 添加到 `TurnPhase`。** 不予采用:独立压缩按设计没有轮次,而带编号的压缩已经具有可见的运行轮次阶段。
**添加第五个 `TimingBucket`。** 不予采用:计时分桶用于划分一个开放模型步骤内的时间,并为其 transcript 页脚提供数据。独立压缩没有步骤转换,新分桶会在每个步骤总计中加入一个没有意义的压缩列。
**让运行、淡出和压缩状态共享一个定时器。** 不予采用:淡出过程独占一个会自行终止的定时器,而实时压缩有独立的开启与闭合生命周期。共享定时器会重构已经评审的动画状态机,却不能消除实际存在的并发定时器。
**扫描日志,查找未匹配的 `compact/start`。** 不予采用:来自先前进程生命周期的陈旧未匹配标记是预期的持久历史。只有实时通知才能证明当前进程正在执行工作。
**使用通用的命令运行指示器。** 本行为不采用该方案,因为压缩标记对是更精确的真源,并且还覆盖非命令路径。未来若实现通用命令指示器,应归属于 `command/run``command/done` 生命周期。
**在 transcript 中添加动画压缩行。** 不予采用:这会为同一个生命周期设置第二处状态动画显示。现有的单格指示器负责显示运行状态,而已落地标记和失败警告负责已结算的 transcript 呈现。
**打印包含持续时间的成功通知。** 不予采用:已落地的替换项已经提供完成标记。标记对的时间戳保留了持续时间,可供未来能够证明新增 transcript 行合理的展示方式使用。
## 后果
手动压缩在 agent 空闲时会在提示词上方显示带名称的已用时间,失败会直接产生警告,恢复会话时的陈旧未匹配标记绝不会显示为活动状态。提示符指示器保持一个终端字符单元宽,状态行和指示器则复用现有的状态动画、语义调色板和终端进度生命周期。
实时状态及其定时器是额外的进程局部状态,在标记对闭合和 TUI 清理这两种情况下都会清除。按设计,这种显示状态不可重建:持久历史提供成功标记与计时事实,只有当前进程的观察才能提供运行中状态。
package级 TUI 测试固定了以下行为:独立开始事件、已用时间刷新、单指示器呈现、排除带编号的开始事件、淡出、失败警告、保留空闲状态、运行轮次优先级、存在未匹配标记时的恢复,以及定时器释放。组装后的 `queued-manual-compact` 终端场景会在真实摘要边界保持开放期间观察到 `Context being compacted 1.0s``dsh ⊙`

View File

@@ -362,7 +362,8 @@ interface ScenarioResult {
}
async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
const clock = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 12, 0, 0).getTime())
const snapshotTime = new Date(2026, 6, 21, 12, 0, 0).getTime()
const clock = vi.spyOn(Date, 'now').mockReturnValue(snapshotTime)
const fixtureFile = join(fixtureDir(scenario), 'session.jsonl')
const childFiles = childFixturePaths(scenario)
const prompts = userPrompts(await readFile(fixtureFile, 'utf8'))
@@ -543,6 +544,13 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
`manual summary did not start; status=${agent.status}; tail=${
agent.session.events.slice(-8).map(event => event.type).join(',')
}`)
clock.mockReturnValue(snapshotTime + 1_000)
await settleTerminal(terminal)
await expect.poll(() => terminal.snapshot()).toContain('dsh ⊙')
await expect.poll(() => terminal.snapshot()).toContain('Context being compacted 1.0s')
const liveCompaction = await terminal.snapshot()
expect(liveCompaction.indexOf('Context being compacted 1.0s')).toBeLessThan(liveCompaction.indexOf('dsh ⊙'))
clock.mockReturnValue(snapshotTime)
// Real keystrokes: the prompt keeps its ordinary queue identity while
// admission is reserved, and an injection appends immediately.

View File

@@ -2366,7 +2366,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: 63c888b1d51c02fa85a8f0cc1617874debd87c4e
README.zh.md: ca5efc9ae26a9833d271991f73a21c607d8fb09d
README.md: 5aaad1d4dd0b13254d5748cca1a352196e06b224
README.zh.md: d2c037788b256239864746ac299868ead445d14f

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. 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.

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 ·` 徽标每条消息排空后随即清除。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标记对处于开启状态期间提示词上方会显示固定的 `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 切换 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

@@ -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.

View File

@@ -80,6 +80,7 @@ import {
import {
fadeGlyph,
formatQueuedStatus,
formatStatusDuration,
openStepPhase,
openTurn,
pulseLevel,
@@ -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)
@@ -537,8 +557,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 +567,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 +600,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 +1527,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 +1596,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

View File

@@ -138,6 +138,12 @@ async function tick(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 25))
}
function promptWidth(output: string): number {
const row = output.split('\n').find(line => line.includes('dsh'))
if (row === undefined) throw new Error('prompt row not rendered')
return visibleWidth(row.slice(row.indexOf('dsh'), row.indexOf('dsh') + 6))
}
async function setup(options: TuiHarnessOptions = {}) {
const terminal = new FakeTerminal()
const exit = vi.fn()
@@ -1951,12 +1957,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 ✻ '],
@@ -1969,8 +1969,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
@@ -1987,12 +1987,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)
@@ -2100,7 +2277,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)
})