From bbff2c58ded649676a54102b3335d26824ae287a Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 30 Jul 2026 21:59:33 +0800 Subject: [PATCH 1/9] feat(tui): show live compaction progress --- ...9-human-transcript-append-origin.i18n.yaml | 4 +- ...26-07-29-human-transcript-append-origin.md | 2 +- ...07-29-human-transcript-append-origin.zh.md | 2 +- ...ranscript-log-ordered-projection.i18n.yaml | 4 +- ...0-web-transcript-log-ordered-projection.md | 2 +- ...eb-transcript-log-ordered-projection.zh.md | 2 +- ...0-compaction-progress-visibility.i18n.yaml | 6 + ...26-07-30-compaction-progress-visibility.md | 47 ++++++ ...07-30-compaction-progress-visibility.zh.md | 47 ++++++ apps/cli/tests/tui.snapshot.ts | 7 +- packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 2 +- packages/ui/tui/README.zh.md | 2 +- packages/ui/tui/src/index.ts | 62 ++++++-- packages/ui/tui/tests/tui.spec.ts | 146 ++++++++++++++++-- 15 files changed, 306 insertions(+), 33 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md create mode 100644 .agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml index 7ee4b1fac8..8926d51744 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .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 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md index dcc4a786c6..a47dd49dd8 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md @@ -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 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md index 0fefc52afa..31639bd9aa 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md @@ -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 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml index 4c078446f1..f0755f984f 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md -2026-07-30-web-transcript-log-ordered-projection.md: 0c58373d58e08fbfe260f16f2104b3f7d1ccc3fd -2026-07-30-web-transcript-log-ordered-projection.zh.md: 49de0a9ecbbf2da92113f8ad63afe6478caedadd +2026-07-30-web-transcript-log-ordered-projection.md: 878feaca0ff0f20bac03237fa9c4ecdeb3465571 +2026-07-30-web-transcript-log-ordered-projection.zh.md: 2b7e66d0b403b19d38ae40367081832834b628b4 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md index 0c58373d58..878feaca0f 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md @@ -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. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md index 49de0a9ecb..2b7e66d0b4 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md @@ -69,4 +69,4 @@ Web e2e 场景现在在它录制的那一轮之上播种一次真实的压缩事 ## Deferred -压缩**进度**——压缩运行期间的指示——需要排队式手动压缩工作引入的“先开括号”顺序,与终端一样不在本次范围内。标记同样不携带**规模**信息:检查点的 `sourceEventSeqs` 已经包含被遮蔽的数量,因此一个计数或区间可以告诉读者每一行折叠了多少内容。两者应当放在一起,读者正是在那里遇到同一份信息的两半。 +终端的[压缩进度决策](../feature/2026-07-30-compaction-progress-visibility.md)使用实时独立标记对驱动单格指示器,并不改变此浏览器投影。标记仍不携带**规模**信息:检查点的 `sourceEventSeqs` 保存被遮蔽的数量,因此可以另行论证后添加计数或区间,而无须将其与进度耦合。 diff --git a/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.i18n.yaml new file mode 100644 index 0000000000..c448c44a8d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/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 diff --git a/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md b/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md new file mode 100644 index 0000000000..fdd927f41b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md @@ -0,0 +1,47 @@ +# 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 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. + +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. + +On `compact/end`, the TUI clears the live cell before starting the ordinary glyph fade-out. An end carrying `error` adds `Compaction failed: ` 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. + +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. + +**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. + +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 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. diff --git a/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.zh.md b/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.zh.md new file mode 100644 index 0000000000..95059a8e12 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.zh.md @@ -0,0 +1,47 @@ +# 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` 状态记录渲染时钟的起始时间,并独占一个动画定时器。现有的单格指示器通过与轮次阶段字形相同的明暗渐变和呼吸律动路径渲染 `⊙`,终端进度标志位会保持活跃,直至标记对闭合。 + +轮次阶段字形的优先级高于 `⊙`。带编号的压缩标记对会被忽略,因为它们处于运行中的轮次内,该轮次的阶段已经激活指示器。压缩状态不会改变空闲编辑器边框、提示或 steering(中途引导)徽标,因此,在独立压缩预留轮次准入期间,界面仍会明确显示提示词已获接纳。 + +该状态只反映实时事件。挂载和 transcript 回放绝不会扫描历史以查找未匹配的 start;只有已挂载的 TUI 观察到 `session/event` 通知,才能开启该状态。轮次状态转换会保留该状态,而终端清理会清除其定时器和进度标志位。 + +收到 `compact/end` 时,TUI 会先清除实时状态,再启动普通字形的淡出。携带 `error` 的结束事件会以警告形式添加 `Compaction failed: `。成功完成仍由已落地替换项的 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)继续负责标记对顺序、锁定机制和陈旧未匹配标记分类。 + +## 曾考虑的替代方案 + +**按照 PR(Pull Request)#669 中探索的方案,为 `CommandDefinition` 添加 `progressLabel` 和第二个 TUI 状态控制器。** 不予采用:命令元数据并非压缩生命周期的权威依据,自动压缩并非由人工命令发起,两个状态控制器也可能对同一个指示器给出不一致状态。 + +**按照 PR #669 中探索的方案,将 `compacting` 添加到 `TurnPhase`。** 不予采用:独立压缩按设计没有轮次,而带编号的压缩已经具有可见的运行轮次阶段。 + +**添加第五个 `TimingBucket`。** 不予采用:计时分桶用于划分一个开放模型步骤内的时间,并为其 transcript 页脚提供数据。独立压缩没有步骤转换,新分桶会在每个步骤总计中加入一个没有意义的压缩列。 + +**让运行、淡出和压缩状态共享一个定时器。** 不予采用:淡出过程独占一个会自行终止的定时器,而实时压缩有独立的开启与闭合生命周期。共享定时器会重构已经评审的动画状态机,却不能消除实际存在的并发定时器。 + +**扫描日志,查找未匹配的 `compact/start`。** 不予采用:来自先前进程生命周期的陈旧未匹配标记是预期的持久历史。只有实时通知才能证明当前进程正在执行工作。 + +**使用通用的命令运行指示器。** 本行为不采用该方案,因为压缩标记对是更精确的真源,并且还覆盖非命令路径。未来若实现通用命令指示器,应归属于 `command/run` 和 `command/done` 生命周期。 + +**打印包含持续时间的成功通知。** 不予采用:已落地的替换项已经提供完成标记。标记对的时间戳保留了持续时间,可供未来能够证明新增 transcript 行合理的展示方式使用。 + +## 后果 + +手动压缩在 agent 空闲时会显示正在运行,失败会直接产生警告,恢复会话时的陈旧未匹配标记绝不会显示为活动状态。指示器保持一个终端字符单元宽,并复用现有的动画、颜色和终端进度行为。 + +实时状态及其定时器是额外的进程局部状态,在标记对闭合和 TUI 清理这两种情况下都会清除。按设计,这种显示状态不可重建:持久历史提供成功标记与计时事实,只有当前进程的观察才能提供运行中状态。 + +包(package)级 TUI 测试固定了以下行为:独立开始事件、排除带编号的开始事件、淡出、失败警告、保留空闲状态、运行轮次优先级、存在未匹配标记时的恢复,以及定时器释放。组装后的 `queued-manual-compact` 终端场景还会在真实摘要边界保持开放期间观察到 `dsh ⊙`。 diff --git a/apps/cli/tests/tui.snapshot.ts b/apps/cli/tests/tui.snapshot.ts index 81bbf5dd05..0bae309170 100644 --- a/apps/cli/tests/tui.snapshot.ts +++ b/apps/cli/tests/tui.snapshot.ts @@ -362,7 +362,8 @@ interface ScenarioResult { } async function runScenario(scenario: Scenario): Promise { - 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,10 @@ async function runScenario(scenario: Scenario): Promise { `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 ⊙') + clock.mockReturnValue(snapshotTime) // Real keystrokes: the prompt keeps its ordinary queue identity while // admission is reserved, and an injection appends immediately. diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index e94be1a857..588e079769 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/tui/README.md -README.md: 63c888b1d51c02fa85a8f0cc1617874debd87c4e -README.zh.md: ca5efc9ae26a9833d271991f73a21c607d8fb09d +README.md: ec21bfa7dc18acead439fd409e60a29391d4daf3 +README.zh.md: b4b49fc3dad77b740800351c87bfdfcdc545d208 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 63c888b1d5..ec21bfa7dc 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -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:)`, 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, the idle prompt caret becomes a one-cell throbbing `⊙` and terminal progress stays active until close; a failed close adds `Compaction failed: `, 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 ` still selects an unambiguous model id directly, while `/model /` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index ca5efc9ae2..b4b49fc3da 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -22,7 +22,7 @@ TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应 挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()` 或 `agent.followup()`,因此空闲 followup 仍会分派 `agent/prompt-submit`,而轮次中的 steering 会在检查点加入且不触发该 hook。 -Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/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)标记对处于开启状态期间,空闲提示符光标会变成占一个终端字符单元并呈呼吸律动的 `⊙`,终端进度状态会保持活跃,直至标记对闭合;闭合失败时会追加 `Compaction failed: `,而恢复会话时遇到的陈旧未匹配 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 ` 仍可直接选择无歧义的模型 id,`/model /` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。 diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index a1250bb5b3..96e8ccc68d 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -269,6 +269,12 @@ export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly */ const COMPACTION_MARKER = '… earlier context was compacted …' +/** + * Status glyph for a live standalone compaction bracket. Compaction is not a + * step phase, so the glyph stays local to the TUI indicator. + */ +const COMPACTING_GLYPH = '⊙' + interface RunningStatus { turn: number | undefined timer: ReturnType @@ -337,6 +343,11 @@ 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 } | 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. @@ -419,6 +430,7 @@ export function createTuiChat( // 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) + ?? (compacting === undefined ? undefined : COMPACTING_GLYPH) // 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 @@ -426,8 +438,9 @@ export function createTuiChat( // glyph the whole turn. 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 && runningGlyph !== undefined + ? { glyph: runningGlyph, level: Math.min(1, (now() - activeSince) / STATUS_FADE_MS) } : fadingStatus !== undefined ? { glyph: fadingStatus.glyph, level: Math.max(0, 1 - (now() - fadingStatus.endedAt) / STATUS_FADE_MS) } : undefined @@ -537,8 +550,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,7 +560,16 @@ 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() } /** @@ -556,12 +578,12 @@ export function createTuiChat( * 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 +593,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 +1520,28 @@ 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 + // Standalone compaction runs while the agent remains idle, so only the + // live durable bracket can announce its in-flight state without mistaking + // a stale resumed orphan for current work. + if (event.type === 'compact/start' && event.data.turn === null) { + compacting = { + startedAt: now(), + timer: setInterval(renderStatus, STATUS_ANIMATION_INTERVAL_MS), + } + runtime.terminal.setProgress(true) + requestRender() + return + } + if (event.type === 'compact/end' && event.data.turn === null && compacting !== undefined) { + clearInterval(compacting.timer) + compacting = undefined + if (event.data.error !== undefined) { + appendNotice(`Compaction failed: ${event.data.error}`, 'warning') + } + beginFadeOut(COMPACTING_GLYPH) + 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)) { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index e5a2c46934..411d10b2b6 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -137,6 +137,12 @@ async function tick(): Promise { 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() @@ -1950,12 +1956,6 @@ describe('pi-tui chat lifecycle and transcript', () => { // `dsh ` 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 ✻ '], @@ -1968,8 +1968,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 @@ -1986,12 +1986,136 @@ 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 one compaction 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 }) + clock = 1_000 + result.terminal.output = '' + await new Promise(resolve => setTimeout(resolve, 75)) + + expect(result.terminal.output).toContain('dsh ⊙ ') + expect(promptWidth(result.terminal.output)).toBe(idleWidth) + expect(result.terminal.progress.at(-1)).toBe(true) + 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 + result.terminal.output = '' + await new Promise(resolve => setTimeout(resolve, 120)) + await tick() + + expect(result.terminal.output).toContain('dsh > ') + expect(result.terminal.output).not.toMatch(/dsh [◍✻●⚙⊙]/u) + 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') + 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 ⊙ ') + await dispose(result) + }) + + 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.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> | 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) @@ -2099,7 +2223,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) }) From 859d60fff539d7d0c10117f0b0a038213286342f Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 30 Jul 2026 22:09:42 +0800 Subject: [PATCH 2/9] fix(tui): preserve compaction indicator ownership --- packages/ui/tui/src/index.ts | 14 ++++++---- packages/ui/tui/tests/tui.spec.ts | 43 +++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 96e8ccc68d..ded1e617c3 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1524,11 +1524,13 @@ export function createTuiChat( // live durable bracket can announce its in-flight state without mistaking // a stale resumed orphan for current work. if (event.type === 'compact/start' && event.data.turn === null) { - compacting = { - startedAt: now(), - timer: setInterval(renderStatus, STATUS_ANIMATION_INTERVAL_MS), + if (compacting === undefined) { + compacting = { + startedAt: now(), + timer: setInterval(renderStatus, STATUS_ANIMATION_INTERVAL_MS), + } + runtime.terminal.setProgress(true) } - runtime.terminal.setProgress(true) requestRender() return } @@ -1538,7 +1540,9 @@ export function createTuiChat( if (event.data.error !== undefined) { appendNotice(`Compaction failed: ${event.data.error}`, 'warning') } - beginFadeOut(COMPACTING_GLYPH) + // 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) beginFadeOut(COMPACTING_GLYPH) requestRender() return } diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 411d10b2b6..bca8889946 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -2075,9 +2075,52 @@ describe('pi-tui chat lifecycle and transcript', () => { 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> | 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.terminal.output = '' + 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, From 949b9fba8d86144bf87b42dc5e03a1846a0bc97b Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 31 Jul 2026 11:46:43 +0800 Subject: [PATCH 3/9] feat(tui): add transcript compaction progress marker --- ...0-compaction-progress-visibility.i18n.yaml | 4 +- ...26-07-30-compaction-progress-visibility.md | 18 +++++--- ...07-30-compaction-progress-visibility.zh.md | 18 +++++--- apps/cli/tests/tui.snapshot.ts | 1 + docs/cordis-catalog/services.md | 2 +- packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 2 +- packages/ui/tui/README.zh.md | 2 +- packages/ui/tui/src/components/transcript.ts | 27 +++++++++++ packages/ui/tui/src/index.ts | 18 +++++++- packages/ui/tui/tests/tui.spec.ts | 45 +++++++++++++++++-- 11 files changed, 115 insertions(+), 26 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.i18n.yaml index c448c44a8d..4f3077ab56 100644 --- a/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .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 diff --git a/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md b/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md index fdd927f41b..e16bc550e9 100644 --- a/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md +++ b/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md @@ -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: ` 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: ` 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. diff --git a/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.zh.md b/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.zh.md index 95059a8e12..e8170e8408 100644 --- a/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.zh.md @@ -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: `。成功完成仍由已落地替换项的 transcript 标记呈现;无需再添加可见行,也可从匹配且已持久记录的开始与结束时间戳推导持续时间。 +收到 `compact/end` 时,TUI 会先移除实时行并清除状态,再启动普通字形的淡出。携带 `error` 的结束事件会以警告形式添加 `Compaction failed: `。成功完成仍由已落地替换项的 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…`。 diff --git a/apps/cli/tests/tui.snapshot.ts b/apps/cli/tests/tui.snapshot.ts index 7d18d7d2de..653904f360 100644 --- a/apps/cli/tests/tui.snapshot.ts +++ b/apps/cli/tests/tui.snapshot.ts @@ -547,6 +547,7 @@ async function runScenario(scenario: Scenario): Promise { 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 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 2c3c2a1897..341cd33a5b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -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` diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index 588e079769..ada426d131 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/tui/README.md -README.md: ec21bfa7dc18acead439fd409e60a29391d4daf3 -README.zh.md: b4b49fc3dad77b740800351c87bfdfcdc545d208 +README.md: 712a7420c802a86a326d9dbcb8921a85fa226c04 +README.zh.md: 138f97facc7c3fc8f99d9e02c353e51e0dbc81b5 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index ec21bfa7dc..712a7420c8 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -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:)`, 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: `, 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: `, 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 ` still selects an unambiguous model id directly, while `/model /` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index b4b49fc3da..138f97facc 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -22,7 +22,7 @@ TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应 挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()` 或 `agent.followup()`,因此空闲 followup 仍会分派 `agent/prompt-submit`,而轮次中的 steering 会在检查点加入且不触发该 hook。 -Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。在实时独立压缩(compaction)标记对处于开启状态期间,空闲提示符光标会变成占一个终端字符单元并呈呼吸律动的 `⊙`,终端进度状态会保持活跃,直至标记对闭合;闭合失败时会追加 `Compaction failed: `,而恢复会话时遇到的陈旧未匹配 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 在空闲时退出。 +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: ` 取代,而恢复会话时遇到的陈旧未匹配 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 ` 仍可直接选择无歧义的模型 id,`/model /` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。 diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts index 774e982f81..034a0a5104 100644 --- a/packages/ui/tui/src/components/transcript.ts +++ b/packages/ui/tui/src/components/transcript.ts @@ -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 diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index ded1e617c3..cadd63e9bb 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -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 } | undefined + let compacting: { + startedAt: number + timer: ReturnType + 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') diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 301b2d8b19..398fa37f92 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -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') From aab3d003e025a302272884bc7b9e0814c795d262 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 31 Jul 2026 11:51:26 +0800 Subject: [PATCH 4/9] docs(tui): clarify compaction disposal outcome --- packages/ui/tui/src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index cadd63e9bb..33690804cc 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1603,6 +1603,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 From 9ca52cbcf00bca00dd83f644390a2d16ab464737 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 31 Jul 2026 13:57:18 +0800 Subject: [PATCH 5/9] docs(tui): simplify compaction state comment --- packages/ui/tui/src/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 33690804cc..76ce6cc0f0 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1531,9 +1531,7 @@ 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 - // Standalone compaction runs while the agent remains idle, so only the - // live durable bracket can announce its in-flight state without mistaking - // a stale resumed orphan for current work. + // Track live standalone compaction state. if (event.type === 'compact/start' && event.data.turn === null) { if (compacting === undefined) { const startedAt = now() From c4d535ee8c9a96b130eb111bc766222ff2eed62a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:06:06 +0800 Subject: [PATCH 6/9] Refresh compaction progress PR head From 85cef9de6f6dc7c2529c4340f4e4fab710ad88ca Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 31 Jul 2026 15:27:10 +0800 Subject: [PATCH 7/9] fix(tui): unify compaction progress presentation --- ...0-compaction-progress-visibility.i18n.yaml | 4 +- ...26-07-30-compaction-progress-visibility.md | 20 +++---- ...07-30-compaction-progress-visibility.zh.md | 20 +++---- apps/cli/tests/tui.snapshot.ts | 4 +- packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 2 +- packages/ui/tui/README.zh.md | 2 +- packages/ui/tui/src/chat/timing.ts | 58 ++++++++++-------- packages/ui/tui/src/components/transcript.ts | 27 --------- packages/ui/tui/src/index.ts | 59 +++++++++---------- packages/ui/tui/tests/tui.spec.ts | 41 ++----------- 11 files changed, 94 insertions(+), 147 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.i18n.yaml index 4f3077ab56..c922eb5a91 100644 --- a/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md -2026-07-30-compaction-progress-visibility.md: e16bc550e9be896b0940f8537178ff4be2ca3220 -2026-07-30-compaction-progress-visibility.zh.md: e8170e8408c7a59e830a8739f0a048f1522b937e +2026-07-30-compaction-progress-visibility.md: b4d95d4bc645924b96eab6a36ee7b8f36b76a2c6 +2026-07-30-compaction-progress-visibility.zh.md: e444fbdfbf1c865a5bb3b6675a5ff318c5cef9e7 diff --git a/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md b/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md index e16bc550e9..b4d95d4bc6 100644 --- a/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md +++ b/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md @@ -6,21 +6,19 @@ 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. 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. +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, 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 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 ` 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. -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. +`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. -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 removes the live row and clears the cell before starting the ordinary glyph fade-out. An end carrying `error` adds `Compaction failed: ` 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. +On `compact/end`, the TUI clears the cell before starting the ordinary glyph fade-out. An end carrying `error` adds `Compaction failed: ` 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. @@ -38,14 +36,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. +**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 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. +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, 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 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, 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. +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. diff --git a/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.zh.md b/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.zh.md index e8170e8408..e444fbdfbf 100644 --- a/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.zh.md @@ -6,21 +6,19 @@ Status: implemented ## 问题 -独立手动压缩(compaction)在轮次之间运行,此时 agent(智能体)保持空闲。因此,在缓慢的摘要操作期间,TUI 的轮次阶段指示器始终显示普通的 `>` 光标;尝试失败时,由于没有替换检查点落地,也不会产生 transcript(文本记录)行。仅把该光标替换成单格字形后,操作虽然在技术上可见,但当注意力仍停留在 transcript 时依然很容易错过。 +独立手动压缩(compaction)在轮次之间运行,此时 agent(智能体)保持空闲。因此,在缓慢的摘要操作期间,TUI 的轮次阶段指示器始终显示普通的 `>` 光标;尝试失败时,由于没有替换检查点落地,也不会产生 transcript(文本记录)行。运行状态显示需要复用现有的状态指示器,不能再设置第二处带动画的状态显示。 进程终止后,持久日志中可能保留未匹配的 `compact/start`。该未匹配标记是有用的恢复证据,但无法证明当前进程中有工作正在运行;若将其回放为进度,恢复后的会话便会永久显示虚假的进度指示。 ## 决策 -TUI 将实时独立的 `compact/start { turn: null }` 与匹配的 `compact/end` 组成的标记对,作为显示进行中压缩状态的真源。模块局部的 `compacting` 状态记录渲染时钟的起始时间,独占一个动画定时器,并保存一个仅存在于终端的进度组件。现有的单格指示器通过与轮次阶段字形相同的明暗渐变和呼吸律动路径渲染 `⊙`,组件则渲染一行 `◐` / `◓` / `◑` / `◒ Compaction in progress…`;终端进度标志位会保持活跃,直至标记对闭合。 +TUI 将实时独立的 `compact/start { turn: null }` 与匹配的 `compact/end` 组成的标记对,作为显示进行中压缩状态的真源。模块局部的 `compacting` 状态记录渲染时钟的起始时间,并独占一个动画定时器。提示词上方的固定行根据该时钟渲染 `Context being compacted `,现有的单格运行状态指示器通过与轮次阶段字形相同的明暗渐变和呼吸律动路径渲染 `⊙`,终端进度标志位会保持活跃,直至标记对闭合。 -进度组件拥有其前导空行,并会在每次压缩渲染时重新固定到 transcript 尾部。因此,新增会话内容、`/clear` 和 transcript 重建都无法让实时标记滞留在旧位置或消失。压缩定时器会同时刷新提示符字形与该行;该行不独占另一个定时器。 +`runningPhaseGlyph` 负责在轮次阶段字形、`⊙` 和空闲光标之间作出选择。轮次阶段字形的优先级更高,因为带编号的压缩标记对处于运行中的轮次内,该轮次的阶段已经激活指示器。该固定行位于 transcript 之外,不带 spinner,也不另设定时器;内容为空时会折叠。压缩状态不会改变空闲编辑器边框、提示或 steering(中途引导)徽标,因此,在独立压缩预留轮次准入期间,界面仍会明确显示提示词已获接纳。 -轮次阶段字形的优先级高于 `⊙`。带编号的压缩标记对会被忽略,因为它们处于运行中的轮次内,该轮次的阶段已经激活指示器。压缩状态不会改变空闲编辑器边框、提示或 steering(中途引导)徽标,因此,在独立压缩预留轮次准入期间,界面仍会明确显示提示词已获接纳。 +该状态只反映实时事件。挂载和 transcript 回放绝不会扫描历史以查找未匹配的 start;只有已挂载的 TUI 观察到 `session/event` 通知,才能开启它。轮次状态转换会保留该状态,而终端清理会清除其定时器和进度标志位。 -该状态与该行只反映实时事件。挂载和 transcript 回放绝不会扫描历史以查找未匹配的 start;只有已挂载的 TUI 观察到 `session/event` 通知,才能开启它们。轮次状态转换会保留该状态,而终端清理会移除该行,并清除其定时器和进度标志位。 - -收到 `compact/end` 时,TUI 会先移除实时行并清除状态,再启动普通字形的淡出。携带 `error` 的结束事件会以警告形式添加 `Compaction failed: `。成功完成仍由已落地替换项的 transcript 标记呈现;无需再添加已结算的行,也可从匹配且已持久记录的开始与结束时间戳推导持续时间。 +收到 `compact/end` 时,TUI 会先清除状态,再启动普通字形的淡出。携带 `error` 的结束事件会以警告形式添加 `Compaction failed: `。成功完成仍由已落地替换项的 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)继续负责标记对顺序、锁定机制和陈旧未匹配标记分类。 @@ -38,14 +36,14 @@ TUI 将实时独立的 `compact/start { turn: null }` 与匹配的 `compact/end` **使用通用的命令运行指示器。** 本行为不采用该方案,因为压缩标记对是更精确的真源,并且还覆盖非命令路径。未来若实现通用命令指示器,应归属于 `command/run` 和 `command/done` 生命周期。 -**仅保留提示符光标字形。** 不予采用:该光标只是外围的单个字符单元,而缓慢压缩期间,用户的注意力主要仍停留在 transcript 上。实时 transcript 行无需创建持久会话事件,也能让同一个标记对清晰可见。 +**在 transcript 中添加动画压缩行。** 不予采用:这会为同一个生命周期设置第二处状态动画显示。现有的单格指示器负责显示运行状态,而已落地标记和失败警告负责已结算的 transcript 呈现。 **打印包含持续时间的成功通知。** 不予采用:已落地的替换项已经提供完成标记。标记对的时间戳保留了持续时间,可供未来能够证明新增 transcript 行合理的展示方式使用。 ## 后果 -手动压缩在 agent 空闲时会同时在 transcript 与提示符中显示正在运行,失败会直接产生警告,恢复会话时的陈旧未匹配标记绝不会显示为活动状态。提示符指示器保持一个终端字符单元宽,两种显示共享现有的压缩定时器、语义调色板和终端进度生命周期。 +手动压缩在 agent 空闲时会在提示词上方显示带名称的已用时间,失败会直接产生警告,恢复会话时的陈旧未匹配标记绝不会显示为活动状态。提示符指示器保持一个终端字符单元宽,状态行和指示器则复用现有的状态动画、语义调色板和终端进度生命周期。 -实时状态、实时行及其定时器是额外的进程局部状态,在标记对闭合和 TUI 清理这两种情况下都会清除。按设计,这种显示状态不可重建:持久历史提供成功标记与计时事实,只有当前进程的观察才能提供运行中状态。 +实时状态及其定时器是额外的进程局部状态,在标记对闭合和 TUI 清理这两种情况下都会清除。按设计,这种显示状态不可重建:持久历史提供成功标记与计时事实,只有当前进程的观察才能提供运行中状态。 -包(package)级 TUI 测试固定了以下行为:独立开始事件、旋转动画、重新固定到 transcript 尾部、排除带编号的开始事件、淡出、失败警告、保留空闲状态、运行轮次优先级、存在未匹配标记时的恢复,以及定时器释放。组装后的 `queued-manual-compact` 终端场景会在真实摘要边界保持开放期间同时观察到 `dsh ⊙` 和 `Compaction in progress…`。 +包(package)级 TUI 测试固定了以下行为:独立开始事件、已用时间刷新、单指示器呈现、排除带编号的开始事件、淡出、失败警告、保留空闲状态、运行轮次优先级、存在未匹配标记时的恢复,以及定时器释放。组装后的 `queued-manual-compact` 终端场景会在真实摘要边界保持开放期间观察到 `Context being compacted 1.0s` 和 `dsh ⊙`。 diff --git a/apps/cli/tests/tui.snapshot.ts b/apps/cli/tests/tui.snapshot.ts index 653904f360..114d69401b 100644 --- a/apps/cli/tests/tui.snapshot.ts +++ b/apps/cli/tests/tui.snapshot.ts @@ -547,7 +547,9 @@ async function runScenario(scenario: Scenario): Promise { clock.mockReturnValue(snapshotTime + 1_000) await settleTerminal(terminal) await expect.poll(() => terminal.snapshot()).toContain('dsh ⊙') - await expect.poll(() => terminal.snapshot()).toContain('Compaction in progress…') + 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 diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index ada426d131..0fe801cd61 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/tui/README.md -README.md: 712a7420c802a86a326d9dbcb8921a85fa226c04 -README.zh.md: 138f97facc7c3fc8f99d9e02c353e51e0dbc81b5 +README.md: 5aaad1d4dd0b13254d5748cca1a352196e06b224 +README.zh.md: d2c037788b256239864746ac299868ead445d14f diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 712a7420c8..5aaad1d4dd 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -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:)`, 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, 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: `, 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. +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 ` 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: ` 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 ` still selects an unambiguous model id directly, while `/model /` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 138f97facc..d2c037788b 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -22,7 +22,7 @@ TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应 挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()` 或 `agent.followup()`,因此空闲 followup 仍会分派 `agent/prompt-submit`,而轮次中的 steering 会在检查点加入且不触发该 hook。 -Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/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: ` 取代,而恢复会话时遇到的陈旧未匹配 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 在空闲时退出。 +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 ` 状态行,空闲提示符光标会变成占一个终端字符单元并呈呼吸律动的 `⊙`,终端进度状态则会保持活跃,直至标记对闭合;该状态行和字形共用标记对的同一个刷新定时器。该实时状态绝不会从日志中重建;闭合失败时会向 transcript 添加 `Compaction failed: `,而恢复会话时遇到的陈旧未匹配 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 ` 仍可直接选择无歧义的模型 id,`/model /` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。 diff --git a/packages/ui/tui/src/chat/timing.ts b/packages/ui/tui/src/chat/timing.ts index 0aff3bc736..2312aa49b6 100644 --- a/packages/ui/tui/src/chat/timing.ts +++ b/packages/ui/tui/src/chat/timing.ts @@ -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 = { 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. diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts index 6375e69082..c991e85c1f 100644 --- a/packages/ui/tui/src/components/transcript.ts +++ b/packages/ui/tui/src/components/transcript.ts @@ -32,14 +32,10 @@ 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 @@ -137,29 +133,6 @@ 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 diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 76ce6cc0f0..62dfbeb154 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -80,6 +80,7 @@ import { import { fadeGlyph, formatQueuedStatus, + formatStatusDuration, openStepPhase, openTurn, pulseLevel, @@ -94,7 +95,6 @@ import { type Config, } from './config.ts' import { - CompactionProgressComponent, ContextCardComponent, type ToolCardVisibility, HeaderComponent, @@ -270,12 +270,6 @@ export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly */ const COMPACTION_MARKER = '… earlier context was compacted …' -/** - * Status glyph for a live standalone compaction bracket. Compaction is not a - * step phase, so the glyph stays local to the TUI indicator. - */ -const COMPACTING_GLYPH = '⊙' - interface RunningStatus { turn: number | undefined timer: ReturnType @@ -336,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. @@ -351,7 +346,6 @@ export function createTuiChat( let compacting: { startedAt: number timer: ReturnType - 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 @@ -416,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) @@ -429,25 +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) - ?? (compacting === undefined ? undefined : COMPACTING_GLYPH) + 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 activeSince = runningStatus?.startedAt ?? compacting?.startedAt - const envelope = activeSince !== undefined && runningGlyph !== undefined - ? { glyph: runningGlyph, level: Math.min(1, (now() - activeSince) / STATUS_FADE_MS) } + 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('>') @@ -456,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(' ')}`) @@ -471,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) @@ -483,11 +485,6 @@ 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)) }) @@ -577,16 +574,15 @@ export function createTuiChat( const clearStatus = (): void => { if (compacting !== undefined) { clearInterval(compacting.timer) - chat.removeChild(compacting.progress) 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 => { clearTurnStatus() @@ -1538,7 +1534,6 @@ export function createTuiChat( compacting = { startedAt, timer: setInterval(renderStatus, STATUS_ANIMATION_INTERVAL_MS), - progress: new CompactionProgressComponent(startedAt, now, palette), } runtime.terminal.setProgress(true) } @@ -1546,15 +1541,15 @@ export function createTuiChat( return } if (event.type === 'compact/end' && event.data.turn === null && compacting !== undefined) { + const fadeOutGlyph = runningPhaseGlyph(agent.session.events, false, true) clearInterval(compacting.timer) - chat.removeChild(compacting.progress) 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) beginFadeOut(COMPACTING_GLYPH) + if (runningStatus === undefined && fadeOutGlyph !== undefined) beginFadeOut(fadeOutGlyph) requestRender() return } diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 5016054ee5..aea6434723 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -1992,27 +1992,25 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(result) }) - it('shows a dynamic transcript marker and one glyph cell for a live standalone bracket while idle', async () => { + 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 }) - await tick() - expect(result.terminal.output).toContain('◐ Compaction in progress…') - 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_050 + clock = 1_450 result.terminal.output = '' await new Promise(resolve => setTimeout(resolve, 75)) - expect(result.terminal.output).toContain('◓') + expect(result.terminal.output).toContain('Context being compacted 1.4s') await dispose(result) }) @@ -2024,7 +2022,6 @@ 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) }) @@ -2046,7 +2043,7 @@ describe('pi-tui chat lifecycle and transcript', () => { 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.output).not.toContain('Context being compacted') expect(result.terminal.progress.at(-1)).toBe(false) await dispose(result) }) @@ -2059,7 +2056,6 @@ 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) }) @@ -2075,7 +2071,6 @@ describe('pi-tui chat lifecycle and transcript', () => { 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) }) @@ -2090,7 +2085,6 @@ 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 = '' @@ -2120,7 +2114,6 @@ describe('pi-tui chat lifecycle and transcript', () => { 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 }) @@ -2148,33 +2141,11 @@ 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.output).not.toContain('Context being compacted') 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') From d558cb4fd48509b49c5c340eb592f34da30aec20 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 31 Jul 2026 13:42:51 +0800 Subject: [PATCH 8/9] feat(cli): even out the shipped tool rosters across both surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two shipped surfaces offered different tools for no recorded reason: session checkpoints, tool-result pruning, the goal tools, and Ralph were in `tui.cordis.yml`; `tool-todo` and web search were in `web.cordis.yml`. Neither offered session search, a string-replacement editor, or a repeat-tool guard, though none of the three is surface-specific. Move the rows that are not surface-specific into `base.cordis.yml` and add those three. Web search moves there too — the TUI decision the change that made it a Web default deferred. Both surfaces now assemble the same 27 tools. This adds only. No row is removed from either surface and no existing row's configuration is edited: executors, sandbox composition, access defaults, `tools.mode`, and the workflow tool are exactly what they were. Two rows stay surface-specific: `tmux-context` (no terminal multiplexer in a browser) and `session-reference` (its index has one writer owner). Ship `dsh-mcp-client` as a dependency without a row: the plugin mounts one server per instance and `command` is required, so a default would name a third-party server and spawn it outside `ctx.bash` on every launch. The CLI README carries the YAML for mounting one from a personal config. --- ...31-even-out-shipped-tool-rosters.i18n.yaml | 6 + ...026-07-31-even-out-shipped-tool-rosters.md | 69 ++++++++++ ...-07-31-even-out-shipped-tool-rosters.zh.md | 69 ++++++++++ apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 21 ++- apps/cli/README.zh.md | 21 ++- apps/cli/composition.md | 33 +++++ apps/cli/config/base.cordis.yml | 68 ++++++++++ apps/cli/config/tui.cordis.yml | 22 +-- apps/cli/config/web.cordis.yml | 25 ---- apps/cli/package.json | 5 + .../tests/fixtures/composition-echo-llm.ts | 51 +++++++ .../composition-keyless-tail.cordis.yml | 52 ++++++++ .../cli/tests/fixtures/composition-settled.ts | 24 ++++ apps/cli/tests/shipped-composition.e2e.ts | 126 ++++++++++++++++++ apps/web/tests/shipped-composition.e2e.ts | 86 ++++++++++++ apps/web/tsconfig.json | 3 +- knip.json | 4 +- pnpm-lock.yaml | 15 +++ tsconfig.host.json | 1 + 20 files changed, 655 insertions(+), 50 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md create mode 100644 .agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md create mode 100644 apps/cli/tests/fixtures/composition-echo-llm.ts create mode 100644 apps/cli/tests/fixtures/composition-keyless-tail.cordis.yml create mode 100644 apps/cli/tests/fixtures/composition-settled.ts create mode 100644 apps/cli/tests/shipped-composition.e2e.ts create mode 100644 apps/web/tests/shipped-composition.e2e.ts diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml new file mode 100644 index 0000000000..8c2ab801a6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md +2026-07-31-even-out-shipped-tool-rosters.md: 28b44e68228043bcccd2d5f8bef96a86ffcae9d4 +2026-07-31-even-out-shipped-tool-rosters.zh.md: 8f26f7e0f86837a235bf96fa25d74f053c48e54b diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md new file mode 100644 index 0000000000..28b44e6822 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md @@ -0,0 +1,69 @@ +# Agent Note: Even out the shipped tool rosters + +Status: implemented + +English | [中文](2026-07-31-even-out-shipped-tool-rosters.zh.md) + +## Problem + +The two shipped `dsh` surfaces offered different tools for no recorded reason. Session checkpoints, tool-result pruning, the goal tools, and Ralph were in `tui.cordis.yml`; `tool-todo` and, later, web search were in `web.cordis.yml`. Neither surface offered session search, a string-replacement editor, or a repeat-tool guard, though all three exist as packages and none is surface-specific. + +The result was a user-visible difference nobody had decided: the same model, asked the same thing, could set a goal on the terminal but not in the browser, and could search the web in the browser but not on the terminal. + +## Decision + +The rows that are not surface-specific move into [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-guard`. Web search moves there too, which is the TUI decision that the change making it a Web default explicitly deferred. Both surfaces now assemble the same twenty-seven tools. + +Two rows stay surface-specific. `tmux-context` is TUI-only because a browser surface has no terminal multiplexer to describe. `session-reference` is TUI-only because it drives the shared session-query index from the launcher's process-local path, and the browser sidebar reconciles that index on its own first search. + +**This change adds only.** No row is removed from either surface and no existing row's configuration is edited: the executors, the sandbox composition, the access defaults, `tools.mode`, and the workflow tool are exactly what they were. A reader comparing the two catalogs before and after should find additions and nothing else. + +### What stays unmounted, and why + +Three capabilities stay out on the evidence their own packages record, and are listed here so "we forgot" and "we decided against" stay distinguishable. + +**`dsh-tool-cordis`** lets the model write JavaScript and mount it as a temporary plugin. Its README states the limit: "The sandbox is containment for honest code, not a security boundary — host-realm helpers on the sandbox global are reachable, so mount code can reach Node" ([Known limitations](../../../../packages/cordis/tool-cordis/README.md)). The `node:vm` realm lives inside the harness process while `dsh-sandbox-local` confines only the argv it spawns, so on the Web surface both the sandbox and the approval seam are bypassed rather than enforced. + +**`dsh-web-fetch-local`** stays unmounted and `dsh-tool-web` keeps `fetch: false`. SSRF protection is deferred in the implementation ([`policy.ts`](../../../../packages/web/web-fetch-local/src/policy.ts) validates protocol, credentials, and length only) and the package says so: "this provider is an SSRF primitive and **must not be enabled** in a deployment that can reach sensitive internal network targets" ([README](../../../../packages/web/web-fetch-local/README.md)). The model chooses the target, which includes the harness's own gateway on loopback, private ranges, and cloud metadata endpoints. + +Withholding it narrows the surface without removing the reach: `bash` is mounted, so `curl` gets the same page, as a live run confirmed. What the absence buys is the removal of an argument-shaped request primitive that needs no shell — and with it the accidental path where a summarization request quietly reaches loopback. A deployment that must contain outbound traffic needs a network-level control. + +**The LSP trio** stays out for an operational reason rather than a security one: `command` resolves from `PATH` at plugin load, so a missing language server fails the whole boot rather than one tool. It becomes mountable once absence degrades to a skipped registration. + +### MCP is a dependency, not a row + +`@deepseek-ai/dsh-mcp-client` becomes a runtime dependency of the CLI without a row in any shipped config. The plugin mounts exactly one server per instance and `command` is required, so a default would have to name a third-party server and spawn it as a child process on every launch — outside `ctx.bash`, and therefore outside the sandbox policy the Web surface composes. + +The layer that would make MCP a default is the one this repository does not have yet: a bridge that reads a user's server list and mounts one client per entry, the same shape [`dsh-hooks-claude`](../../../../packages/hooks/hooks-claude/README.md) already has for a Claude Code `hooks.json`. Shipping the dependency means an installed `dsh` can mount servers from `$DSH_HOME/config.yaml` today; the CLI README carries the YAML. + +## Testing + +[`apps/cli/tests/shipped-composition.e2e.ts`](../../../../apps/cli/tests/shipped-composition.e2e.ts) boots the shipped tree through the real Loader in a pseudo-terminal and reads the tool names out of the `request/header` the session log persisted, so the assertion is the catalog the model was actually sent. Its `--config` overlay, [`composition-keyless-tail.cordis.yml`](../../../../apps/cli/tests/fixtures/composition-keyless-tail.cordis.yml), is test isolation only: a network-free adapter and workspace-local session artifacts. + +That tail also inserts [`composition-settled.ts`](../../../../apps/cli/tests/fixtures/composition-settled.ts), which announces settled Loader activation on the terminal stream. The TUI renders as soon as its own fiber starts, so a prompt typed at the banner can reach the loop while tool rows and persistence are still activating and assemble a partial catalog; gating the smoke's first prompt on that marker is what makes the assertion deterministic. + +The same smoke pins the TUI's unchanged execution posture from the same artifact: `tool-bash` emits its `sandbox_permissions` escalation pair only when the mounted executor has wider modes to escalate to, so asserting its **absence** fails if a later change quietly sandboxes this surface. + +[`apps/web/tests/shipped-composition.e2e.ts`](../../../../apps/web/tests/shipped-composition.e2e.ts) covers the Web surface in the built lane, asserting its catalog, that its access default is untouched, and that `workspace-write`'s writable roots include the temp directories — a trap that makes sandbox tests lie when the workspace sits under `/tmp` ([`roots.ts`](../../../../packages/sandbox/sandbox/src/roots.ts)). + +`glob` and `grep` are asserted as an all-or-nothing pair rather than fixed members: `dsh-tool-fs-search` probes `command -v rg` at load and registers neither tool without ripgrep, which is a host dependency. + +Beyond the committed tests, both surfaces were driven against a real key from the built `apps/cli/lib/bin.js` under plain Node. Every mounted tool executed successfully, including `ralph` and `web_search`; the model never reached `cordis_*` or `mcp_*`, fell back to `grep` when asked for LSP navigation, and used a background `bash` task when asked for a persistent terminal. + +## Alternatives considered + +**Duplicate the shared rows into both overlays instead of promoting them.** Rejected on the one-home rule: three of the new rows would exist twice with no reason for the copies to diverge, and the next roster change would have to remember both. + +**Sandbox the TUI in the same change.** The TUI mounts unrestricted executors, which is a real gap, and an earlier revision of this change closed it by moving the confined stack into the base. Rejected as a separate decision that does not belong in a roster change: it alters what an existing surface does rather than what it offers, and it needs its own evidence — not least because the TUI has no `approval/request` answerer, so an escalation there fails closed instead of prompting. + +**Enable Code Mode.** Its trust posture is bash-equivalent by design and its tool calls pass the same `tools/pre-execute` gate as bash, so it is not the same call as the model-code tools above. Rejected here anyway: `both` changes every model-visible request on both surfaces, and `code` replaces the wire rather than adding to it — either is a presentation decision, not a roster one. + +**Mount an MCP server by default.** Rejected because a shipped default would have to name one, and any choice spawns a third-party child process on every user's machine outside the sandbox. The dependency ships instead. + +## Consequences + +The same model gets the same tools on both surfaces, and the difference that existed for no recorded reason is gone. Twenty-seven names, asserted exactly and identically on both sides, so a later change that adds a tool to one surface only — or drops one from either — fails a check instead of shipping quietly. + +`apps/cli` gains five workspace dependencies: four the shipped tree now mounts, plus `dsh-mcp-client`, which it does not mount and which exists so an installed `dsh` can. + +Nothing about execution changed. The TUI still runs the model's commands through unrestricted executors with no approval seam, and the Web surface still defaults to `danger-full-access`. Both are pinned by assertions in this change, which makes them visible rather than fixed — the sandbox decision is still open. diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md new file mode 100644 index 0000000000..8f26f7e0f8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md @@ -0,0 +1,69 @@ +# Agent Note: 拉平交付的工具清单 + +Status: implemented + +[English](2026-07-31-even-out-shipped-tool-rosters.md) | 中文 + +## 问题 + +两个交付的 `dsh` surface 提供着不同的工具,而没有任何记录说明为什么。会话检查点、工具结果裁剪、goal 工具和 Ralph 在 `tui.cordis.yml`;`tool-todo` 以及后来的 web 搜索在 `web.cordis.yml`。两个 surface 都没有会话搜索、字符串替换编辑器和重复工具守卫,尽管这三者都已成包存在,且没有一个是 surface 专属的。 + +结果是一处没人做过决定的用户可见差异:同一个模型、同一个请求,在终端上能定目标而在浏览器里不能,在浏览器里能搜网页而在终端上不能。 + +## 决策 + +那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-guard`。Web 搜索也一并移入,这正是把它做成 Web 默认的那次改动明确推迟的 TUI 决定。两个 surface 现在组装出同样的二十七个工具。 + +有两行仍是 surface 专属。`tmux-context` 只在 TUI,因为浏览器 surface 没有终端复用器可描述。`session-reference` 只在 TUI,因为它以 launcher 的进程本地路径驱动共享的 session-query 索引,而浏览器侧边栏会在自己的首次搜索里重建该索引。 + +**本次改动只做加法。** 两个 surface 都没有任何一行被移除,也没有任何既有行的配置被编辑:执行器、沙箱组合、访问默认值、`tools.mode` 以及 workflow 工具,全都保持原样。对比改动前后的两份目录,读者应当只看到新增,别无其他。 + +### 什么保持不挂,以及为什么 + +有三项能力基于其自身包所记录的证据保持在外,列在这里是为了让「我们忘了」和「我们决定不要」保持可区分。 + +**`dsh-tool-cordis`** 让模型写一段 JavaScript 并挂成临时插件。它的 README 写明了这个界限:「The sandbox is containment for honest code, not a security boundary — host-realm helpers on the sandbox global are reachable, so mount code can reach Node」([Known limitations](../../../../packages/cordis/tool-cordis/README.md))。`node:vm` 的 realm 就在 harness 进程内,而 `dsh-sandbox-local` 只约束它 spawn 出去的 argv,因此在 Web surface 上,沙箱与批准接缝是被绕过而非被执行。 + +**`dsh-web-fetch-local`** 保持不挂,`dsh-tool-web` 保持 `fetch: false`。SSRF 防护在实现中是 deferred 状态([`policy.ts`](../../../../packages/web/web-fetch-local/src/policy.ts) 只校验协议、凭据与长度),包里也直说了:「this provider is an SSRF primitive and **must not be enabled** in a deployment that can reach sensitive internal network targets」([README](../../../../packages/web/web-fetch-local/README.md))。目标由模型选择,其中包括 harness 自己跑在环回地址上的网关、内网段和云元数据端点。 + +弃用它收窄的是接触面而非可达性:`bash` 是挂着的,`curl` 照样能拿到同一个页面——一次真实运行确认了这点。这个缺席买到的是去掉一个无需 shell、以参数成形的请求原语,以及随之而来的那条意外路径:一次「帮我总结这个页面」悄悄打到环回地址。真要收住出站流量的部署需要的是网络层管控。 + +**LSP 三件套**留在外面是运维原因而非安全原因:`command` 在插件加载时从 `PATH` 解析,因此缺少语言服务器会让整次启动失败,而不只是失去一个工具。等到「缺失」退化为「跳过注册」之后,它就可以挂了。 + +### MCP 是依赖,不是配置行 + +`@deepseek-ai/dsh-mcp-client` 成为本 CLI 的运行时依赖,但在任何交付配置里都没有对应的行。该插件每个实例只挂载一台服务器,且 `command` 是必填,因此一个默认值必须点名一台第三方服务器,并在每次启动时把它作为子进程 spawn——不经 `ctx.bash`,因而也在 Web surface 所组合的沙箱策略之外。 + +真正能让 MCP 成为默认的那一层,恰恰是本仓库尚未拥有的:一个读取用户服务器清单、按条目逐台挂载客户端的桥接,形态与 [`dsh-hooks-claude`](../../../../packages/hooks/hooks-claude/README.md) 读取 Claude Code 的 `hooks.json` 完全相同。交付这个依赖意味着已安装的 `dsh` 今天就能从 `$DSH_HOME/config.yaml` 挂载服务器;CLI README 里给了那段 YAML。 + +## 测试 + +[`apps/cli/tests/shipped-composition.e2e.ts`](../../../../apps/cli/tests/shipped-composition.e2e.ts) 在伪终端中通过真实 Loader 启动交付树,并从会话日志持久化的 `request/header` 中读出工具名,因此断言的正是模型实际收到的目录。它传入的 `--config` overlay [`composition-keyless-tail.cordis.yml`](../../../../apps/cli/tests/fixtures/composition-keyless-tail.cordis.yml) 只做测试隔离:一个无网络适配器,以及落在工作区内的会话产物。 + +该尾部还插入了 [`composition-settled.ts`](../../../../apps/cli/tests/fixtures/composition-settled.ts),它在终端字节流上宣告 Loader 激活已 settle。TUI 在自己的 fiber 一启动就渲染,因此在 banner 处敲下的提示词可能在工具行与持久化仍在激活时就抵达循环,从而组装出不完整的目录;把冒烟的首个提示词 gate 在该标记上,正是断言得以确定的原因。 + +同一份冒烟还从同一份产物上钉住 TUI 未改变的执行姿态:`tool-bash` 只在挂载的执行器确实有更宽模式可升级时才发出 `sandbox_permissions` 升级参数对,因此断言它的**缺席**会在日后有人悄悄给这个 surface 加上沙箱时失败。 + +[`apps/web/tests/shipped-composition.e2e.ts`](../../../../apps/web/tests/shipped-composition.e2e.ts) 在构建产物 lane 中覆盖 Web surface,断言它的工具目录、它的访问默认值未被触碰,以及 `workspace-write` 的可写根包含临时目录——一个会让沙箱测试说谎的陷阱,当工作区落在 `/tmp` 下时([`roots.ts`](../../../../packages/sandbox/sandbox/src/roots.ts))。 + +`glob` 与 `grep` 被作为全有或全无的一对断言,而不是固定成员:`dsh-tool-fs-search` 在加载时探测 `command -v rg`,没有 ripgrep 就两个工具都不注册,这是宿主依赖。 + +除入库测试外,两个 surface 都以 plain Node 从构建产物 `apps/cli/lib/bin.js` 出发、用真实密钥驱动过。每一个已挂载的工具都执行成功,包括 `ralph` 与 `web_search`;模型从未触达 `cordis_*` 或 `mcp_*`,被要求做 LSP 跳转时退化到 `grep`,被要求开持久终端时用了后台 `bash` 任务。 + +## 曾考虑的替代方案 + +**把共享的行复制进两份 overlay,而不是提升到 base。** 基于「一处归属」原则否决:新增行里有三行会存在两份,而这些副本没有任何理由发生分歧,下一次改工具清单还得记着改两处。 + +**在同一次改动里给 TUI 加沙箱。** TUI 挂的是不受限执行器,这确实是个真实缺口,本次改动的早先一版曾把受限栈移入 base 来堵上它。作为一个不属于工具清单改动的独立决定被否决:它改变的是一个既有 surface 的行为而非它提供的东西,而且需要它自己的证据——尤其因为 TUI 没有 `approval/request` 的应答方,升级请求在那里是 fail-closed 而不是弹出提示。 + +**开启 Code Mode。** 它的信任立场按设计与 bash 同级,工具调用要过与 bash 相同的 `tools/pre-execute` 闸门,所以它与上面那些模型写码工具不是同一个判断。在这里仍被否决:`both` 会改变两个 surface 上每一个模型可见请求,而 `code` 是把线路替换而非加一个——两者都是呈现方式的决定,不是工具清单的决定。 + +**默认挂一台 MCP 服务器。** 否决,因为交付默认值必须点名一台,而任何选择都会在每个用户的机器上、在沙箱之外 spawn 一个第三方子进程。改为交付依赖。 + +## 后果 + +同一个模型在两个 surface 上拿到同样的工具,那处没有记录理由的差异消失了。二十七个名字,两侧精确且一致地断言,因此日后只给一个 surface 加工具、或从任一侧丢掉工具,都会让检查失败而不是悄悄发出去。 + +`apps/cli` 增加五个 workspace 依赖:四个是交付树现在挂载的,外加 `dsh-mcp-client`——它并不被挂载,存在的意义是让已安装的 `dsh` 能挂。 + +执行相关的一切都没有变。TUI 仍以不受限执行器运行模型的命令且没有批准接缝,Web surface 仍默认 `danger-full-access`。两者都由本次改动中的断言钉住,这让它们变得可见而非被修复——沙箱那个决定仍然悬着。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 33fed58119..c436fd5bbd 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: cf038ad19c631721c7b3182ffe83e75e3837d9ba -README.zh.md: c790f973a9ab0071253ab161bd8bc7835ebb2e02 +README.md: 3b1f7b9e938c7886e208498416fa3193ef05fd54 +README.zh.md: 374c22b2c2f62742897495f572c1c0ed8278e4cf diff --git a/apps/cli/README.md b/apps/cli/README.md index cf038ad19c..3b1f7b9e93 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -23,12 +23,29 @@ The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment. -The Web/headless composition also registers only `web_search`. Search uses DeepSeek's Anthropic-compatible Messages endpoint, resolves the same `DEEPSEEK_API_KEY` reference for every call, and accepts the separate `DEEPSEEK_SEARCH_BASE_URL` endpoint override; each search is an auxiliary model request with its own latency and token cost. `web_fetch` remains disabled and the composition mounts no default fetch provider, so deployments that need arbitrary page retrieval must opt in through an overlay. The TUI composition does not mount Web tools by default. The deployment decision and its security boundary live in the [default Web search Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md). +Every surface also registers `web_search` and only `web_search`. Search uses DeepSeek's Anthropic-compatible Messages endpoint, resolves the same `DEEPSEEK_API_KEY` reference for every call, and accepts the separate `DEEPSEEK_SEARCH_BASE_URL` endpoint override; each search is an auxiliary model request with its own latency and token cost. `web_fetch` remains disabled and the composition mounts no default fetch provider, so deployments that need arbitrary page retrieval must opt in through an overlay. The deployment decision and its security boundary live in the [default Web search Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md). -`DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode). +Every surface presents tools in `both` mode: the model receives every native schema **and** the Code Mode `run_code` transport, so a task that would be a long tool-call chain can be one program instead. Code Mode's trust posture is bash-equivalent by design — model code reaches Node APIs, but its tool calls pass the same `tools/pre-execute` gate as bash, and the worker adds containment bash has no equivalent for (a separate isolate, an empty environment, a heap cap, and hard termination). `DSH_TOOLS_MODE` overrides the presentation for a whole Web/headless process — `native` drops `run_code`, `code` sends only it — and any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam, process-wide because Loader composition is static, removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode). Every `dsh` surface — TUI, Web, and headless — reports session telemetry by default (the row lives in the shared `base.cordis.yml`): every session-log event streams as OTLP/HTTP log records to `https://harness-telemetry.deepseeksvc.com/v1/logs` on a 10-second batch cadence. `DSH_TELEMETRY_OTLP_URL` points the exporter at a different collector; setting `DSH_TELEMETRY_DISABLED` to ANY non-empty value — including `0` or `false` — disables the row before it loads (a privacy switch prefers off-by-mistake over on-by-mistake). No redaction rule is mounted in this composition yet: exported records are the raw captured copy, including message text, tool arguments and results, and the session's working-directory path. The deployment rulings live in the [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md). +MCP servers are not a shipped default, because a default would have to name one: `@deepseek-ai/dsh-mcp-client` mounts exactly one server per row and spawns it as a child process, outside `ctx.bash` and so outside the sandbox policy. The package is a runtime dependency of this CLI, so an installed `dsh` can mount your own servers from `$DSH_HOME/config.yaml` or a `--config` overlay without a source checkout: + +```yaml +- insert: + - id: mcp-github + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: github + transport: stdio + command: npx + args: ['-y', '@modelcontextprotocol/server-github'] + env: + GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN +``` + +The model then sees `mcp__github__*`. See the [mcp-client README](../../packages/mcp/mcp-client/README.md) for the Streamable HTTP transport and the full field table. + ## Install (developer machine) Symlink the source-running launcher onto your PATH; it resolves the checkout through its own real path, so code changes apply on the next launch with no build step: diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index c790f973a9..374c22b2c2 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -23,12 +23,29 @@ Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。 -Web/无头组合还只会注册 `web_search`。搜索使用 DeepSeek 的 Anthropic 兼容 Messages 端点,每次调用都会解析同一个 `DEEPSEEK_API_KEY` 凭据引用,并接受独立的 `DEEPSEEK_SEARCH_BASE_URL` 端点覆盖;每次搜索都是一次辅助模型请求,会产生独立的延迟与 token 成本。`web_fetch` 仍处于禁用状态,组合也未挂载默认抓取提供方;需要任意页面抓取能力的部署必须通过覆盖层选择启用。TUI 组合默认不挂载 Web 工具。部署决策及其安全边界见[默认 Web 搜索 Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md)。 +每个界面也都只注册 `web_search` 这一个 Web 工具。搜索使用 DeepSeek 的 Anthropic 兼容 Messages 端点,每次调用都会解析同一个 `DEEPSEEK_API_KEY` 凭据引用,并接受独立的 `DEEPSEEK_SEARCH_BASE_URL` 端点覆盖;每次搜索都是一次辅助模型请求,会产生独立的延迟与 token 成本。`web_fetch` 仍处于禁用状态,组合也未挂载默认抓取提供方;需要任意页面抓取能力的部署必须通过覆盖层选择启用。部署决策及其安全边界见[默认 Web 搜索 Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md)。 -`DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。 +每个界面都以 `both` 模式呈现工具:模型同时收到全部原生 schema **和** Code Mode 的 `run_code` 传输通道,于是原本要一长串工具调用的任务可以写成一段程序。Code Mode 的信任立场按设计与 bash 同级——模型代码可以触达 Node API,但它的工具调用要过与 bash 相同的 `tools/pre-execute` 闸门,而该 worker 还提供了 bash 没有的收容(独立 isolate、空环境、堆上限、硬终止)。`DSH_TOOLS_MODE` 可为整个 Web/无头进程覆盖呈现模式——`native` 去掉 `run_code`,`code` 则只发它——任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。 每个 `dsh` 界面——TUI、Web 与无头——都默认上报会话遥测(该行位于共享的 `base.cordis.yml`):每条会话日志事件以 OTLP/HTTP 日志记录的形式、按 10 秒批处理节奏流向 `https://harness-telemetry.deepseeksvc.com/v1/logs`。`DSH_TELEMETRY_OTLP_URL` 可将 exporter 指向其他 collector;将 `DSH_TELEMETRY_DISABLED` 设为**任意非空值**——包括 `0` 或 `false`——都会在该行加载前将其关停(隐私开关取「宁可误关、不可误开」)。该组合当前未挂载任何脱敏规则:导出记录即原始捕获副本,包含消息正文、工具参数与结果、以及会话工作目录路径。部署口径见 [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)。 +MCP 服务器不是交付默认值,因为默认值必须点名一台:`@deepseek-ai/dsh-mcp-client` 每一行只挂载一台服务器,并把它作为子进程 spawn,该进程不经 `ctx.bash`,因此也不受沙箱策略约束。该包是本 CLI 的运行时依赖,所以已安装的 `dsh` 无需源码检出即可从 `$DSH_HOME/config.yaml` 或 `--config` 覆盖层挂载你自己的服务器: + +```yaml +- insert: + - id: mcp-github + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: github + transport: stdio + command: npx + args: ['-y', '@modelcontextprotocol/server-github'] + env: + GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN +``` + +模型随后会看到 `mcp__github__*`。Streamable HTTP 传输与完整字段表见 [mcp-client README](../../packages/mcp/mcp-client/README.md)。 + ## 安装(开发机) 将从源码运行的启动器符号链接到 PATH 上;它通过自身真实路径解析 checkout,因此代码更改会在下次启动时生效,无需构建: diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 870b926054..35f615a207 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -96,6 +96,28 @@ flowchart LR cfg --> plugin_tui_spill_local plugin_tui_spill_policy["spill-policy
@deepseek-ai/dsh-spill-policy"] cfg --> plugin_tui_spill_policy + plugin_tui_session_checkpoint_policy["session-checkpoint-policy
@deepseek-ai/dsh-session-checkpoint-policy"] + cfg --> plugin_tui_session_checkpoint_policy + plugin_tui_tool_result_prune["tool-result-prune
@deepseek-ai/dsh-compact-tool-result-prune"] + cfg --> plugin_tui_tool_result_prune + plugin_tui_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"] + cfg --> plugin_tui_tool_todo + plugin_tui_tool_goal["tool-goal
@deepseek-ai/dsh-tool-goal"] + cfg --> plugin_tui_tool_goal + plugin_tui_tool_ralph["tool-ralph
@deepseek-ai/dsh-tool-ralph"] + cfg --> plugin_tui_tool_ralph + plugin_tui_tool_session_query["tool-session-query
@deepseek-ai/dsh-tool-session-query"] + cfg --> plugin_tui_tool_session_query + plugin_tui_tool_str_replace_editor["tool-str-replace-editor
@deepseek-ai/dsh-tool-str-replace-editor"] + cfg --> plugin_tui_tool_str_replace_editor + plugin_tui_repeat_tool_guard["repeat-tool-guard
@deepseek-ai/dsh-repeat-tool-guard"] + cfg --> plugin_tui_repeat_tool_guard + plugin_tui_web["web
@deepseek-ai/dsh-web"] + cfg --> plugin_tui_web + plugin_tui_web_search_deepseek["web-search-deepseek
@deepseek-ai/dsh-web-search-deepseek"] + cfg --> plugin_tui_web_search_deepseek + plugin_tui_tool_web["tool-web
@deepseek-ai/dsh-tool-web"] + cfg --> plugin_tui_tool_web plugin_tui_tools["tools
@deepseek-ai/dsh-tools"] cfg --> plugin_tui_tools plugin_tui_system_prompt["system-prompt
@deepseek-ai/dsh-system-prompt"] @@ -154,6 +176,17 @@ flowchart LR | `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` | | `spill-local` | `@deepseek-ai/dsh-spill-local` | | `spill-policy` | `@deepseek-ai/dsh-spill-policy` | +| `session-checkpoint-policy` | `@deepseek-ai/dsh-session-checkpoint-policy` | +| `tool-result-prune` | `@deepseek-ai/dsh-compact-tool-result-prune` | +| `tool-todo` | `@deepseek-ai/dsh-tool-todo` | +| `tool-goal` | `@deepseek-ai/dsh-tool-goal` | +| `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` | +| `tool-session-query` | `@deepseek-ai/dsh-tool-session-query` | +| `tool-str-replace-editor` | `@deepseek-ai/dsh-tool-str-replace-editor` | +| `repeat-tool-guard` | `@deepseek-ai/dsh-repeat-tool-guard` | +| `web` | `@deepseek-ai/dsh-web` | +| `web-search-deepseek` | `@deepseek-ai/dsh-web-search-deepseek` | +| `tool-web` | `@deepseek-ai/dsh-tool-web` | | `tools` | `@deepseek-ai/dsh-tools` | | `system-prompt` | `@deepseek-ai/dsh-system-prompt` | | `agent-loop` | `@deepseek-ai/dsh-agent-loop` | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index 041e7939bc..4817e0921a 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -246,6 +246,74 @@ config: maxInlineBytes: 50000 +# Durability checkpoints before each model request and top-level dispatch. +- id: session-checkpoint-policy + name: '@deepseek-ai/dsh-session-checkpoint-policy' + +# Compacts oversized tool results before the broader conversation compactor +# runs, preserving the model-visible result within the configured budget. +- id: tool-result-prune + name: '@deepseek-ai/dsh-compact-tool-result-prune' + config: + thresholdChars: 8192 + headChars: 4096 + tailChars: 1024 + +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + +# Persisted same-session goals reach the model and the slash menu here; the +# domain, driver, and `/goal` command are above. +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + +# Fresh-agent Ralph iteration over a build-time-fixed script. +- id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' + config: + subagentProvider: spawn + maxRounds: 64 + +- id: tool-session-query + name: '@deepseek-ai/dsh-tool-session-query' + config: + maxSearchResults: 100 + searchTimeoutMs: 30000 + +- id: tool-str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 + +# Consecutive-repeat reminders on the tool chain. +- id: repeat-tool-guard + name: '@deepseek-ai/dsh-repeat-tool-guard' + config: + thresholds: [3, 5, 8] + argumentsPreviewChars: 500 + +# Every surface enables the stable web_search model surface. DeepSeek search +# resolves the same DEEPSEEK_API_KEY credential the Models page manages for +# chat, at each search; its Messages endpoint is separate from the +# chat-completions endpoint, so it takes its own base-URL override. Fetch stays +# disabled and no fetch provider is mounted: that provider defers SSRF +# protection and the model would choose the request target. +- id: web + name: '@deepseek-ai/dsh-web' + config: + searchProvider: deepseek-official + +- id: web-search-deepseek + name: '@deepseek-ai/dsh-web-search-deepseek' + config: + apiKeyEnv: DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL + +- id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + fetch: false + # ── rows every surface mounts, whose values each overlay states ────────────── # The tool registry. Presentation mode is a surface choice, so each overlay diff --git a/apps/cli/config/tui.cordis.yml b/apps/cli/config/tui.cordis.yml index 980f9d80ad..d9ce7de680 100644 --- a/apps/cli/config/tui.cordis.yml +++ b/apps/cli/config/tui.cordis.yml @@ -71,29 +71,17 @@ - id: agent-loop-invariant name: '@deepseek-ai/dsh-agent-loop/invariant' - - id: session-checkpoint-policy - name: '@deepseek-ai/dsh-session-checkpoint-policy' - # The derived query index behind `/resume`. The launcher provides a unique # process-local path because this SQLite backend has one writer owner; the # project-local fallback applies when no launcher sets the typed slot. - id: session-reference name: '@deepseek-ai/dsh-session-reference' - # Compacts oversized tool results before the broader conversation compactor - # runs, preserving the model-visible result within the configured budget. - - id: tool-result-prune - name: '@deepseek-ai/dsh-compact-tool-result-prune' - - # Persisted same-session goals reach the model and the slash menu here; the - # domain, driver, and `/goal` command are in the base. - - id: tool-goal - name: '@deepseek-ai/dsh-tool-goal' - - # A separate fixed consumer demonstrates fresh-agent Ralph iteration without - # changing the workflow tool or same-session goal behavior. - - id: tool-ralph - name: '@deepseek-ai/dsh-tool-ralph' + # Terminal-multiplexer context, mounted only where a terminal exists. + - id: tmux-context + name: '@deepseek-ai/dsh-tmux-context' + config: + refreshIntervalMs: 900000 # The keyboard-backed provider behind ask_user_question and the plan-mode # review, and the front door it renders inside. diff --git a/apps/cli/config/web.cordis.yml b/apps/cli/config/web.cordis.yml index 238b3788fe..10c2209184 100644 --- a/apps/cli/config/web.cordis.yml +++ b/apps/cli/config/web.cordis.yml @@ -80,28 +80,6 @@ - id: fs-local disabled: true -# The Web/headless product enables only the stable web_search model surface. -# DeepSeek search resolves the same DEEPSEEK_API_KEY credential the Models page -# manages for chat, at each search; its Messages endpoint is separate from the -# chat-completions endpoint. Fetch remains disabled and no default fetch -# provider is mounted. -- insert: - - id: web - name: '@deepseek-ai/dsh-web' - config: - searchProvider: deepseek-official - - - id: web-search-deepseek - name: '@deepseek-ai/dsh-web-search-deepseek' - config: - apiKeyEnv: DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL - - - id: tool-web - name: '@deepseek-ai/dsh-tool-web' - config: - fetch: false - # ── web-only host rows, the transport layer, and the browser roster ───────── # `dshClient` rows are the browser roster the modules node half scans into @@ -136,9 +114,6 @@ writeEveryEvents: 200 writeIntervalMs: 5000 - - id: tool-todo - name: '@deepseek-ai/dsh-tool-todo' - # Resolve bind host, SSH launch, and display once at boot, then mount the # matching dual-face directory picker. Mount -native or -browse directly in # an overlay to pin the interaction. diff --git a/apps/cli/package.json b/apps/cli/package.json index 5556411e15..ff0c34f306 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -68,9 +68,11 @@ "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", + "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", @@ -100,6 +102,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-timeout-policy": "workspace:^", + "@deepseek-ai/dsh-tmux-context": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", @@ -108,7 +111,9 @@ "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", "@deepseek-ai/dsh-tool-ralph": "workspace:^", + "@deepseek-ai/dsh-tool-session-query": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", + "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", diff --git a/apps/cli/tests/fixtures/composition-echo-llm.ts b/apps/cli/tests/fixtures/composition-echo-llm.ts new file mode 100644 index 0000000000..9b34754e10 --- /dev/null +++ b/apps/cli/tests/fixtures/composition-echo-llm.ts @@ -0,0 +1,51 @@ +import type { Context } from 'cordis' +import type { + GenerateOptions, + LlmModelInfo, + LlmResolvedModelInfo, + StreamChunk, +} from '@deepseek-ai/dsh-llm' +import { LlmAdapter } from '@deepseek-ai/dsh-llm' + +/** Terminal marker the preset smoke waits for before it asks the TUI to exit. */ +export const COMPOSITION_REPLY_TEXT = 'Shipped composition acknowledged.' + +// Provider id and model the keyless tail routes `main` to; that overlay is the +// only caller, so the pair lives here as plain constants. +const COMPOSITION_PROVIDER = 'composition-keyless' +const COMPOSITION_MODEL = 'composition-keyless-model' + +/** + * Network-free adapter for the shipped-composition smoke. It answers every + * request — tool-ful agent turns and the tool-less auxiliary calls alike — with + * one fixed text and never calls a tool, because the assertion under test is the + * assembled tool catalog the loop logs, not any tool's behavior. + */ +class CompositionEchoAdapter extends LlmAdapter { + override listModels(provider: string): Promise { + return Promise.resolve([{ provider, id: COMPOSITION_MODEL, name: 'Preset Keyless' }]) + } + + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ provider, id: model, name: 'Preset Keyless', context: { contextWindow: 128_000 } }) + } + + override async * stream(_options: GenerateOptions): AsyncIterable { + yield { type: 'block-start', index: 0, blockType: 'text' } + for (const char of COMPOSITION_REPLY_TEXT) yield { type: 'text-delta', index: 0, text: char } + yield { type: 'block-end', index: 0, block: { type: 'text', text: COMPOSITION_REPLY_TEXT } } + yield { type: 'usage', usage: { inputTokens: 20, outputTokens: COMPOSITION_REPLY_TEXT.length } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +export const name = 'composition-echo-llm' +export const inject = ['llm'] + +/** + * Register the network-free adapter the shipped-composition smoke routes through. + * @param ctx - the loader-mounted plugin context. + */ +export function apply(ctx: Context): void { + ctx.llm.registerAdapter([COMPOSITION_PROVIDER], new CompositionEchoAdapter()) +} diff --git a/apps/cli/tests/fixtures/composition-keyless-tail.cordis.yml b/apps/cli/tests/fixtures/composition-keyless-tail.cordis.yml new file mode 100644 index 0000000000..443fed6be7 --- /dev/null +++ b/apps/cli/tests/fixtures/composition-keyless-tail.cordis.yml @@ -0,0 +1,52 @@ +# Keyless tail for the shipped-composition smoke, applied as `--config` so the +# launcher boots `base.cordis.yml` + `tui.cordis.yml` and then this file. +# +# Everything below is test isolation, never composition under test: the model is +# replaced so no request leaves the process, the settle marker gates the smoke's +# first prompt, and the session artifacts move into the smoke's temporary +# workspace so the log inspection can read them. + +# A patch's `name` is an assertion rather than a replacement, so the base +# adapter row is disabled and the scripted one inserted. Relative specifiers +# resolve against the INCLUDED file's directory (apps/cli/config), not this +# file's, because the include moves baseUrl there. +- id: llm-deepseek + disabled: true + +- insert: + - id: composition-echo-llm + name: '../tests/fixtures/composition-echo-llm.ts' + - id: composition-settled + name: '../tests/fixtures/composition-settled.ts' + +- id: agent-loop + config: + agents: + - id: main + provider: composition-keyless + model: composition-keyless-model + cwd: !!js process.cwd() + +- id: session-persistence-jsonl + config: + root: './.sessions' + compression: none + +- id: session-query-sqlite + config: + path: './.sessions/session-query.db' + +# The title call is a second, tool-less request that would race the log +# inspection for no coverage: the catalog under test rides the agent turn. +- id: session-title-llm + disabled: true + +- id: tui + config: + sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main' + welcome: 'composition smoke ready.' + showReasoning: true + +# HMR watches the repository; a PTY subprocess test must not start a watcher. +- id: hmr + disabled: true diff --git a/apps/cli/tests/fixtures/composition-settled.ts b/apps/cli/tests/fixtures/composition-settled.ts new file mode 100644 index 0000000000..0e3aff9c4e --- /dev/null +++ b/apps/cli/tests/fixtures/composition-settled.ts @@ -0,0 +1,24 @@ +import type { Context } from 'cordis' + +/** + * Marker the shipped-composition smoke gates its first prompt on. The TUI renders as soon as + * its own fiber starts, so a prompt typed at the banner can reach the loop while + * later rows — tool plugins, persistence — are still activating, and would + * assemble a partial catalog. Waiting for this line makes the turn observe the + * settled tree. + */ +export const COMPOSITION_SETTLED_MARKER = 'COMPOSITION_TREE_SETTLED' + +export const name = 'composition-settled' + +/** + * Announce settled Loader activation on the terminal byte stream, after every + * entry in the booted tree has started. The write is detached: awaiting the + * Loader from inside an entry would wait on this entry's own activation. + * @param ctx - the loader-mounted plugin context. + */ +export function apply(ctx: Context): void { + void ctx.loader.await().then(() => { + process.stdout.write(`\n${COMPOSITION_SETTLED_MARKER}\n`) + }) +} diff --git a/apps/cli/tests/shipped-composition.e2e.ts b/apps/cli/tests/shipped-composition.e2e.ts new file mode 100644 index 0000000000..2fef01de52 --- /dev/null +++ b/apps/cli/tests/shipped-composition.e2e.ts @@ -0,0 +1,126 @@ +import { readdir, readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { COMPOSITION_REPLY_TEXT } from './fixtures/composition-echo-llm.ts' +import { COMPOSITION_SETTLED_MARKER } from './fixtures/composition-settled.ts' +import { runTuiPtySmoke } from './pty-harness.ts' + +const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url)) +const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +// An overlay over the shipped tree, so the catalog under test is the one +// `base.cordis.yml` + `tui.cordis.yml` assemble; the tail only swaps the model +// and redirects session artifacts. +const keylessTail = fileURLToPath(new URL('./fixtures/composition-keyless-tail.cordis.yml', import.meta.url)) + +/** + * The catalog the shipped `dsh` TUI puts in front of the model, as the loop + * logged it, minus the ripgrep-dependent pair below. + * The absences are the composition's security decisions, not incidental gaps: + * `workflow` and the `cordis_*` toolset both execute model-written JavaScript + * that no sandbox row confines, `web_fetch` chooses its own request target, and + * `mcp_*` servers spawn outside `ctx.bash`. `ralph` proves the narrower + * alternative survives: it runs a script this repository fixed at build time, + * so the engine stays mounted without a model-code tool on top of it. The composition Agent Note owns + * the rationale and its sources. + */ +const EXPECTED_TUI_TOOLS = [ + 'ask_user_question', + 'bash', + 'create_goal', + 'edit', + 'exit_plan_mode', + 'get_goal', + 'ralph', + 'read', + 'session_event_read', + 'session_event_search', + 'session_event_trace', + 'session_search', + 'session_trace', + 'skill', + 'str_replace_editor', + 'subagent', + 'subagent_fork', + 'task_kill', + 'task_list', + 'task_output', + 'todo_write', + 'update_goal', + 'web_search', + 'workflow', + 'write', +] + +/** + * `glob` and `grep` come from `dsh-tool-fs-search`, which probes `command -v rg` + * through the mounted bash executor at load and registers neither tool when + * ripgrep is absent. That is a host dependency, not a composition decision, so the + * pair is asserted separately — present together or absent together. + */ +const RIPGREP_TOOLS = ['glob', 'grep'] + +/** The assembled request header the smoke asserts on. */ +interface LoggedHeader { + /** Assembled tool names, sorted. */ + names: string[] + /** `bash`'s assembled parameter properties; the escalation pair is present only under a confining executor. */ + bashArguments: Record +} + +/** + * Read the request header the loop assembled for its first request from the + * session log the smoke's workspace persisted — the model-visible composition + * itself, not a registry projection taken beside it. + * @param cwd - the smoke's temporary workspace. + * @returns the assembled catalog, system prompt, and `bash` argument shape. + */ +async function loggedHeader(cwd: string): Promise { + const sessionsDir = join(cwd, '.sessions') + const entries = await readdir(sessionsDir, { recursive: true }) + // A single keyless run writes one session log. + const logRelPath = entries.find(name => name.endsWith('.jsonl')) + if (logRelPath === undefined) throw new Error(`no session log written under ${sessionsDir}`) + const lines = (await readFile(join(sessionsDir, logRelPath), 'utf8')).split('\n').filter(Boolean) + for (const line of lines) { + const event = JSON.parse(line) as SessionEvent + if (event.type !== 'request/header') continue + const tools = event.data.header.tools ?? [] + const bash = tools.find(schema => schema.name === 'bash') + return { + names: tools.map(schema => schema.name).sort(), + bashArguments: (bash?.parameters as { properties?: Record } | undefined)?.properties ?? {}, + } + } + throw new Error(`session log ${logRelPath} has no request/header event`) +} + +describe('shipped dsh composition (real Loader tree in a PTY)', () => { + it('assembles exactly the shipped TUI catalog', async () => { + let observed: LoggedHeader | undefined + const output = await runTuiPtySmoke({ + label: 'dsh shipped composition', + tempDirPrefix: 'dsh-shipped-tui-', + binScript: dshBinScript, + tsconfigPath, + configPath: keylessTail, + env: { DEEPSEEK_API_KEY: 'keyless-composition-no-call', DSH_TELEMETRY_DISABLED: '1' }, + // Artifact CI builds and smokes concurrently on a contended runner. + ...(process.env.DSH_EXAMPLE_MODE === 'lib' ? { timeoutMs: 60_000 } : {}), + actions: [ + { waitFor: COMPOSITION_SETTLED_MARKER, send: 'Describe the shipped composition.\r' }, + { waitFor: COMPOSITION_REPLY_TEXT, send: '/exit\r' }, + ], + inspect: async (cwd) => { observed = await loggedHeader(cwd) }, + }) + expect(output).toContain(COMPOSITION_REPLY_TEXT) + expect(observed?.names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TUI_TOOLS) + expect([[], RIPGREP_TOOLS]).toContainEqual(observed?.names.filter(name => RIPGREP_TOOLS.includes(name))) + // The TUI mounts the unrestricted local executors, so `tool-bash` emits no + // escalation pair. Pinning its absence keeps a later sandbox change from + // arriving here unannounced. + expect(Object.keys(observed?.bashArguments ?? {})).not.toContain('sandbox_permissions') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts new file mode 100644 index 0000000000..1cba7f2b38 --- /dev/null +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -0,0 +1,86 @@ +// Boots the shipped Web composition over the built dist this lane already uses +// and asserts what that composition produces: the model-visible tool catalog +// and the sandbox/approval knobs it ships with. No browser and no model call — +// these are composition facts, and the browser scenarios in this lane cover the +// surface itself. +import { tmpdir } from 'node:os' +import { afterEach, expect, it } from 'vitest' +import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox' +// Empty type imports carry the tools/sandboxPolicy/approval Context merges. +import type {} from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-sandbox-policy' +import type {} from '@deepseek-ai/dsh-user-approval' +import { launchWebScaffold, type WebScaffold } from './scaffold.ts' + +/** + * The catalog the shipped Web composition puts in front of the model, minus the + * ripgrep-dependent pair below. The absences are + * deliberate, not incidental gaps: `workflow` and the + * `cordis_*` toolset both execute model-written JavaScript that no sandbox row + * confines, `web_fetch` chooses its own request target, and `mcp_*` servers + * spawn outside `ctx.bash`. `ralph` and `web_search` prove the narrower + * alternatives survive: Ralph runs a harness-fixed script, and `web_search` + * reaches one pinned provider endpoint. The composition Agent Note owns the + * rationale and its sources. + */ +const EXPECTED_TOOLS = [ + 'ask_user_question', + 'bash', + 'create_goal', + 'edit', + 'exit_plan_mode', + 'get_goal', + 'ralph', + 'read', + 'session_event_read', + 'session_event_search', + 'session_event_trace', + 'session_search', + 'session_trace', + 'skill', + 'str_replace_editor', + 'subagent', + 'subagent_fork', + 'task_kill', + 'task_list', + 'task_output', + 'todo_write', + 'update_goal', + 'web_search', + 'workflow', + 'write', +] + +/** + * `glob` and `grep` come from `dsh-tool-fs-search`, which probes `command -v rg` + * through the mounted bash executor at load and registers neither tool when + * ripgrep is absent. That is a host dependency, not a composition decision, so the + * pair is asserted separately — present together or absent together. + */ +const RIPGREP_TOOLS = ['glob', 'grep'] + +let scaffold: WebScaffold | undefined + +afterEach(async () => { + await scaffold?.close() + scaffold = undefined +}) + +it('assembles the shipped Web catalog and keeps its access default', async () => { + scaffold = await launchWebScaffold() + const names = scaffold.ctx.tools.schemas().map(schema => schema.name).sort() + expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS) + expect([[], RIPGREP_TOOLS]).toContainEqual(names.filter(name => RIPGREP_TOOLS.includes(name))) + // `workspace-write` is not "the workspace and nothing else": the shared roots + // helper always admits the temp directories too. Pinning it against an + // explicit mode keeps the claim independent of this surface's default, and + // keeps a future boundary test from being run inside /tmp — where an + // "escape" write succeeds by design and reads as a sandbox failure. + expect(writableRoots(scaffold.ctx.sandboxPolicy.resolve({ mode: 'workspace-write' }))).toEqual( + expect.arrayContaining([canonicalPath('/tmp'), canonicalPath(tmpdir())]), + ) + // The Web surface keeps its shipped access default; the base's confined one + // reaches the TUI. Pinning both keeps a base change from moving Web silently. + expect(scaffold.ctx.sandboxPolicy.defaultMode).toBe('danger-full-access') + expect(scaffold.ctx.approval.config.policy).toBe('never') +}, 120_000) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index a4cd2d9121..804044d1e8 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -47,7 +47,8 @@ "tests/queue-actions.e2e.ts", "tests/skill-invocation-policy.e2e.ts", "tests/permission-policy-context.e2e.ts", - "tests/access-confirmation.e2e.ts" + "tests/access-confirmation.e2e.ts", + "tests/shipped-composition.e2e.ts" ], "references": [ { diff --git a/knip.json b/knip.json index f5379c8c6e..99b152738d 100644 --- a/knip.json +++ b/knip.json @@ -622,7 +622,9 @@ "tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/**/*.snapshot.ts", - "tests/fixtures/tui-scripted-llm.ts" + "tests/fixtures/tui-scripted-llm.ts", + "tests/fixtures/composition-echo-llm.ts", + "tests/fixtures/composition-settled.ts" ], "project": [ "src/**/*.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbd187e678..69c8eda579 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -279,6 +279,9 @@ importers: '@deepseek-ai/dsh-llm-retry': specifier: workspace:^ version: link:../../packages/llm/llm-retry + '@deepseek-ai/dsh-mcp-client': + specifier: workspace:^ + version: link:../../packages/mcp/mcp-client '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../packages/util/paths @@ -288,6 +291,9 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../packages/plan/plan-mode + '@deepseek-ai/dsh-repeat-tool-guard': + specifier: workspace:^ + version: link:../../packages/guard/repeat-tool-guard '@deepseek-ai/dsh-sandbox-local': specifier: workspace:^ version: link:../../packages/sandbox/sandbox-local @@ -375,6 +381,9 @@ importers: '@deepseek-ai/dsh-timeout-policy': specifier: workspace:^ version: link:../../packages/timeout/timeout-policy + '@deepseek-ai/dsh-tmux-context': + specifier: workspace:^ + version: link:../../packages/context/tmux-context '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../../packages/llm/token-meter @@ -399,9 +408,15 @@ importers: '@deepseek-ai/dsh-tool-ralph': specifier: workspace:^ version: link:../../packages/workflow/tool-ralph + '@deepseek-ai/dsh-tool-session-query': + specifier: workspace:^ + version: link:../../packages/session-query/tool-session-query '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../packages/skill/tool-skill + '@deepseek-ai/dsh-tool-str-replace-editor': + specifier: workspace:^ + version: link:../../packages/fs/tool-str-replace-editor '@deepseek-ai/dsh-tool-subagent': specifier: workspace:^ version: link:../../packages/subagent/tool-subagent diff --git a/tsconfig.host.json b/tsconfig.host.json index 0b2a16c7f8..d1b5d8caa8 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -35,6 +35,7 @@ "apps/web/tests/skill-invocation-policy.e2e.ts", "apps/web/tests/permission-policy-context.e2e.ts", "apps/web/tests/access-confirmation.e2e.ts", + "apps/web/tests/shipped-composition.e2e.ts", "apps/cli/tests/**/*.ts", "examples/*/src/**/*.ts", "examples/*/start.ts", From e9c040894fa27498700bf91022fdb069cca0bb6a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:25:03 +0800 Subject: [PATCH 9/9] fix(cli): align shipped composition contracts --- ...6-07-24-model-facing-session-query-tools.i18n.yaml | 6 +++--- .../2026-07-24-model-facing-session-query-tools.md | 4 ++-- .../2026-07-24-model-facing-session-query-tools.zh.md | 4 ++-- .../2026-07-27-tmux-location-context.i18n.yaml | 4 ++-- .../feature/2026-07-27-tmux-location-context.md | 4 ++-- .../feature/2026-07-27-tmux-location-context.zh.md | 4 ++-- ...2026-07-31-even-out-shipped-tool-rosters.i18n.yaml | 4 ++-- .../2026-07-31-even-out-shipped-tool-rosters.md | 6 +++--- .../2026-07-31-even-out-shipped-tool-rosters.zh.md | 6 +++--- .../feature/2026-07-31-web-default-search.i18n.yaml | 4 ++-- .../feature/2026-07-31-web-default-search.md | 8 ++++---- .../feature/2026-07-31-web-default-search.zh.md | 8 ++++---- apps/cli/README.i18n.yaml | 4 ++-- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/tests/shipped-composition.e2e.ts | 10 ++++------ apps/web/tests/shipped-composition.e2e.ts | 11 ++++------- packages/context/tmux-context/README.i18n.yaml | 4 ++-- packages/context/tmux-context/README.md | 2 +- packages/context/tmux-context/README.zh.md | 2 +- .../session-query/tool-session-query/README.i18n.yaml | 4 ++-- packages/session-query/tool-session-query/README.md | 2 +- .../session-query/tool-session-query/README.zh.md | 2 +- 23 files changed, 51 insertions(+), 56 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml index 9087db9dd7..ce1ef3af95 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-24-model-facing-session-query-tools.md: 2a9a20a8b39dea309e759f4eb6ddcdabe25dd8be -2026-07-24-model-facing-session-query-tools.zh.md: 7fbe746681b329b3e50ae74608a8b2e5167c5ae9 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md +2026-07-24-model-facing-session-query-tools.md: 82fb70349a94916af2e99b83fcbdac765aae3dd0 +2026-07-24-model-facing-session-query-tools.zh.md: 3ffc142b2a27c612bb8a3238823f536871e5ea17 diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md index 2a9a20a8b3..82fb70349a 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md @@ -36,7 +36,7 @@ Session-level results include the latest folded title when available. Each tool ## Host composition -The consumer is an opt-in plugin. The shipped ACP and TUI apps mount `ctx.sessionQuery` for non-model consumers, while the shared Web/headless composition mounts neither the query service nor the consumer. No shipped composition mounts `@deepseek-ai/dsh-tool-session-query`, so default model requests gain no query prompt or schemas. A composition that opts in also chooses whether to mount the generic timeout and spill policies; the dedicated ACP snapshot fixture mounts both and uses private local spill storage. Generic tool presentation requires no session-query-specific client plugin. +The consumer is an opt-in plugin. The shipped TUI, Web, and headless compositions mount both `ctx.sessionQuery` and `@deepseek-ai/dsh-tool-session-query` through their shared base, so their default model requests include the query prompt and five schemas; the automation-only ACP composition mounts neither. These compositions also supply the generic timeout and spill policies. The dedicated ACP snapshot fixture mounts the consumer and both policies explicitly, with private local spill storage. Generic tool presentation requires no session-query-specific client plugin. ## Alternatives considered @@ -48,7 +48,7 @@ The consumer is an opt-in plugin. The shipped ACP and TUI apps mount `ctx.sessio ## Verification -Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, parent-filter preauthorization and oracle resistance, changed-observation rejection, service-diagnostic redaction for ordinary and adversarial unknown values, best-effort cyclic-cause logging, logger-failure containment, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, exclusive search and parallel exact-read classification, count caps, exact-signal forwarding, abort-reason preservation, persistence cleanup quiescence, one-scan bounded batch title enrichment, projection-before-dequeue ordering, queued-work suppression, started-worker quiescence, per-header validation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Shipped configuration, app composition tests, and assembled ACP request-header snapshots prove that the model-facing consumer remains absent while `ctx.sessionQuery` stays available where mounted. A package-owned Loader smoke and dedicated keyless ACP snapshot explicitly mount the consumer with timeout and spill support, pinning its prompt guidance, schemas, and path-independent exact event-read retention behavior. +Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, parent-filter preauthorization and oracle resistance, changed-observation rejection, service-diagnostic redaction for ordinary and adversarial unknown values, best-effort cyclic-cause logging, logger-failure containment, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, exclusive search and parallel exact-read classification, count caps, exact-signal forwarding, abort-reason preservation, persistence cleanup quiescence, one-scan bounded batch title enrichment, projection-before-dequeue ordering, queued-work suppression, started-worker quiescence, per-header validation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Shipped configuration and the TUI/Web composition tests prove that the model-facing consumer is present on the TUI, Web, and headless surfaces, while assembled ACP request-header snapshots prove that the automation surface omits it by default. A package-owned Loader smoke and dedicated keyless ACP snapshot explicitly mount the consumer with timeout and spill support, pinning its prompt guidance, schemas, and path-independent exact event-read retention behavior. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md index 7fbe746681..3ffc142b2a 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md @@ -36,7 +36,7 @@ Status: implemented ## 宿主组合 -该消费方是一个需显式启用的插件。发布的 ACP 与 TUI 应用为非模型消费方挂载 `ctx.sessionQuery`,而 Web/headless 共享组合既不挂载查询服务,也不挂载该消费方。发布的组合均未挂载 `@deepseek-ai/dsh-tool-session-query`,因此默认模型请求中不包含查询提示词或 schema。选择启用该插件的组合还要决定是否挂载通用的超时与 spill 策略;专用的 ACP 快照 fixture(测试前置数据)同时挂载这两项策略,并使用私有的本地 spill 存储。通用工具表现无需会话查询专用客户端插件。 +该消费方是一个需显式启用的插件。已交付的 TUI、Web 与无头组合通过共享 base 同时挂载 `ctx.sessionQuery` 和 `@deepseek-ai/dsh-tool-session-query`,因此其默认模型请求包含查询提示词与五个 schema;仅用于自动化的 ACP 组合两者均不挂载。这些组合还提供通用的超时与 spill 策略。专用的 ACP 快照 fixture(测试前置数据)显式挂载该消费方与这两项策略,并使用私有的本地 spill 存储。通用工具表现无需会话查询专用客户端插件。 ## 考虑过的替代方案 @@ -48,7 +48,7 @@ Status: implemented ## 验证 -包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、父级过滤预授权与抵御预言机探测、变更观测拒绝、普通值与对抗性未知值的服务诊断脱敏、尽力记录循环 cause、日志失败隔离、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、搜索独占与精确读取并行分类、数量上限、精确信号传递、中止原因保留、持久化清理静止、单次扫描且并发有界的批量标题扩充、先投影再取出下一个任务的顺序、抑制排队工作、等待已启动 worker 静止、逐会话头校验、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。发布配置、应用组合测试与组装后的 ACP 请求头快照证明:面向模型的消费方仍未挂载,而 `ctx.sessionQuery` 在已经挂载该服务的组合中保持可用。包自身的 Loader 冒烟测试与专用无密钥 ACP 快照显式挂载该消费方,并配套启用超时与 spill 支持,固定其提示词指引、schema 以及与路径无关的精确事件读取保留行为。 +包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、父级过滤预授权与抵御预言机探测、变更观测拒绝、普通值与对抗性未知值的服务诊断脱敏、尽力记录循环 cause、日志失败隔离、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、搜索独占与精确读取并行分类、数量上限、精确信号传递、中止原因保留、持久化清理静止、单次扫描且并发有界的批量标题扩充、先投影再取出下一个任务的顺序、抑制排队工作、等待已启动 worker 静止、逐会话头校验、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。发布配置与 TUI/Web 组合测试证明面向模型的消费方存在于 TUI、Web 与无头界面,而组装后的 ACP 请求头快照证明自动化界面默认不包含它。包自身的 Loader 冒烟测试与专用无密钥 ACP 快照显式挂载该消费方,并配套启用超时与 spill 支持,固定其提示词指引、schema 以及与路径无关的精确事件读取保留行为。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml index b5fa2609a4..e670431c9c 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-tmux-location-context.md -2026-07-27-tmux-location-context.md: bac5861f7f55c259de04d153115f164d90c415ad -2026-07-27-tmux-location-context.zh.md: 03cd722381c45604f7aae8f3d0a9f9fbb8b12bb5 +2026-07-27-tmux-location-context.md: 9f5e931e56565b0bf3ee219567c6913c40ef1d97 +2026-07-27-tmux-location-context.zh.md: e47eb43c5a9c64bab4518d118cb4dfd580de64e9 diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md index bac5861f7f..9f5e931e56 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md +++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md @@ -12,7 +12,7 @@ tmux exposes this without a daemon: `$TMUX_PANE` names the process's pane, and ` ## Decision -`@deepseek-ai/dsh-tmux-context` is an opt-in function plugin in `packages/context/tmux-context/`, alongside the other bounded request-context enrichments that define neither a tool nor a service. Shipped examples do not mount it because tmux-location disclosure and its token cost are deployment policy. +`@deepseek-ai/dsh-tmux-context` is an opt-in function plugin in `packages/context/tmux-context/`, alongside the other bounded request-context enrichments that define neither a tool nor a service. The shipped TUI mounts it because terminal-multiplexer context is specific to that surface; `dsh-agent-spine-demo` and the Web/headless surfaces stay silent. **Pull on the first step of each turn, not a tmux push.** The plugin prepends an `agent/step` listener and acts only when `step === 1`. A pull model needs no background process, no hook installation in the user's tmux, and no teardown; it re-reads current state each turn so a moved, renamed, or re-laid-out pane is picked up naturally. Gating on the first step makes the reading per-turn: a location is stable within a turn, and re-querying every step would add cost without new information. A pane moved mid-turn is reflected on the next turn, which is the accepted tradeoff for the simpler design. @@ -42,7 +42,7 @@ The published `./invariant` companion registers no runtime check: a reading is a ## Consequences -An agent booted inside tmux now receives its own session/window/pane location and window layout as durable, source-attributed context, updated per turn when the location changes. Deployments opt in through cordis.yml; the default spine and shipped examples stay silent. Outside a real tmux pane — including a terminal that merely inherited `$TMUX`/`$TMUX_PANE` — or without a `ctx.bash` executor, the plugin is inert with no error, so composing it is safe everywhere. Because the reading is one durable `user/message`, it survives compaction as ordinary history, contributes nothing to system-prompt assembly or request headers, and costs at most one two-line message per changed turn. The pull model adds one `tmux display-message` subprocess (through the sandboxed bash seam) on the first step of each turn that is due. The optional interval floor is checked before the query and so suppresses both; an unchanged location is detected only by comparing the returned state, so it suppresses the injection while still paying for the query. +An agent booted inside tmux now receives its own session/window/pane location and window layout as durable, source-attributed context, updated per turn when the location changes. The shipped TUI opts in; custom deployments may compose the plugin directly. Outside a real tmux pane — including a terminal that merely inherited `$TMUX`/`$TMUX_PANE` — or without a `ctx.bash` executor, the plugin is inert with no error, so composing it is safe everywhere. Because the reading is one durable `user/message`, it survives compaction as ordinary history, contributes nothing to system-prompt assembly or request headers, and costs at most one two-line message per changed turn. The pull model adds one `tmux display-message` subprocess (through the sandboxed bash seam) on the first step of each turn that is due. The optional interval floor is checked before the query and so suppresses both; an unchanged location is detected only by comparing the returned state, so it suppresses the injection while still paying for the query. ## Testing diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md index 03cd722381..e47eb43c5a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md @@ -12,7 +12,7 @@ tmux 无需守护进程即可暴露这些信息:`$TMUX_PANE` 标识进程所 ## 决策 -`@deepseek-ai/dsh-tmux-context` 是位于 `packages/context/tmux-context/` 的可选启用型函数插件,与其他既不定义工具也不定义服务的有界请求上下文增强并列。随附示例不挂载它,因为 tmux 位置披露及其 token 成本属于部署策略。 +`@deepseek-ai/dsh-tmux-context` 是位于 `packages/context/tmux-context/` 的可选启用型函数插件,与其他既不定义工具也不定义服务的有界请求上下文增强并列。已交付的 TUI 会挂载它,因为终端复用器上下文是该界面特有的;`dsh-agent-spine-demo` 与 Web/无头界面保持沉默。 **在每轮的第一个 step 拉取,而非 tmux 推送。** 插件前置注册一个 `agent/step` 监听器,仅在 `step === 1` 时动作。拉取模型无需后台进程、无需在用户的 tmux 中安装 hook、也无需清理;它每轮重新读取当前状态,因此被移动、改名或重新布局的 pane 都会被自然感知。以第一个 step 为门槛使读数按轮次生成:位置在一轮内是稳定的,逐步骤重复查询只会增加成本而不带来新信息。轮次中途移动的 pane 会在下一轮反映,这是换取更简单设计所接受的取舍。 @@ -42,7 +42,7 @@ window active=<0|1>, pane active=<0|1>, layout ## 后果 -启动于 tmux 内的 agent 现在会以持久、带来源标记的上下文收到自身的 session/window/pane 位置及 window 布局,并在位置变化时按轮次更新。部署方通过 cordis.yml 选择启用;默认 spine 与随附示例保持沉默。在真实 tmux pane 之外——包括仅继承了 `$TMUX`/`$TMUX_PANE` 的终端——或没有 `ctx.bash` 执行器时,插件保持惰性且不报错,因此在任何地方组合它都安全。由于读数是一条持久的 `user/message`,它作为普通历史经受压缩,对系统提示装配与请求头毫无贡献,且每个发生变化的轮次至多花费一条两行消息。拉取模型在每个到期轮次的第一个 step 增加一次 `tmux display-message` 子进程(经沙箱化的 bash seam)。可选的间隔下限在查询之前检查,因此同时抑制查询与注入;而位置未变化只能通过比较查询返回的状态得知,因此它只抑制注入,查询开销仍会付出。 +启动于 tmux 内的 agent 现在会以持久、带来源标记的上下文收到自身的 session/window/pane 位置及 window 布局,并在位置变化时按轮次更新。已交付的 TUI 选择启用;自定义部署可直接组合该插件。在真实 tmux pane 之外——包括仅继承了 `$TMUX`/`$TMUX_PANE` 的终端——或没有 `ctx.bash` 执行器时,插件保持惰性且不报错,因此在任何地方组合它都安全。由于读数是一条持久的 `user/message`,它作为普通历史经受压缩,对系统提示装配与请求头毫无贡献,且每个发生变化的轮次至多花费一条两行消息。拉取模型在每个到期轮次的第一个 step 增加一次 `tmux display-message` 子进程(经沙箱化的 bash seam)。可选的间隔下限在查询之前检查,因此同时抑制查询与注入;而位置未变化只能通过比较查询返回的状态得知,因此它只抑制注入,查询开销仍会付出。 ## 测试 diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml index 8c2ab801a6..83e965b391 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md -2026-07-31-even-out-shipped-tool-rosters.md: 28b44e68228043bcccd2d5f8bef96a86ffcae9d4 -2026-07-31-even-out-shipped-tool-rosters.zh.md: 8f26f7e0f86837a235bf96fa25d74f053c48e54b +2026-07-31-even-out-shipped-tool-rosters.md: 316e5045e559e2da162c53d64989ccecfd18b857 +2026-07-31-even-out-shipped-tool-rosters.zh.md: ed39212dc4877f4df1dc1c6e84142b61a866c548 diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md index 28b44e6822..316e5045e5 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md @@ -12,7 +12,7 @@ The result was a user-visible difference nobody had decided: the same model, ask ## Decision -The rows that are not surface-specific move into [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-guard`. Web search moves there too, which is the TUI decision that the change making it a Web default explicitly deferred. Both surfaces now assemble the same twenty-seven tools. +The rows that are not surface-specific move into [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-guard`. Web search moves there too; its [deployment decision](2026-07-31-web-default-search.md) owns the security boundary while the shared base owns its surface-neutral mount. Both surfaces now assemble the same roster: twenty-five tools on every host, plus `glob` and `grep` when ripgrep is available. Two rows stay surface-specific. `tmux-context` is TUI-only because a browser surface has no terminal multiplexer to describe. `session-reference` is TUI-only because it drives the shared session-query index from the launcher's process-local path, and the browser sidebar reconciles that index on its own first search. @@ -54,7 +54,7 @@ Beyond the committed tests, both surfaces were driven against a real key from th **Duplicate the shared rows into both overlays instead of promoting them.** Rejected on the one-home rule: three of the new rows would exist twice with no reason for the copies to diverge, and the next roster change would have to remember both. -**Sandbox the TUI in the same change.** The TUI mounts unrestricted executors, which is a real gap, and an earlier revision of this change closed it by moving the confined stack into the base. Rejected as a separate decision that does not belong in a roster change: it alters what an existing surface does rather than what it offers, and it needs its own evidence — not least because the TUI has no `approval/request` answerer, so an escalation there fails closed instead of prompting. +**Sandbox the TUI in the same change.** Rejected as a separate decision that does not belong in a roster change: the TUI mounts unrestricted executors, and replacing them alters what an existing surface does rather than what it offers. That decision needs its own evidence — not least because the TUI has no `approval/request` answerer, so an escalation there fails closed instead of prompting. **Enable Code Mode.** Its trust posture is bash-equivalent by design and its tool calls pass the same `tools/pre-execute` gate as bash, so it is not the same call as the model-code tools above. Rejected here anyway: `both` changes every model-visible request on both surfaces, and `code` replaces the wire rather than adding to it — either is a presentation decision, not a roster one. @@ -62,7 +62,7 @@ Beyond the committed tests, both surfaces were driven against a real key from th ## Consequences -The same model gets the same tools on both surfaces, and the difference that existed for no recorded reason is gone. Twenty-seven names, asserted exactly and identically on both sides, so a later change that adds a tool to one surface only — or drops one from either — fails a check instead of shipping quietly. +The same model gets the same tools on both surfaces, and the difference that existed for no recorded reason is gone. The tests assert the twenty-five unconditional names exactly and require the ripgrep-dependent pair to be either present together or absent together on both sides, so a later change that alters only one surface fails a check instead of shipping quietly. `apps/cli` gains five workspace dependencies: four the shipped tree now mounts, plus `dsh-mcp-client`, which it does not mount and which exists so an installed `dsh` can. diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md index 8f26f7e0f8..ed39212dc4 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-guard`。Web 搜索也一并移入,这正是把它做成 Web 默认的那次改动明确推迟的 TUI 决定。两个 surface 现在组装出同样的二十七个工具。 +那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-guard`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 现在组装同一份清单:每台宿主上都有二十五个工具,ripgrep 可用时再加上 `glob` 和 `grep`。 有两行仍是 surface 专属。`tmux-context` 只在 TUI,因为浏览器 surface 没有终端复用器可描述。`session-reference` 只在 TUI,因为它以 launcher 的进程本地路径驱动共享的 session-query 索引,而浏览器侧边栏会在自己的首次搜索里重建该索引。 @@ -54,7 +54,7 @@ Status: implemented **把共享的行复制进两份 overlay,而不是提升到 base。** 基于「一处归属」原则否决:新增行里有三行会存在两份,而这些副本没有任何理由发生分歧,下一次改工具清单还得记着改两处。 -**在同一次改动里给 TUI 加沙箱。** TUI 挂的是不受限执行器,这确实是个真实缺口,本次改动的早先一版曾把受限栈移入 base 来堵上它。作为一个不属于工具清单改动的独立决定被否决:它改变的是一个既有 surface 的行为而非它提供的东西,而且需要它自己的证据——尤其因为 TUI 没有 `approval/request` 的应答方,升级请求在那里是 fail-closed 而不是弹出提示。 +**在同一次改动里给 TUI 加沙箱。** 不予采纳,因为这是一个不属于工具清单改动的独立决定:TUI 挂的是不受限执行器,替换它们会改变一个既有 surface 做什么,而非它提供什么。这个决定需要自己的证据——尤其因为 TUI 没有 `approval/request` 的应答方,升级请求在那里会 fail-closed,而不是弹出提示。 **开启 Code Mode。** 它的信任立场按设计与 bash 同级,工具调用要过与 bash 相同的 `tools/pre-execute` 闸门,所以它与上面那些模型写码工具不是同一个判断。在这里仍被否决:`both` 会改变两个 surface 上每一个模型可见请求,而 `code` 是把线路替换而非加一个——两者都是呈现方式的决定,不是工具清单的决定。 @@ -62,7 +62,7 @@ Status: implemented ## 后果 -同一个模型在两个 surface 上拿到同样的工具,那处没有记录理由的差异消失了。二十七个名字,两侧精确且一致地断言,因此日后只给一个 surface 加工具、或从任一侧丢掉工具,都会让检查失败而不是悄悄发出去。 +同一个模型在两个 surface 上拿到同样的工具,那处没有记录理由的差异消失了。测试会精确断言二十五个无条件提供的名称,并要求依赖 ripgrep 的一对工具在两侧要么同时存在、要么同时缺席,因此日后只改一个 surface 都会让检查失败而不是悄悄发出去。 `apps/cli` 增加五个 workspace 依赖:四个是交付树现在挂载的,外加 `dsh-mcp-client`——它并不被挂载,存在的意义是让已安装的 `dsh` 能挂。 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-default-search.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-default-search.i18n.yaml index f06c290bb3..6b244f3d12 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-default-search.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-web-default-search.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-web-default-search.md -2026-07-31-web-default-search.md: ddc047a963212cb228da67c6c33128877cacf92c -2026-07-31-web-default-search.zh.md: 05c30b625953ccd54c127a97b646ad7db75f693b +2026-07-31-web-default-search.md: d9616c27410bb5be9b385a9aaa56c22f6054eeb1 +2026-07-31-web-default-search.zh.md: 27cd330427669a78c03b939c737b37f79fd7965a diff --git a/.agents/notes/implemented/feature/2026-07-31-web-default-search.md b/.agents/notes/implemented/feature/2026-07-31-web-default-search.md index ddc047a963..d9616c2741 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-default-search.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-default-search.md @@ -1,4 +1,4 @@ -# Agent Note: Default Web search in the Web/headless composition +# Agent Note: Default Web search in shipped compositions Status: implemented @@ -10,7 +10,7 @@ The harness had a complete Web capability family—provider registry, DeepSeek/E ## Decision -`apps/cli/config/web.cordis.yml` explicitly mounts `dsh-web` with `searchProvider: deepseek-official`, `dsh-web-search-deepseek`, and `dsh-tool-web` with `fetch: false`. It does not mount `dsh-web-fetch-local` or select a fetch provider. The shared overlay makes only `web_search` a default for browser and headless sessions; the TUI composition remains unchanged. The explicit search provider id keeps selection independent of registration order and leaves personal or `--config` overlays able to replace or disable the rows. +`apps/cli/config/base.cordis.yml` explicitly mounts `dsh-web` with `searchProvider: deepseek-official`, `dsh-web-search-deepseek`, and `dsh-tool-web` with `fetch: false`. It does not mount `dsh-web-fetch-local` or select a fetch provider. The shared base makes only `web_search` a default for TUI, browser, and headless sessions. The explicit search provider id keeps selection independent of registration order and leaves personal or `--config` overlays able to replace or disable the rows. DeepSeek search uses the same `DEEPSEEK_API_KEY` credential reference as the official conversation adapter. The provider resolves that reference inside every search through the optional `ctx.credentials` service; only a composition without the seam falls back to the launching process environment, and a non-empty literal `apiKey` remains the programmatic last resort. A stored or rotated Web Models key therefore reaches the next search without restarting or retaining the value on the provider. Because `WebSearchProvider.available()` is synchronous, it treats an installed resolver as locally usable and missing dynamic credentials fail the operation with the provider-specific `WEB_PROVIDER_CREDENTIAL_MISSING` code while the stable tool schema stays registered. @@ -26,10 +26,10 @@ The default mount does not create a Web-specific permission policy. `web_search` **Freeze `process.env.DEEPSEEK_API_KEY` at provider load.** Rejected because the Web Models page writes through `ctx.credentials`; the product's documented first-run path must make the next operation work without a restart. -**Mount Web tools in `base.cordis.yml`.** Rejected because that would also change the TUI deployment. The browser and headless entries already share `web.cordis.yml`; they gain the capability together while TUI remains an explicit later decision. +**Keep Web tools in `web.cordis.yml`.** Rejected because it preserves an unexplained tool-roster difference between TUI and Web/headless. The rows are not surface-specific, so `base.cordis.yml` is their one home; the [tool-roster decision](2026-07-31-even-out-shipped-tool-rosters.md) records the shared composition. **Enable search and fetch together.** Rejected because default `web_fetch` would allow model-selected anonymous outbound HTTP(S) retrieval to arbitrary URLs. Search covers discovery; deployments that accept broader retrieval can opt into `dsh-web-fetch-local` and set `dsh-tool-web`'s `fetch` option to `true` in their overlay. ## Consequences -Web/headless model requests carry only the `web_search` schema and search-only prompt guidance in native mode; Code Mode exposes the same search capability beneath `run_code`. The prompt tells the model to use returned snippets and never advertises the disabled `web_fetch` tool. Search adds a complete auxiliary model call and may use the native server tool multiple times; its exact secret-free request remains reconstructable from the initiating session log. The default offers search-result snippets and source metadata but no arbitrary page retrieval; deployments that need full-page fetch must opt in. The Web snapshot lane boots the shipped tree, drives a replayed `web_search` call through the real DeepSeek provider against a local Messages fixture, asserts the durable auxiliary request and structured result, and pins the settled browser presentation. The real-composition smoke test pins the absence of `web_fetch`; provider tests pin missing, stored, and rotated credential behavior plus literal and ambient compatibility. +Native model requests on every shipped surface carry only the `web_search` schema and search-only prompt guidance; Web/headless Code Mode exposes the same search capability beneath `run_code`. The prompt tells the model to use returned snippets and never advertises the disabled `web_fetch` tool. Search adds a complete auxiliary model call and may use the native server tool multiple times; its exact secret-free request remains reconstructable from the initiating session log. The default offers search-result snippets and source metadata but no arbitrary page retrieval; deployments that need full-page fetch must opt in. The Web snapshot lane boots the shipped tree, drives a replayed `web_search` call through the real DeepSeek provider against a local Messages fixture, asserts the durable auxiliary request and structured result, and pins the settled browser presentation. The TUI/Web composition smokes pin the shared `web_search` roster and absence of `web_fetch`; provider tests pin missing, stored, and rotated credential behavior plus literal and ambient compatibility. diff --git a/.agents/notes/implemented/feature/2026-07-31-web-default-search.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-default-search.zh.md index 05c30b6259..27cd330427 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-default-search.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-default-search.zh.md @@ -1,4 +1,4 @@ -# Agent Note: Web/无头组合中的默认 Web 搜索 +# Agent Note: 已交付组合中的默认 Web 搜索 Status: implemented @@ -10,7 +10,7 @@ Status: implemented ## 决策 -`apps/cli/config/web.cordis.yml` 明确挂载 `dsh-web`,配置 `searchProvider: deepseek-official`,同时挂载 `dsh-web-search-deepseek`,并以 `fetch: false` 挂载 `dsh-tool-web`。它不挂载 `dsh-web-fetch-local`,也不选择抓取提供方。共享覆盖层只将 `web_search` 设为浏览器与无头会话的默认工具;TUI 组合保持不变。显式搜索提供方 id 使选择不受注册顺序影响,同时个人覆盖层或 `--config` 覆盖层仍可替换或禁用这些配置项。 +`apps/cli/config/base.cordis.yml` 明确挂载 `dsh-web`,配置 `searchProvider: deepseek-official`,同时挂载 `dsh-web-search-deepseek`,并以 `fetch: false` 挂载 `dsh-tool-web`。它不挂载 `dsh-web-fetch-local`,也不选择抓取提供方。共享 base 只将 `web_search` 设为 TUI、浏览器与无头会话的默认工具。显式搜索提供方 id 使选择不受注册顺序影响,同时个人覆盖层或 `--config` 覆盖层仍可替换或禁用这些配置项。 DeepSeek 搜索使用与官方会话适配器相同的 `DEEPSEEK_API_KEY` 凭据引用。提供方在每次搜索内部通过可选的 `ctx.credentials` 服务解析该引用;只有未挂载该 seam 的组合才会回退到启动进程的环境变量,非空的 `apiKey` 字面值仍作为程序化配置的最后兜底。因此,由 Web 的 Models 页存储或轮换的密钥无需重启即可用于下一次搜索,提供方也无需保留该值。由于 `WebSearchProvider.available()` 是同步方法,它会将已安装解析器视为本地可用;若动态凭据缺失,操作会以提供方专属错误码 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败,而稳定的工具 schema 仍保持注册。 @@ -26,10 +26,10 @@ DeepSeek 搜索使用与官方会话适配器相同的 `DEEPSEEK_API_KEY` 凭据 **在提供方加载时固定读取 `process.env.DEEPSEEK_API_KEY`。** 不予采纳:Web Models 页面通过 `ctx.credentials` 写入密钥;产品文档规定的首次运行路径必须保证下一次操作无需重启即可生效。 -**在 `base.cordis.yml` 中挂载 Web 工具。** 不予采纳:这也会改变 TUI 部署。浏览器与无头入口已经共享 `web.cordis.yml`;两者会一同获得该能力,是否为 TUI 启用则仍留作后续显式决策。 +**将 Web 工具保留在 `web.cordis.yml` 中。** 不予采纳:这会保留 TUI 与 Web/无头界面之间无法解释的工具清单差异。这些配置行并非界面特有,因此其唯一归属是 `base.cordis.yml`;[工具清单决策](2026-07-31-even-out-shipped-tool-rosters.md)记录了这一共享组合。 **同时启用搜索和抓取。** 不予采纳:默认启用 `web_fetch` 会允许模型自行选择任意 URL,执行匿名出站 HTTP(S) 抓取。搜索负责发现信息;接受更广泛抓取范围的部署可以在覆盖层中选择启用 `dsh-web-fetch-local`,并将 `dsh-tool-web` 的 `fetch` 选项设为 `true`。 ## 后果 -Web/无头模型请求在原生模式下只会携带 `web_search` schema,以及仅用于搜索的提示词指引;Code Mode 通过 `run_code` 公开相同的搜索能力。该提示词要求模型使用返回的 snippet,且绝不会向模型提及已禁用的 `web_fetch` 工具。搜索会增加一次完整的辅助模型调用,并可能多次使用原生服务器工具;发起会话的日志仍可精确重建其不含密钥的请求。默认配置会提供搜索结果 snippet 与来源元数据,但不支持任意页面抓取;需要抓取完整页面的部署必须自行选择启用抓取。Web 快照通道会启动已交付配置树,使用本地 Messages fixture(测试前置数据),经由真实 DeepSeek 提供方驱动一次回放的 `web_search` 调用,断言持久化的辅助请求与结构化结果,并固定最终浏览器呈现。真实组合冒烟测试固定了不提供 `web_fetch` 这一事实;提供方测试固定缺失、已存储及已轮换凭据的行为,以及字面值与环境变量的兼容性。 +每个已交付界面的原生模型请求都只会携带 `web_search` schema,以及仅用于搜索的提示词指引;Web/无头 Code Mode 通过 `run_code` 公开相同的搜索能力。该提示词要求模型使用返回的 snippet,且绝不会向模型提及已禁用的 `web_fetch` 工具。搜索会增加一次完整的辅助模型调用,并可能多次使用原生服务器工具;发起会话的日志仍可精确重建其不含密钥的请求。默认配置会提供搜索结果 snippet 与来源元数据,但不支持任意页面抓取;需要抓取完整页面的部署必须自行选择启用抓取。Web 快照通道会启动已交付配置树,使用本地 Messages fixture(测试前置数据),经由真实 DeepSeek 提供方驱动一次回放的 `web_search` 调用,断言持久化的辅助请求与结构化结果,并固定最终浏览器呈现。TUI/Web 组合冒烟测试固定了共享的 `web_search` 清单及不提供 `web_fetch` 这一事实;提供方测试固定缺失、已存储及已轮换凭据的行为,以及字面值与环境变量的兼容性。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index c436fd5bbd..3836ea1260 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: 3b1f7b9e938c7886e208498416fa3193ef05fd54 -README.zh.md: 374c22b2c2f62742897495f572c1c0ed8278e4cf +README.md: 3b2b8808b55e3882de9832029a0d5f1ac457d169 +README.zh.md: c2f998f1eb2cac737e8822205e0b71b1a951bbb1 diff --git a/apps/cli/README.md b/apps/cli/README.md index 3b1f7b9e93..3b2b8808b5 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -25,7 +25,7 @@ The shipped TUI and Web compositions register the native DeepSeek adapter plus p Every surface also registers `web_search` and only `web_search`. Search uses DeepSeek's Anthropic-compatible Messages endpoint, resolves the same `DEEPSEEK_API_KEY` reference for every call, and accepts the separate `DEEPSEEK_SEARCH_BASE_URL` endpoint override; each search is an auxiliary model request with its own latency and token cost. `web_fetch` remains disabled and the composition mounts no default fetch provider, so deployments that need arbitrary page retrieval must opt in through an overlay. The deployment decision and its security boundary live in the [default Web search Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md). -Every surface presents tools in `both` mode: the model receives every native schema **and** the Code Mode `run_code` transport, so a task that would be a long tool-call chain can be one program instead. Code Mode's trust posture is bash-equivalent by design — model code reaches Node APIs, but its tool calls pass the same `tools/pre-execute` gate as bash, and the worker adds containment bash has no equivalent for (a separate isolate, an empty environment, a heap cap, and hard termination). `DSH_TOOLS_MODE` overrides the presentation for a whole Web/headless process — `native` drops `run_code`, `code` sends only it — and any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam, process-wide because Loader composition is static, removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode). +`DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it and pins `native`. Every `dsh` surface — TUI, Web, and headless — reports session telemetry by default (the row lives in the shared `base.cordis.yml`): every session-log event streams as OTLP/HTTP log records to `https://harness-telemetry.deepseeksvc.com/v1/logs` on a 10-second batch cadence. `DSH_TELEMETRY_OTLP_URL` points the exporter at a different collector; setting `DSH_TELEMETRY_DISABLED` to ANY non-empty value — including `0` or `false` — disables the row before it loads (a privacy switch prefers off-by-mistake over on-by-mistake). No redaction rule is mounted in this composition yet: exported records are the raw captured copy, including message text, tool arguments and results, and the session's working-directory path. The deployment rulings live in the [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md). diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 374c22b2c2..c2f998f1eb 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -25,7 +25,7 @@ Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 每个界面也都只注册 `web_search` 这一个 Web 工具。搜索使用 DeepSeek 的 Anthropic 兼容 Messages 端点,每次调用都会解析同一个 `DEEPSEEK_API_KEY` 凭据引用,并接受独立的 `DEEPSEEK_SEARCH_BASE_URL` 端点覆盖;每次搜索都是一次辅助模型请求,会产生独立的延迟与 token 成本。`web_fetch` 仍处于禁用状态,组合也未挂载默认抓取提供方;需要任意页面抓取能力的部署必须通过覆盖层选择启用。部署决策及其安全边界见[默认 Web 搜索 Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md)。 -每个界面都以 `both` 模式呈现工具:模型同时收到全部原生 schema **和** Code Mode 的 `run_code` 传输通道,于是原本要一长串工具调用的任务可以写成一段程序。Code Mode 的信任立场按设计与 bash 同级——模型代码可以触达 Node API,但它的工具调用要过与 bash 相同的 `tools/pre-execute` 闸门,而该 worker 还提供了 bash 没有的收容(独立 isolate、空环境、堆上限、硬终止)。`DSH_TOOLS_MODE` 可为整个 Web/无头进程覆盖呈现模式——`native` 去掉 `run_code`,`code` 则只发它——任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。 +`DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:`native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 线路)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam——Loader 组合是静态的,因此该设置作用于整个进程——待 Web UI 负责逐会话工具模式选择后便会移除;TUI 界面会忽略该变量并固定为 `native`。 每个 `dsh` 界面——TUI、Web 与无头——都默认上报会话遥测(该行位于共享的 `base.cordis.yml`):每条会话日志事件以 OTLP/HTTP 日志记录的形式、按 10 秒批处理节奏流向 `https://harness-telemetry.deepseeksvc.com/v1/logs`。`DSH_TELEMETRY_OTLP_URL` 可将 exporter 指向其他 collector;将 `DSH_TELEMETRY_DISABLED` 设为**任意非空值**——包括 `0` 或 `false`——都会在该行加载前将其关停(隐私开关取「宁可误关、不可误开」)。该组合当前未挂载任何脱敏规则:导出记录即原始捕获副本,包含消息正文、工具参数与结果、以及会话工作目录路径。部署口径见 [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)。 diff --git a/apps/cli/tests/shipped-composition.e2e.ts b/apps/cli/tests/shipped-composition.e2e.ts index 2fef01de52..565aacd269 100644 --- a/apps/cli/tests/shipped-composition.e2e.ts +++ b/apps/cli/tests/shipped-composition.e2e.ts @@ -19,12 +19,10 @@ const keylessTail = fileURLToPath(new URL('./fixtures/composition-keyless-tail.c * The catalog the shipped `dsh` TUI puts in front of the model, as the loop * logged it, minus the ripgrep-dependent pair below. * The absences are the composition's security decisions, not incidental gaps: - * `workflow` and the `cordis_*` toolset both execute model-written JavaScript - * that no sandbox row confines, `web_fetch` chooses its own request target, and - * `mcp_*` servers spawn outside `ctx.bash`. `ralph` proves the narrower - * alternative survives: it runs a script this repository fixed at build time, - * so the engine stays mounted without a model-code tool on top of it. The composition Agent Note owns - * the rationale and its sources. + * the `cordis_*` toolset executes model-written JavaScript that no sandbox row + * confines, `web_fetch` chooses its own request target, and `mcp_*` servers + * spawn outside `ctx.bash`. The composition Agent Note owns the rationale and + * its sources. */ const EXPECTED_TUI_TOOLS = [ 'ask_user_question', diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 1cba7f2b38..0cad833303 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -14,13 +14,10 @@ import { launchWebScaffold, type WebScaffold } from './scaffold.ts' /** * The catalog the shipped Web composition puts in front of the model, minus the - * ripgrep-dependent pair below. The absences are - * deliberate, not incidental gaps: `workflow` and the - * `cordis_*` toolset both execute model-written JavaScript that no sandbox row - * confines, `web_fetch` chooses its own request target, and `mcp_*` servers - * spawn outside `ctx.bash`. `ralph` and `web_search` prove the narrower - * alternatives survive: Ralph runs a harness-fixed script, and `web_search` - * reaches one pinned provider endpoint. The composition Agent Note owns the + * ripgrep-dependent pair below. The absences are deliberate, not incidental + * gaps: the `cordis_*` toolset executes model-written JavaScript that no + * sandbox row confines, `web_fetch` chooses its own request target, and + * `mcp_*` servers spawn outside `ctx.bash`. The composition Agent Note owns the * rationale and its sources. */ const EXPECTED_TOOLS = [ diff --git a/packages/context/tmux-context/README.i18n.yaml b/packages/context/tmux-context/README.i18n.yaml index 9a6f113cc8..213e74f2ca 100644 --- a/packages/context/tmux-context/README.i18n.yaml +++ b/packages/context/tmux-context/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/context/tmux-context/README.md -README.md: a166a46d20f472cb5d8f045e2456ce3e6de7a2f2 -README.zh.md: 0575d549e352239e7d954870eaf40beea1169cc6 +README.md: 053206797398aa952522298e82992a7320daf74c +README.zh.md: 439f3e7712b0803b07a9a7e9dd10d9e863876154 diff --git a/packages/context/tmux-context/README.md b/packages/context/tmux-context/README.md index a166a46d20..0532067973 100644 --- a/packages/context/tmux-context/README.md +++ b/packages/context/tmux-context/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Opt-in durable context naming the tmux session, window, and pane this agent process runs in, plus the window's pane-tree layout. Sampled once per turn during model-request preparation. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md). +Opt-in durable context naming the tmux session, window, and pane this agent process runs in, plus the window's pane-tree layout. Sampled once per turn during model-request preparation. The shipped TUI mounts it; `dsh-agent-spine-demo` and the Web/headless surfaces do not. Decision record: [the tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md). ## Config diff --git a/packages/context/tmux-context/README.zh.md b/packages/context/tmux-context/README.zh.md index 0575d549e3..439f3e7712 100644 --- a/packages/context/tmux-context/README.zh.md +++ b/packages/context/tmux-context/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -可选启用的持久上下文,记录本 agent 进程所在的 tmux session、window、pane,以及该 window 的 pane 树布局。在准备模型请求时每轮采样一次。`dsh-agent-spine-demo` 与随附示例均不挂载它。决策记录见:[tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md)。 +可选启用的持久上下文,记录本 agent 进程所在的 tmux session、window、pane,以及该 window 的 pane 树布局。在准备模型请求时每轮采样一次。已交付的 TUI 会挂载它;`dsh-agent-spine-demo` 与 Web/无头界面均不挂载。决策记录见:[tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md)。 ## 配置 diff --git a/packages/session-query/tool-session-query/README.i18n.yaml b/packages/session-query/tool-session-query/README.i18n.yaml index e86449af9c..5df258e899 100644 --- a/packages/session-query/tool-session-query/README.i18n.yaml +++ b/packages/session-query/tool-session-query/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-query/tool-session-query/README.md -README.md: d973daf1124c4be05f7335b18661d431d45be39f -README.zh.md: b27d79a905a029d3750f24573e3c32785a314015 +README.md: 9a70f29d7c39af816c9efcf479ad129f0148883c +README.zh.md: 55717aef20d53686cce963d09b2e41350d274a75 diff --git a/packages/session-query/tool-session-query/README.md b/packages/session-query/tool-session-query/README.md index d973daf112..9a70f29d7c 100644 --- a/packages/session-query/tool-session-query/README.md +++ b/packages/session-query/tool-session-query/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Workspace-authorized model tools over `ctx.sessionQuery`. The opt-in package depends only on the unified interface and registers `session_search`, `session_event_search`, `session_trace`, `session_event_trace`, and `session_event_read`; shipped host compositions do not mount it by default. +Workspace-authorized model tools over `ctx.sessionQuery`. The opt-in package depends only on the unified interface and registers `session_search`, `session_event_search`, `session_trace`, `session_event_trace`, and `session_event_read`; the shipped TUI, Web, and headless compositions mount it by default, while ACP does not. ## Configuration diff --git a/packages/session-query/tool-session-query/README.zh.md b/packages/session-query/tool-session-query/README.zh.md index b27d79a905..55717aef20 100644 --- a/packages/session-query/tool-session-query/README.zh.md +++ b/packages/session-query/tool-session-query/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -位于 `ctx.sessionQuery` 之上、经工作区授权的模型工具。该 opt-in 包(package)只依赖统一接口,并注册 `session_search`、`session_event_search`、`session_trace`、`session_event_trace` 和 `session_event_read`;已发布的宿主组合默认不挂载它。 +位于 `ctx.sessionQuery` 之上、经工作区授权的模型工具。该 opt-in 包(package)只依赖统一接口,并注册 `session_search`、`session_event_search`、`session_trace`、`session_event_trace` 和 `session_event_read`;已交付的 TUI、Web 与无头组合默认挂载它,而 ACP(Agent Client Protocol)不挂载。 ## 配置