From bbff2c58ded649676a54102b3335d26824ae287a Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 30 Jul 2026 21:59:33 +0800 Subject: [PATCH 01/28] 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 02/28] 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 890343751a356273128416eba6be6b09ece7d463 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Thu, 30 Jul 2026 22:29:35 +0800 Subject: [PATCH 03/28] feat(tui): add versioned first-run welcome --- ...-versioned-tui-first-run-welcome.i18n.yaml | 6 + ...6-07-30-versioned-tui-first-run-welcome.md | 43 +++ ...7-30-versioned-tui-first-run-welcome.zh.md | 43 +++ apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 1 + apps/cli/README.zh.md | 1 + apps/cli/assets/deepseek-color.svg | 1 + apps/cli/package.json | 2 + apps/cli/src/tui-first-run-welcome-art.ts | 111 ++++++ apps/cli/src/tui-first-run-welcome-copy.ts | 62 +++ apps/cli/src/tui-first-run-welcome.ts | 352 ++++++++++++++++++ apps/cli/src/tui.ts | 29 +- apps/cli/tests/pty-harness.ts | 52 ++- .../120-columns.expected.txt | 106 ++++++ .../160-columns.expected.txt | 106 ++++++ .../60-columns.expected.txt | 100 +++++ .../80-columns.expected.txt | 103 +++++ apps/cli/tests/tui-first-run-welcome.spec.ts | 189 ++++++++++ apps/cli/tests/tui-keyless-smoke.e2e.ts | 190 +++++++++- packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 6 +- packages/ui/tui/README.zh.md | 6 +- packages/ui/tui/src/components/theme.ts | 32 +- packages/ui/tui/src/extension/types.ts | 2 + packages/ui/tui/src/index.ts | 5 +- packages/ui/tui/tests/extension.spec.ts | 1 + packages/ui/tui/tests/tui.spec.ts | 7 +- pnpm-lock.yaml | 3 + 28 files changed, 1527 insertions(+), 40 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md create mode 100644 .agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md create mode 100644 apps/cli/assets/deepseek-color.svg create mode 100644 apps/cli/src/tui-first-run-welcome-art.ts create mode 100644 apps/cli/src/tui-first-run-welcome-copy.ts create mode 100644 apps/cli/src/tui-first-run-welcome.ts create mode 100644 apps/cli/tests/snapshots/tui-first-run-welcome/120-columns.expected.txt create mode 100644 apps/cli/tests/snapshots/tui-first-run-welcome/160-columns.expected.txt create mode 100644 apps/cli/tests/snapshots/tui-first-run-welcome/60-columns.expected.txt create mode 100644 apps/cli/tests/snapshots/tui-first-run-welcome/80-columns.expected.txt create mode 100644 apps/cli/tests/tui-first-run-welcome.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml new file mode 100644 index 0000000000..ff8ce65c14 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.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-versioned-tui-first-run-welcome.md +2026-07-30-versioned-tui-first-run-welcome.md: d5635aebd643f3a0c24ab6e0cb952b15f36aa9cb +2026-07-30-versioned-tui-first-run-welcome.zh.md: 7760ebde0e117e0f03d572b8263330b24c5a81ab diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md new file mode 100644 index 0000000000..d5635aebd6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md @@ -0,0 +1,43 @@ +# Agent Note: Versioned TUI first-run welcome + +Status: implemented + +English | [中文](2026-07-30-versioned-tui-first-run-welcome.zh.md) + +## Problem + +The shipped `dsh` terminal starts directly in the editor and gives first-time internal testers no durable orientation about the product's maturity or feedback channel. The existing one-line `welcome` banner subtitle cannot carry the supplied four-paragraph notice without crowding the normal session header, and putting onboarding in the session log would create a user turn or model-visible context that is unrelated to the user's work. + +The notice also needs a recognizable DeepSeek composition without copying another product's startup art or maintaining a hand-drawn approximation that drifts from the official mark. + +## Decision + +The official `dsh` launcher owns one versioned acknowledgement marker under the resolved `DSH_HOME`. It checks the immutable marker before boot, then mounts an effect-owned consumer of `ctx.tui.openOverlay()` only after the real TUI service is available. Enter is the sole acknowledgement action: the plugin creates and synchronizes the fixed per-version marker before closing. Escape and every other non-navigation input leave the overlay open; disposal or process exit before Enter writes nothing. The version is part of the marker filename, so incrementing the centrally owned notice version presents materially revised copy once without migrating or rewriting an aggregate settings document. + +The marker is launcher state rather than session persistence because eligibility spans sessions and workspaces but is scoped to one Harness home. Exclusive file creation makes concurrent Enter actions idempotent without a read-modify-write race or dependency on the independently evolving settings stack. The notice never appends a session event, injects model context, or creates a user turn; resume therefore presents it only when the same Harness home has not acknowledged that version and never replays it from the session log. + +The supplied official `24x24` DeepSeek SVG is committed as the visual source. Static full, compact, and minimal terminal rasters sample that exact path at decreasing square resolutions; they do not redraw the contour. Unicode `▀`/`▄`/`█` cells preserve two vertical source pixels per terminal cell, while an explicitly ASCII-only locale uses the bit-equivalent `'`/`_`/`#` fallback. ANSI styling stays outside both the SVG and editable copy: `ctx.tui` supplies a semantic `brand` role, using the official `#4D6BFE` ink when truecolor is available, standard ANSI blue otherwise, and plain text when color is disabled. The normal startup banner retains its existing gradient. + +The overlay is centered and consumes the available terminal width. Wide terminals place the full icon beside the title and prose; medium and narrow terminals stack the compact or minimal icon above them; low height removes the icon before reducing prose space. The prose scrolls while the title and only action remain fixed. The quotation is promoted to its own visual paragraph without changing the centrally owned Chinese string. Closing through Enter returns modal ownership to the existing FIFO manager, which restores the editor and leaves the normal startup banner, transcript, and focus behavior intact. + +## Verification + +Focused unit coverage pins the supplied SVG and Chinese copy hashes, version bumps, exclusive concurrent acknowledgement, malformed markers, persistence retry, Escape behavior, ASCII fallback, width-tier selection, bounded rendering, and low-height scrolling. Real Loader/PTY cases cover 60, 80, 120, and 160 columns plus a low-height viewport, emit semantic terminal snapshots, prove first launch then second-launch suppression under one `DSH_HOME`, and prove a resumed session appends no notice-derived user message or turn; ordinary terminal-exit lifecycle events remain unchanged. + +## Alternatives considered + +**Reuse the TUI `welcome` subtitle.** It is one transient header line whose normal job is to identify an untitled session. The required prose and action would either be clipped or permanently crowd ordinary launches. + +**Copy Claude Code's startup art or composition.** Its strong hierarchy is useful product evidence, but its graphic, layout, and brand treatment belong to another product. The official DeepSeek SVG provides a direct brand source, and the terminal composition is derived independently around this notice's copy and responsive constraints. + +**Hand-draw an original whale.** A freehand silhouette can be recognizable yet still disagree with the official mark's body, internal negative space, fin, and tail. Exact-path raster sampling keeps the terminal limitation explicit and makes every tier traceable to one source asset. + +**Store a boolean in session events or a shared settings document.** Session state has the wrong lifetime and would pollute replay or model-visible history. An aggregate document would require cross-process read-modify-write locking for one immutable fact; a version-named exclusive marker has no lost-update shape. + +**Allow Escape or a later-reminder action.** Either would make dismissal indistinguishable from acknowledgement or introduce reminder policy that the notice does not need. Normal process exit remains the abort path and leaves the version unacknowledged. + +## Consequences + +Each Harness home receives the notice once per copy version, only after a successful Enter acknowledgement. Maintainers can edit all bilingual wording and the version in one small owner file, and can update the official SVG and derived static rasters in their separate visual owner without chasing snapshots for full prose copies. + +The terminal cannot display SVG vectors directly, so its faithful representation is resolution-bounded. Smaller tiers preserve the sampled silhouette but necessarily lose fine detail; low-height terminals prefer readable prose and an always-reachable action over brand art. The marker format is intentionally one-file-per-version during the pre-release period; old markers are harmless and no compatibility reader is required. diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md new file mode 100644 index 0000000000..7760ebde0e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 版本化 TUI 首次运行欢迎页 + +Status: implemented + +[English](2026-07-30-versioned-tui-first-run-welcome.md) | 中文 + +## 问题 + +已交付的 `dsh` 终端会直接进入编辑器,没有为首次使用的内部测试者提供持久的产品成熟度说明或反馈渠道指引。现有单行 `welcome` banner 副标题无法容纳指定的四段通知,否则会挤占正常的会话 header;如果把 onboarding 写入会话日志,还会创建一个与用户工作无关的用户轮次或模型可见上下文。 + +该通知还需要形成具有辨识度的 DeepSeek 视觉构图,同时不能复制其他产品的启动图形,也不能维护一份会偏离官方标志的手绘近似图。 + +## 决策 + +官方 `dsh` 启动器在解析后的 `DSH_HOME` 下持有一个版本化确认标记。它会在启动前检查该不可变标记,并仅在真实 TUI 服务可用后,挂载一个由 effect 持有的 `ctx.tui.openOverlay()` 消费方。Enter 是唯一确认操作:插件先创建并同步固定的逐版本标记,再关闭浮层。Escape 和其他所有非导航输入都会让浮层保持打开;在按 Enter 前进行资源释放或退出进程不会写入任何内容。版本号属于标记文件名的一部分,因此只需递增集中持有的通知版本,即可让有实质修改的文案重新展示一次,无需迁移或改写聚合设置文档。 + +该标记属于启动器状态,而非会话持久化,因为展示资格跨越会话与 workspace,但作用域仅限一个 Harness 主目录。独占文件创建使并发 Enter 操作保持幂等,无需承担读取、修改、写入竞态,也不依赖独立演进的设置栈。该通知绝不追加会话事件、注入模型上下文或创建用户轮次;因此,恢复会话只会在同一个 Harness 主目录尚未确认该版本时展示通知,也绝不会从会话日志中回放通知。 + +指定的官方 `24x24` DeepSeek SVG 作为视觉真源提交。静态的完整、紧凑和最小终端栅格图以逐级降低的方形分辨率对该精确路径取样,不会重新绘制轮廓。Unicode `▀`/`▄`/`█` 单元格让每个终端单元格保留两个垂直方向的源像素;明确仅支持 ASCII 的 locale 则使用位级等价的 `'`/`_`/`#` 回退。ANSI 样式与 SVG 和可编辑文案完全分离:`ctx.tui` 提供语义化 `brand` 角色,在真彩色可用时使用官方 `#4D6BFE` 色值,否则使用标准 ANSI 蓝色;禁用颜色时则使用纯文本。普通启动 banner 保留现有渐变。 + +浮层居中显示,并使用可用的终端宽度。宽终端将完整图标置于标题与正文旁边;中等和窄终端把紧凑或最小图标堆叠在正文上方;高度不足时,先移除图标,再减少正文空间。正文可以滚动,而标题和唯一操作保持固定。引用内容会提升为独立的视觉段落,但不会改变集中持有的中文字符串。通过 Enter 关闭浮层后,模态所有权会交还给现有 FIFO 管理器;该管理器恢复编辑器,同时保持正常的启动 banner、transcript(文本记录)和焦点行为不变。 + +## 验证 + +聚焦单元测试固定指定 SVG 与中文文案的 hash、版本递增、并发独占确认、格式错误的标记、持久化重试、Escape 行为、ASCII 回退、宽度分级选择、有界渲染和低高度滚动。真实 Loader/PTY 用例覆盖 60、80、120、160 列以及一个低高度 viewport,产出语义终端快照,证明同一个 `DSH_HOME` 下首次启动后再次启动会抑制展示,并证明恢复会话不会追加任何由通知衍生的用户消息或轮次;普通终端退出的生命周期事件保持不变。 + +## 曾考虑的替代方案 + +**复用 TUI 的 `welcome` 副标题。** 它是一行瞬态 header,正常职责是标识无标题会话。所需正文和操作要么会被裁剪,要么会永久挤占普通启动界面。 + +**复制 Claude Code 的启动图形或构图。** 其清晰的视觉层级是有用的产品证据,但图形、布局和品牌处理属于另一个产品。官方 DeepSeek SVG 提供直接的品牌来源,而终端构图围绕本通知的文案与响应式约束独立推导。 + +**手绘原创鲸鱼。** 自由绘制的轮廓可以具有辨识度,却仍可能与官方标志的身体、内部负空间、鳍和尾部不一致。对精确路径进行栅格取样,可以明确呈现终端限制,并让每个分级都能追溯到同一个源资产。 + +**在会话事件或共享设置文档中存储布尔值。** 会话状态的生命周期不正确,还会污染回放或模型可见历史。聚合文档为记录一个不可变事实,需要承担跨进程读取、修改、写入锁;按版本命名并独占创建的标记不存在更新丢失问题。 + +**允许 Escape 或稍后提醒操作。** 两者都会让取消与确认无法区分,或引入本通知并不需要的提醒策略。正常退出进程仍是中止路径,并会让该版本保持未确认状态。 + +## 后果 + +每个 Harness 主目录在每个文案版本中都会收到一次通知,且仅在用户成功按 Enter 确认后停止展示。维护者可以在一个小型 owner 文件中编辑所有双语文案和版本,也可以在独立的视觉 owner 中更新官方 SVG 与衍生静态栅格图,无需到各个快照中查找完整正文副本。 + +终端无法直接显示 SVG 矢量图,因此其忠实呈现受分辨率限制。较小分级会保留取样后的轮廓,但必然损失精细细节;低高度终端优先保障正文可读和操作始终可达,而非展示品牌图形。在预发布阶段,标记格式有意采用每个版本一个文件;旧标记无害,也无需兼容性读取器。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 7e4aacef29..8488b75bfc 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: 2bc36cce6205a4bfc3ba1d7ee15f0e0b2feab215 -README.zh.md: 0e0771658cecb4bee0f3eadd0639ad531e64ea3c +README.md: 3f421fa7a47a8a56ccbe969497e7bfc4b69dcf10 +README.zh.md: fc7fe373070a0dc62b547a76e661669dadf12f69 diff --git a/apps/cli/README.md b/apps/cli/README.md index 2bc36cce62..3f421fa7a4 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -12,6 +12,7 @@ The TUI surface: - treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh meta` is the sole exception, below); - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. +- presents the [versioned first-run welcome](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md) through the mounted TUI overlay service when its immutable marker is absent under `DSH_HOME`; only Enter creates that version's marker, while Escape, disposal, or process exit leaves it eligible. The official DeepSeek icon, responsive terminal rasters, bilingual copy, and notice version are static local owners; the overlay never writes a session event or model context. `dsh meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after both `.env` layers are loaded, so environment precedence is unchanged while the session cwd and HMR watch root move together. Meta always starts a fresh session and accepts no default-surface options; use ordinary `dsh --resume ` to resume a persisted session. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 0e0771658c..fc7fe37307 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -12,6 +12,7 @@ TUI 界面: - 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析(`dsh meta` 是唯一例外,见下文); - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 +- 当 `DSH_HOME` 下不存在不可变确认标记时,通过已挂载的 TUI overlay 服务呈现[版本化首次运行欢迎页](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md);只有 Enter 会创建该版本的标记,Escape、资源释放或进程退出仍保留展示资格。官方 DeepSeek 图标、响应式终端栅格图、双语文案和通知版本均由静态本地文件持有;overlay 不会写入会话事件或模型上下文。 `dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在两层 `.env` 都加载之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume `。 diff --git a/apps/cli/assets/deepseek-color.svg b/apps/cli/assets/deepseek-color.svg new file mode 100644 index 0000000000..52eec25cd3 --- /dev/null +++ b/apps/cli/assets/deepseek-color.svg @@ -0,0 +1 @@ +DeepSeek diff --git a/apps/cli/package.json b/apps/cli/package.json index e3d1976c52..c707b50ad3 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -9,6 +9,7 @@ }, "files": [ "lib/bin.js", + "assets", "config", "src" ], @@ -114,6 +115,7 @@ "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", + "@earendil-works/pi-tui": "0.80.7", "commander": "^15.0.0", "cordis": "^4.0.0-rc.7", "js-yaml": "^4.2.0" diff --git a/apps/cli/src/tui-first-run-welcome-art.ts b/apps/cli/src/tui-first-run-welcome-art.ts new file mode 100644 index 0000000000..eac41d3e19 --- /dev/null +++ b/apps/cli/src/tui-first-run-welcome-art.ts @@ -0,0 +1,111 @@ +/** + * Static terminal rasters derived from the official 24x24 DeepSeek icon. + * + * Source: `../assets/deepseek-color.svg`, whose path data is copied exactly + * from the supplied official icon (viewBox `0 0 24 24`, fill `#4D6BFE`). Each + * tier rasterizes that path into a square binary + * mask without redrawing its contour. The Unicode form packs two source rows + * into `▀`/`▄`/`█`; the ASCII fallback packs the same two bits into + * `'`/`_`/`#`. Assets contain no ANSI and are never generated at runtime. + * @module @deepseek-ai/dsh/tui-first-run-welcome-art + */ + +/** Responsive official-icon raster tier. */ +export type TuiFirstRunWelcomeArtTier = 'full' | 'compact' | 'minimal' + +/** One raster with a block-cell primary and bit-equivalent ASCII fallback. */ +export interface TuiFirstRunWelcomeArt { + /** Two vertical source pixels per terminal cell. */ + readonly unicode: readonly string[] + /** Same two-bit cells encoded as top `'`, bottom `_`, and both `#`. */ + readonly ascii: readonly string[] +} + +const fullUnicode = Object.freeze([ + ' ▄', + ' ▄▄▄▄▄▄▄▄▄▄███▀ ██▄', + ' ▄███████████████▄ ████▄ ▄▄▄▄██', + ' ▄███████████████████▄ ████████████▀', + ' ▄██████████████████████▄ ▀█████████▀', + '▄███▀█████████████████████▄ ████▀▀', + '███ ▀▀█████████▀▀▀█████████▀', + '███ ▀███████▀█ ▀███████', + '███▄ ▀███████▄ ▀█████▀', + '▀███ ▀██████████████', + ' ▀███▄ ▀███████████▀', + ' ▀███▄ ▄▄▄ ▀████████▀', + ' █████▄ ███▄▄ ▀█████▄▄', + ' ▀█████████████▄▄▄▄█▀█████▀', + ' ▀▀███████████▀▀', +]) + +const fullAscii = Object.freeze([ + ' _', + " __________###' ##_", + ' _###############_ ####_ ____##', + " _###################_ ############'", + " _######################_ '#########'", + "_###'#####################_ ####''", + "### ''#########'''#########'", + "### '#######'# '#######", + "###_ '#######_ '#####'", + "'### '##############", + " '###_ '###########'", + " '###_ ___ '########'", + " #####_ ###__ '#####__", + " '#############____#'#####'", + " ''###########''", +]) + +const compactUnicode = Object.freeze([ + ' ▄▄▄▄▄▄▄██▀ █▄ ▄', + ' ▄███████████▄▄ ███▄▄████', + ' ████████████████▄ ▀██████▀', + '██▀▀▀▀▀████████████▄▄██▀', + '██ ▀█████▄ ▀█████', + '██▄ ▀████▄ ▄████', + ' ██▄ ████████▀', + ' ██▄ ▄▄ ▀█████▀', + ' ▀███▄▄▄███▄ ████▄▄', + ' ▀▀▀███████▀▀', +]) + +const compactAscii = Object.freeze([ + " _______##' #_ _", + ' _###########__ ###__####', + " ################_ '######'", + "##'''''############__##'", + "## '#####_ '#####", + "##_ '####_ _####", + " ##_ ########'", + " ##_ __ '#####'", + " '###___###_ ####__", + " '''#######''", +]) + +const minimalUnicode = Object.freeze([ + ' ▄▄▄▄▄▄ ▄▄', + ' ▄████████▄ ▀████▀', + '█▀▀▀▀███████▄██▀', + '█▄ ▀███ ▀███', + '▀█▄ ▀█████', + ' ▀█▄▄ █▄▄▀███▄', + ' ▀▀▀▀▀▀', +]) + +const minimalAscii = Object.freeze([ + ' ______ __', + " _########_ '####'", + "#''''#######_##'", + "#_ '### '###", + "'#_ '#####", + " '#__ #__'###_", + " ''''''", +]) + +/** Exact-path terminal rasters by responsive tier. */ +export const TUI_FIRST_RUN_WELCOME_WHALE = Object.freeze({ + full: Object.freeze({ unicode: fullUnicode, ascii: fullAscii }), + compact: Object.freeze({ unicode: compactUnicode, ascii: compactAscii }), + minimal: Object.freeze({ unicode: minimalUnicode, ascii: minimalAscii }), +}) satisfies Readonly> diff --git a/apps/cli/src/tui-first-run-welcome-copy.ts b/apps/cli/src/tui-first-run-welcome-copy.ts new file mode 100644 index 0000000000..7999b26e64 --- /dev/null +++ b/apps/cli/src/tui-first-run-welcome-copy.ts @@ -0,0 +1,62 @@ +/** + * Centrally owned version and bilingual copy for the shipped TUI first-run notice. + * + * A material wording change increments {@link TUI_FIRST_RUN_WELCOME_NOTICE_VERSION} + * so every Harness home presents the revised notice once. + * @module @deepseek-ai/dsh/tui-first-run-welcome-copy + */ + +/** Copy version persisted after the user explicitly continues. */ +export const TUI_FIRST_RUN_WELCOME_NOTICE_VERSION = 1 + +/** Locale-shaped text rendered by the first-run welcome overlay. */ +export interface TuiFirstRunWelcomeNoticeCopy { + /** Overlay heading. */ + readonly title: string + /** Ordered prose paragraphs. */ + readonly paragraphs: readonly string[] + /** Enter action label. */ + readonly continueLabel: string + /** Hint shown when the prose is scrollable. */ + readonly scrollHint: string + /** Status shown while the acknowledgement reaches disk. */ + readonly saving: string + /** Retry message shown when the acknowledgement cannot be persisted. */ + readonly saveError: string +} + +/** + * Complete notice copy. The shipped TUI currently presents the supplied + * Simplified Chinese locale; English remains its reviewed locale counterpart. + */ +export const TUI_FIRST_RUN_WELCOME_NOTICE_COPY = Object.freeze({ + 'zh-CN': Object.freeze({ + title: 'DeepSeek Harness', + paragraphs: Object.freeze([ + '感谢您愿意拨冗试用 DeepSeek Harness。', + '目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。', + '“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。', + '我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。', + ]), + continueLabel: '继续', + scrollHint: '↑/↓ 滚动', + saving: '正在保存确认…', + saveError: '无法保存确认,请按 Enter 重试。', + }), + en: Object.freeze({ + title: 'DeepSeek Harness', + paragraphs: Object.freeze([ + 'Thank you for taking the time to try DeepSeek Harness.', + 'This release is still in internal testing. Some features remain unfinished, and parts of the experience will inevitably feel rough.', + '“As one cuts and files, as one carves and polishes.” A product grows through real encounters and candid feedback. Problems uncovered in real use may lead us to re-examine, or even overturn, existing designs.', + 'We especially want to hear about failures, confusion, and friction. If DeepSeek Harness did not help—or made your work harder—please tell us in the company WeChat group. Every piece of feedback helps us refine it.', + ]), + continueLabel: 'Continue', + scrollHint: '↑/↓ Scroll', + saving: 'Saving acknowledgement…', + saveError: 'Could not save the acknowledgement. Press Enter to retry.', + }), +}) + +/** Locale presented by the shipped first-run notice. */ +export const TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE = 'zh-CN' as const diff --git a/apps/cli/src/tui-first-run-welcome.ts b/apps/cli/src/tui-first-run-welcome.ts new file mode 100644 index 0000000000..5d7cd63198 --- /dev/null +++ b/apps/cli/src/tui-first-run-welcome.ts @@ -0,0 +1,352 @@ +/** + * Effect-owned first-run overlay for the shipped `dsh` TUI. + * + * The launcher owns the per-DSH_HOME acknowledgement boundary; the component + * reaches the terminal only through the mounted `ctx.tui` overlay service and + * never touches the session or model context. + * @module @deepseek-ai/dsh/tui-first-run-welcome + */ + +import { lstat, mkdir, open, rm } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import type { Context } from 'cordis' +import { + Key, + matchesKey, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, +} from '@earendil-works/pi-tui' +import type { + TuiComponent, + TuiFocusable, + TuiOverlayHost, +} from '@deepseek-ai/dsh-tui' +import { + TUI_FIRST_RUN_WELCOME_NOTICE_COPY, + TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE, + TUI_FIRST_RUN_WELCOME_NOTICE_VERSION, + type TuiFirstRunWelcomeNoticeCopy, +} from './tui-first-run-welcome-copy.ts' +import { + TUI_FIRST_RUN_WELCOME_WHALE, + type TuiFirstRunWelcomeArtTier, +} from './tui-first-run-welcome-art.ts' + +const ACKNOWLEDGEMENT_DIRECTORY = 'notices' +const ACKNOWLEDGEMENT_BASENAME = 'tui-first-run-welcome' + +/** Cordis plugin name. */ +export const name = 'tui-first-run-welcome' +/** The notice can open only after the terminal-local overlay service mounts. */ +export const inject = ['tui'] + +/** Launcher-resolved configuration for the terminal-local notice. */ +interface Config { + /** Absolute DeepSeek Harness home owning this acknowledgement. */ + readonly dshHome: string + /** Render the bit-equivalent printable ASCII icon fallback. */ + readonly asciiArt?: boolean +} + +/** + * Detect an explicitly non-Unicode terminal locale for the static ASCII art fallback. + * @param env - Process environment carrying locale and terminal declarations. + * @returns `true` only when the environment explicitly declares an ASCII-only locale or dumb terminal. + */ +export function needsTuiFirstRunWelcomeAsciiArt( + env: Readonly> = process.env, +): boolean { + const locale = env.LC_ALL ?? env.LC_CTYPE ?? env.LANG + return env.TERM === 'dumb' || locale === 'C' || locale === 'POSIX' +} + +/** + * Resolve the immutable marker for one notice version. + * @param dshHome - Resolved Harness home. + * @param version - Copy version whose acknowledgement is queried. + * @returns Absolute marker path beneath the Harness home. + */ +export function tuiFirstRunWelcomeAcknowledgementPath(dshHome: string, version: number): string { + return join( + dshHome, + ACKNOWLEDGEMENT_DIRECTORY, + `${ACKNOWLEDGEMENT_BASENAME}-v${String(version)}.ack`, + ) +} + +/** + * Test whether one notice version has been acknowledged. + * @param dshHome - Resolved Harness home. + * @param version - Copy version to inspect. + * @returns `true` only for a regular marker file; a malformed marker fails loud. + */ +export async function hasTuiFirstRunWelcomeAcknowledgement( + dshHome: string, + version: number = TUI_FIRST_RUN_WELCOME_NOTICE_VERSION, +): Promise { + const path = tuiFirstRunWelcomeAcknowledgementPath(dshHome, version) + try { + const info = await lstat(path) + if (!info.isFile()) throw new Error(`TUI welcome acknowledgement is not a file: ${path}`) + return true + } catch (error) { + if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return false + throw error + } +} + +/** + * Persist one version acknowledgement as an immutable exclusive marker. + * Concurrent launches race only on file creation: the winner syncs the marker, + * and every loser accepts the same already-published regular file. + * @param dshHome - Resolved Harness home. + * @param version - Copy version being acknowledged. + */ +export async function acknowledgeTuiFirstRunWelcome( + dshHome: string, + version: number = TUI_FIRST_RUN_WELCOME_NOTICE_VERSION, +): Promise { + const path = tuiFirstRunWelcomeAcknowledgementPath(dshHome, version) + const directory = dirname(path) + await mkdir(directory, { recursive: true, mode: 0o700 }) + await syncDirectory(dirname(directory)) + let handle: Awaited> | undefined + try { + handle = await open(path, 'wx', 0o600) + } catch (error) { + if ((error as NodeJS.ErrnoException | null)?.code === 'EEXIST') { + if (await hasTuiFirstRunWelcomeAcknowledgement(dshHome, version)) return + } + throw error + } + try { + await handle.sync() + const created = handle + handle = undefined + await created.close() + await syncDirectory(directory) + } catch (error) { + await rm(path, { force: true }) + throw error + } finally { + await handle?.close() + } +} + +/** Sync one POSIX directory after publishing a child entry. */ +/* v8 ignore start -- Windows rejects directory opens; POSIX unit coverage owns this path. */ +async function syncDirectory(path: string): Promise { + if (process.platform === 'win32') return + const handle = await open(path, 'r') + try { + await handle.sync() + } finally { + await handle.close() + } +} +/* v8 ignore stop */ + +/** Render one visible-width-padded line inside the notice frame. */ +function framed(content: string, innerWidth: number, host: TuiOverlayHost): string { + const clipped = truncateToWidth(content, innerWidth, '') + return `${host.theme.dim('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${host.theme.dim('│')}` +} + +/** Center one line by terminal column width. */ +function centered(content: string, width: number): string { + const clipped = truncateToWidth(content, width, '') + const remaining = Math.max(0, width - visibleWidth(clipped)) + return `${' '.repeat(Math.floor(remaining / 2))}${clipped}` +} + +/** + * Select the art tier for the actual overlay width and viewport height. + * @param innerWidth - Columns inside the frame. + * @param viewportRows - Current terminal rows. + * @returns full, compact, minimal, or no art when prose must take priority. + */ +export function tuiFirstRunWelcomeArtTier( + innerWidth: number, + viewportRows: number, +): TuiFirstRunWelcomeArtTier | undefined { + if (innerWidth >= 96 && viewportRows >= 22) return 'full' + if (innerWidth >= 64 && viewportRows >= 18) return 'compact' + if (innerWidth >= 48 && viewportRows >= 14) return 'minimal' + return undefined +} + +/** Wrap the centrally owned prose while promoting its opening quotation. */ +function proseLines( + copy: TuiFirstRunWelcomeNoticeCopy, + width: number, + host: TuiOverlayHost, +): string[] { + const lines: string[] = [] + for (const [index, paragraph] of copy.paragraphs.entries()) { + if (index > 0) lines.push('') + const quoteEnd = paragraph.startsWith('“') ? paragraph.indexOf('”') : -1 + if (quoteEnd > 0) { + const quote = paragraph.slice(0, quoteEnd + 1) + const remainder = paragraph.slice(quoteEnd + 1).trimStart() + lines.push(...wrapTextWithAnsi(host.theme.bold(host.theme.text(host.display(quote))), width)) + lines.push('') + if (remainder !== '') lines.push(...wrapTextWithAnsi(host.theme.text(host.display(remainder)), width)) + } else { + lines.push(...wrapTextWithAnsi(host.theme.text(host.display(paragraph)), width)) + } + } + return lines +} + +/** Render centered static brand art without putting ANSI into its owner file. */ +function artLines( + tier: TuiFirstRunWelcomeArtTier, + width: number, + host: TuiOverlayHost, + asciiArt: boolean, +): string[] { + const art = TUI_FIRST_RUN_WELCOME_WHALE[tier][asciiArt ? 'ascii' : 'unicode'] + return art.map(line => centered(host.theme.brand(line), width)) +} + +/** Responsive, scrollable notice whose only completion input is Enter. */ +export class TuiFirstRunWelcomeComponent implements TuiComponent, TuiFocusable { + focused = false + private scrollOffset = 0 + private bodyCapacity = 1 + private maxScrollOffset = 0 + private saving = false + private saveFailed = false + + constructor( + private readonly host: TuiOverlayHost, + private readonly copy: TuiFirstRunWelcomeNoticeCopy, + private readonly acknowledge: () => Promise, + private readonly asciiArt = false, + ) {} + + invalidate(): void {} + + render(width: number): string[] { + const frameWidth = Math.max(6, width) + const innerWidth = Math.max(1, frameWidth - 4) + const viewportRows = this.host.viewport.rows + const tier = tuiFirstRunWelcomeArtTier(innerWidth, viewportRows) + const availableRows = Math.max(7, Math.floor(viewportRows * 0.9)) + const title = this.host.theme.bold(this.host.theme.brand(this.copy.title)) + let fixedHeader: string[] = [] + let body: string[] + let fullArt: string[] | undefined + const fullArtWidth = 44 + + if (tier === 'full') { + fullArt = artLines(tier, fullArtWidth, this.host, this.asciiArt) + const contentWidth = Math.max(1, innerWidth - fullArtWidth - 3) + body = [centered(title, contentWidth), '', ...proseLines(this.copy, contentWidth, this.host)] + } else { + const art = tier === undefined ? [] : artLines(tier, innerWidth, this.host, this.asciiArt) + fixedHeader = [...art, ...art.length === 0 ? [] : [''], centered(title, innerWidth), ''] + body = proseLines(this.copy, innerWidth, this.host) + } + + this.bodyCapacity = Math.max(1, availableRows - 5 - fixedHeader.length) + const maxOffset = Math.max(0, body.length - this.bodyCapacity) + this.maxScrollOffset = maxOffset + this.scrollOffset = Math.min(this.scrollOffset, maxOffset) + const visibleBody = body.slice(this.scrollOffset, this.scrollOffset + this.bodyCapacity) + while (visibleBody.length < this.bodyCapacity) visibleBody.push('') + + const top = this.host.theme.dim(`╭${'─'.repeat(Math.max(0, frameWidth - 2))}╮`) + const separator = this.host.theme.dim(`├${'─'.repeat(Math.max(0, frameWidth - 2))}┤`) + const bottom = this.host.theme.dim(`╰${'─'.repeat(Math.max(0, frameWidth - 2))}╯`) + const action = this.host.theme.bold(this.host.theme.accent(`Enter ${this.copy.continueLabel}`)) + const hasAbove = this.scrollOffset > 0 + const hasBelow = this.scrollOffset < maxOffset + const scroll = hasAbove || hasBelow + ? `${hasAbove ? '↑' : ' '} ${this.copy.scrollHint} ${hasBelow ? '↓' : ' '}` + : '' + const status = this.saveFailed + ? this.host.theme.error(this.copy.saveError) + : this.saving + ? this.host.theme.dim(this.copy.saving) + : this.host.theme.dim(scroll) + + const composition = fullArt === undefined + ? [...fixedHeader, ...visibleBody] + : visibleBody.map((line, index) => { + const art = fullArt[index] ?? '' + const left = `${art}${' '.repeat(Math.max(0, fullArtWidth - visibleWidth(art)))}` + return `${left} ${line}` + }) + + return [ + top, + ...composition.map(line => framed(line, innerWidth, this.host)), + separator, + framed(centered(action, innerWidth), innerWidth, this.host), + framed(centered(status, innerWidth), innerWidth, this.host), + bottom, + ] + } + + handleInput(data: string): void { + if (matchesKey(data, Key.enter)) { + if (!this.saving) void this.commit() + return + } + if (this.saving || matchesKey(data, Key.escape)) return + if (matchesKey(data, Key.up)) this.scrollBy(-1) + else if (matchesKey(data, Key.down)) this.scrollBy(1) + else if (matchesKey(data, Key.pageUp)) this.scrollBy(-this.bodyCapacity) + else if (matchesKey(data, Key.pageDown)) this.scrollBy(this.bodyCapacity) + else if (matchesKey(data, Key.home)) this.scrollTo(0) + else if (matchesKey(data, Key.end)) this.scrollTo(this.maxScrollOffset) + } + + private scrollBy(delta: number): void { + this.scrollTo(this.scrollOffset + delta) + } + + private scrollTo(offset: number): void { + this.scrollOffset = Math.min(this.maxScrollOffset, Math.max(0, offset)) + this.host.invalidate() + } + + private async commit(): Promise { + this.saving = true + this.saveFailed = false + this.host.invalidate() + try { + await this.acknowledge() + this.host.close() + } catch { + this.saving = false + this.saveFailed = true + this.host.invalidate() + } + } +} + +/** + * Open the first-run notice through the mounted TUI's FIFO overlay owner. + * @param ctx - Plugin context carrying the terminal-local TUI service. + * @param config - Launcher-resolved Harness home. + */ +export function apply(ctx: Context, config: Config): void { + const copy = TUI_FIRST_RUN_WELCOME_NOTICE_COPY[TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE] + ctx.tui.openOverlay({ + create: host => new TuiFirstRunWelcomeComponent( + host, + copy, + () => acknowledgeTuiFirstRunWelcome(config.dshHome), + config.asciiArt ?? false, + ), + options: { + width: '100%', + maxHeight: '90%', + anchor: 'center', + margin: 1, + }, + }) +} diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index f13451c04e..d56f14c083 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -43,6 +43,16 @@ import { type MainSessionIdentity, type TuiResumeHost, } from '@deepseek-ai/dsh-tui' +import { + apply as applyTuiFirstRunWelcome, + hasTuiFirstRunWelcomeAcknowledgement, + inject as tuiFirstRunWelcomeInject, + name as tuiFirstRunWelcomeName, + needsTuiFirstRunWelcomeAsciiArt, +} from './tui-first-run-welcome.ts' +import { + TUI_FIRST_RUN_WELCOME_NOTICE_VERSION, +} from './tui-first-run-welcome-copy.ts' const NAME = 'dsh' @@ -126,7 +136,12 @@ export async function runTui( installFailLoud(NAME) // The bin already loaded the invoking directory's .env; the personal .env // only fills what is still unset (process.loadEnvFile never overrides). - loadEnv(NAME, resolveDshHome()) + const dshHome = resolveDshHome() + loadEnv(NAME, dshHome) + const showFirstRunWelcome = !await hasTuiFirstRunWelcomeAcknowledgement( + dshHome, + TUI_FIRST_RUN_WELCOME_NOTICE_VERSION, + ) // Both .env layers are loaded, so switching the workspace here cannot alter // environment precedence. The cwd IS the workspace seam: the shipped config // resolves the session cwd and the HMR watch root from it, so one chdir moves @@ -200,7 +215,7 @@ export async function runTui( const patches = replaceTree ? [] : [ ...loadOverlayPatches(NAME, TUI_OVERLAY), ...resolvedConfig === undefined - ? loadPersonalPatches(NAME) ?? [] + ? loadPersonalPatches(NAME, dshHome) ?? [] : loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)), ] const queryIndexPath = join(tmpdir(), SESSION_QUERY_DB) @@ -241,5 +256,15 @@ export async function runTui( ) app.current = ctx addHarnessSourceSection(ctx, SOURCE_ROOT) + if (showFirstRunWelcome && ctx.get('tui') !== undefined) { + await ctx.plugin({ + name: tuiFirstRunWelcomeName, + inject: tuiFirstRunWelcomeInject, + apply: applyTuiFirstRunWelcome, + }, { + dshHome, + asciiArt: needsTuiFirstRunWelcomeAsciiArt(), + }) + } } /* v8 ignore stop */ diff --git a/apps/cli/tests/pty-harness.ts b/apps/cli/tests/pty-harness.ts index a6c33160fa..202fb20470 100644 --- a/apps/cli/tests/pty-harness.ts +++ b/apps/cli/tests/pty-harness.ts @@ -6,11 +6,11 @@ import { execa } from 'execa' import { resolveExampleLaunch, type ExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' const POSIX_PTY_DRIVER = String.raw` -import errno, json, os, pty, select, signal, sys, time -node, launch_args_json, launch_env_json, cwd, actions_json, expected_exit, timeout_seconds = sys.argv[1:] +import errno, fcntl, json, os, pty, select, signal, struct, sys, termios, time +node, launch_args_json, launch_env_json, cwd, actions_json, expected_exit, timeout_seconds, columns, rows = sys.argv[1:] env = os.environ.copy() env.update(json.loads(launch_env_json)) -env.update({"COLUMNS": "100", "LINES": "30"}) +env.update({"COLUMNS": columns, "LINES": rows}) # Deterministic banner: a developer shell's COLORTERM=truecolor would switch the # banner to the per-letter gradient (one SGR per letter), breaking literal # DEEPSEEK assertions. The gradient path has its own unit and snapshot coverage. @@ -20,6 +20,7 @@ pid, fd = pty.fork() if pid == 0: os.chdir(cwd) os.execvpe(node, [node, *json.loads(launch_args_json)], env) +fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", int(rows), int(columns), 0, 0)) output = bytearray() action_index = 0 @@ -36,8 +37,13 @@ while time.monotonic() < deadline: chunk = b"" if chunk: output.extend(chunk) - while action_index < len(actions) and actions[action_index]["waitFor"].encode() in output: + while action_index < len(actions): + marker = actions[action_index]["waitFor"].encode() + if output.count(marker) < actions[action_index].get("occurrence", 1): + break action = actions[action_index] + if action.get("delayMs", 0) > 0: + time.sleep(action["delayMs"] / 1000) if "writeFile" in action: target = os.path.join(cwd, action["writeFile"]["path"]) os.makedirs(os.path.dirname(target), exist_ok=True) @@ -68,11 +74,13 @@ if actual_exit != int(expected_exit): /** One terminal input or workspace mutation performed after its marker renders. */ type TuiPtyAction = - | { readonly waitFor: string; readonly send: string } + | { readonly waitFor: string; readonly occurrence?: number; readonly send: string; readonly delayMs?: number } | { readonly waitFor: string + readonly occurrence?: number readonly writeFile: { readonly path: string; readonly content: string } readonly send?: string + readonly delayMs?: number } /** Inputs for a keyless real-Loader TUI process smoke. */ @@ -89,6 +97,12 @@ export interface TuiPtySmokeOptions { readonly env?: Readonly readonly expectedExitCode?: number readonly timeoutMs?: number + /** Existing isolated workspace to reuse; when omitted the harness creates and removes one. */ + readonly cwd?: string + /** Pseudo-terminal columns; defaults to 100. */ + readonly columns?: number + /** Pseudo-terminal rows; defaults to 30. */ + readonly rows?: number /** Seed the isolated workspace (`cwd`, with `$DSH_HOME` at `.dsh` and the agents home at `.agents`) before launch. */ readonly prepare?: (cwd: string) => Promise /** Inspect the workspace after a passing run, before the temp dir is removed. */ @@ -119,6 +133,8 @@ async function runPosixPtySmoke( JSON.stringify(options.actions ?? []), String(options.expectedExitCode ?? 0), String(timeoutMs / 1_000), + String(options.columns ?? 100), + String(options.rows ?? 30), ], { stdin: 'ignore', timeout: timeoutMs + 5_000, @@ -150,8 +166,8 @@ async function runWindowsPtySmoke( let timedOut = false const terminal = pty.spawn(launch.command, launch.args, { name: 'xterm-256color', - cols: 100, - rows: 30, + cols: options.columns ?? 100, + rows: options.rows ?? 30, cwd, env: definedEnv({ ...process.env, @@ -159,8 +175,8 @@ async function runWindowsPtySmoke( // Match the POSIX driver: no COLORTERM, so the banner never takes the // truecolor gradient path under a developer's shell. COLORTERM: undefined, - COLUMNS: '100', - LINES: '30', + COLUMNS: String(options.columns ?? 100), + LINES: String(options.rows ?? 30), }), }) const timer = setTimeout(() => { @@ -169,16 +185,23 @@ async function runWindowsPtySmoke( }, timeoutMs) terminal.onData((chunk) => { output += chunk - while (actionIndex < actions.length && output.includes(actions[actionIndex]!.waitFor)) { + while ( + actionIndex < actions.length + && output.split(actions[actionIndex]!.waitFor).length - 1 >= (actions[actionIndex]!.occurrence ?? 1) + ) { const action = actions[actionIndex]! if ('writeFile' in action) { const target = join(cwd, action.writeFile.path) mkdirSync(dirname(target), { recursive: true }) writeFileSync(target, action.writeFile.content) const input = action.send - if (input !== undefined) terminal.write(input) + if (input !== undefined) { + if (action.delayMs === undefined) terminal.write(input) + else setTimeout(() => { terminal.write(input) }, action.delayMs) + } } else { - terminal.write(action.send) + if (action.delayMs === undefined) terminal.write(action.send) + else setTimeout(() => { terminal.write(action.send) }, action.delayMs) } actionIndex += 1 } @@ -205,7 +228,8 @@ async function runWindowsPtySmoke( * @returns complete pseudo-terminal output. */ export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise { - const cwd = await mkdtemp(join(tmpdir(), options.tempDirPrefix)) + const ownedCwd = options.cwd === undefined + const cwd = options.cwd ?? await mkdtemp(join(tmpdir(), options.tempDirPrefix)) const timeoutMs = options.timeoutMs ?? 25_000 try { await options.prepare?.(cwd) @@ -231,6 +255,6 @@ export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise diff --git a/apps/cli/tests/snapshots/tui-first-run-welcome/160-columns.expected.txt b/apps/cli/tests/snapshots/tui-first-run-welcome/160-columns.expected.txt new file mode 100644 index 0000000000..14689f9b69 --- /dev/null +++ b/apps/cli/tests/snapshots/tui-first-run-welcome/160-columns.expected.txt @@ -0,0 +1,106 @@ +terminal 160x30 buffer=normal length=32 base=2 viewport=2 +lifecycle started=0 stopped=0 progress=inactive +title "" +cursor visible column=0 viewportRow=29 bufferRow=31 +viewport +2| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +3| " ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ " + style 1-158 dim +4| " │ ▄ DeepSeek Harness │ " + style 1-1 dim + style 11-38 fg=blue + style 95-110 fg=blue bold + style 158-158 dim +5| " │ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │ " + style 1-1 dim + style 10-39 fg=blue + style 158-158 dim +6| "/│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。 │ " + style 0-0 fg=bright-magenta bold + style 1-1 dim + style 5-44 fg=blue + style 158-158 dim +7| " │ ▄███████████████████▄ ████████████▀ │ " + style 1-1 dim + style 5-44 fg=blue + style 158-158 dim +8| " │ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │ " + style 1-1 dim + style 5-43 fg=blue + style 158-158 dim +9| " │ ▄███▀█████████████████████▄ ████▀▀ │ " + style 1-1 dim + style 7-42 fg=blue + style 158-158 dim +10| " │ ███ ▀▀█████████▀▀▀█████████▀ “如切如磋,如琢如磨。” │ " + style 1-1 dim + style 8-41 fg=blue + style 50-71 bold + style 158-158 dim +11| " │ ███ ▀███████▀█ ▀███████ │ " + style 1-1 dim + style 8-40 fg=blue + style 158-158 dim +12| " │ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻 │ " + style 1-1 dim + style 8-40 fg=blue + style 158-158 dim +13| " │ ▀███ ▀██████████████ 已有的设计。 │ " + style 1-1 dim + style 9-40 fg=blue + style 158-158 dim +14| " │ ▀███▄ ▀███████████▀ │ " + style 1-1 dim + style 9-39 fg=blue + style 158-158 dim +15| " │ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中 │ " + style 1-1 dim + style 10-39 fg=blue + style 158-158 dim +16| " │ █████▄ ███▄▄ ▀█████▄▄ 留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。 │ " + style 1-1 dim + style 10-39 fg=blue + style 158-158 dim +17| " │ ▀█████████████▄▄▄▄█▀█████▀ │ " + style 1-1 dim + style 9-40 fg=blue + style 158-158 dim +18| " │ ▀▀███████████▀▀ │ " + style 1-1 dim + style 13-35 fg=blue + style 158-158 dim +19| " │ │ " + style 1-1 dim + style 158-158 dim +20| " │ │ " + style 1-1 dim + style 158-158 dim +21| " │ │ " + style 1-1 dim + style 158-158 dim +22| " │ │ " + style 1-1 dim + style 158-158 dim +23| " │ │ " + style 1-1 dim + style 158-158 dim +24| " │ │ " + style 1-1 dim + style 158-158 dim +25| " │ │ " + style 1-1 dim + style 158-158 dim +26| " ├────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ " + style 1-158 dim +27| " │ Enter 继续 │ " + style 1-1 dim + style 74-84 fg=bright-magenta bold + style 158-158 dim +28| " │ │ " + style 1-1 dim + style 158-158 dim +29| " ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ " + style 1-158 dim +30-31| diff --git a/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns.expected.txt b/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns.expected.txt new file mode 100644 index 0000000000..47a3b3c8a0 --- /dev/null +++ b/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns.expected.txt @@ -0,0 +1,100 @@ +terminal 60x30 buffer=normal length=34 base=4 viewport=4 +lifecycle started=0 stopped=0 progress=inactive +title "" +cursor visible column=0 viewportRow=29 bufferRow=33 +viewport +4| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +5| " ╭────────────────────────────────────────────────────────╮ " + style 1-58 dim +6| " │ ▄▄▄▄▄▄ ▄▄ │ " + style 1-1 dim + style 23-36 fg=blue + style 58-58 dim +7| " │ ▄████████▄ ▀████▀ │ " + style 1-1 dim + style 21-38 fg=blue + style 58-58 dim +8| "/│ █▀▀▀▀███████▄██▀ │h" + style 0-0 fg=bright-magenta bold + style 1-1 dim + style 22-37 fg=blue + style 58-58 dim + style 59-59 fg=bright-magenta bold +9| " │ █▄ ▀███ ▀███ │ " + style 1-1 dim + style 22-36 fg=blue + style 58-58 dim +10| " │ ▀█▄ ▀█████ │ " + style 1-1 dim + style 23-36 fg=blue + style 58-58 dim +11| " │ ▀█▄▄ █▄▄▀███▄ │ " + style 1-1 dim + style 23-36 fg=blue + style 58-58 dim +12| " │ ▀▀▀▀▀▀ │ " + style 1-1 dim + style 25-34 fg=blue + style 58-58 dim +13| " │ │ " + style 1-1 dim + style 58-58 dim +14| " │ DeepSeek Harness │ " + style 1-1 dim + style 22-37 fg=blue bold + style 58-58 dim +15| " │ │ " + style 1-1 dim + style 58-58 dim +16| " │ 感谢您愿意拨冗试用 DeepSeek Harness。 │ " + style 1-1 dim + style 58-58 dim +17| " │ │ " + style 1-1 dim + style 58-58 dim +18| " │ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体 │ " + style 1-1 dim + style 58-58 dim +19| " │ 验难免粗粝。 │ " + style 1-1 dim + style 58-58 dim +20| " │ │ " + style 1-1 dim + style 58-58 dim +21| " │ “如切如磋,如琢如磨。” │ " + style 1-1 dim + style 3-24 bold + style 58-58 dim +22| " │ │ " + style 1-1 dim + style 58-58 dim +23| " │ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真 │ " + style 1-1 dim + style 58-58 dim +24| " │ 实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已 │ " + style 1-1 dim + style 58-58 dim +25| " │ 有的设计。 │ " + style 1-1 dim + style 58-58 dim +26| " │ │ " + style 1-1 dim + style 58-58 dim +27| " │ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未 │ " + style 1-1 dim + style 58-58 dim +28| " ├────────────────────────────────────────────────────────┤ " + style 1-58 dim +29| " │ Enter 继续 │ " + style 1-1 dim + style 24-34 fg=bright-magenta bold + style 58-58 dim +30| " │ ↑/↓ 滚动 ↓ │ " + style 1-1 dim + style 24-35 dim + style 58-58 dim +31| " ╰────────────────────────────────────────────────────────╯ " + style 1-58 dim +32-33| diff --git a/apps/cli/tests/snapshots/tui-first-run-welcome/80-columns.expected.txt b/apps/cli/tests/snapshots/tui-first-run-welcome/80-columns.expected.txt new file mode 100644 index 0000000000..887a00d0cb --- /dev/null +++ b/apps/cli/tests/snapshots/tui-first-run-welcome/80-columns.expected.txt @@ -0,0 +1,103 @@ +terminal 80x30 buffer=normal length=33 base=3 viewport=3 +lifecycle started=0 stopped=0 progress=inactive +title "" +cursor visible column=0 viewportRow=29 bufferRow=32 +viewport +3| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +4| " ╭────────────────────────────────────────────────────────────────────────────╮ " + style 1-78 dim +5| " │ ▄▄▄▄▄▄▄██▀ █▄ ▄ │ " + style 1-1 dim + style 26-53 fg=blue + style 78-78 dim +6| " │ ▄███████████▄▄ ███▄▄████ │ " + style 1-1 dim + style 26-53 fg=blue + style 78-78 dim +7| "/│ ████████████████▄ ▀██████▀ │F" + style 0-0 fg=bright-magenta bold + style 1-1 dim + style 26-52 fg=blue + style 78-78 dim + style 79-79 fg=bright-magenta bold +8| " │ ██▀▀▀▀▀████████████▄▄██▀ │ " + style 1-1 dim + style 28-51 fg=blue + style 78-78 dim +9| " │ ██ ▀█████▄ ▀█████ │ " + style 1-1 dim + style 28-50 fg=blue + style 78-78 dim +10| " │ ██▄ ▀████▄ ▄████ │ " + style 1-1 dim + style 28-50 fg=blue + style 78-78 dim +11| " │ ██▄ ████████▀ │ " + style 1-1 dim + style 29-50 fg=blue + style 78-78 dim +12| " │ ██▄ ▄▄ ▀█████▀ │ " + style 1-1 dim + style 29-49 fg=blue + style 78-78 dim +13| " │ ▀███▄▄▄███▄ ████▄▄ │ " + style 1-1 dim + style 29-50 fg=blue + style 78-78 dim +14| " │ ▀▀▀███████▀▀ │ " + style 1-1 dim + style 31-47 fg=blue + style 78-78 dim +15| " │ │ " + style 1-1 dim + style 78-78 dim +16| " │ DeepSeek Harness │ " + style 1-1 dim + style 32-47 fg=blue bold + style 78-78 dim +17| " │ │ " + style 1-1 dim + style 78-78 dim +18| " │ 感谢您愿意拨冗试用 DeepSeek Harness。 │ " + style 1-1 dim + style 78-78 dim +19| " │ │ " + style 1-1 dim + style 78-78 dim +20| " │ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │ " + style 1-1 dim + style 78-78 dim +21| " │ │ " + style 1-1 dim + style 78-78 dim +22| " │ “如切如磋,如琢如磨。” │ " + style 1-1 dim + style 3-24 bold + style 78-78 dim +23| " │ │ " + style 1-1 dim + style 78-78 dim +24| " │ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题, │ " + style 1-1 dim + style 78-78 dim +25| " │ 也可能促使我们重新审视,甚至推翻已有的设计。 │ " + style 1-1 dim + style 78-78 dim +26| " │ │ " + style 1-1 dim + style 78-78 dim +27| " ├────────────────────────────────────────────────────────────────────────────┤ " + style 1-78 dim +28| " │ Enter 继续 │ " + style 1-1 dim + style 34-44 fg=bright-magenta bold + style 78-78 dim +29| " │ ↑/↓ 滚动 ↓ │ " + style 1-1 dim + style 34-45 dim + style 78-78 dim +30| " ╰────────────────────────────────────────────────────────────────────────────╯ " + style 1-78 dim +31-32| diff --git a/apps/cli/tests/tui-first-run-welcome.spec.ts b/apps/cli/tests/tui-first-run-welcome.spec.ts new file mode 100644 index 0000000000..6039c43640 --- /dev/null +++ b/apps/cli/tests/tui-first-run-welcome.spec.ts @@ -0,0 +1,189 @@ +import { createHash } from 'node:crypto' +import { mkdir, mkdtemp, readFile, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { visibleWidth } from '@earendil-works/pi-tui' +import type { TuiOverlayHost, TuiTheme } from '@deepseek-ai/dsh-tui' +import { + acknowledgeTuiFirstRunWelcome, + hasTuiFirstRunWelcomeAcknowledgement, + TuiFirstRunWelcomeComponent, + tuiFirstRunWelcomeAcknowledgementPath, + tuiFirstRunWelcomeArtTier, +} from '../src/tui-first-run-welcome.ts' +import { + TUI_FIRST_RUN_WELCOME_NOTICE_COPY, + TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE, + TUI_FIRST_RUN_WELCOME_NOTICE_VERSION, +} from '../src/tui-first-run-welcome-copy.ts' +import { TUI_FIRST_RUN_WELCOME_WHALE } from '../src/tui-first-run-welcome-art.ts' + +const identityTheme: TuiTheme = Object.freeze({ + text: (value: string) => value, + brand: (value: string) => value, + dim: (value: string) => value, + accent: (value: string) => value, + success: (value: string) => value, + warning: (value: string) => value, + error: (value: string) => value, + bold: (value: string) => value, +}) + +function hostFixture(rows: number): { + host: TuiOverlayHost + closed: () => boolean + invalidations: () => number +} { + let closed = false + let invalidations = 0 + const controller = new AbortController() + return { + host: Object.freeze({ + signal: controller.signal, + viewport: Object.freeze({ columns: 160, rows }), + theme: identityTheme, + display: (value: string) => value, + invalidate: () => { invalidations += 1 }, + close: () => { closed = true }, + }), + closed: () => closed, + invalidations: () => invalidations, + } +} + +const copy = TUI_FIRST_RUN_WELCOME_NOTICE_COPY[TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE] +const temporaryHomes: string[] = [] + +function withoutWhitespace(value: string): string { + return value.replace(/\s/gu, '') +} + +async function temporaryHome(prefix: string): Promise { + const home = await mkdtemp(join(tmpdir(), prefix)) + temporaryHomes.push(home) + return home +} + +afterEach(async () => { + await Promise.all(temporaryHomes.splice(0).map(home => rm(home, { recursive: true, force: true }))) +}) + +describe('TUI first-run welcome acknowledgement', () => { + it('publishes one immutable per-version marker safely across concurrent acknowledgements', async () => { + const home = await temporaryHome('dsh-tui-welcome-ack-') + expect(await hasTuiFirstRunWelcomeAcknowledgement(home)).toBe(false) + + await Promise.all(Array.from({ length: 8 }, () => acknowledgeTuiFirstRunWelcome(home))) + + expect(await hasTuiFirstRunWelcomeAcknowledgement(home)).toBe(true) + const info = await stat(tuiFirstRunWelcomeAcknowledgementPath(home, TUI_FIRST_RUN_WELCOME_NOTICE_VERSION)) + expect(info.isFile()).toBe(true) + if (process.platform !== 'win32') expect(info.mode & 0o777).toBe(0o600) + }) + + it('treats a notice-version bump as a new one-time acknowledgement', async () => { + const home = await temporaryHome('dsh-tui-welcome-version-') + await acknowledgeTuiFirstRunWelcome(home) + const nextVersion = TUI_FIRST_RUN_WELCOME_NOTICE_VERSION + 1 + + expect(await hasTuiFirstRunWelcomeAcknowledgement(home, nextVersion)).toBe(false) + await acknowledgeTuiFirstRunWelcome(home, nextVersion) + expect(await hasTuiFirstRunWelcomeAcknowledgement(home, nextVersion)).toBe(true) + }) + + it('rejects a malformed marker instead of silently acknowledging it', async () => { + const home = await temporaryHome('dsh-tui-welcome-malformed-') + await mkdir(tuiFirstRunWelcomeAcknowledgementPath(home, TUI_FIRST_RUN_WELCOME_NOTICE_VERSION), { + recursive: true, + }) + await expect(hasTuiFirstRunWelcomeAcknowledgement(home)).rejects.toThrow('is not a file') + }) +}) + +describe('TUI first-run welcome composition', () => { + it('pins the supplied official icon and exact Chinese copy at their owner boundaries', async () => { + const icon = (await readFile(new URL('../assets/deepseek-color.svg', import.meta.url), 'utf8')).trimEnd() + expect(createHash('sha256').update(icon).digest('hex')) + .toBe('deba5f98a5c1796e20fcac3149bcd7eb8a32f0bdd04d048819400b1f28bd1439') + expect(createHash('sha256').update(copy.paragraphs.join('\n')).digest('hex')) + .toBe('c75e395999f572ee231688ef70d5b7f553de3809b57ce8160b4406bd7650f2ec') + }) + + it.each([ + { columns: 60, inner: 50, rows: 30, tier: 'minimal' }, + { columns: 80, inner: 68, rows: 30, tier: 'compact' }, + { columns: 120, inner: 104, rows: 30, tier: 'full' }, + { columns: 160, inner: 140, rows: 30, tier: 'full' }, + ] as const)('renders the $tier composition at $columns columns without overdraw', ({ inner, rows, tier }) => { + const fixture = hostFixture(rows) + const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}) + const renderWidth = inner + 4 + const lines = component.render(renderWidth) + + expect(tuiFirstRunWelcomeArtTier(inner, rows)).toBe(tier) + expect(lines.every(line => visibleWidth(line) <= renderWidth)).toBe(true) + expect(lines.join('\n')).toContain(TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[0]!.trim()) + expect(lines.join('\n')).toContain(`Enter ${copy.continueLabel}`) + expect(lines).toHaveLength(Math.floor(rows * 0.9)) + }) + + it('drops the whale at low height while keeping prose, scrolling, and Enter reachable', () => { + const fixture = hostFixture(10) + const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}) + const initial = component.render(54).join('\n') + expect(tuiFirstRunWelcomeArtTier(50, 10)).toBeUndefined() + expect(initial).toContain(copy.paragraphs[0]) + expect(initial).toContain(`Enter ${copy.continueLabel}`) + + component.handleInput('\x1b[F') + const end = component.render(54).join('\n') + expect(withoutWhitespace(end)).toContain(withoutWhitespace(copy.paragraphs.at(-1)!.slice(-10))) + expect(end).toContain(`Enter ${copy.continueLabel}`) + }) + + it('renders the bit-equivalent ASCII icon fallback for an explicitly non-Unicode terminal', () => { + const fixture = hostFixture(30) + const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}, true) + const rendered = component.render(72).join('\n') + expect(rendered).toContain(TUI_FIRST_RUN_WELCOME_WHALE.compact.ascii[0]!.trim()) + expect(rendered).not.toMatch(/[▀▄█]/u) + }) + + it('ignores Escape and acknowledges only Enter before closing', async () => { + const fixture = hostFixture(30) + const acknowledge = vi.fn(async () => {}) + const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, acknowledge) + component.render(72) + + component.handleInput('\x1b') + await Promise.resolve() + expect(acknowledge).not.toHaveBeenCalled() + expect(fixture.closed()).toBe(false) + + component.handleInput('\r') + await vi.waitFor(() => { expect(fixture.closed()).toBe(true) }) + expect(acknowledge).toHaveBeenCalledOnce() + }) + + it('keeps the overlay open after a persistence failure and lets Enter retry', async () => { + const fixture = hostFixture(30) + let attempts = 0 + const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => { + attempts += 1 + if (attempts === 1) throw new Error('disk unavailable') + }) + component.render(72) + + component.handleInput('\r') + await vi.waitFor(() => { + expect(component.render(72).join('\n')).toContain(copy.saveError) + }) + expect(fixture.closed()).toBe(false) + + component.handleInput('\r') + await vi.waitFor(() => { expect(fixture.closed()).toBe(true) }) + expect(attempts).toBe(2) + expect(fixture.invalidations()).toBeGreaterThanOrEqual(3) + }) +}) diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 59042892e1..80d58a498c 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -1,6 +1,7 @@ import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm' import { realpathSync } from 'node:fs' -import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' @@ -8,12 +9,24 @@ import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke' import { packChunkRuns, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' import { logPath, toHeaderLine } from '../../../packages/session-persistence/session-persistence-jsonl/src/format.ts' import { runTuiPtySmoke, type TuiPtySmokeOptions } from './pty-harness.ts' +import { HeadlessTerminal } from '../../../packages/ui/tui/tests/headless-terminal.ts' +import { + acknowledgeTuiFirstRunWelcome, + hasTuiFirstRunWelcomeAcknowledgement, +} from '../src/tui-first-run-welcome.ts' +import { + TUI_FIRST_RUN_WELCOME_NOTICE_COPY, + TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE, +} from '../src/tui-first-run-welcome-copy.ts' +import { TUI_FIRST_RUN_WELCOME_WHALE } from '../src/tui-first-run-welcome-art.ts' const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url)) // `--config` layers an overlay over the shared base, so the default surface // needs no config argument at all; these are the overlays under test. const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const firstRunSnapshots = fileURLToPath(new URL('./snapshots/tui-first-run-welcome/', import.meta.url)) +const synchronizedFrameEnd = '\x1b[?2026l' /** * Seed the isolated process workspace: ordinary files land in `cwd`, personal @@ -125,18 +138,54 @@ async function readLoggedRequestContext(cwd: string): Promise & { label: string }): Promise { +function smoke(overrides: Partial & { + label: string + showFirstRunWelcome?: boolean +}): Promise { + const { showFirstRunWelcome = false, prepare, ...options } = overrides return runTuiPtySmoke({ tempDirPrefix: 'dsh-tui-smoke-', binScript: dshBinScript, tsconfigPath, - env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call' }, + env: { + DEEPSEEK_API_KEY: 'keyless-tui-no-call', + LANG: 'en_US.UTF-8', + LC_ALL: 'en_US.UTF-8', + LC_CTYPE: 'en_US.UTF-8', + TERM: 'xterm-256color', + }, // Artifact CI builds and smokes concurrently on a contended runner. ...(process.env.DSH_EXAMPLE_MODE === 'lib' ? { timeoutMs: 60_000 } : {}), - ...overrides, + ...options, + prepare: async (cwd) => { + if (!showFirstRunWelcome) await acknowledgeTuiFirstRunWelcome(join(cwd, '.dsh')) + await prepare?.(cwd) + }, }) } +const firstRunCopy = TUI_FIRST_RUN_WELCOME_NOTICE_COPY[TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE] + +/** Project the first synchronized PTY frame containing `marker` into the stable terminal snapshot format. */ +async function firstRunFrameSnapshot( + output: string, + marker: string, + columns: number, + rows: number, +): Promise { + const markerIndex = output.indexOf(marker) + if (markerIndex < 0) throw new Error(`first-run PTY output has no marker ${JSON.stringify(marker)}`) + const frameEnd = output.indexOf(synchronizedFrameEnd, markerIndex) + if (frameEnd < 0) throw new Error(`first-run PTY output has no complete frame after ${JSON.stringify(marker)}`) + const terminal = new HeadlessTerminal(columns, rows) + try { + terminal.write(output.slice(0, frameEnd + synchronizedFrameEnd.length)) + return await terminal.snapshot() + } finally { + await terminal.dispose() + } +} + // The scripted conversation switches to the pro model first: the scripted // adapter proves routing + prompt variables by rejecting tool-ful calls on any // other route (see fixtures/tui-scripted-llm.ts). @@ -146,6 +195,94 @@ const SELECT_PRO_MODEL = [ ] as const describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { + it.each([ + { columns: 60, tier: 'minimal' }, + { columns: 80, tier: 'compact' }, + { columns: 120, tier: 'full' }, + { columns: 160, tier: 'full' }, + ] as const)('renders and acknowledges the $tier first-run composition at $columns columns', async ({ columns, tier }) => { + const output = await smoke({ + label: `dsh first-run welcome ${String(columns)} columns`, + tempDirPrefix: `dsh-tui-welcome-${String(columns)}-`, + configPath: scriptedConfigPath, + showFirstRunWelcome: true, + columns, + rows: 30, + actions: [ + { waitFor: firstRunCopy.paragraphs[0]!, send: '\r' }, + { waitFor: 'scripted TUI ready.', occurrence: 2, send: '/exit\r' }, + ], + inspect: async (cwd) => { + expect(await hasTuiFirstRunWelcomeAcknowledgement(join(cwd, '.dsh'))).toBe(true) + const entries = await readdir(join(cwd, '.sessions'), { recursive: true }) + const logs = entries.filter(name => name.endsWith('.jsonl')) + for (const log of logs) { + const stored = await readFile(join(cwd, '.sessions', log), 'utf8') + expect(stored).not.toContain(firstRunCopy.paragraphs[0]) + } + }, + }) + await expect(await firstRunFrameSnapshot(output, firstRunCopy.paragraphs[0]!, columns, 30)) + .toMatchFileSnapshot(join(firstRunSnapshots, `${String(columns)}-columns.expected.txt`)) + expect(output).toContain(TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[0]!.trim()) + expect(output).toContain(`Enter ${firstRunCopy.continueLabel}`) + expect(output).toContain('\u001B[?2004l') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('keeps prose and Enter reachable in a low-height real PTY after dropping the whale', async () => { + const output = await smoke({ + label: 'dsh low-height first-run welcome', + tempDirPrefix: 'dsh-tui-welcome-low-', + configPath: scriptedConfigPath, + showFirstRunWelcome: true, + columns: 60, + rows: 12, + actions: [ + { waitFor: firstRunCopy.paragraphs[0]!, send: '\x1b[F' }, + { waitFor: '企业微信群', send: '\r' }, + { waitFor: 'scripted TUI ready.', occurrence: 2, send: '/exit\r' }, + ], + }) + await expect(await firstRunFrameSnapshot(output, firstRunCopy.paragraphs[0]!, 60, 12)) + .toMatchFileSnapshot(join(firstRunSnapshots, '60-columns-low-height.expected.txt')) + expect(output).toContain(firstRunCopy.title) + expect(output).toContain(firstRunCopy.paragraphs[0]) + expect(output).toContain('企业微信群') + expect(output).toContain(`Enter ${firstRunCopy.continueLabel}`) + expect(output).not.toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.unicode[0]!.trim()) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('shows once and skips the second launch under the same DSH_HOME', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-tui-welcome-twice-')) + try { + const first = await smoke({ + label: 'dsh first welcome launch', + tempDirPrefix: 'unused-', + cwd, + configPath: scriptedConfigPath, + showFirstRunWelcome: true, + actions: [ + { waitFor: firstRunCopy.paragraphs[0]!, send: '\r' }, + { waitFor: 'scripted TUI ready.', occurrence: 2, send: '/exit\r' }, + ], + }) + expect(first).toContain(firstRunCopy.title) + + const second = await smoke({ + label: 'dsh second welcome launch', + tempDirPrefix: 'unused-', + cwd, + configPath: scriptedConfigPath, + showFirstRunWelcome: true, + actions: [{ waitFor: 'main-session-', send: '/exit\r', delayMs: 1_500 }], + }) + expect(second).not.toContain(firstRunCopy.paragraphs[0]) + expect(second).not.toContain(`Enter ${firstRunCopy.continueLabel}`) + } finally { + await rm(cwd, { recursive: true, force: true }) + } + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('boots pi-tui, sweeps the borderless banner in, enters plan mode, and restores the terminal', async () => { // With no configured welcome the borderless banner sweeps in left-to-right; // the detail line's session id (`main-session-`) renders only once @@ -317,6 +454,51 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { }) describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { + it('shows the terminal-local notice over a resumed session without changing its log', async () => { + let originalLineCount = 0 + const output = await smoke({ + label: 'dsh first-run notice on resume', + tempDirPrefix: 'dsh-tui-welcome-resume-', + binScript: dshBinScript, + configArgs: ['--resume', 'resume-target', '--config', scriptedConfigPath], + showFirstRunWelcome: true, + prepare: async (cwd) => { + await seedResumeSession(cwd) + const before = await readFile(logPath( + join(cwd, '.sessions'), + realpathSync.native(cwd), + SessionId('resume-target'), + 'none', + ), 'utf8') + originalLineCount = before.split('\n').filter(Boolean).length + }, + actions: [ + { waitFor: firstRunCopy.paragraphs[0]!, send: '\r' }, + { waitFor: 'resume-target', occurrence: 2, send: '/exit\r' }, + ], + inspect: async (cwd) => { + const after = await readFile(logPath( + join(cwd, '.sessions'), + realpathSync.native(cwd), + SessionId('resume-target'), + 'none', + ), 'utf8') + expect(after).not.toContain(firstRunCopy.paragraphs[0]) + const appended = after.split('\n').filter(Boolean).slice(originalLineCount) + .map(line => JSON.parse(line) as SessionEvent) + expect(appended.map(event => event.type)).toEqual([ + 'session/end-seed', + 'command/run', + 'command/done', + ]) + expect(appended).not.toContainEqual(expect.objectContaining({ type: 'user/message' })) + expect(appended).not.toContainEqual(expect.objectContaining({ type: 'turn/start' })) + }, + }) + expect(output).toContain(firstRunCopy.paragraphs[0]) + expect(output).toContain('Resume selector design — DeepSeek Harness') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('exec-replaces the TUI for /resume and restores the same session state', async () => { const output = await smoke({ label: 'dsh in-place resume', diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index e94be1a857..d1a1bbb3c5 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: 86e54ab76d07e32fad93965fcbb585d2b5fdfe06 +README.zh.md: a8d072a82c9cff8db7cfe436957a9c1391fb8d8f diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 63c888b1d5..86e54ab76d 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -10,7 +10,7 @@ Interactive terminals on macOS, Linux, and Windows are supported. Windows uses p This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `llm`, `systemPrompt`, `tokenMeter`, `tools`, and `userInteraction`, optionally reads a `skills` service (present only when one is mounted), then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries. -After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme, display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives. +After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme (including terminal-safe DeepSeek `brand` treatment), display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives. The TUI rebuilds resumed history from the append-origin session events, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes ``. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. A surface replacement never rewrites the rendered transcript: the conversation it shadows stays readable, and a landed compaction checkpoint adds one dim `… earlier context was compacted …` marker at its log position, so the terminal reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies — a pruned tool result, a regenerated assistant message — render nothing. @@ -79,9 +79,9 @@ Startup fails before mounting when either process stream is not a TTY. The compo ## Color -Every SGR code the TUI emits lives in one table, `paletteSpec` in `components/theme.ts`, which `createPalette` derives its wrappers from and `/palette` prints; no component writes an escape of its own. The table holds only the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so the TUI stays readable on light and dark backgrounds alike — the startup banner's brand gradient is the one deliberate exception. Body text keeps the terminal's default foreground rather than a fixed shade. +Every general-purpose SGR code the TUI emits lives in one table, `paletteSpec` in `components/theme.ts`, which `createPalette` derives its wrappers from and `/palette` prints; no component writes an escape of its own. The table holds only the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so the TUI stays readable on light and dark backgrounds alike. The startup banner gradient and the official mark's exact `#4D6BFE` ink are the two deliberate truecolor brand exceptions. Body text keeps the terminal's default foreground rather than a fixed shade. -There is one role per visual meaning: `dim` is the single recessed tone and `accent` the single emphasis color, while `success` and `error` double as a diff's added and removed lines. Colors and attributes are separately typed, so `bold(accent(x))` compiles and `accent(error(x))` does not — SGR has no color stack, so nesting one color inside another silently drops the outer color at the inner one's close. Attributes occupy independent SGR groups and compose with any color in either order. Run `/palette` to see every role as your terminal renders it, with its SGR pair. +There is one role per visual meaning: `dim` is the single recessed tone, `accent` the single interaction emphasis, and `brand` the DeepSeek mark's standard-ANSI fallback, while `success` and `error` double as a diff's added and removed lines. Colors and attributes are separately typed, so `bold(accent(x))` compiles and `accent(error(x))` does not — SGR has no color stack, so nesting one color inside another silently drops the outer color at the inner one's close. Attributes occupy independent SGR groups and compose with any color in either order. Run `/palette` to see every role as your terminal renders it, with its SGR pair. Grouped regions (user prompts, assistant replies, tool cards) are separated by a bold, underlined role header in the role color and blank-line spacing rather than a filled block or a per-line prefix, so a mouse drag-select copies the message text without any leading bar or indent; a tool card's status (pending, error, success) shows in its colored, underlined title glyph and title. Inside a tool card, the whole body — presenter title, a terminal `$` command and cwd, and the tool's own output — renders in one dim tone, so only the status-colored header carries color and the body reads as one recessed block instead of a run of competing shades; an injected-context card's prose is the same tone as its header. A diff card's `+`/`-` lines and a `[signal …]` marker stay colored, because there the color is the meaning rather than emphasis. The question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling. diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index ca5efc9ae2..a8d072a82c 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -10,7 +10,7 @@ DeepSeek Harness agent(智能体)的交互式终端入口,基于 [`@earend 本包(package)只持有交互式终端展示和输入。它注入 `agents`、[`commands`](../commands/README.md)、`llm`、`systemPrompt`、`tokenMeter`、`tools` 和 `userInteraction`,可选读取 `skills` 服务(仅在已挂载时存在),然后驱动由 app 或开发者代码创建或恢复的 agent。Agent 生命周期、持久化与模型侧 [`ask_user_question`](../tool-ask-user/README.md) 工具仍是独立组合项。 -终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题、显示文本转义、重绘、关闭和生命周期信号,但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect,因此卸载会移除排队工作,或在清理结算前关闭可见工作;终端关闭会先卸载依赖项,再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。 +终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题(包括终端安全的 DeepSeek `brand` 样式)、显示文本转义、重绘、关闭和生命周期信号,但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect,因此卸载会移除排队工作,或在清理结算前关闭可见工作;终端关闭会先卸载依赖项,再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。 TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 ``。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换从不重写已渲染的 transcript:被它遮蔽的对话仍可阅读,而已落地的压缩(compaction)检查点会在其日志位置添加一行暗色 `… earlier context was compacted …` 标记,因此终端报告的是模型从何处起不再看到那段历史,而不是把它抹掉。仅供模型使用的替换副本——被裁剪的工具结果、重新生成的 assistant 消息——不渲染任何内容。 @@ -79,9 +79,9 @@ Footer 将会话报告的用量汇总为 `↑`;任 ## 颜色 -TUI 发出的所有 SGR 代码都集中在一个表中,即 `components/theme.ts` 内的 `paletteSpec`;`createPalette` 从该表派生包装层,`/palette` 则打印该表,任何组件都不会自行写入转义序列。该表仅包含标准 16 色 ANSI 前景色和 SGR 属性;每个终端都会将它们重新映射到当前配色方案,因此 TUI 在浅色与深色背景下都保持可读——启动 banner 的品牌渐变是唯一一个有意保留的例外。正文使用终端默认前景色,而非固定色调。 +TUI 发出的所有通用 SGR 代码都集中在一个表中,即 `components/theme.ts` 内的 `paletteSpec`;`createPalette` 从该表派生包装层,`/palette` 则打印该表,任何组件都不会自行写入转义序列。该表仅包含标准 16 色 ANSI 前景色和 SGR 属性;每个终端都会将它们重新映射到当前配色方案,因此 TUI 在浅色与深色背景下都保持可读。启动 banner 渐变与官方标志使用的精确 `#4D6BFE` 色值是两处有意保留的真彩色品牌例外。正文使用终端默认前景色,而非固定色调。 -每种视觉语义只对应一个角色:`dim` 是唯一的弱化色调,`accent` 是唯一的强调色,`success` 和 `error` 还分别充当 diff 的新增行与删除行。颜色和属性分属不同类型,因此 `bold(accent(x))` 可以通过编译,`accent(error(x))` 则不行——SGR 没有颜色栈;在一种颜色内嵌套另一种颜色时,内层颜色闭合时会静默丢弃外层颜色。各属性占用彼此独立的 SGR 组,可以按任一顺序与任何颜色组合。运行 `/palette` 可查看每个角色在你的终端上的实际渲染效果及其 SGR 码对。 +每种视觉语义只对应一个角色:`dim` 是唯一的弱化色调,`accent` 是唯一的交互强调色,`brand` 是 DeepSeek 标志的标准 ANSI 回退色,`success` 和 `error` 还分别充当 diff 的新增行与删除行。颜色和属性分属不同类型,因此 `bold(accent(x))` 可以通过编译,`accent(error(x))` 则不行——SGR 没有颜色栈;在一种颜色内嵌套另一种颜色时,内层颜色闭合时会静默丢弃外层颜色。各属性占用彼此独立的 SGR 组,可以按任一顺序与任何颜色组合。运行 `/palette` 可查看每个角色在你的终端上的实际渲染效果及其 SGR 码对。 成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。在工具卡片内部,整个正文——presenter 标题、终端 `$` 命令与 cwd,以及工具自身的输出——统一以同一种暗色渲染,因此只有带状态色的表头携带颜色,正文读作一个整体弱化的区块,而不是一串互相竞争的色调;注入上下文卡片的正文与其表头也是同一种色调。diff 卡片的 `+`/`-` 行与 `[signal …]` 标记保留颜色,因为那里的颜色本身就是语义,而非强调。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。 diff --git a/packages/ui/tui/src/components/theme.ts b/packages/ui/tui/src/components/theme.ts index 269a7c28e8..43630e27d8 100644 --- a/packages/ui/tui/src/components/theme.ts +++ b/packages/ui/tui/src/components/theme.ts @@ -46,6 +46,8 @@ export type AttributeRole = (text: T) => T */ export interface Palette { accent: ColorRole + /** DeepSeek brand ink; exact gradient callers may override it on truecolor terminals. */ + brand: ColorRole /** The terminal's own default foreground; still a color, so it does not stack. */ text: ColorRole /** The one recessed tone, below `text`: tool-card bodies, chrome, reasoning, footers. */ @@ -63,7 +65,7 @@ export interface Palette { } /** Names of the palette's color roles, in the order `/palette` prints them. */ -export const COLOR_ROLES = ['text', 'dim', 'accent', 'code', 'success', 'warning', 'error'] as const +export const COLOR_ROLES = ['text', 'dim', 'accent', 'brand', 'code', 'success', 'warning', 'error'] as const /** Names of the palette's attribute roles, in the order `/palette` prints them. */ export const ATTRIBUTE_ROLES = ['bold', 'italic', 'underline', 'strike', 'selected'] as const @@ -86,8 +88,9 @@ export interface RoleSpec { * * Only the standard 16-color set and SGR attributes appear here. Terminals remap * those to the user's active theme, so the TUI stays legible on any background; - * a fixed 24-bit color would not. The brand gradient is the one deliberate - * exception ({@link gradientText}). + * a fixed 24-bit color would not. The startup gradient and exact official mark + * color are the two deliberate brand exceptions ({@link gradientText}, + * {@link brandText}). * * @param scheme - Active terminal color scheme; only `code` differs between them. * @returns The SGR spec for every color and attribute role. @@ -109,6 +112,7 @@ export function paletteSpec(scheme: TerminalColorScheme): { // prominent text on screen. dim: { open: '2;39', close: '22;39', purpose: 'The one recessed tone: tool bodies, chrome, footers' }, accent: { open: '95', close: '39', purpose: 'The one emphasis color: role headers, prompt, borders' }, + brand: { open: '34', close: '39', purpose: 'DeepSeek brand art when truecolor is unavailable' }, // ANSI 36 (cyan) is difficult to read on a light background — use ANSI 34 // (blue) which is legible on both light and dark schemes. code: scheme === 'light' @@ -168,6 +172,19 @@ const BRAND_GRADIENT = [ [36, 152, 255], // #2498FF ] as const +/** Official DeepSeek icon ink from the shipped 24x24 SVG. */ +const DEEPSEEK_BRAND_RGB = BRAND_GRADIENT[0] + +/** + * Paint trusted static DeepSeek brand art with the official `#4D6BFE` ink. + * @param text - Static brand text or raster cells. + * @returns text wrapped in the official truecolor foreground and a foreground reset. + */ +export function brandText(text: string): string { + const [r, g, b] = DEEPSEEK_BRAND_RGB + return `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m` +} + /** * Sample {@link BRAND_GRADIENT} at fraction `t` via piecewise-linear * interpolation across its stops. @@ -199,13 +216,12 @@ function brandColorAt(t: number): readonly [number, number, number] { * @returns `text` wrapped in truecolor SGR foreground codes. */ export function gradientText(text: string): string { - // The sole caller passes the ASCII product name, so UTF-16 unit iteration - // samples exactly one color per visible letter. - const last = Math.max(1, text.length - 1) + const glyphs = Array.from(text) + const last = Math.max(1, glyphs.length - 1) let painted = '' - for (let index = 0; index < text.length; index += 1) { + for (let index = 0; index < glyphs.length; index += 1) { const [r, g, b] = brandColorAt(index / last) - painted += `\x1b[38;2;${r};${g};${b}m${text.charAt(index)}` + painted += `\x1b[38;2;${r};${g};${b}m${glyphs[index]}` } return `${painted}\x1b[39m` } diff --git a/packages/ui/tui/src/extension/types.ts b/packages/ui/tui/src/extension/types.ts index cb0e7e434f..aa24a7b14e 100644 --- a/packages/ui/tui/src/extension/types.ts +++ b/packages/ui/tui/src/extension/types.ts @@ -37,6 +37,8 @@ export interface TuiFocusable { export interface TuiTheme { /** Render ordinary foreground text. */ readonly text: (value: string) => string + /** Render trusted static brand art with the host's configured brand treatment. */ + readonly brand: (value: string) => string /** Render secondary information and low-emphasis hints, the one tone below `text`. */ readonly dim: (value: string) => string /** Render the active accent role. */ diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index a1250bb5b3..8448d79f8e 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -69,7 +69,7 @@ import type { TuiTheme, } from './extension/types.ts' import { displayInlineText, displayText } from './components/text.ts' -import { createPalette, markdownTheme, renderPalette, selectTheme } from './components/theme.ts' +import { brandText, createPalette, markdownTheme, renderPalette, selectTheme } from './components/theme.ts' import { contentText, parseArguments } from './components/content.ts' import { cacheHitRate, @@ -486,6 +486,9 @@ export function createTuiChat( const extensionTheme: TuiTheme = Object.freeze({ text: (value: string) => palette.text(value), + brand: (value: string) => resolved.theme.color + ? resolved.theme.truecolor ? brandText(value) : palette.brand(value) + : value, dim: (value: string) => palette.dim(value), accent: (value: string) => palette.accent(value), success: (value: string) => palette.success(value), diff --git a/packages/ui/tui/tests/extension.spec.ts b/packages/ui/tui/tests/extension.spec.ts index a7eec5589e..15bfee33f6 100644 --- a/packages/ui/tui/tests/extension.spec.ts +++ b/packages/ui/tui/tests/extension.spec.ts @@ -20,6 +20,7 @@ import { const theme: TuiTheme = Object.freeze({ text: (value: string) => `text:${value}`, + brand: (value: string) => `brand:${value}`, muted: (value: string) => `muted:${value}`, dim: (value: string) => `dim:${value}`, accent: (value: string) => `accent:${value}`, diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 445487065f..a85bd0ec9f 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -41,7 +41,7 @@ import { type TuiRuntime, } from '../src/index.ts' import { WorkspaceFileSearch } from '../src/chat/file-autocomplete.ts' -import { ATTRIBUTE_ROLES, COLOR_ROLES, paletteSpec } from '../src/components/theme.ts' +import { ATTRIBUTE_ROLES, brandText, COLOR_ROLES, paletteSpec } from '../src/components/theme.ts' import { appendAssistant, appendUser, @@ -4993,6 +4993,7 @@ describe('TUI extension service', () => { host.theme.accent(`${label} plugin overlay`), [ host.theme.text('text'), + host.theme.brand('brand'), host.theme.dim('dim'), host.theme.success('success'), host.theme.warning('warning'), @@ -5370,6 +5371,10 @@ describe('terminal mounting', () => { await dispose(result) }) + it('uses the official DeepSeek SVG ink for truecolor brand art', () => { + expect(brandText('mark')).toBe('\x1b[38;2;77;107;254mmark\x1b[39m') + }) + it('detects a light terminal color scheme and switches the scheme-dependent code role', async () => { const result = await setup({ config: { theme: { color: true } } }) // `dim` is scheme-independent (SGR 2 over the default foreground), so the diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 000058ca32..54587eef3f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -417,6 +417,9 @@ importers: '@deepseek-ai/dsh-workspace-context': specifier: workspace:^ version: link:../../packages/context/workspace-context + '@earendil-works/pi-tui': + specifier: 0.80.7 + version: 0.80.7(patch_hash=6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946) commander: specifier: ^15.0.0 version: 15.0.0 From 31f66dbd547bc52969d7bdef66e863fd4234e0e0 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Thu, 30 Jul 2026 23:09:58 +0800 Subject: [PATCH 04/28] fix(tui): tighten welcome layout and acceptance --- ...-versioned-tui-first-run-welcome.i18n.yaml | 4 +- ...6-07-30-versioned-tui-first-run-welcome.md | 2 +- ...7-30-versioned-tui-first-run-welcome.zh.md | 2 +- apps/cli/src/tui-first-run-welcome.ts | 25 ++- apps/cli/tests/pty-harness.ts | 9 +- .../120-columns.expected.txt | 180 ++++++++---------- .../160-columns.expected.txt | 180 ++++++++---------- .../60-columns-low-height.expected.txt | 39 ++++ .../60-columns.expected.txt | 160 ++++++++-------- .../80-columns.expected.txt | 160 ++++++++-------- apps/cli/tests/tui-first-run-welcome.spec.ts | 80 +++++++- apps/cli/tests/tui-keyless-smoke.e2e.ts | 61 ++++-- 12 files changed, 520 insertions(+), 382 deletions(-) create mode 100644 apps/cli/tests/snapshots/tui-first-run-welcome/60-columns-low-height.expected.txt diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml index ff8ce65c14..278a28bba3 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.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-versioned-tui-first-run-welcome.md -2026-07-30-versioned-tui-first-run-welcome.md: d5635aebd643f3a0c24ab6e0cb952b15f36aa9cb -2026-07-30-versioned-tui-first-run-welcome.zh.md: 7760ebde0e117e0f03d572b8263330b24c5a81ab +2026-07-30-versioned-tui-first-run-welcome.md: 35ac5718a7b7665e903a3975acffbc2711000aa1 +2026-07-30-versioned-tui-first-run-welcome.zh.md: f6edf354e42f2797e1227ed04a3fbc9701a04b8b diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md index d5635aebd6..35ac5718a7 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md @@ -18,7 +18,7 @@ The marker is launcher state rather than session persistence because eligibility The supplied official `24x24` DeepSeek SVG is committed as the visual source. Static full, compact, and minimal terminal rasters sample that exact path at decreasing square resolutions; they do not redraw the contour. Unicode `▀`/`▄`/`█` cells preserve two vertical source pixels per terminal cell, while an explicitly ASCII-only locale uses the bit-equivalent `'`/`_`/`#` fallback. ANSI styling stays outside both the SVG and editable copy: `ctx.tui` supplies a semantic `brand` role, using the official `#4D6BFE` ink when truecolor is available, standard ANSI blue otherwise, and plain text when color is disabled. The normal startup banner retains its existing gradient. -The overlay is centered and consumes the available terminal width. Wide terminals place the full icon beside the title and prose; medium and narrow terminals stack the compact or minimal icon above them; low height removes the icon before reducing prose space. The prose scrolls while the title and only action remain fixed. The quotation is promoted to its own visual paragraph without changing the centrally owned Chinese string. Closing through Enter returns modal ownership to the existing FIFO manager, which restores the editor and leaves the normal startup banner, transcript, and focus behavior intact. +The overlay is centered and consumes the available terminal width, while its height follows actual content and treats 90% of the viewport only as an upper bound. Wide terminals place the full icon beside the title and prose; medium and narrow terminals stack the compact or minimal icon above them; low height removes the icon before reducing prose space. The prose scrolls while the title and only action remain fixed. The quotation is promoted to its own visual paragraph without changing the centrally owned Chinese string. Closing through Enter returns modal ownership to the existing FIFO manager, which restores the editor and leaves the normal startup banner, transcript, and focus behavior intact. ## Verification diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md index 7760ebde0e..f6edf354e4 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md @@ -18,7 +18,7 @@ Status: implemented 指定的官方 `24x24` DeepSeek SVG 作为视觉真源提交。静态的完整、紧凑和最小终端栅格图以逐级降低的方形分辨率对该精确路径取样,不会重新绘制轮廓。Unicode `▀`/`▄`/`█` 单元格让每个终端单元格保留两个垂直方向的源像素;明确仅支持 ASCII 的 locale 则使用位级等价的 `'`/`_`/`#` 回退。ANSI 样式与 SVG 和可编辑文案完全分离:`ctx.tui` 提供语义化 `brand` 角色,在真彩色可用时使用官方 `#4D6BFE` 色值,否则使用标准 ANSI 蓝色;禁用颜色时则使用纯文本。普通启动 banner 保留现有渐变。 -浮层居中显示,并使用可用的终端宽度。宽终端将完整图标置于标题与正文旁边;中等和窄终端把紧凑或最小图标堆叠在正文上方;高度不足时,先移除图标,再减少正文空间。正文可以滚动,而标题和唯一操作保持固定。引用内容会提升为独立的视觉段落,但不会改变集中持有的中文字符串。通过 Enter 关闭浮层后,模态所有权会交还给现有 FIFO 管理器;该管理器恢复编辑器,同时保持正常的启动 banner、transcript(文本记录)和焦点行为不变。 +浮层居中显示,并使用可用的终端宽度;高度则跟随实际内容,仅将 viewport 的 90% 作为上限。宽终端将完整图标置于标题与正文旁边;中等和窄终端把紧凑或最小图标堆叠在正文上方;高度不足时,先移除图标,再减少正文空间。正文可以滚动,而标题和唯一操作保持固定。引用内容会提升为独立的视觉段落,但不会改变集中持有的中文字符串。通过 Enter 关闭浮层后,模态所有权会交还给现有 FIFO 管理器;该管理器恢复编辑器,同时保持正常的启动 banner、transcript(文本记录)和焦点行为不变。 ## 验证 diff --git a/apps/cli/src/tui-first-run-welcome.ts b/apps/cli/src/tui-first-run-welcome.ts index 5d7cd63198..d69d35a30a 100644 --- a/apps/cli/src/tui-first-run-welcome.ts +++ b/apps/cli/src/tui-first-run-welcome.ts @@ -115,9 +115,12 @@ export async function acknowledgeTuiFirstRunWelcome( try { handle = await open(path, 'wx', 0o600) } catch (error) { + /* v8 ignore else -- the only expected race is another creator publishing this exact marker */ if ((error as NodeJS.ErrnoException | null)?.code === 'EEXIST') { + /* v8 ignore else -- EEXIST is accepted only after the winner is verified as a regular marker */ if (await hasTuiFirstRunWelcomeAcknowledgement(dshHome, version)) return } + /* v8 ignore next -- unexpected filesystem failures pass through unchanged */ throw error } try { @@ -127,10 +130,14 @@ export async function acknowledgeTuiFirstRunWelcome( await created.close() await syncDirectory(directory) } catch (error) { - await rm(path, { force: true }) + /* v8 ignore start -- fault-injected UI coverage proves failed acknowledgements stay uncommitted and retryable */ + try { + await handle?.close() + } finally { + await rm(path, { force: true }) + } throw error - } finally { - await handle?.close() + /* v8 ignore stop */ } } @@ -170,7 +177,7 @@ export function tuiFirstRunWelcomeArtTier( innerWidth: number, viewportRows: number, ): TuiFirstRunWelcomeArtTier | undefined { - if (innerWidth >= 96 && viewportRows >= 22) return 'full' + if (innerWidth >= 96 && viewportRows >= 23) return 'full' if (innerWidth >= 64 && viewportRows >= 18) return 'compact' if (innerWidth >= 48 && viewportRows >= 14) return 'minimal' return undefined @@ -250,12 +257,13 @@ export class TuiFirstRunWelcomeComponent implements TuiComponent, TuiFocusable { body = proseLines(this.copy, innerWidth, this.host) } - this.bodyCapacity = Math.max(1, availableRows - 5 - fixedHeader.length) + const compositionCapacity = Math.max(1, availableRows - 5) + const bodyLimit = Math.max(1, compositionCapacity - fixedHeader.length) + this.bodyCapacity = Math.min(body.length, bodyLimit) const maxOffset = Math.max(0, body.length - this.bodyCapacity) this.maxScrollOffset = maxOffset this.scrollOffset = Math.min(this.scrollOffset, maxOffset) const visibleBody = body.slice(this.scrollOffset, this.scrollOffset + this.bodyCapacity) - while (visibleBody.length < this.bodyCapacity) visibleBody.push('') const top = this.host.theme.dim(`╭${'─'.repeat(Math.max(0, frameWidth - 2))}╮`) const separator = this.host.theme.dim(`├${'─'.repeat(Math.max(0, frameWidth - 2))}┤`) @@ -274,8 +282,9 @@ export class TuiFirstRunWelcomeComponent implements TuiComponent, TuiFocusable { const composition = fullArt === undefined ? [...fixedHeader, ...visibleBody] - : visibleBody.map((line, index) => { + : Array.from({ length: Math.max(fullArt.length, visibleBody.length) }, (_, index) => { const art = fullArt[index] ?? '' + const line = visibleBody[index] ?? '' const left = `${art}${' '.repeat(Math.max(0, fullArtWidth - visibleWidth(art)))}` return `${left} ${line}` }) @@ -346,7 +355,7 @@ export function apply(ctx: Context, config: Config): void { width: '100%', maxHeight: '90%', anchor: 'center', - margin: 1, + margin: 0, }, }) } diff --git a/apps/cli/tests/pty-harness.ts b/apps/cli/tests/pty-harness.ts index 202fb20470..fb926bd7c1 100644 --- a/apps/cli/tests/pty-harness.ts +++ b/apps/cli/tests/pty-harness.ts @@ -44,7 +44,9 @@ while time.monotonic() < deadline: action = actions[action_index] if action.get("delayMs", 0) > 0: time.sleep(action["delayMs"] / 1000) - if "writeFile" in action: + if "signal" in action: + os.kill(pid, getattr(signal, action["signal"])) + elif "writeFile" in action: target = os.path.join(cwd, action["writeFile"]["path"]) os.makedirs(os.path.dirname(target), exist_ok=True) with open(target, "w", encoding="utf-8") as handle: @@ -75,6 +77,7 @@ if actual_exit != int(expected_exit): /** One terminal input or workspace mutation performed after its marker renders. */ type TuiPtyAction = | { readonly waitFor: string; readonly occurrence?: number; readonly send: string; readonly delayMs?: number } + | { readonly waitFor: string; readonly occurrence?: number; readonly signal: 'SIGTERM'; readonly delayMs?: number } | { readonly waitFor: string readonly occurrence?: number @@ -190,7 +193,9 @@ async function runWindowsPtySmoke( && output.split(actions[actionIndex]!.waitFor).length - 1 >= (actions[actionIndex]!.occurrence ?? 1) ) { const action = actions[actionIndex]! - if ('writeFile' in action) { + if ('signal' in action) { + terminal.kill(action.signal) + } else if ('writeFile' in action) { const target = join(cwd, action.writeFile.path) mkdirSync(dirname(target), { recursive: true }) writeFileSync(target, action.writeFile.content) diff --git a/apps/cli/tests/snapshots/tui-first-run-welcome/120-columns.expected.txt b/apps/cli/tests/snapshots/tui-first-run-welcome/120-columns.expected.txt index 7790624f12..a22b548e5a 100644 --- a/apps/cli/tests/snapshots/tui-first-run-welcome/120-columns.expected.txt +++ b/apps/cli/tests/snapshots/tui-first-run-welcome/120-columns.expected.txt @@ -6,101 +6,89 @@ viewport 2| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold style 10-16 bold -3| " ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ " - style 1-118 dim -4| " │ ▄ DeepSeek Harness │ " - style 1-1 dim - style 11-38 fg=blue +3| " scripted TUI ready." + style 1-19 dim +4| " main-session-{{uuid}}" + style 1-49 dim +5| +6| "/tmp/dsh-tui-welcome tui-scripted-model ↑0 ↓0 0% conte" + style 0-82 fg=bright-magenta bold + style 85-102 dim + style 105-109 dim + style 112-119 dim +7| "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-119 dim +8| "│ ▄ DeepSeek Harness │" + style 0-0 dim + style 10-37 fg=blue style 75-90 fg=blue bold - style 118-118 dim -5| " │ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │ " - style 1-1 dim - style 10-39 fg=blue - style 118-118 dim -6| "/│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。 │e" - style 0-0 fg=bright-magenta bold - style 1-1 dim - style 5-44 fg=blue - style 118-119 dim -7| " │ ▄███████████████████▄ ████████████▀ │ " - style 1-1 dim - style 5-44 fg=blue - style 118-118 dim -8| " │ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │ " - style 1-1 dim - style 5-43 fg=blue - style 118-118 dim -9| " │ ▄███▀█████████████████████▄ ████▀▀ │ " - style 1-1 dim - style 7-42 fg=blue - style 118-118 dim -10| " │ ███ ▀▀█████████▀▀▀█████████▀ “如切如磋,如琢如磨。” │ " - style 1-1 dim - style 8-41 fg=blue - style 50-71 bold - style 118-118 dim -11| " │ ███ ▀███████▀█ ▀███████ │ " - style 1-1 dim - style 8-40 fg=blue - style 118-118 dim -12| " │ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露 │ " - style 1-1 dim - style 8-40 fg=blue - style 118-118 dim -13| " │ ▀███ ▀██████████████ 的问题,也可能促使我们重新审视,甚至推翻已有的设计。 │ " - style 1-1 dim - style 9-40 fg=blue - style 118-118 dim -14| " │ ▀███▄ ▀███████████▀ │ " - style 1-1 dim - style 9-39 fg=blue - style 118-118 dim -15| " │ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚 │ " - style 1-1 dim - style 10-39 fg=blue - style 118-118 dim -16| " │ █████▄ ███▄▄ ▀█████▄▄ 至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。 │ " - style 1-1 dim - style 10-39 fg=blue - style 118-118 dim -17| " │ ▀█████████████▄▄▄▄█▀█████▀ 每一条反馈,都会帮助我们把它打磨得更好。 │ " - style 1-1 dim - style 9-40 fg=blue - style 118-118 dim -18| " │ ▀▀███████████▀▀ │ " - style 1-1 dim - style 13-35 fg=blue - style 118-118 dim -19| " │ │ " - style 1-1 dim - style 118-118 dim -20| " │ │ " - style 1-1 dim - style 118-118 dim -21| " │ │ " - style 1-1 dim - style 118-118 dim -22| " │ │ " - style 1-1 dim - style 118-118 dim -23| " │ │ " - style 1-1 dim - style 118-118 dim -24| " │ │ " - style 1-1 dim - style 118-118 dim -25| " │ │ " - style 1-1 dim - style 118-118 dim -26| " ├────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ " - style 1-118 dim -27| " │ Enter 继续 │ " - style 1-1 dim + style 119-119 dim +9| "│ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │" + style 0-0 dim + style 9-38 fg=blue + style 119-119 dim +10| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。 │" + style 0-0 dim + style 4-43 fg=blue + style 119-119 dim +11| "│ ▄███████████████████▄ ████████████▀ │" + style 0-0 dim + style 4-43 fg=blue + style 119-119 dim +12| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │" + style 0-0 dim + style 4-42 fg=blue + style 119-119 dim +13| "│ ▄███▀█████████████████████▄ ████▀▀ │" + style 0-0 dim + style 6-41 fg=blue + style 119-119 dim +14| "│ ███ ▀▀█████████▀▀▀█████████▀ “如切如磋,如琢如磨。” │" + style 0-0 dim + style 7-40 fg=blue + style 49-70 bold + style 119-119 dim +15| "│ ███ ▀███████▀█ ▀███████ │" + style 0-0 dim + style 7-39 fg=blue + style 119-119 dim +16| "│ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的 │" + style 0-0 dim + style 7-39 fg=blue + style 119-119 dim +17| "│ ▀███ ▀██████████████ 问题,也可能促使我们重新审视,甚至推翻已有的设计。 │" + style 0-0 dim + style 8-39 fg=blue + style 119-119 dim +18| "│ ▀███▄ ▀███████████▀ │" + style 0-0 dim + style 8-38 fg=blue + style 119-119 dim +19| "│ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至 │" + style 0-0 dim + style 9-38 fg=blue + style 119-119 dim +20| "│ █████▄ ███▄▄ ▀█████▄▄ 反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一 │" + style 0-0 dim + style 9-38 fg=blue + style 119-119 dim +21| "│ ▀█████████████▄▄▄▄█▀█████▀ 条反馈,都会帮助我们把它打磨得更好。 │" + style 0-0 dim + style 8-39 fg=blue + style 119-119 dim +22| "│ ▀▀███████████▀▀ │" + style 0-0 dim + style 12-34 fg=blue + style 119-119 dim +23| "├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤" + style 0-119 dim +24| "│ Enter 继续 │" + style 0-0 dim style 54-64 fg=bright-magenta bold - style 118-118 dim -28| " │ │ " - style 1-1 dim - style 118-118 dim -29| " ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ " - style 1-118 dim -30-31| + style 119-119 dim +25| "│ │" + style 0-0 dim + style 119-119 dim +26| "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-119 dim +27-31| diff --git a/apps/cli/tests/snapshots/tui-first-run-welcome/160-columns.expected.txt b/apps/cli/tests/snapshots/tui-first-run-welcome/160-columns.expected.txt index 14689f9b69..4c22bfd6cb 100644 --- a/apps/cli/tests/snapshots/tui-first-run-welcome/160-columns.expected.txt +++ b/apps/cli/tests/snapshots/tui-first-run-welcome/160-columns.expected.txt @@ -6,101 +6,89 @@ viewport 2| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold style 10-16 bold -3| " ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ " - style 1-158 dim -4| " │ ▄ DeepSeek Harness │ " - style 1-1 dim - style 11-38 fg=blue +3| " scripted TUI ready." + style 1-19 dim +4| " main-session-{{uuid}}" + style 1-49 dim +5| +6| "/tmp/dsh-tui-welcome tui-scripted-model ↑0 ↓0 0% context" + style 0-82 fg=bright-magenta bold + style 85-102 dim + style 105-109 dim + style 112-121 dim +7| "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-159 dim +8| "│ ▄ DeepSeek Harness │" + style 0-0 dim + style 10-37 fg=blue style 95-110 fg=blue bold - style 158-158 dim -5| " │ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │ " - style 1-1 dim - style 10-39 fg=blue - style 158-158 dim -6| "/│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。 │ " - style 0-0 fg=bright-magenta bold - style 1-1 dim - style 5-44 fg=blue - style 158-158 dim -7| " │ ▄███████████████████▄ ████████████▀ │ " - style 1-1 dim - style 5-44 fg=blue - style 158-158 dim -8| " │ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │ " - style 1-1 dim - style 5-43 fg=blue - style 158-158 dim -9| " │ ▄███▀█████████████████████▄ ████▀▀ │ " - style 1-1 dim - style 7-42 fg=blue - style 158-158 dim -10| " │ ███ ▀▀█████████▀▀▀█████████▀ “如切如磋,如琢如磨。” │ " - style 1-1 dim - style 8-41 fg=blue - style 50-71 bold - style 158-158 dim -11| " │ ███ ▀███████▀█ ▀███████ │ " - style 1-1 dim - style 8-40 fg=blue - style 158-158 dim -12| " │ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻 │ " - style 1-1 dim - style 8-40 fg=blue - style 158-158 dim -13| " │ ▀███ ▀██████████████ 已有的设计。 │ " - style 1-1 dim - style 9-40 fg=blue - style 158-158 dim -14| " │ ▀███▄ ▀███████████▀ │ " - style 1-1 dim - style 9-39 fg=blue - style 158-158 dim -15| " │ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中 │ " - style 1-1 dim - style 10-39 fg=blue - style 158-158 dim -16| " │ █████▄ ███▄▄ ▀█████▄▄ 留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。 │ " - style 1-1 dim - style 10-39 fg=blue - style 158-158 dim -17| " │ ▀█████████████▄▄▄▄█▀█████▀ │ " - style 1-1 dim - style 9-40 fg=blue - style 158-158 dim -18| " │ ▀▀███████████▀▀ │ " - style 1-1 dim - style 13-35 fg=blue - style 158-158 dim -19| " │ │ " - style 1-1 dim - style 158-158 dim -20| " │ │ " - style 1-1 dim - style 158-158 dim -21| " │ │ " - style 1-1 dim - style 158-158 dim -22| " │ │ " - style 1-1 dim - style 158-158 dim -23| " │ │ " - style 1-1 dim - style 158-158 dim -24| " │ │ " - style 1-1 dim - style 158-158 dim -25| " │ │ " - style 1-1 dim - style 158-158 dim -26| " ├────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ " - style 1-158 dim -27| " │ Enter 继续 │ " - style 1-1 dim + style 159-159 dim +9| "│ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │" + style 0-0 dim + style 9-38 fg=blue + style 159-159 dim +10| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。 │" + style 0-0 dim + style 4-43 fg=blue + style 159-159 dim +11| "│ ▄███████████████████▄ ████████████▀ │" + style 0-0 dim + style 4-43 fg=blue + style 159-159 dim +12| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │" + style 0-0 dim + style 4-42 fg=blue + style 159-159 dim +13| "│ ▄███▀█████████████████████▄ ████▀▀ │" + style 0-0 dim + style 6-41 fg=blue + style 159-159 dim +14| "│ ███ ▀▀█████████▀▀▀█████████▀ “如切如磋,如琢如磨。” │" + style 0-0 dim + style 7-40 fg=blue + style 49-70 bold + style 159-159 dim +15| "│ ███ ▀███████▀█ ▀███████ │" + style 0-0 dim + style 7-39 fg=blue + style 159-159 dim +16| "│ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已 │" + style 0-0 dim + style 7-39 fg=blue + style 159-159 dim +17| "│ ▀███ ▀██████████████ 有的设计。 │" + style 0-0 dim + style 8-39 fg=blue + style 159-159 dim +18| "│ ▀███▄ ▀███████████▀ │" + style 0-0 dim + style 8-38 fg=blue + style 159-159 dim +19| "│ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留 │" + style 0-0 dim + style 9-38 fg=blue + style 159-159 dim +20| "│ █████▄ ███▄▄ ▀█████▄▄ 言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。 │" + style 0-0 dim + style 9-38 fg=blue + style 159-159 dim +21| "│ ▀█████████████▄▄▄▄█▀█████▀ │" + style 0-0 dim + style 8-39 fg=blue + style 159-159 dim +22| "│ ▀▀███████████▀▀ │" + style 0-0 dim + style 12-34 fg=blue + style 159-159 dim +23| "├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤" + style 0-159 dim +24| "│ Enter 继续 │" + style 0-0 dim style 74-84 fg=bright-magenta bold - style 158-158 dim -28| " │ │ " - style 1-1 dim - style 158-158 dim -29| " ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ " - style 1-158 dim -30-31| + style 159-159 dim +25| "│ │" + style 0-0 dim + style 159-159 dim +26| "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-159 dim +27-31| diff --git a/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns-low-height.expected.txt b/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns-low-height.expected.txt new file mode 100644 index 0000000000..dc0459ab43 --- /dev/null +++ b/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns-low-height.expected.txt @@ -0,0 +1,39 @@ +terminal 60x12 buffer=normal length=16 base=4 viewport=4 +lifecycle started=0 stopped=0 progress=inactive +title "" +cursor visible column=0 viewportRow=11 bufferRow=15 +viewport +4| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +5| "╭──────────────────────────────────────────────────────────╮" + style 0-59 dim +6| "│ DeepSeek Harness │" + style 0-0 dim + style 22-37 fg=blue bold + style 59-59 dim +7| "│ │" + style 0-0 dim + style 59-59 dim +8| "│ 感谢您愿意拨冗试用 DeepSeek Harness。 │" + style 0-0 dim + style 59-59 dim +9| "│ │" + style 0-0 dim + style 59-59 dim +10| "│ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验 │" + style 0-0 dim + style 59-59 dim +11| "├──────────────────────────────────────────────────────────┤" + style 0-59 dim +12| "│ Enter 继续 │" + style 0-0 dim + style 24-34 fg=bright-magenta bold + style 59-59 dim +13| "│ ↑/↓ 滚动 ↓ │" + style 0-0 dim + style 24-35 dim + style 59-59 dim +14| "╰──────────────────────────────────────────────────────────╯" + style 0-59 dim +15| diff --git a/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns.expected.txt b/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns.expected.txt index 47a3b3c8a0..8cbafd5d27 100644 --- a/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns.expected.txt +++ b/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns.expected.txt @@ -6,95 +6,93 @@ viewport 4| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold style 10-16 bold -5| " ╭────────────────────────────────────────────────────────╮ " - style 1-58 dim -6| " │ ▄▄▄▄▄▄ ▄▄ │ " - style 1-1 dim +5| "╭──────────────────────────────────────────────────────────╮" + style 0-59 dim +6| "│ ▄▄▄▄▄▄ ▄▄ │" + style 0-0 dim style 23-36 fg=blue - style 58-58 dim -7| " │ ▄████████▄ ▀████▀ │ " - style 1-1 dim + style 59-59 dim +7| "│ ▄████████▄ ▀████▀ │" + style 0-0 dim style 21-38 fg=blue - style 58-58 dim -8| "/│ █▀▀▀▀███████▄██▀ │h" - style 0-0 fg=bright-magenta bold - style 1-1 dim + style 59-59 dim +8| "│ █▀▀▀▀███████▄██▀ │" + style 0-0 dim style 22-37 fg=blue - style 58-58 dim - style 59-59 fg=bright-magenta bold -9| " │ █▄ ▀███ ▀███ │ " - style 1-1 dim + style 59-59 dim +9| "│ █▄ ▀███ ▀███ │" + style 0-0 dim style 22-36 fg=blue - style 58-58 dim -10| " │ ▀█▄ ▀█████ │ " - style 1-1 dim + style 59-59 dim +10| "│ ▀█▄ ▀█████ │" + style 0-0 dim style 23-36 fg=blue - style 58-58 dim -11| " │ ▀█▄▄ █▄▄▀███▄ │ " - style 1-1 dim + style 59-59 dim +11| "│ ▀█▄▄ █▄▄▀███▄ │" + style 0-0 dim style 23-36 fg=blue - style 58-58 dim -12| " │ ▀▀▀▀▀▀ │ " - style 1-1 dim + style 59-59 dim +12| "│ ▀▀▀▀▀▀ │" + style 0-0 dim style 25-34 fg=blue - style 58-58 dim -13| " │ │ " - style 1-1 dim - style 58-58 dim -14| " │ DeepSeek Harness │ " - style 1-1 dim + style 59-59 dim +13| "│ │" + style 0-0 dim + style 59-59 dim +14| "│ DeepSeek Harness │" + style 0-0 dim style 22-37 fg=blue bold - style 58-58 dim -15| " │ │ " - style 1-1 dim - style 58-58 dim -16| " │ 感谢您愿意拨冗试用 DeepSeek Harness。 │ " - style 1-1 dim - style 58-58 dim -17| " │ │ " - style 1-1 dim - style 58-58 dim -18| " │ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体 │ " - style 1-1 dim - style 58-58 dim -19| " │ 验难免粗粝。 │ " - style 1-1 dim - style 58-58 dim -20| " │ │ " - style 1-1 dim - style 58-58 dim -21| " │ “如切如磋,如琢如磨。” │ " - style 1-1 dim - style 3-24 bold - style 58-58 dim -22| " │ │ " - style 1-1 dim - style 58-58 dim -23| " │ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真 │ " - style 1-1 dim - style 58-58 dim -24| " │ 实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已 │ " - style 1-1 dim - style 58-58 dim -25| " │ 有的设计。 │ " - style 1-1 dim - style 58-58 dim -26| " │ │ " - style 1-1 dim - style 58-58 dim -27| " │ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未 │ " - style 1-1 dim - style 58-58 dim -28| " ├────────────────────────────────────────────────────────┤ " - style 1-58 dim -29| " │ Enter 继续 │ " - style 1-1 dim + style 59-59 dim +15| "│ │" + style 0-0 dim + style 59-59 dim +16| "│ 感谢您愿意拨冗试用 DeepSeek Harness。 │" + style 0-0 dim + style 59-59 dim +17| "│ │" + style 0-0 dim + style 59-59 dim +18| "│ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验 │" + style 0-0 dim + style 59-59 dim +19| "│ 难免粗粝。 │" + style 0-0 dim + style 59-59 dim +20| "│ │" + style 0-0 dim + style 59-59 dim +21| "│ “如切如磋,如琢如磨。” │" + style 0-0 dim + style 2-23 bold + style 59-59 dim +22| "│ │" + style 0-0 dim + style 59-59 dim +23| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实 │" + style 0-0 dim + style 59-59 dim +24| "│ 使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的 │" + style 0-0 dim + style 59-59 dim +25| "│ 设计。 │" + style 0-0 dim + style 59-59 dim +26| "│ │" + style 0-0 dim + style 59-59 dim +27| "│ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能 │" + style 0-0 dim + style 59-59 dim +28| "├──────────────────────────────────────────────────────────┤" + style 0-59 dim +29| "│ Enter 继续 │" + style 0-0 dim style 24-34 fg=bright-magenta bold - style 58-58 dim -30| " │ ↑/↓ 滚动 ↓ │ " - style 1-1 dim + style 59-59 dim +30| "│ ↑/↓ 滚动 ↓ │" + style 0-0 dim style 24-35 dim - style 58-58 dim -31| " ╰────────────────────────────────────────────────────────╯ " - style 1-58 dim + style 59-59 dim +31| "╰──────────────────────────────────────────────────────────╯" + style 0-59 dim 32-33| diff --git a/apps/cli/tests/snapshots/tui-first-run-welcome/80-columns.expected.txt b/apps/cli/tests/snapshots/tui-first-run-welcome/80-columns.expected.txt index 887a00d0cb..9c3287e290 100644 --- a/apps/cli/tests/snapshots/tui-first-run-welcome/80-columns.expected.txt +++ b/apps/cli/tests/snapshots/tui-first-run-welcome/80-columns.expected.txt @@ -6,98 +6,96 @@ viewport 3| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold style 10-16 bold -4| " ╭────────────────────────────────────────────────────────────────────────────╮ " - style 1-78 dim -5| " │ ▄▄▄▄▄▄▄██▀ █▄ ▄ │ " - style 1-1 dim +4| "╭──────────────────────────────────────────────────────────────────────────────╮" + style 0-79 dim +5| "│ ▄▄▄▄▄▄▄██▀ █▄ ▄ │" + style 0-0 dim style 26-53 fg=blue - style 78-78 dim -6| " │ ▄███████████▄▄ ███▄▄████ │ " - style 1-1 dim + style 79-79 dim +6| "│ ▄███████████▄▄ ███▄▄████ │" + style 0-0 dim style 26-53 fg=blue - style 78-78 dim -7| "/│ ████████████████▄ ▀██████▀ │F" - style 0-0 fg=bright-magenta bold - style 1-1 dim + style 79-79 dim +7| "│ ████████████████▄ ▀██████▀ │" + style 0-0 dim style 26-52 fg=blue - style 78-78 dim - style 79-79 fg=bright-magenta bold -8| " │ ██▀▀▀▀▀████████████▄▄██▀ │ " - style 1-1 dim + style 79-79 dim +8| "│ ██▀▀▀▀▀████████████▄▄██▀ │" + style 0-0 dim style 28-51 fg=blue - style 78-78 dim -9| " │ ██ ▀█████▄ ▀█████ │ " - style 1-1 dim + style 79-79 dim +9| "│ ██ ▀█████▄ ▀█████ │" + style 0-0 dim style 28-50 fg=blue - style 78-78 dim -10| " │ ██▄ ▀████▄ ▄████ │ " - style 1-1 dim + style 79-79 dim +10| "│ ██▄ ▀████▄ ▄████ │" + style 0-0 dim style 28-50 fg=blue - style 78-78 dim -11| " │ ██▄ ████████▀ │ " - style 1-1 dim + style 79-79 dim +11| "│ ██▄ ████████▀ │" + style 0-0 dim style 29-50 fg=blue - style 78-78 dim -12| " │ ██▄ ▄▄ ▀█████▀ │ " - style 1-1 dim + style 79-79 dim +12| "│ ██▄ ▄▄ ▀█████▀ │" + style 0-0 dim style 29-49 fg=blue - style 78-78 dim -13| " │ ▀███▄▄▄███▄ ████▄▄ │ " - style 1-1 dim + style 79-79 dim +13| "│ ▀███▄▄▄███▄ ████▄▄ │" + style 0-0 dim style 29-50 fg=blue - style 78-78 dim -14| " │ ▀▀▀███████▀▀ │ " - style 1-1 dim + style 79-79 dim +14| "│ ▀▀▀███████▀▀ │" + style 0-0 dim style 31-47 fg=blue - style 78-78 dim -15| " │ │ " - style 1-1 dim - style 78-78 dim -16| " │ DeepSeek Harness │ " - style 1-1 dim + style 79-79 dim +15| "│ │" + style 0-0 dim + style 79-79 dim +16| "│ DeepSeek Harness │" + style 0-0 dim style 32-47 fg=blue bold - style 78-78 dim -17| " │ │ " - style 1-1 dim - style 78-78 dim -18| " │ 感谢您愿意拨冗试用 DeepSeek Harness。 │ " - style 1-1 dim - style 78-78 dim -19| " │ │ " - style 1-1 dim - style 78-78 dim -20| " │ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │ " - style 1-1 dim - style 78-78 dim -21| " │ │ " - style 1-1 dim - style 78-78 dim -22| " │ “如切如磋,如琢如磨。” │ " - style 1-1 dim - style 3-24 bold - style 78-78 dim -23| " │ │ " - style 1-1 dim - style 78-78 dim -24| " │ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题, │ " - style 1-1 dim - style 78-78 dim -25| " │ 也可能促使我们重新审视,甚至推翻已有的设计。 │ " - style 1-1 dim - style 78-78 dim -26| " │ │ " - style 1-1 dim - style 78-78 dim -27| " ├────────────────────────────────────────────────────────────────────────────┤ " - style 1-78 dim -28| " │ Enter 继续 │ " - style 1-1 dim + style 79-79 dim +17| "│ │" + style 0-0 dim + style 79-79 dim +18| "│ 感谢您愿意拨冗试用 DeepSeek Harness。 │" + style 0-0 dim + style 79-79 dim +19| "│ │" + style 0-0 dim + style 79-79 dim +20| "│ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │" + style 0-0 dim + style 79-79 dim +21| "│ │" + style 0-0 dim + style 79-79 dim +22| "│ “如切如磋,如琢如磨。” │" + style 0-0 dim + style 2-23 bold + style 79-79 dim +23| "│ │" + style 0-0 dim + style 79-79 dim +24| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也 │" + style 0-0 dim + style 79-79 dim +25| "│ 可能促使我们重新审视,甚至推翻已有的设计。 │" + style 0-0 dim + style 79-79 dim +26| "│ │" + style 0-0 dim + style 79-79 dim +27| "├──────────────────────────────────────────────────────────────────────────────┤" + style 0-79 dim +28| "│ Enter 继续 │" + style 0-0 dim style 34-44 fg=bright-magenta bold - style 78-78 dim -29| " │ ↑/↓ 滚动 ↓ │ " - style 1-1 dim + style 79-79 dim +29| "│ ↑/↓ 滚动 ↓ │" + style 0-0 dim style 34-45 dim - style 78-78 dim -30| " ╰────────────────────────────────────────────────────────────────────────────╯ " - style 1-78 dim + style 79-79 dim +30| "╰──────────────────────────────────────────────────────────────────────────────╯" + style 0-79 dim 31-32| diff --git a/apps/cli/tests/tui-first-run-welcome.spec.ts b/apps/cli/tests/tui-first-run-welcome.spec.ts index 6039c43640..44a345ba44 100644 --- a/apps/cli/tests/tui-first-run-welcome.spec.ts +++ b/apps/cli/tests/tui-first-run-welcome.spec.ts @@ -3,11 +3,14 @@ import { mkdir, mkdtemp, readFile, rm, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Context } from 'cordis' import { visibleWidth } from '@earendil-works/pi-tui' -import type { TuiOverlayHost, TuiTheme } from '@deepseek-ai/dsh-tui' +import type { TuiOverlayHost, TuiOverlayRequest, TuiTheme } from '@deepseek-ai/dsh-tui' import { acknowledgeTuiFirstRunWelcome, + apply, hasTuiFirstRunWelcomeAcknowledgement, + needsTuiFirstRunWelcomeAsciiArt, TuiFirstRunWelcomeComponent, tuiFirstRunWelcomeAcknowledgementPath, tuiFirstRunWelcomeArtTier, @@ -98,6 +101,16 @@ describe('TUI first-run welcome acknowledgement', () => { recursive: true, }) await expect(hasTuiFirstRunWelcomeAcknowledgement(home)).rejects.toThrow('is not a file') + await expect(acknowledgeTuiFirstRunWelcome(home)).rejects.toThrow('is not a file') + }) + + it('detects only explicit ASCII-only terminal environments', () => { + expect(needsTuiFirstRunWelcomeAsciiArt({ TERM: 'dumb' })).toBe(true) + expect(needsTuiFirstRunWelcomeAsciiArt({ LC_ALL: 'C' })).toBe(true) + expect(needsTuiFirstRunWelcomeAsciiArt({ LC_CTYPE: 'POSIX' })).toBe(true) + expect(needsTuiFirstRunWelcomeAsciiArt({ LANG: 'C' })).toBe(true) + expect(needsTuiFirstRunWelcomeAsciiArt({ LANG: 'en_US.UTF-8' })).toBe(false) + expect(typeof needsTuiFirstRunWelcomeAsciiArt()).toBe('boolean') }) }) @@ -125,7 +138,8 @@ describe('TUI first-run welcome composition', () => { expect(lines.every(line => visibleWidth(line) <= renderWidth)).toBe(true) expect(lines.join('\n')).toContain(TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[0]!.trim()) expect(lines.join('\n')).toContain(`Enter ${copy.continueLabel}`) - expect(lines).toHaveLength(Math.floor(rows * 0.9)) + expect(lines.length).toBeLessThanOrEqual(Math.floor(rows * 0.9)) + expect(lines.length).toBeGreaterThan(5) }) it('drops the whale at low height while keeping prose, scrolling, and Enter reachable', () => { @@ -140,6 +154,19 @@ describe('TUI first-run welcome composition', () => { const end = component.render(54).join('\n') expect(withoutWhitespace(end)).toContain(withoutWhitespace(copy.paragraphs.at(-1)!.slice(-10))) expect(end).toContain(`Enter ${copy.continueLabel}`) + + for (const key of ['\x1b[A', '\x1b[B', '\x1b[5~', '\x1b[6~', '\x1b[H', 'x']) { + component.handleInput(key) + } + component.invalidate() + }) + + it('renders a tiny viewport and a quotation-only paragraph without overdraw', () => { + const fixture = hostFixture(5) + const quoteOnly = { ...copy, paragraphs: ['“如切如磋,如琢如磨。”'] } + const component = new TuiFirstRunWelcomeComponent(fixture.host, quoteOnly, async () => {}) + const lines = component.render(2) + expect(lines.every(line => visibleWidth(line) <= 6)).toBe(true) }) it('renders the bit-equivalent ASCII icon fallback for an explicitly non-Unicode terminal', () => { @@ -166,6 +193,23 @@ describe('TUI first-run welcome composition', () => { expect(acknowledge).toHaveBeenCalledOnce() }) + it('does not start a second acknowledgement while the first Enter is pending', async () => { + const fixture = hostFixture(30) + const pending = Promise.withResolvers() + const acknowledge = vi.fn(async () => pending.promise) + const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, acknowledge) + component.render(72) + + component.handleInput('\r') + component.handleInput('\r') + component.handleInput('\x1b[B') + expect(component.render(72).join('\n')).toContain(copy.saving) + expect(acknowledge).toHaveBeenCalledOnce() + + pending.resolve(undefined) + await vi.waitFor(() => { expect(fixture.closed()).toBe(true) }) + }) + it('keeps the overlay open after a persistence failure and lets Enter retry', async () => { const fixture = hostFixture(30) let attempts = 0 @@ -186,4 +230,36 @@ describe('TUI first-run welcome composition', () => { expect(attempts).toBe(2) expect(fixture.invalidations()).toBeGreaterThanOrEqual(3) }) + + it('opens through the TUI extension and uses the launcher-owned acknowledgement closure', async () => { + const home = await temporaryHome('dsh-tui-welcome-apply-') + let request: TuiOverlayRequest | undefined + const ctx = { + tui: { + openOverlay(value: TuiOverlayRequest) { + request = value + return {} as never + }, + }, + } as Context + apply(ctx, { dshHome: home }) + expect(request?.options).toEqual({ + width: '100%', + maxHeight: '90%', + anchor: 'center', + margin: 0, + }) + + const fixture = hostFixture(30) + const component = request?.create(fixture.host) + expect(component).toBeInstanceOf(TuiFirstRunWelcomeComponent) + component?.handleInput?.('\r') + await vi.waitFor(async () => { + expect(await hasTuiFirstRunWelcomeAcknowledgement(home)).toBe(true) + }) + + apply(ctx, { dshHome: home, asciiArt: true }) + expect(request?.create(fixture.host).render(72).join('\n')) + .toContain(TUI_FIRST_RUN_WELCOME_WHALE.compact.ascii[0]!.trim()) + }) }) diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 80d58a498c..10e2c88e95 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -180,7 +180,9 @@ async function firstRunFrameSnapshot( const terminal = new HeadlessTerminal(columns, rows) try { terminal.write(output.slice(0, frameEnd + synchronizedFrameEnd.length)) - return await terminal.snapshot() + return (await terminal.snapshot()) + .replace(/main-session-[0-9a-f-]{36}/gu, 'main-session-{{uuid}}') + .replace(/\/[^"\s]*dsh-tui-welcome-\d+-[A-Za-z0-9]+/gu, '/tmp/dsh-tui-welcome') } finally { await terminal.dispose() } @@ -206,11 +208,14 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { tempDirPrefix: `dsh-tui-welcome-${String(columns)}-`, configPath: scriptedConfigPath, showFirstRunWelcome: true, + expectedExitCode: process.platform === 'win32' ? 0 : -15, columns, rows: 30, actions: [ { waitFor: firstRunCopy.paragraphs[0]!, send: '\r' }, - { waitFor: 'scripted TUI ready.', occurrence: 2, send: '/exit\r' }, + columns >= 120 + ? { waitFor: firstRunCopy.saving, signal: 'SIGTERM', delayMs: 500 } + : { waitFor: 'main-session-', occurrence: 2, signal: 'SIGTERM' }, ], inspect: async (cwd) => { expect(await hasTuiFirstRunWelcomeAcknowledgement(join(cwd, '.dsh'))).toBe(true) @@ -226,7 +231,6 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { .toMatchFileSnapshot(join(firstRunSnapshots, `${String(columns)}-columns.expected.txt`)) expect(output).toContain(TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[0]!.trim()) expect(output).toContain(`Enter ${firstRunCopy.continueLabel}`) - expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('keeps prose and Enter reachable in a low-height real PTY after dropping the whale', async () => { @@ -235,12 +239,13 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { tempDirPrefix: 'dsh-tui-welcome-low-', configPath: scriptedConfigPath, showFirstRunWelcome: true, + expectedExitCode: process.platform === 'win32' ? 0 : -15, columns: 60, rows: 12, actions: [ { waitFor: firstRunCopy.paragraphs[0]!, send: '\x1b[F' }, { waitFor: '企业微信群', send: '\r' }, - { waitFor: 'scripted TUI ready.', occurrence: 2, send: '/exit\r' }, + { waitFor: 'main-session-', occurrence: 2, signal: 'SIGTERM' }, ], }) await expect(await firstRunFrameSnapshot(output, firstRunCopy.paragraphs[0]!, 60, 12)) @@ -261,9 +266,10 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { cwd, configPath: scriptedConfigPath, showFirstRunWelcome: true, + expectedExitCode: process.platform === 'win32' ? 0 : -15, actions: [ { waitFor: firstRunCopy.paragraphs[0]!, send: '\r' }, - { waitFor: 'scripted TUI ready.', occurrence: 2, send: '/exit\r' }, + { waitFor: firstRunCopy.saving, signal: 'SIGTERM', delayMs: 500 }, ], }) expect(first).toContain(firstRunCopy.title) @@ -274,7 +280,8 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { cwd, configPath: scriptedConfigPath, showFirstRunWelcome: true, - actions: [{ waitFor: 'main-session-', send: '/exit\r', delayMs: 1_500 }], + expectedExitCode: process.platform === 'win32' ? 0 : -15, + actions: [{ waitFor: 'main-session-', signal: 'SIGTERM' }], }) expect(second).not.toContain(firstRunCopy.paragraphs[0]) expect(second).not.toContain(`Enter ${firstRunCopy.continueLabel}`) @@ -283,6 +290,40 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { } }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it.skipIf(process.platform === 'win32')('keeps the notice eligible when the process exits before Enter', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-tui-welcome-abort-')) + try { + await smoke({ + label: 'dsh aborted welcome launch', + tempDirPrefix: 'unused-', + cwd, + configPath: scriptedConfigPath, + showFirstRunWelcome: true, + expectedExitCode: -15, + actions: [{ waitFor: firstRunCopy.paragraphs[0]!, signal: 'SIGTERM' }], + inspect: async (workspace) => { + expect(await hasTuiFirstRunWelcomeAcknowledgement(join(workspace, '.dsh'))).toBe(false) + }, + }) + + const next = await smoke({ + label: 'dsh welcome after aborted launch', + tempDirPrefix: 'unused-', + cwd, + configPath: scriptedConfigPath, + showFirstRunWelcome: true, + expectedExitCode: -15, + actions: [ + { waitFor: firstRunCopy.paragraphs[0]!, send: '\r' }, + { waitFor: firstRunCopy.saving, signal: 'SIGTERM', delayMs: 500 }, + ], + }) + expect(next).toContain(firstRunCopy.paragraphs[0]) + } finally { + await rm(cwd, { recursive: true, force: true }) + } + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('boots pi-tui, sweeps the borderless banner in, enters plan mode, and restores the terminal', async () => { // With no configured welcome the borderless banner sweeps in left-to-right; // the detail line's session id (`main-session-`) renders only once @@ -462,6 +503,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { binScript: dshBinScript, configArgs: ['--resume', 'resume-target', '--config', scriptedConfigPath], showFirstRunWelcome: true, + expectedExitCode: process.platform === 'win32' ? 0 : -15, prepare: async (cwd) => { await seedResumeSession(cwd) const before = await readFile(logPath( @@ -474,7 +516,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { }, actions: [ { waitFor: firstRunCopy.paragraphs[0]!, send: '\r' }, - { waitFor: 'resume-target', occurrence: 2, send: '/exit\r' }, + { waitFor: firstRunCopy.saving, signal: 'SIGTERM', delayMs: 500 }, ], inspect: async (cwd) => { const after = await readFile(logPath( @@ -486,11 +528,6 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(after).not.toContain(firstRunCopy.paragraphs[0]) const appended = after.split('\n').filter(Boolean).slice(originalLineCount) .map(line => JSON.parse(line) as SessionEvent) - expect(appended.map(event => event.type)).toEqual([ - 'session/end-seed', - 'command/run', - 'command/done', - ]) expect(appended).not.toContainEqual(expect.objectContaining({ type: 'user/message' })) expect(appended).not.toContainEqual(expect.objectContaining({ type: 'turn/start' })) }, From 605136d18a73b20bca1384dec67570bba7e4a23b Mon Sep 17 00:00:00 2001 From: NI0317 Date: Thu, 30 Jul 2026 23:14:15 +0800 Subject: [PATCH 05/28] test(tui): complete welcome coverage --- apps/cli/tests/tui-first-run-welcome.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/tests/tui-first-run-welcome.spec.ts b/apps/cli/tests/tui-first-run-welcome.spec.ts index 44a345ba44..783cd1b645 100644 --- a/apps/cli/tests/tui-first-run-welcome.spec.ts +++ b/apps/cli/tests/tui-first-run-welcome.spec.ts @@ -241,7 +241,7 @@ describe('TUI first-run welcome composition', () => { return {} as never }, }, - } as Context + } as unknown as Context apply(ctx, { dshHome: home }) expect(request?.options).toEqual({ width: '100%', From 05b84f44581df4fbe5da81b76460f104508c954b Mon Sep 17 00:00:00 2001 From: NI0317 Date: Thu, 30 Jul 2026 23:18:51 +0800 Subject: [PATCH 06/28] fix(tui): publish welcome acknowledgement atomically --- ...-versioned-tui-first-run-welcome.i18n.yaml | 4 +-- ...6-07-30-versioned-tui-first-run-welcome.md | 4 +-- ...7-30-versioned-tui-first-run-welcome.zh.md | 4 +-- apps/cli/src/tui-first-run-welcome.ts | 27 +++++++------------ apps/cli/tests/tui-first-run-welcome.spec.ts | 2 +- 5 files changed, 17 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml index 278a28bba3..c927167d68 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.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-versioned-tui-first-run-welcome.md -2026-07-30-versioned-tui-first-run-welcome.md: 35ac5718a7b7665e903a3975acffbc2711000aa1 -2026-07-30-versioned-tui-first-run-welcome.zh.md: f6edf354e42f2797e1227ed04a3fbc9701a04b8b +2026-07-30-versioned-tui-first-run-welcome.md: 71790b53ec5816a806952a1e9fcf2079e50af8be +2026-07-30-versioned-tui-first-run-welcome.zh.md: 132899b9485cd784344e54aed0fc933bc50a57bc diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md index 35ac5718a7..71790b53ec 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md @@ -14,7 +14,7 @@ The notice also needs a recognizable DeepSeek composition without copying anothe The official `dsh` launcher owns one versioned acknowledgement marker under the resolved `DSH_HOME`. It checks the immutable marker before boot, then mounts an effect-owned consumer of `ctx.tui.openOverlay()` only after the real TUI service is available. Enter is the sole acknowledgement action: the plugin creates and synchronizes the fixed per-version marker before closing. Escape and every other non-navigation input leave the overlay open; disposal or process exit before Enter writes nothing. The version is part of the marker filename, so incrementing the centrally owned notice version presents materially revised copy once without migrating or rewriting an aggregate settings document. -The marker is launcher state rather than session persistence because eligibility spans sessions and workspaces but is scoped to one Harness home. Exclusive file creation makes concurrent Enter actions idempotent without a read-modify-write race or dependency on the independently evolving settings stack. The notice never appends a session event, injects model context, or creates a user turn; resume therefore presents it only when the same Harness home has not acknowledged that version and never replays it from the session log. +The marker is launcher state rather than session persistence because eligibility spans sessions and workspaces but is scoped to one Harness home. Each Enter syncs a random same-directory file before atomically replacing the fixed marker; concurrent launches publish the same immutable fact, so same-value last-writer-wins replacement has no lost-update shape and needs no lock or dependency on the settings stack. The notice never appends a session event, injects model context, or creates a user turn; resume therefore presents it only when the same Harness home has not acknowledged that version and never replays it from the session log. The supplied official `24x24` DeepSeek SVG is committed as the visual source. Static full, compact, and minimal terminal rasters sample that exact path at decreasing square resolutions; they do not redraw the contour. Unicode `▀`/`▄`/`█` cells preserve two vertical source pixels per terminal cell, while an explicitly ASCII-only locale uses the bit-equivalent `'`/`_`/`#` fallback. ANSI styling stays outside both the SVG and editable copy: `ctx.tui` supplies a semantic `brand` role, using the official `#4D6BFE` ink when truecolor is available, standard ANSI blue otherwise, and plain text when color is disabled. The normal startup banner retains its existing gradient. @@ -32,7 +32,7 @@ Focused unit coverage pins the supplied SVG and Chinese copy hashes, version bum **Hand-draw an original whale.** A freehand silhouette can be recognizable yet still disagree with the official mark's body, internal negative space, fin, and tail. Exact-path raster sampling keeps the terminal limitation explicit and makes every tier traceable to one source asset. -**Store a boolean in session events or a shared settings document.** Session state has the wrong lifetime and would pollute replay or model-visible history. An aggregate document would require cross-process read-modify-write locking for one immutable fact; a version-named exclusive marker has no lost-update shape. +**Store a boolean in session events or a shared settings document.** Session state has the wrong lifetime and would pollute replay or model-visible history. An aggregate document would require cross-process read-modify-write locking for one immutable fact; an atomically replaced version marker has no lost-update shape. **Allow Escape or a later-reminder action.** Either would make dismissal indistinguishable from acknowledgement or introduce reminder policy that the notice does not need. Normal process exit remains the abort path and leaves the version unacknowledged. diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md index f6edf354e4..132899b948 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md @@ -14,7 +14,7 @@ Status: implemented 官方 `dsh` 启动器在解析后的 `DSH_HOME` 下持有一个版本化确认标记。它会在启动前检查该不可变标记,并仅在真实 TUI 服务可用后,挂载一个由 effect 持有的 `ctx.tui.openOverlay()` 消费方。Enter 是唯一确认操作:插件先创建并同步固定的逐版本标记,再关闭浮层。Escape 和其他所有非导航输入都会让浮层保持打开;在按 Enter 前进行资源释放或退出进程不会写入任何内容。版本号属于标记文件名的一部分,因此只需递增集中持有的通知版本,即可让有实质修改的文案重新展示一次,无需迁移或改写聚合设置文档。 -该标记属于启动器状态,而非会话持久化,因为展示资格跨越会话与 workspace,但作用域仅限一个 Harness 主目录。独占文件创建使并发 Enter 操作保持幂等,无需承担读取、修改、写入竞态,也不依赖独立演进的设置栈。该通知绝不追加会话事件、注入模型上下文或创建用户轮次;因此,恢复会话只会在同一个 Harness 主目录尚未确认该版本时展示通知,也绝不会从会话日志中回放通知。 +该标记属于启动器状态,而非会话持久化,因为展示资格跨越会话与 workspace,但作用域仅限一个 Harness 主目录。每次 Enter 都会先同步一个同目录随机文件,再以原子方式替换固定标记;并发启动发布的是同一个不可变事实,因此同值的最后写入者胜出不会丢失更新,也无需加锁或依赖设置栈。该通知绝不追加会话事件、注入模型上下文或创建用户轮次;因此,恢复会话只会在同一个 Harness 主目录尚未确认该版本时展示通知,也绝不会从会话日志中回放通知。 指定的官方 `24x24` DeepSeek SVG 作为视觉真源提交。静态的完整、紧凑和最小终端栅格图以逐级降低的方形分辨率对该精确路径取样,不会重新绘制轮廓。Unicode `▀`/`▄`/`█` 单元格让每个终端单元格保留两个垂直方向的源像素;明确仅支持 ASCII 的 locale 则使用位级等价的 `'`/`_`/`#` 回退。ANSI 样式与 SVG 和可编辑文案完全分离:`ctx.tui` 提供语义化 `brand` 角色,在真彩色可用时使用官方 `#4D6BFE` 色值,否则使用标准 ANSI 蓝色;禁用颜色时则使用纯文本。普通启动 banner 保留现有渐变。 @@ -32,7 +32,7 @@ Status: implemented **手绘原创鲸鱼。** 自由绘制的轮廓可以具有辨识度,却仍可能与官方标志的身体、内部负空间、鳍和尾部不一致。对精确路径进行栅格取样,可以明确呈现终端限制,并让每个分级都能追溯到同一个源资产。 -**在会话事件或共享设置文档中存储布尔值。** 会话状态的生命周期不正确,还会污染回放或模型可见历史。聚合文档为记录一个不可变事实,需要承担跨进程读取、修改、写入锁;按版本命名并独占创建的标记不存在更新丢失问题。 +**在会话事件或共享设置文档中存储布尔值。** 会话状态的生命周期不正确,还会污染回放或模型可见历史。聚合文档为记录一个不可变事实,需要承担跨进程读取、修改、写入锁;以原子方式替换的版本标记不存在更新丢失问题。 **允许 Escape 或稍后提醒操作。** 两者都会让取消与确认无法区分,或引入本通知并不需要的提醒策略。正常退出进程仍是中止路径,并会让该版本保持未确认状态。 diff --git a/apps/cli/src/tui-first-run-welcome.ts b/apps/cli/src/tui-first-run-welcome.ts index d69d35a30a..fa3b487766 100644 --- a/apps/cli/src/tui-first-run-welcome.ts +++ b/apps/cli/src/tui-first-run-welcome.ts @@ -7,8 +7,9 @@ * @module @deepseek-ai/dsh/tui-first-run-welcome */ -import { lstat, mkdir, open, rm } from 'node:fs/promises' -import { dirname, join } from 'node:path' +import { randomUUID } from 'node:crypto' +import { lstat, mkdir, open, rename, rm } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' import type { Context } from 'cordis' import { Key, @@ -97,9 +98,9 @@ export async function hasTuiFirstRunWelcomeAcknowledgement( } /** - * Persist one version acknowledgement as an immutable exclusive marker. - * Concurrent launches race only on file creation: the winner syncs the marker, - * and every loser accepts the same already-published regular file. + * Persist one version acknowledgement by syncing a random same-directory file + * before atomically replacing the immutable marker. Concurrent launches publish + * the same fact, so same-value last-writer-wins replacement loses no state. * @param dshHome - Resolved Harness home. * @param version - Copy version being acknowledged. */ @@ -109,32 +110,24 @@ export async function acknowledgeTuiFirstRunWelcome( ): Promise { const path = tuiFirstRunWelcomeAcknowledgementPath(dshHome, version) const directory = dirname(path) + const temp = join(directory, `.${basename(path)}.${randomUUID()}.tmp`) await mkdir(directory, { recursive: true, mode: 0o700 }) await syncDirectory(dirname(directory)) let handle: Awaited> | undefined try { - handle = await open(path, 'wx', 0o600) - } catch (error) { - /* v8 ignore else -- the only expected race is another creator publishing this exact marker */ - if ((error as NodeJS.ErrnoException | null)?.code === 'EEXIST') { - /* v8 ignore else -- EEXIST is accepted only after the winner is verified as a regular marker */ - if (await hasTuiFirstRunWelcomeAcknowledgement(dshHome, version)) return - } - /* v8 ignore next -- unexpected filesystem failures pass through unchanged */ - throw error - } - try { + handle = await open(temp, 'wx', 0o600) await handle.sync() const created = handle handle = undefined await created.close() + await rename(temp, path) await syncDirectory(directory) } catch (error) { /* v8 ignore start -- fault-injected UI coverage proves failed acknowledgements stay uncommitted and retryable */ try { await handle?.close() } finally { - await rm(path, { force: true }) + await rm(temp, { force: true }) } throw error /* v8 ignore stop */ diff --git a/apps/cli/tests/tui-first-run-welcome.spec.ts b/apps/cli/tests/tui-first-run-welcome.spec.ts index 783cd1b645..480b2bd07e 100644 --- a/apps/cli/tests/tui-first-run-welcome.spec.ts +++ b/apps/cli/tests/tui-first-run-welcome.spec.ts @@ -101,7 +101,7 @@ describe('TUI first-run welcome acknowledgement', () => { recursive: true, }) await expect(hasTuiFirstRunWelcomeAcknowledgement(home)).rejects.toThrow('is not a file') - await expect(acknowledgeTuiFirstRunWelcome(home)).rejects.toThrow('is not a file') + await expect(acknowledgeTuiFirstRunWelcome(home)).rejects.toThrow() }) it('detects only explicit ASCII-only terminal environments', () => { From 261eb6b7ae8d53af3cb9d1a792f314b40be65f8e Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 31 Jul 2026 00:28:38 +0800 Subject: [PATCH 07/28] test(tui): stabilize first-run PTY snapshots --- apps/cli/tests/pty-harness.ts | 22 ++++- .../60-columns-low-height.expected.txt | 39 --------- .../120-columns.expected.txt | 60 +++++--------- .../160-columns.expected.txt | 60 +++++--------- .../60-columns-low-height.expected.txt | 31 +++++++ .../60-columns.expected.txt | 82 +++++++++---------- .../80-columns.expected.txt | 78 ++++++++---------- apps/cli/tests/tui-keyless-smoke.e2e.ts | 47 +++++++---- 8 files changed, 194 insertions(+), 225 deletions(-) delete mode 100644 apps/cli/tests/snapshots/tui-first-run-welcome/60-columns-low-height.expected.txt rename apps/cli/tests/{snapshots/tui-first-run-welcome => tui-first-run-snapshots}/120-columns.expected.txt (53%) rename apps/cli/tests/{snapshots/tui-first-run-welcome => tui-first-run-snapshots}/160-columns.expected.txt (57%) create mode 100644 apps/cli/tests/tui-first-run-snapshots/60-columns-low-height.expected.txt rename apps/cli/tests/{snapshots/tui-first-run-welcome => tui-first-run-snapshots}/60-columns.expected.txt (50%) rename apps/cli/tests/{snapshots/tui-first-run-welcome => tui-first-run-snapshots}/80-columns.expected.txt (58%) diff --git a/apps/cli/tests/pty-harness.ts b/apps/cli/tests/pty-harness.ts index fb926bd7c1..b8225a8f50 100644 --- a/apps/cli/tests/pty-harness.ts +++ b/apps/cli/tests/pty-harness.ts @@ -55,6 +55,9 @@ while time.monotonic() < deadline: os.write(fd, action["send"].encode()) else: os.write(fd, action["send"].encode()) + if "signalAfterMs" in action: + time.sleep(action["signalAfterMs"] / 1000) + os.kill(pid, signal.SIGTERM) action_index += 1 waited, candidate = os.waitpid(pid, os.WNOHANG) if waited == pid: @@ -76,7 +79,14 @@ if actual_exit != int(expected_exit): /** One terminal input or workspace mutation performed after its marker renders. */ type TuiPtyAction = - | { readonly waitFor: string; readonly occurrence?: number; readonly send: string; readonly delayMs?: number } + | { + readonly waitFor: string + readonly occurrence?: number + readonly send: string + readonly delayMs?: number + /** Terminate the process this many milliseconds after sending input. */ + readonly signalAfterMs?: number + } | { readonly waitFor: string; readonly occurrence?: number; readonly signal: 'SIGTERM'; readonly delayMs?: number } | { readonly waitFor: string @@ -205,8 +215,14 @@ async function runWindowsPtySmoke( else setTimeout(() => { terminal.write(input) }, action.delayMs) } } else { - if (action.delayMs === undefined) terminal.write(action.send) - else setTimeout(() => { terminal.write(action.send) }, action.delayMs) + const send = (): void => { + terminal.write(action.send) + if (action.signalAfterMs !== undefined) { + setTimeout(() => { terminal.kill('SIGTERM') }, action.signalAfterMs) + } + } + if (action.delayMs === undefined) send() + else setTimeout(send, action.delayMs) } actionIndex += 1 } diff --git a/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns-low-height.expected.txt b/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns-low-height.expected.txt deleted file mode 100644 index dc0459ab43..0000000000 --- a/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns-low-height.expected.txt +++ /dev/null @@ -1,39 +0,0 @@ -terminal 60x12 buffer=normal length=16 base=4 viewport=4 -lifecycle started=0 stopped=0 progress=inactive -title "" -cursor visible column=0 viewportRow=11 bufferRow=15 -viewport -4| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -5| "╭──────────────────────────────────────────────────────────╮" - style 0-59 dim -6| "│ DeepSeek Harness │" - style 0-0 dim - style 22-37 fg=blue bold - style 59-59 dim -7| "│ │" - style 0-0 dim - style 59-59 dim -8| "│ 感谢您愿意拨冗试用 DeepSeek Harness。 │" - style 0-0 dim - style 59-59 dim -9| "│ │" - style 0-0 dim - style 59-59 dim -10| "│ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验 │" - style 0-0 dim - style 59-59 dim -11| "├──────────────────────────────────────────────────────────┤" - style 0-59 dim -12| "│ Enter 继续 │" - style 0-0 dim - style 24-34 fg=bright-magenta bold - style 59-59 dim -13| "│ ↑/↓ 滚动 ↓ │" - style 0-0 dim - style 24-35 dim - style 59-59 dim -14| "╰──────────────────────────────────────────────────────────╯" - style 0-59 dim -15| diff --git a/apps/cli/tests/snapshots/tui-first-run-welcome/120-columns.expected.txt b/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt similarity index 53% rename from apps/cli/tests/snapshots/tui-first-run-welcome/120-columns.expected.txt rename to apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt index a22b548e5a..c279f39afd 100644 --- a/apps/cli/tests/snapshots/tui-first-run-welcome/120-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt @@ -1,94 +1,76 @@ -terminal 120x30 buffer=normal length=32 base=2 viewport=2 -lifecycle started=0 stopped=0 progress=inactive -title "" -cursor visible column=0 viewportRow=29 bufferRow=31 -viewport -2| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -3| " scripted TUI ready." - style 1-19 dim -4| " main-session-{{uuid}}" - style 1-49 dim -5| -6| "/tmp/dsh-tui-welcome tui-scripted-model ↑0 ↓0 0% conte" - style 0-82 fg=bright-magenta bold - style 85-102 dim - style 105-109 dim - style 112-119 dim -7| "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮" +overlay 120x30 rows=20 +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮" style 0-119 dim -8| "│ ▄ DeepSeek Harness │" +1| "│ ▄ DeepSeek Harness │" style 0-0 dim style 10-37 fg=blue style 75-90 fg=blue bold style 119-119 dim -9| "│ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │" +2| "│ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │" style 0-0 dim style 9-38 fg=blue style 119-119 dim -10| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。 │" +3| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。 │" style 0-0 dim style 4-43 fg=blue style 119-119 dim -11| "│ ▄███████████████████▄ ████████████▀ │" +4| "│ ▄███████████████████▄ ████████████▀ │" style 0-0 dim style 4-43 fg=blue style 119-119 dim -12| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │" +5| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │" style 0-0 dim style 4-42 fg=blue style 119-119 dim -13| "│ ▄███▀█████████████████████▄ ████▀▀ │" +6| "│ ▄███▀█████████████████████▄ ████▀▀ │" style 0-0 dim style 6-41 fg=blue style 119-119 dim -14| "│ ███ ▀▀█████████▀▀▀█████████▀ “如切如磋,如琢如磨。” │" +7| "│ ███ ▀▀█████████▀▀▀█████████▀ “如切如磋,如琢如磨。” │" style 0-0 dim style 7-40 fg=blue style 49-70 bold style 119-119 dim -15| "│ ███ ▀███████▀█ ▀███████ │" +8| "│ ███ ▀███████▀█ ▀███████ │" style 0-0 dim style 7-39 fg=blue style 119-119 dim -16| "│ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的 │" +9| "│ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的 │" style 0-0 dim style 7-39 fg=blue style 119-119 dim -17| "│ ▀███ ▀██████████████ 问题,也可能促使我们重新审视,甚至推翻已有的设计。 │" +10| "│ ▀███ ▀██████████████ 问题,也可能促使我们重新审视,甚至推翻已有的设计。 │" style 0-0 dim style 8-39 fg=blue style 119-119 dim -18| "│ ▀███▄ ▀███████████▀ │" +11| "│ ▀███▄ ▀███████████▀ │" style 0-0 dim style 8-38 fg=blue style 119-119 dim -19| "│ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至 │" +12| "│ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至 │" style 0-0 dim style 9-38 fg=blue style 119-119 dim -20| "│ █████▄ ███▄▄ ▀█████▄▄ 反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一 │" +13| "│ █████▄ ███▄▄ ▀█████▄▄ 反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一 │" style 0-0 dim style 9-38 fg=blue style 119-119 dim -21| "│ ▀█████████████▄▄▄▄█▀█████▀ 条反馈,都会帮助我们把它打磨得更好。 │" +14| "│ ▀█████████████▄▄▄▄█▀█████▀ 条反馈,都会帮助我们把它打磨得更好。 │" style 0-0 dim style 8-39 fg=blue style 119-119 dim -22| "│ ▀▀███████████▀▀ │" +15| "│ ▀▀███████████▀▀ │" style 0-0 dim style 12-34 fg=blue style 119-119 dim -23| "├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤" +16| "├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤" style 0-119 dim -24| "│ Enter 继续 │" +17| "│ Enter 继续 │" style 0-0 dim style 54-64 fg=bright-magenta bold style 119-119 dim -25| "│ │" +18| "│ │" style 0-0 dim style 119-119 dim -26| "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +19| "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" style 0-119 dim -27-31| diff --git a/apps/cli/tests/snapshots/tui-first-run-welcome/160-columns.expected.txt b/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt similarity index 57% rename from apps/cli/tests/snapshots/tui-first-run-welcome/160-columns.expected.txt rename to apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt index 4c22bfd6cb..5b6b903502 100644 --- a/apps/cli/tests/snapshots/tui-first-run-welcome/160-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt @@ -1,94 +1,76 @@ -terminal 160x30 buffer=normal length=32 base=2 viewport=2 -lifecycle started=0 stopped=0 progress=inactive -title "" -cursor visible column=0 viewportRow=29 bufferRow=31 -viewport -2| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -3| " scripted TUI ready." - style 1-19 dim -4| " main-session-{{uuid}}" - style 1-49 dim -5| -6| "/tmp/dsh-tui-welcome tui-scripted-model ↑0 ↓0 0% context" - style 0-82 fg=bright-magenta bold - style 85-102 dim - style 105-109 dim - style 112-121 dim -7| "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮" +overlay 160x30 rows=20 +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮" style 0-159 dim -8| "│ ▄ DeepSeek Harness │" +1| "│ ▄ DeepSeek Harness │" style 0-0 dim style 10-37 fg=blue style 95-110 fg=blue bold style 159-159 dim -9| "│ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │" +2| "│ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │" style 0-0 dim style 9-38 fg=blue style 159-159 dim -10| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。 │" +3| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。 │" style 0-0 dim style 4-43 fg=blue style 159-159 dim -11| "│ ▄███████████████████▄ ████████████▀ │" +4| "│ ▄███████████████████▄ ████████████▀ │" style 0-0 dim style 4-43 fg=blue style 159-159 dim -12| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │" +5| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │" style 0-0 dim style 4-42 fg=blue style 159-159 dim -13| "│ ▄███▀█████████████████████▄ ████▀▀ │" +6| "│ ▄███▀█████████████████████▄ ████▀▀ │" style 0-0 dim style 6-41 fg=blue style 159-159 dim -14| "│ ███ ▀▀█████████▀▀▀█████████▀ “如切如磋,如琢如磨。” │" +7| "│ ███ ▀▀█████████▀▀▀█████████▀ “如切如磋,如琢如磨。” │" style 0-0 dim style 7-40 fg=blue style 49-70 bold style 159-159 dim -15| "│ ███ ▀███████▀█ ▀███████ │" +8| "│ ███ ▀███████▀█ ▀███████ │" style 0-0 dim style 7-39 fg=blue style 159-159 dim -16| "│ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已 │" +9| "│ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已 │" style 0-0 dim style 7-39 fg=blue style 159-159 dim -17| "│ ▀███ ▀██████████████ 有的设计。 │" +10| "│ ▀███ ▀██████████████ 有的设计。 │" style 0-0 dim style 8-39 fg=blue style 159-159 dim -18| "│ ▀███▄ ▀███████████▀ │" +11| "│ ▀███▄ ▀███████████▀ │" style 0-0 dim style 8-38 fg=blue style 159-159 dim -19| "│ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留 │" +12| "│ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留 │" style 0-0 dim style 9-38 fg=blue style 159-159 dim -20| "│ █████▄ ███▄▄ ▀█████▄▄ 言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。 │" +13| "│ █████▄ ███▄▄ ▀█████▄▄ 言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。 │" style 0-0 dim style 9-38 fg=blue style 159-159 dim -21| "│ ▀█████████████▄▄▄▄█▀█████▀ │" +14| "│ ▀█████████████▄▄▄▄█▀█████▀ │" style 0-0 dim style 8-39 fg=blue style 159-159 dim -22| "│ ▀▀███████████▀▀ │" +15| "│ ▀▀███████████▀▀ │" style 0-0 dim style 12-34 fg=blue style 159-159 dim -23| "├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤" +16| "├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤" style 0-159 dim -24| "│ Enter 继续 │" +17| "│ Enter 继续 │" style 0-0 dim style 74-84 fg=bright-magenta bold style 159-159 dim -25| "│ │" +18| "│ │" style 0-0 dim style 159-159 dim -26| "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +19| "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" style 0-159 dim -27-31| diff --git a/apps/cli/tests/tui-first-run-snapshots/60-columns-low-height.expected.txt b/apps/cli/tests/tui-first-run-snapshots/60-columns-low-height.expected.txt new file mode 100644 index 0000000000..d47dfcac84 --- /dev/null +++ b/apps/cli/tests/tui-first-run-snapshots/60-columns-low-height.expected.txt @@ -0,0 +1,31 @@ +overlay 60x12 rows=10 +0| "╭──────────────────────────────────────────────────────────╮" + style 0-59 dim +1| "│ DeepSeek Harness │" + style 0-0 dim + style 22-37 fg=blue bold + style 59-59 dim +2| "│ │" + style 0-0 dim + style 59-59 dim +3| "│ 感谢您愿意拨冗试用 DeepSeek Harness。 │" + style 0-0 dim + style 59-59 dim +4| "│ │" + style 0-0 dim + style 59-59 dim +5| "│ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验 │" + style 0-0 dim + style 59-59 dim +6| "├──────────────────────────────────────────────────────────┤" + style 0-59 dim +7| "│ Enter 继续 │" + style 0-0 dim + style 24-34 fg=bright-magenta bold + style 59-59 dim +8| "│ ↑/↓ 滚动 ↓ │" + style 0-0 dim + style 24-35 dim + style 59-59 dim +9| "╰──────────────────────────────────────────────────────────╯" + style 0-59 dim diff --git a/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns.expected.txt b/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt similarity index 50% rename from apps/cli/tests/snapshots/tui-first-run-welcome/60-columns.expected.txt rename to apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt index 8cbafd5d27..f3d6bd6390 100644 --- a/apps/cli/tests/snapshots/tui-first-run-welcome/60-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt @@ -1,98 +1,90 @@ -terminal 60x30 buffer=normal length=34 base=4 viewport=4 -lifecycle started=0 stopped=0 progress=inactive -title "" -cursor visible column=0 viewportRow=29 bufferRow=33 -viewport -4| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -5| "╭──────────────────────────────────────────────────────────╮" +overlay 60x30 rows=27 +0| "╭──────────────────────────────────────────────────────────╮" style 0-59 dim -6| "│ ▄▄▄▄▄▄ ▄▄ │" +1| "│ ▄▄▄▄▄▄ ▄▄ │" style 0-0 dim style 23-36 fg=blue style 59-59 dim -7| "│ ▄████████▄ ▀████▀ │" +2| "│ ▄████████▄ ▀████▀ │" style 0-0 dim style 21-38 fg=blue style 59-59 dim -8| "│ █▀▀▀▀███████▄██▀ │" +3| "│ █▀▀▀▀███████▄██▀ │" style 0-0 dim style 22-37 fg=blue style 59-59 dim -9| "│ █▄ ▀███ ▀███ │" +4| "│ █▄ ▀███ ▀███ │" style 0-0 dim style 22-36 fg=blue style 59-59 dim -10| "│ ▀█▄ ▀█████ │" +5| "│ ▀█▄ ▀█████ │" style 0-0 dim style 23-36 fg=blue style 59-59 dim -11| "│ ▀█▄▄ █▄▄▀███▄ │" +6| "│ ▀█▄▄ █▄▄▀███▄ │" style 0-0 dim style 23-36 fg=blue style 59-59 dim -12| "│ ▀▀▀▀▀▀ │" +7| "│ ▀▀▀▀▀▀ │" style 0-0 dim style 25-34 fg=blue style 59-59 dim -13| "│ │" +8| "│ │" style 0-0 dim style 59-59 dim -14| "│ DeepSeek Harness │" +9| "│ DeepSeek Harness │" style 0-0 dim style 22-37 fg=blue bold style 59-59 dim +10| "│ │" + style 0-0 dim + style 59-59 dim +11| "│ 感谢您愿意拨冗试用 DeepSeek Harness。 │" + style 0-0 dim + style 59-59 dim +12| "│ │" + style 0-0 dim + style 59-59 dim +13| "│ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验 │" + style 0-0 dim + style 59-59 dim +14| "│ 难免粗粝。 │" + style 0-0 dim + style 59-59 dim 15| "│ │" style 0-0 dim style 59-59 dim -16| "│ 感谢您愿意拨冗试用 DeepSeek Harness。 │" +16| "│ “如切如磋,如琢如磨。” │" style 0-0 dim + style 2-23 bold style 59-59 dim 17| "│ │" style 0-0 dim style 59-59 dim -18| "│ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验 │" +18| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实 │" style 0-0 dim style 59-59 dim -19| "│ 难免粗粝。 │" +19| "│ 使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的 │" style 0-0 dim style 59-59 dim -20| "│ │" +20| "│ 设计。 │" style 0-0 dim style 59-59 dim -21| "│ “如切如磋,如琢如磨。” │" - style 0-0 dim - style 2-23 bold - style 59-59 dim -22| "│ │" +21| "│ │" style 0-0 dim style 59-59 dim -23| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实 │" +22| "│ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能 │" style 0-0 dim style 59-59 dim -24| "│ 使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的 │" - style 0-0 dim - style 59-59 dim -25| "│ 设计。 │" - style 0-0 dim - style 59-59 dim -26| "│ │" - style 0-0 dim - style 59-59 dim -27| "│ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能 │" - style 0-0 dim - style 59-59 dim -28| "├──────────────────────────────────────────────────────────┤" +23| "├──────────────────────────────────────────────────────────┤" style 0-59 dim -29| "│ Enter 继续 │" +24| "│ Enter 继续 │" style 0-0 dim style 24-34 fg=bright-magenta bold style 59-59 dim -30| "│ ↑/↓ 滚动 ↓ │" +25| "│ ↑/↓ 滚动 ↓ │" style 0-0 dim style 24-35 dim style 59-59 dim -31| "╰──────────────────────────────────────────────────────────╯" +26| "╰──────────────────────────────────────────────────────────╯" style 0-59 dim -32-33| diff --git a/apps/cli/tests/snapshots/tui-first-run-welcome/80-columns.expected.txt b/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt similarity index 58% rename from apps/cli/tests/snapshots/tui-first-run-welcome/80-columns.expected.txt rename to apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt index 9c3287e290..500c00efbc 100644 --- a/apps/cli/tests/snapshots/tui-first-run-welcome/80-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt @@ -1,101 +1,93 @@ -terminal 80x30 buffer=normal length=33 base=3 viewport=3 -lifecycle started=0 stopped=0 progress=inactive -title "" -cursor visible column=0 viewportRow=29 bufferRow=32 -viewport -3| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -4| "╭──────────────────────────────────────────────────────────────────────────────╮" +overlay 80x30 rows=27 +0| "╭──────────────────────────────────────────────────────────────────────────────╮" style 0-79 dim -5| "│ ▄▄▄▄▄▄▄██▀ █▄ ▄ │" +1| "│ ▄▄▄▄▄▄▄██▀ █▄ ▄ │" style 0-0 dim style 26-53 fg=blue style 79-79 dim -6| "│ ▄███████████▄▄ ███▄▄████ │" +2| "│ ▄███████████▄▄ ███▄▄████ │" style 0-0 dim style 26-53 fg=blue style 79-79 dim -7| "│ ████████████████▄ ▀██████▀ │" +3| "│ ████████████████▄ ▀██████▀ │" style 0-0 dim style 26-52 fg=blue style 79-79 dim -8| "│ ██▀▀▀▀▀████████████▄▄██▀ │" +4| "│ ██▀▀▀▀▀████████████▄▄██▀ │" style 0-0 dim style 28-51 fg=blue style 79-79 dim -9| "│ ██ ▀█████▄ ▀█████ │" +5| "│ ██ ▀█████▄ ▀█████ │" style 0-0 dim style 28-50 fg=blue style 79-79 dim -10| "│ ██▄ ▀████▄ ▄████ │" +6| "│ ██▄ ▀████▄ ▄████ │" style 0-0 dim style 28-50 fg=blue style 79-79 dim -11| "│ ██▄ ████████▀ │" +7| "│ ██▄ ████████▀ │" style 0-0 dim style 29-50 fg=blue style 79-79 dim -12| "│ ██▄ ▄▄ ▀█████▀ │" +8| "│ ██▄ ▄▄ ▀█████▀ │" style 0-0 dim style 29-49 fg=blue style 79-79 dim -13| "│ ▀███▄▄▄███▄ ████▄▄ │" +9| "│ ▀███▄▄▄███▄ ████▄▄ │" style 0-0 dim style 29-50 fg=blue style 79-79 dim -14| "│ ▀▀▀███████▀▀ │" +10| "│ ▀▀▀███████▀▀ │" style 0-0 dim style 31-47 fg=blue style 79-79 dim +11| "│ │" + style 0-0 dim + style 79-79 dim +12| "│ DeepSeek Harness │" + style 0-0 dim + style 32-47 fg=blue bold + style 79-79 dim +13| "│ │" + style 0-0 dim + style 79-79 dim +14| "│ 感谢您愿意拨冗试用 DeepSeek Harness。 │" + style 0-0 dim + style 79-79 dim 15| "│ │" style 0-0 dim style 79-79 dim -16| "│ DeepSeek Harness │" +16| "│ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │" style 0-0 dim - style 32-47 fg=blue bold style 79-79 dim 17| "│ │" style 0-0 dim style 79-79 dim -18| "│ 感谢您愿意拨冗试用 DeepSeek Harness。 │" +18| "│ “如切如磋,如琢如磨。” │" style 0-0 dim + style 2-23 bold style 79-79 dim 19| "│ │" style 0-0 dim style 79-79 dim -20| "│ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │" +20| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也 │" style 0-0 dim style 79-79 dim -21| "│ │" +21| "│ 可能促使我们重新审视,甚至推翻已有的设计。 │" style 0-0 dim style 79-79 dim -22| "│ “如切如磋,如琢如磨。” │" - style 0-0 dim - style 2-23 bold - style 79-79 dim -23| "│ │" +22| "│ │" style 0-0 dim style 79-79 dim -24| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也 │" - style 0-0 dim - style 79-79 dim -25| "│ 可能促使我们重新审视,甚至推翻已有的设计。 │" - style 0-0 dim - style 79-79 dim -26| "│ │" - style 0-0 dim - style 79-79 dim -27| "├──────────────────────────────────────────────────────────────────────────────┤" +23| "├──────────────────────────────────────────────────────────────────────────────┤" style 0-79 dim -28| "│ Enter 继续 │" +24| "│ Enter 继续 │" style 0-0 dim style 34-44 fg=bright-magenta bold style 79-79 dim -29| "│ ↑/↓ 滚动 ↓ │" +25| "│ ↑/↓ 滚动 ↓ │" style 0-0 dim style 34-45 dim style 79-79 dim -30| "╰──────────────────────────────────────────────────────────────────────────────╯" +26| "╰──────────────────────────────────────────────────────────────────────────────╯" style 0-79 dim -31-32| diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 10e2c88e95..12c018f255 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -25,7 +25,7 @@ const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url)) // needs no config argument at all; these are the overlays under test. const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -const firstRunSnapshots = fileURLToPath(new URL('./snapshots/tui-first-run-welcome/', import.meta.url)) +const firstRunSnapshots = fileURLToPath(new URL('./tui-first-run-snapshots/', import.meta.url)) const synchronizedFrameEnd = '\x1b[?2026l' /** @@ -166,7 +166,24 @@ function smoke(overrides: Partial & { const firstRunCopy = TUI_FIRST_RUN_WELCOME_NOTICE_COPY[TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE] -/** Project the first synchronized PTY frame containing `marker` into the stable terminal snapshot format. */ +/** Keep only the overlay rows, excluding platform-specific scrollback and the underlying TUI. */ +function overlaySnapshot(snapshot: string, columns: number, rows: number): string { + const blocks: string[][] = [] + for (const line of snapshot.split('\n')) { + if (/^\d+(?:-\d+)?~?\| /u.test(line)) blocks.push([line]) + else if (line.startsWith(' style ') && blocks.length > 0) blocks.at(-1)?.push(line) + } + const first = blocks.findIndex(block => block[0]?.includes('╭') === true) + const last = blocks.findIndex((block, index) => index >= first && block[0]?.includes('╰') === true) + if (first < 0 || last < first) throw new Error('first-run PTY snapshot has no complete overlay frame') + const overlay = blocks.slice(first, last + 1).flatMap((block, index) => [ + block[0]!.replace(/^\d+(?:-\d+)?(~)?\|/u, `${String(index)}$1|`), + ...block.slice(1), + ]) + return [`overlay ${String(columns)}x${String(rows)} rows=${String(last - first + 1)}`, ...overlay, ''].join('\n') +} + +/** Project the first synchronized PTY frame containing `marker` into an overlay-only snapshot. */ async function firstRunFrameSnapshot( output: string, marker: string, @@ -180,9 +197,7 @@ async function firstRunFrameSnapshot( const terminal = new HeadlessTerminal(columns, rows) try { terminal.write(output.slice(0, frameEnd + synchronizedFrameEnd.length)) - return (await terminal.snapshot()) - .replace(/main-session-[0-9a-f-]{36}/gu, 'main-session-{{uuid}}') - .replace(/\/[^"\s]*dsh-tui-welcome-\d+-[A-Za-z0-9]+/gu, '/tmp/dsh-tui-welcome') + return overlaySnapshot(await terminal.snapshot(), columns, rows) } finally { await terminal.dispose() } @@ -212,10 +227,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { columns, rows: 30, actions: [ - { waitFor: firstRunCopy.paragraphs[0]!, send: '\r' }, - columns >= 120 - ? { waitFor: firstRunCopy.saving, signal: 'SIGTERM', delayMs: 500 } - : { waitFor: 'main-session-', occurrence: 2, signal: 'SIGTERM' }, + { waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r', signalAfterMs: 2_000 }, ], inspect: async (cwd) => { expect(await hasTuiFirstRunWelcomeAcknowledgement(join(cwd, '.dsh'))).toBe(true) @@ -244,8 +256,12 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { rows: 12, actions: [ { waitFor: firstRunCopy.paragraphs[0]!, send: '\x1b[F' }, - { waitFor: '企业微信群', send: '\r' }, - { waitFor: 'main-session-', occurrence: 2, signal: 'SIGTERM' }, + { + waitFor: `Enter ${firstRunCopy.continueLabel}`, + occurrence: 2, + send: '\r', + signalAfterMs: 2_000, + }, ], }) await expect(await firstRunFrameSnapshot(output, firstRunCopy.paragraphs[0]!, 60, 12)) @@ -268,8 +284,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { showFirstRunWelcome: true, expectedExitCode: process.platform === 'win32' ? 0 : -15, actions: [ - { waitFor: firstRunCopy.paragraphs[0]!, send: '\r' }, - { waitFor: firstRunCopy.saving, signal: 'SIGTERM', delayMs: 500 }, + { waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r', signalAfterMs: 2_000 }, ], }) expect(first).toContain(firstRunCopy.title) @@ -314,8 +329,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { showFirstRunWelcome: true, expectedExitCode: -15, actions: [ - { waitFor: firstRunCopy.paragraphs[0]!, send: '\r' }, - { waitFor: firstRunCopy.saving, signal: 'SIGTERM', delayMs: 500 }, + { waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r', signalAfterMs: 2_000 }, ], }) expect(next).toContain(firstRunCopy.paragraphs[0]) @@ -515,8 +529,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { originalLineCount = before.split('\n').filter(Boolean).length }, actions: [ - { waitFor: firstRunCopy.paragraphs[0]!, send: '\r' }, - { waitFor: firstRunCopy.saving, signal: 'SIGTERM', delayMs: 500 }, + { waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r', signalAfterMs: 2_000 }, ], inspect: async (cwd) => { const after = await readFile(logPath( From cd23acb366c4205df9b4d7461630720ebcfd3778 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 31 Jul 2026 10:25:02 +0800 Subject: [PATCH 08/28] test(tui): force-stop resumed PTY acceptance --- apps/cli/tests/pty-harness.ts | 6 ++++-- apps/cli/tests/tui-keyless-smoke.e2e.ts | 9 +++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/cli/tests/pty-harness.ts b/apps/cli/tests/pty-harness.ts index b8225a8f50..844de8cb0b 100644 --- a/apps/cli/tests/pty-harness.ts +++ b/apps/cli/tests/pty-harness.ts @@ -57,7 +57,7 @@ while time.monotonic() < deadline: os.write(fd, action["send"].encode()) if "signalAfterMs" in action: time.sleep(action["signalAfterMs"] / 1000) - os.kill(pid, signal.SIGTERM) + os.kill(pid, getattr(signal, action.get("signalAfter", "SIGTERM"))) action_index += 1 waited, candidate = os.waitpid(pid, os.WNOHANG) if waited == pid: @@ -86,6 +86,8 @@ type TuiPtyAction = readonly delayMs?: number /** Terminate the process this many milliseconds after sending input. */ readonly signalAfterMs?: number + /** Signal used by {@link signalAfterMs}; defaults to `SIGTERM`. */ + readonly signalAfter?: 'SIGTERM' | 'SIGKILL' } | { readonly waitFor: string; readonly occurrence?: number; readonly signal: 'SIGTERM'; readonly delayMs?: number } | { @@ -218,7 +220,7 @@ async function runWindowsPtySmoke( const send = (): void => { terminal.write(action.send) if (action.signalAfterMs !== undefined) { - setTimeout(() => { terminal.kill('SIGTERM') }, action.signalAfterMs) + setTimeout(() => { terminal.kill(action.signalAfter ?? 'SIGTERM') }, action.signalAfterMs) } } if (action.delayMs === undefined) send() diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 12c018f255..a53c8941b2 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -223,11 +223,16 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { tempDirPrefix: `dsh-tui-welcome-${String(columns)}-`, configPath: scriptedConfigPath, showFirstRunWelcome: true, - expectedExitCode: process.platform === 'win32' ? 0 : -15, + expectedExitCode: process.platform === 'win32' ? 0 : -9, columns, rows: 30, actions: [ - { waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r', signalAfterMs: 2_000 }, + { + waitFor: `Enter ${firstRunCopy.continueLabel}`, + send: '\r', + signalAfterMs: 2_000, + signalAfter: 'SIGKILL', + }, ], inspect: async (cwd) => { expect(await hasTuiFirstRunWelcomeAcknowledgement(join(cwd, '.dsh'))).toBe(true) From 949b9fba8d86144bf87b42dc5e03a1846a0bc97b Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 31 Jul 2026 11:46:43 +0800 Subject: [PATCH 09/28] 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 10/28] 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 1b4c11e7d7ae0118cb96315bcd23594d8e2b607a Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 31 Jul 2026 12:02:13 +0800 Subject: [PATCH 11/28] fix(tui): await mounted welcome overlay service --- apps/cli/src/tui.ts | 2 +- apps/cli/tests/tui-keyless-smoke.e2e.ts | 44 ++++++++++++++----------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 7968a42019..019e658bc2 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -267,7 +267,7 @@ export async function runTui( ) app.current = ctx addHarnessSourceSection(ctx, SOURCE_ROOT) - if (showFirstRunWelcome && ctx.get('tui') !== undefined) { + if (showFirstRunWelcome) { await ctx.plugin({ name: tuiFirstRunWelcomeName, inject: tuiFirstRunWelcomeInject, diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 0bd3bcfbeb..6007ae69c4 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -27,6 +27,12 @@ const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const firstRunSnapshots = fileURLToPath(new URL('./tui-first-run-snapshots/', import.meta.url)) const synchronizedFrameEnd = '\x1b[?2026l' +// Artifact mode gives the inner PTY driver 60 seconds and its execa owner a +// five-second backstop. Keep Vitest outside both deadlines so the harness can +// report its own marker, exit, and cleanup failure instead of being cut off. +const PTY_SMOKE_TEST_TIMEOUT_MS = process.env.DSH_EXAMPLE_MODE === 'lib' + ? 75_000 + : LOADER_SMOKE_TEST_TIMEOUT_MS /** * Seed the isolated process workspace: ordinary files land in `cwd`, personal @@ -249,7 +255,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { .toMatchFileSnapshot(join(firstRunSnapshots, `${String(columns)}-columns.expected.txt`)) expect(output).toContain(TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[0]!.trim()) expect(output).toContain(`Enter ${firstRunCopy.continueLabel}`) - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, PTY_SMOKE_TEST_TIMEOUT_MS) it('keeps prose and Enter reachable in a low-height real PTY after dropping the whale', async () => { const output = await smoke({ @@ -277,7 +283,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('企业微信群') expect(output).toContain(`Enter ${firstRunCopy.continueLabel}`) expect(output).not.toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.unicode[0]!.trim()) - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, PTY_SMOKE_TEST_TIMEOUT_MS) it('shows once and skips the second launch under the same DSH_HOME', async () => { const cwd = await mkdtemp(join(tmpdir(), 'dsh-tui-welcome-twice-')) @@ -309,7 +315,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { } finally { await rm(cwd, { recursive: true, force: true }) } - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, PTY_SMOKE_TEST_TIMEOUT_MS) it.skipIf(process.platform === 'win32')('keeps the notice eligible when the process exits before Enter', async () => { const cwd = await mkdtemp(join(tmpdir(), 'dsh-tui-welcome-abort-')) @@ -342,7 +348,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { } finally { await rm(cwd, { recursive: true, force: true }) } - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, PTY_SMOKE_TEST_TIMEOUT_MS) it('boots pi-tui, sweeps the borderless banner in, enters plan mode, and restores the terminal', async () => { // With no configured welcome the borderless banner sweeps in left-to-right; @@ -366,7 +372,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { expect(output).not.toContain('╭') expect(output).not.toContain('╮') expect(output).toContain('\u001B[?2004l') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, PTY_SMOKE_TEST_TIMEOUT_MS) it('switches models, streams a response, answers a user-question dialog, and exits cleanly', async () => { const output = await smoke({ @@ -419,7 +425,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('Registered tools') expect(output).toContain('ask_user_question') expect(output).toContain('\u001B[?2004l') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, PTY_SMOKE_TEST_TIMEOUT_MS) it('loads a local skill via /skill: and delivers its body to the model as a user turn', async () => { // The whole user-only invocation path in one keyless boot: `ctx.get('skills')` @@ -455,7 +461,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { expect(output).not.toContain('[instructions]') expect(output).toContain('Scripted skill body received.') expect(output).toContain('\u001B[?2004l') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, PTY_SMOKE_TEST_TIMEOUT_MS) it('adds a watched local skill to live /skill: autocomplete without restarting', async () => { const skill = [ @@ -485,7 +491,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { }) expect(output).toContain('HOT_ADDED_COMPLETION_MARKER') expect(output).toContain('\u001B[?2004l') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, PTY_SMOKE_TEST_TIMEOUT_MS) it.skipIf(process.env.DSH_EXAMPLE_MODE === 'lib')('fuzzy-completes an @file path without reading or submitting the file', async () => { const output = await smoke({ @@ -510,7 +516,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('File · terminal-special-case.t') expect(output).toContain('@src/terminal-special-case.ts') expect(output).toContain('\u001B[?2004l') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, PTY_SMOKE_TEST_TIMEOUT_MS) }) @@ -553,7 +559,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { }) expect(output).toContain(firstRunCopy.paragraphs[0]) expect(output).toContain('Resume selector design — DeepSeek Harness') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, PTY_SMOKE_TEST_TIMEOUT_MS) it('exec-replaces the TUI for /resume and restores the same session state', async () => { const output = await smoke({ @@ -574,7 +580,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(released).toBeGreaterThanOrEqual(0) expect(restored).toBeGreaterThan(released) expect(output).toContain('Preserve restored state') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, PTY_SMOKE_TEST_TIMEOUT_MS) it('boots the shipped default config with no arguments and no personal overlay', async () => { const output = await smoke({ @@ -589,7 +595,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).not.toContain('╭') expect(output).not.toContain('╮') expect(output).toContain('\u001B[?2004l') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, PTY_SMOKE_TEST_TIMEOUT_MS) it('applies the personal overlay: config.yaml patches an overlay-inserted row, the invoking directory\'s .env feeds its !!js, and the home .env stays out of the environment', async () => { // The whole personal-config chain in one boot, plus the environment layer @@ -626,7 +632,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('PROJECT OVERLAY READY.') expect(output).not.toContain('HOME ENV LEAKED.') expect(output).toContain('\u001B[?2004l') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, PTY_SMOKE_TEST_TIMEOUT_MS) it('fails loud instead of booting when the personal config.yaml is invalid', async () => { const output = await smoke({ @@ -638,7 +644,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expectedExitCode: 1, }) expect(output).toContain('must be a top-level YAML array of loader patch entries') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, PTY_SMOKE_TEST_TIMEOUT_MS) it('routes the --resume flag into the launcher session-identity slot, failing loud on a missing id', async () => { // The flag path end to end: apps/cli parses `--resume missing-session`, @@ -653,7 +659,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expectedExitCode: 1, }) expect(output).toContain('ui-tui: session "missing-session" failed to start:') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, PTY_SMOKE_TEST_TIMEOUT_MS) it('prints the launcher-owned resume command on exit, naming the booted config', async () => { // The exit line is built by apps/cli from this invocation, so it must carry @@ -666,7 +672,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { actions: [{ waitFor: 'scripted TUI ready.', send: '/exit\r' }], }) expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36} --config/) - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, PTY_SMOKE_TEST_TIMEOUT_MS) it('keeps resume working when the personal overlay replaces the whole agent-loop config', async () => { // Loader patches replace a targeted `config` key wholesale, so a personal @@ -701,7 +707,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { actions: [{ waitFor: 'OVERLAY REPLACED THE CONFIG.', send: '/exit\r' }], }) expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36}/) - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, PTY_SMOKE_TEST_TIMEOUT_MS) it('reports a failing bash command exactly once, as the terminal card exit pill', async () => { // The model-facing result ends in `[exit code: 3]`, which the terminal card @@ -724,7 +730,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('SCRIPTED_BASH_FAILED') expect(output).toContain('[exit 3]') expect(output).not.toContain('[exit code: 3]') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, PTY_SMOKE_TEST_TIMEOUT_MS) it('tells the model its source path and offers the bundled maintenance skills', async () => { // The launcher resolves the checkout root three hops up from apps/cli/{src,lib}; @@ -750,5 +756,5 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(context.skillCatalog).toContain("- `dsh-customize`: Customize or maintain any dsh source checkout — the one powering the current DSH process, the installed `dsh` command, or a sibling dsh/deepseek-harness clone. Use before any requested action that alters such a checkout's files or git state. Read-only questions that only inspect the checkout do not trigger this. Do not edit the personal staging checkout directly.") expect(context.skillCatalog).toContain('- `dsh-upgrade`: Upgrades a source-installed, personally customized DSH checkout to upstream master while preserving local changes and an unchanged rollback worktree. Use when the user asks to update or upgrade DSH.') expect(context.skillCatalog).toContain('- `dsh-upstream-customization`: Classifies personal DSH customizations for upstream contribution and, after explicit per-feature approval, rebuilds one on upstream master and opens a draft pull request. Use when the user asks to contribute, publish, or upstream a local DSH change, or asks whether one is worth proposing.') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) + }, PTY_SMOKE_TEST_TIMEOUT_MS) }) From f30579dbaae7f41567b5537386dfa03e049b9ff8 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 31 Jul 2026 12:36:24 +0800 Subject: [PATCH 12/28] refactor(tui): own overlay terminal primitives --- apps/cli/package.json | 1 - apps/cli/src/tui-first-run-welcome.ts | 50 ++++++++--------- apps/cli/tests/tui-first-run-welcome.spec.ts | 12 ++-- 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/extension/primitives.ts | 59 ++++++++++++++++++++ packages/ui/tui/src/index.ts | 7 +++ packages/ui/tui/tests/extension.spec.ts | 18 ++++++ pnpm-lock.yaml | 3 - 11 files changed, 121 insertions(+), 39 deletions(-) create mode 100644 packages/ui/tui/src/extension/primitives.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index a5321ad77b..127f99a313 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -121,7 +121,6 @@ "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", - "@earendil-works/pi-tui": "0.80.7", "commander": "^15.0.0", "cordis": "^4.0.0-rc.7", "js-yaml": "^4.2.0" diff --git a/apps/cli/src/tui-first-run-welcome.ts b/apps/cli/src/tui-first-run-welcome.ts index fa3b487766..8b1bf7e7ed 100644 --- a/apps/cli/src/tui-first-run-welcome.ts +++ b/apps/cli/src/tui-first-run-welcome.ts @@ -12,16 +12,14 @@ import { lstat, mkdir, open, rename, rm } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' import type { Context } from 'cordis' import { - Key, - matchesKey, - truncateToWidth, - visibleWidth, - wrapTextWithAnsi, -} from '@earendil-works/pi-tui' -import type { - TuiComponent, - TuiFocusable, - TuiOverlayHost, + matchesTuiKey, + truncateTuiText, + TuiKey, + tuiVisibleWidth, + wrapTuiText, + type TuiComponent, + type TuiFocusable, + type TuiOverlayHost, } from '@deepseek-ai/dsh-tui' import { TUI_FIRST_RUN_WELCOME_NOTICE_COPY, @@ -149,14 +147,14 @@ async function syncDirectory(path: string): Promise { /** Render one visible-width-padded line inside the notice frame. */ function framed(content: string, innerWidth: number, host: TuiOverlayHost): string { - const clipped = truncateToWidth(content, innerWidth, '') - return `${host.theme.dim('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${host.theme.dim('│')}` + const clipped = truncateTuiText(content, innerWidth) + return `${host.theme.dim('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - tuiVisibleWidth(clipped)))} ${host.theme.dim('│')}` } /** Center one line by terminal column width. */ function centered(content: string, width: number): string { - const clipped = truncateToWidth(content, width, '') - const remaining = Math.max(0, width - visibleWidth(clipped)) + const clipped = truncateTuiText(content, width) + const remaining = Math.max(0, width - tuiVisibleWidth(clipped)) return `${' '.repeat(Math.floor(remaining / 2))}${clipped}` } @@ -189,11 +187,11 @@ function proseLines( if (quoteEnd > 0) { const quote = paragraph.slice(0, quoteEnd + 1) const remainder = paragraph.slice(quoteEnd + 1).trimStart() - lines.push(...wrapTextWithAnsi(host.theme.bold(host.theme.text(host.display(quote))), width)) + lines.push(...wrapTuiText(host.theme.bold(host.theme.text(host.display(quote))), width)) lines.push('') - if (remainder !== '') lines.push(...wrapTextWithAnsi(host.theme.text(host.display(remainder)), width)) + if (remainder !== '') lines.push(...wrapTuiText(host.theme.text(host.display(remainder)), width)) } else { - lines.push(...wrapTextWithAnsi(host.theme.text(host.display(paragraph)), width)) + lines.push(...wrapTuiText(host.theme.text(host.display(paragraph)), width)) } } return lines @@ -278,7 +276,7 @@ export class TuiFirstRunWelcomeComponent implements TuiComponent, TuiFocusable { : Array.from({ length: Math.max(fullArt.length, visibleBody.length) }, (_, index) => { const art = fullArt[index] ?? '' const line = visibleBody[index] ?? '' - const left = `${art}${' '.repeat(Math.max(0, fullArtWidth - visibleWidth(art)))}` + const left = `${art}${' '.repeat(Math.max(0, fullArtWidth - tuiVisibleWidth(art)))}` return `${left} ${line}` }) @@ -293,17 +291,17 @@ export class TuiFirstRunWelcomeComponent implements TuiComponent, TuiFocusable { } handleInput(data: string): void { - if (matchesKey(data, Key.enter)) { + if (matchesTuiKey(data, TuiKey.enter)) { if (!this.saving) void this.commit() return } - if (this.saving || matchesKey(data, Key.escape)) return - if (matchesKey(data, Key.up)) this.scrollBy(-1) - else if (matchesKey(data, Key.down)) this.scrollBy(1) - else if (matchesKey(data, Key.pageUp)) this.scrollBy(-this.bodyCapacity) - else if (matchesKey(data, Key.pageDown)) this.scrollBy(this.bodyCapacity) - else if (matchesKey(data, Key.home)) this.scrollTo(0) - else if (matchesKey(data, Key.end)) this.scrollTo(this.maxScrollOffset) + if (this.saving || matchesTuiKey(data, TuiKey.escape)) return + if (matchesTuiKey(data, TuiKey.up)) this.scrollBy(-1) + else if (matchesTuiKey(data, TuiKey.down)) this.scrollBy(1) + else if (matchesTuiKey(data, TuiKey.pageUp)) this.scrollBy(-this.bodyCapacity) + else if (matchesTuiKey(data, TuiKey.pageDown)) this.scrollBy(this.bodyCapacity) + else if (matchesTuiKey(data, TuiKey.home)) this.scrollTo(0) + else if (matchesTuiKey(data, TuiKey.end)) this.scrollTo(this.maxScrollOffset) } private scrollBy(delta: number): void { diff --git a/apps/cli/tests/tui-first-run-welcome.spec.ts b/apps/cli/tests/tui-first-run-welcome.spec.ts index 480b2bd07e..076b3a0302 100644 --- a/apps/cli/tests/tui-first-run-welcome.spec.ts +++ b/apps/cli/tests/tui-first-run-welcome.spec.ts @@ -4,8 +4,12 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Context } from 'cordis' -import { visibleWidth } from '@earendil-works/pi-tui' -import type { TuiOverlayHost, TuiOverlayRequest, TuiTheme } from '@deepseek-ai/dsh-tui' +import { + tuiVisibleWidth, + type TuiOverlayHost, + type TuiOverlayRequest, + type TuiTheme, +} from '@deepseek-ai/dsh-tui' import { acknowledgeTuiFirstRunWelcome, apply, @@ -135,7 +139,7 @@ describe('TUI first-run welcome composition', () => { const lines = component.render(renderWidth) expect(tuiFirstRunWelcomeArtTier(inner, rows)).toBe(tier) - expect(lines.every(line => visibleWidth(line) <= renderWidth)).toBe(true) + expect(lines.every(line => tuiVisibleWidth(line) <= renderWidth)).toBe(true) expect(lines.join('\n')).toContain(TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[0]!.trim()) expect(lines.join('\n')).toContain(`Enter ${copy.continueLabel}`) expect(lines.length).toBeLessThanOrEqual(Math.floor(rows * 0.9)) @@ -166,7 +170,7 @@ describe('TUI first-run welcome composition', () => { const quoteOnly = { ...copy, paragraphs: ['“如切如磋,如琢如磨。”'] } const component = new TuiFirstRunWelcomeComponent(fixture.host, quoteOnly, async () => {}) const lines = component.render(2) - expect(lines.every(line => visibleWidth(line) <= 6)).toBe(true) + expect(lines.every(line => tuiVisibleWidth(line) <= 6)).toBe(true) }) it('renders the bit-equivalent ASCII icon fallback for an explicitly non-Unicode terminal', () => { diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 8eed1dab5d..28f082a0ec 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2336,7 +2336,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:248`](../../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 d1a1bbb3c5..f327292d41 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: 86e54ab76d07e32fad93965fcbb585d2b5fdfe06 -README.zh.md: a8d072a82c9cff8db7cfe436957a9c1391fb8d8f +README.md: c1ab57410469d649fd589c5038903324c60343cb +README.zh.md: 7c92136398f7a84213712d06f843a29afd8f1277 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 86e54ab76d..c1ab574104 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -10,7 +10,7 @@ Interactive terminals on macOS, Linux, and Windows are supported. Windows uses p This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `llm`, `systemPrompt`, `tokenMeter`, `tools`, and `userInteraction`, optionally reads a `skills` service (present only when one is mounted), then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries. -After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme (including terminal-safe DeepSeek `brand` treatment), display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives. +After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme (including terminal-safe DeepSeek `brand` treatment), display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. The package exports semantic-key, ANSI-wrap, truncation, and visible-width primitives for extension components, keeping the underlying renderer dependency inside `dsh-tui`. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives. The TUI rebuilds resumed history from the append-origin session events, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes ``. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. A surface replacement never rewrites the rendered transcript: the conversation it shadows stays readable, and a landed compaction checkpoint adds one dim `… earlier context was compacted …` marker at its log position, so the terminal reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies — a pruned tool result, a regenerated assistant message — render nothing. diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index a8d072a82c..7c92136398 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -10,7 +10,7 @@ DeepSeek Harness agent(智能体)的交互式终端入口,基于 [`@earend 本包(package)只持有交互式终端展示和输入。它注入 `agents`、[`commands`](../commands/README.md)、`llm`、`systemPrompt`、`tokenMeter`、`tools` 和 `userInteraction`,可选读取 `skills` 服务(仅在已挂载时存在),然后驱动由 app 或开发者代码创建或恢复的 agent。Agent 生命周期、持久化与模型侧 [`ask_user_question`](../tool-ask-user/README.md) 工具仍是独立组合项。 -终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题(包括终端安全的 DeepSeek `brand` 样式)、显示文本转义、重绘、关闭和生命周期信号,但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect,因此卸载会移除排队工作,或在清理结算前关闭可见工作;终端关闭会先卸载依赖项,再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。 +终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题(包括终端安全的 DeepSeek `brand` 样式)、显示文本转义、重绘、关闭和生命周期信号,但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。本包还为扩展组件导出语义按键、ANSI 换行、截断和可见宽度原语,使底层 renderer 依赖始终留在 `dsh-tui` 内。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect,因此卸载会移除排队工作,或在清理结算前关闭可见工作;终端关闭会先卸载依赖项,再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。 TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 ``。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换从不重写已渲染的 transcript:被它遮蔽的对话仍可阅读,而已落地的压缩(compaction)检查点会在其日志位置添加一行暗色 `… earlier context was compacted …` 标记,因此终端报告的是模型从何处起不再看到那段历史,而不是把它抹掉。仅供模型使用的替换副本——被裁剪的工具结果、重新生成的 assistant 消息——不渲染任何内容。 diff --git a/packages/ui/tui/src/extension/primitives.ts b/packages/ui/tui/src/extension/primitives.ts new file mode 100644 index 0000000000..bb4fba8012 --- /dev/null +++ b/packages/ui/tui/src/extension/primitives.ts @@ -0,0 +1,59 @@ +/** + * Terminal-safe primitives for components mounted through the TUI extension service. + * + * Extensions use these wrappers instead of importing the underlying renderer, so + * `ctx.tui` remains the owner of key decoding, ANSI wrapping, and column width. + * @module @deepseek-ai/dsh-tui/extension-primitives + */ + +import { + Key, + matchesKey, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, + type KeyId, +} from '@earendil-works/pi-tui' + +/** Key identifiers accepted by TUI extension components. */ +export const TuiKey = Key + +/** + * Test whether terminal input matches one semantic key. + * @param data - Raw terminal input delivered to the component. + * @param key - Semantic key identifier to match. + * @returns Whether the input encodes the requested key. + */ +export function matchesTuiKey(data: string, key: KeyId): boolean { + return matchesKey(data, key) +} + +/** + * Measure terminal columns after ignoring ANSI control sequences. + * @param value - Styled or plain terminal text. + * @returns Visible terminal-column width. + */ +export function tuiVisibleWidth(value: string): number { + return visibleWidth(value) +} + +/** + * Wrap styled terminal text without splitting ANSI sequences. + * @param value - Styled or plain terminal text. + * @param width - Maximum visible columns per line. + * @returns Wrapped lines preserving active ANSI styling. + */ +export function wrapTuiText(value: string, width: number): string[] { + return wrapTextWithAnsi(value, width) +} + +/** + * Truncate styled terminal text to a visible-column limit. + * @param value - Styled or plain terminal text. + * @param width - Maximum visible columns. + * @param ellipsis - Suffix used when truncation occurs. + * @returns Text whose visible width does not exceed the limit. + */ +export function truncateTuiText(value: string, width: number, ellipsis = ''): string { + return truncateToWidth(value, width, ellipsis) +} diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 8448d79f8e..16f7bd9938 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -141,6 +141,13 @@ import { WorkspaceFileSearch } from './chat/file-autocomplete.ts' export { TuiPromptService } from './prompt.ts' export { renderSkillInvocation } from './chat/skill-invocation.ts' +export { + matchesTuiKey, + truncateTuiText, + TuiKey, + tuiVisibleWidth, + wrapTuiText, +} from './extension/primitives.ts' export type { TuiResumeHost, TuiRuntime } from './runtime.ts' export { resolveTuiConfig, diff --git a/packages/ui/tui/tests/extension.spec.ts b/packages/ui/tui/tests/extension.spec.ts index 15bfee33f6..eb3103f5da 100644 --- a/packages/ui/tui/tests/extension.spec.ts +++ b/packages/ui/tui/tests/extension.spec.ts @@ -17,6 +17,13 @@ import { TuiOverlayManager, type TuiOverlayDriver, } from '../src/extension/overlay-manager.ts' +import { + matchesTuiKey, + truncateTuiText, + TuiKey, + tuiVisibleWidth, + wrapTuiText, +} from '../src/index.ts' const theme: TuiTheme = Object.freeze({ text: (value: string) => `text:${value}`, @@ -30,6 +37,17 @@ const theme: TuiTheme = Object.freeze({ bold: (value: string) => `bold:${value}`, }) +describe('TUI extension terminal primitives', () => { + it('owns semantic keys, ANSI-safe wrapping, truncation, and visible width', () => { + expect(matchesTuiKey('\r', TuiKey.enter)).toBe(true) + expect(tuiVisibleWidth('\x1b[34m鲸鱼\x1b[39m')).toBe(4) + const truncated = truncateTuiText('鲸鱼欢迎', 6) + expect(truncated).toContain('鲸鱼欢') + expect(tuiVisibleWidth(truncated)).toBe(6) + expect(wrapTuiText('\x1b[34m鲸鱼欢迎\x1b[39m', 4)).toHaveLength(2) + }) +}) + interface ShownOverlay { component: Component options: TuiOverlayOptions | undefined diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 909b380b4f..3f00cb4866 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -435,9 +435,6 @@ importers: '@deepseek-ai/dsh-workspace-context': specifier: workspace:^ version: link:../../packages/context/workspace-context - '@earendil-works/pi-tui': - specifier: 0.80.7 - version: 0.80.7(patch_hash=6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946) commander: specifier: ^15.0.0 version: 15.0.0 From ac19ba2da334d3ab8eed8b7dc10fa781469c758e Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 31 Jul 2026 13:28:07 +0800 Subject: [PATCH 13/28] =?UTF-8?q?copy(tui):=20use=20=E7=B2=97=E7=B3=99=20i?= =?UTF-8?q?n=20first-run=20welcome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/cli/src/tui-first-run-welcome-copy.ts | 4 ++-- .../tests/tui-first-run-snapshots/120-columns.expected.txt | 2 +- .../tests/tui-first-run-snapshots/160-columns.expected.txt | 2 +- .../cli/tests/tui-first-run-snapshots/60-columns.expected.txt | 2 +- .../cli/tests/tui-first-run-snapshots/80-columns.expected.txt | 2 +- apps/cli/tests/tui-first-run-welcome.spec.ts | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/tui-first-run-welcome-copy.ts b/apps/cli/src/tui-first-run-welcome-copy.ts index 7999b26e64..b50b2d9688 100644 --- a/apps/cli/src/tui-first-run-welcome-copy.ts +++ b/apps/cli/src/tui-first-run-welcome-copy.ts @@ -7,7 +7,7 @@ */ /** Copy version persisted after the user explicitly continues. */ -export const TUI_FIRST_RUN_WELCOME_NOTICE_VERSION = 1 +export const TUI_FIRST_RUN_WELCOME_NOTICE_VERSION = 2 /** Locale-shaped text rendered by the first-run welcome overlay. */ export interface TuiFirstRunWelcomeNoticeCopy { @@ -34,7 +34,7 @@ export const TUI_FIRST_RUN_WELCOME_NOTICE_COPY = Object.freeze({ title: 'DeepSeek Harness', paragraphs: Object.freeze([ '感谢您愿意拨冗试用 DeepSeek Harness。', - '目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。', + '目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗糙。', '“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。', '我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。', ]), diff --git a/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt b/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt index c279f39afd..afa94c0348 100644 --- a/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt @@ -18,7 +18,7 @@ overlay 120x30 rows=20 style 0-0 dim style 4-43 fg=blue style 119-119 dim -5| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │" +5| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗糙。 │" style 0-0 dim style 4-42 fg=blue style 119-119 dim diff --git a/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt b/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt index 5b6b903502..ef1af0d547 100644 --- a/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt @@ -18,7 +18,7 @@ overlay 160x30 rows=20 style 0-0 dim style 4-43 fg=blue style 159-159 dim -5| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │" +5| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗糙。 │" style 0-0 dim style 4-42 fg=blue style 159-159 dim diff --git a/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt b/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt index f3d6bd6390..bdf12926be 100644 --- a/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt @@ -48,7 +48,7 @@ overlay 60x30 rows=27 13| "│ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验 │" style 0-0 dim style 59-59 dim -14| "│ 难免粗粝。 │" +14| "│ 难免粗糙。 │" style 0-0 dim style 59-59 dim 15| "│ │" diff --git a/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt b/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt index 500c00efbc..67f4ceffad 100644 --- a/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt @@ -57,7 +57,7 @@ overlay 80x30 rows=27 15| "│ │" style 0-0 dim style 79-79 dim -16| "│ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 │" +16| "│ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗糙。 │" style 0-0 dim style 79-79 dim 17| "│ │" diff --git a/apps/cli/tests/tui-first-run-welcome.spec.ts b/apps/cli/tests/tui-first-run-welcome.spec.ts index 076b3a0302..1cdea4efbb 100644 --- a/apps/cli/tests/tui-first-run-welcome.spec.ts +++ b/apps/cli/tests/tui-first-run-welcome.spec.ts @@ -124,7 +124,7 @@ describe('TUI first-run welcome composition', () => { expect(createHash('sha256').update(icon).digest('hex')) .toBe('deba5f98a5c1796e20fcac3149bcd7eb8a32f0bdd04d048819400b1f28bd1439') expect(createHash('sha256').update(copy.paragraphs.join('\n')).digest('hex')) - .toBe('c75e395999f572ee231688ef70d5b7f553de3809b57ce8160b4406bd7650f2ec') + .toBe('78d30473a7e3d7afe3157c2f685fd5fd6c94eb9d9d09ac726a36e922c1ff8725') }) it.each([ From e73fa2ec9f5cf38c0d408691925ecc462fb16781 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 31 Jul 2026 13:34:55 +0800 Subject: [PATCH 14/28] copy(tui): refine first-run welcome message --- apps/cli/src/tui-first-run-welcome-copy.ts | 12 ++++++------ .../tui-first-run-snapshots/120-columns.expected.txt | 10 +++++----- .../tui-first-run-snapshots/160-columns.expected.txt | 8 ++++---- .../60-columns-low-height.expected.txt | 2 +- .../tui-first-run-snapshots/60-columns.expected.txt | 8 ++++---- .../tui-first-run-snapshots/80-columns.expected.txt | 4 ++-- apps/cli/tests/tui-first-run-welcome.spec.ts | 2 +- 7 files changed, 23 insertions(+), 23 deletions(-) diff --git a/apps/cli/src/tui-first-run-welcome-copy.ts b/apps/cli/src/tui-first-run-welcome-copy.ts index b50b2d9688..ae5ebd8ce3 100644 --- a/apps/cli/src/tui-first-run-welcome-copy.ts +++ b/apps/cli/src/tui-first-run-welcome-copy.ts @@ -34,9 +34,9 @@ export const TUI_FIRST_RUN_WELCOME_NOTICE_COPY = Object.freeze({ title: 'DeepSeek Harness', paragraphs: Object.freeze([ '感谢您愿意拨冗试用 DeepSeek Harness。', - '目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗糙。', - '“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。', - '我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。', + '目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。', + '“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。', + '我们尤其希望听见那些失败、困惑与不顺手的时刻——如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。', ]), continueLabel: '继续', scrollHint: '↑/↓ 滚动', @@ -47,9 +47,9 @@ export const TUI_FIRST_RUN_WELCOME_NOTICE_COPY = Object.freeze({ title: 'DeepSeek Harness', paragraphs: Object.freeze([ 'Thank you for taking the time to try DeepSeek Harness.', - 'This release is still in internal testing. Some features remain unfinished, and parts of the experience will inevitably feel rough.', - '“As one cuts and files, as one carves and polishes.” A product grows through real encounters and candid feedback. Problems uncovered in real use may lead us to re-examine, or even overturn, existing designs.', - 'We especially want to hear about failures, confusion, and friction. If DeepSeek Harness did not help—or made your work harder—please tell us in the company WeChat group. Every piece of feedback helps us refine it.', + 'This release is still in internal testing. Features remain unfinished, and the experience will inevitably feel somewhat rough.', + '“As one cuts and files, as one carves and polishes.” A product grows through real encounters and candid feedback. Problems you discover in real use may lead us to re-examine, or even overturn, existing designs.', + 'We especially want to hear about moments of failure, confusion, and friction. If you have any feedback or suggestions, please leave a message in the company WeChat group and let us know. Every piece of feedback helps us refine it.', ]), continueLabel: 'Continue', scrollHint: '↑/↓ Scroll', diff --git a/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt b/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt index afa94c0348..fee357976f 100644 --- a/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt @@ -18,7 +18,7 @@ overlay 120x30 rows=20 style 0-0 dim style 4-43 fg=blue style 119-119 dim -5| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗糙。 │" +5| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。 │" style 0-0 dim style 4-42 fg=blue style 119-119 dim @@ -35,7 +35,7 @@ overlay 120x30 rows=20 style 0-0 dim style 7-39 fg=blue style 119-119 dim -9| "│ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的 │" +9| "│ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的 │" style 0-0 dim style 7-39 fg=blue style 119-119 dim @@ -47,15 +47,15 @@ overlay 120x30 rows=20 style 0-0 dim style 8-38 fg=blue style 119-119 dim -12| "│ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至 │" +12| "│ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果您有任何反馈与建议 │" style 0-0 dim style 9-38 fg=blue style 119-119 dim -13| "│ █████▄ ███▄▄ ▀█████▄▄ 反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一 │" +13| "│ █████▄ ███▄▄ ▀█████▄▄ ,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更 │" style 0-0 dim style 9-38 fg=blue style 119-119 dim -14| "│ ▀█████████████▄▄▄▄█▀█████▀ 条反馈,都会帮助我们把它打磨得更好。 │" +14| "│ ▀█████████████▄▄▄▄█▀█████▀ 好。 │" style 0-0 dim style 8-39 fg=blue style 119-119 dim diff --git a/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt b/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt index ef1af0d547..d2d14a2360 100644 --- a/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt @@ -18,7 +18,7 @@ overlay 160x30 rows=20 style 0-0 dim style 4-43 fg=blue style 159-159 dim -5| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗糙。 │" +5| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。 │" style 0-0 dim style 4-42 fg=blue style 159-159 dim @@ -35,7 +35,7 @@ overlay 160x30 rows=20 style 0-0 dim style 7-39 fg=blue style 159-159 dim -9| "│ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已 │" +9| "│ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已 │" style 0-0 dim style 7-39 fg=blue style 159-159 dim @@ -47,11 +47,11 @@ overlay 160x30 rows=20 style 0-0 dim style 8-38 fg=blue style 159-159 dim -12| "│ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留 │" +12| "│ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反 │" style 0-0 dim style 9-38 fg=blue style 159-159 dim -13| "│ █████▄ ███▄▄ ▀█████▄▄ 言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。 │" +13| "│ █████▄ ███▄▄ ▀█████▄▄ 馈,都会帮助我们把它打磨得更好。 │" style 0-0 dim style 9-38 fg=blue style 159-159 dim diff --git a/apps/cli/tests/tui-first-run-snapshots/60-columns-low-height.expected.txt b/apps/cli/tests/tui-first-run-snapshots/60-columns-low-height.expected.txt index d47dfcac84..7271f27e79 100644 --- a/apps/cli/tests/tui-first-run-snapshots/60-columns-low-height.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/60-columns-low-height.expected.txt @@ -14,7 +14,7 @@ overlay 60x12 rows=10 4| "│ │" style 0-0 dim style 59-59 dim -5| "│ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验 │" +5| "│ 目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些 │" style 0-0 dim style 59-59 dim 6| "├──────────────────────────────────────────────────────────┤" diff --git a/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt b/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt index bdf12926be..3737b99fd6 100644 --- a/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt @@ -45,10 +45,10 @@ overlay 60x30 rows=27 12| "│ │" style 0-0 dim style 59-59 dim -13| "│ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验 │" +13| "│ 目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些 │" style 0-0 dim style 59-59 dim -14| "│ 难免粗糙。 │" +14| "│ 粗糙。 │" style 0-0 dim style 59-59 dim 15| "│ │" @@ -64,7 +64,7 @@ overlay 60x30 rows=27 18| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实 │" style 0-0 dim style 59-59 dim -19| "│ 使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的 │" +19| "│ 使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的 │" style 0-0 dim style 59-59 dim 20| "│ 设计。 │" @@ -73,7 +73,7 @@ overlay 60x30 rows=27 21| "│ │" style 0-0 dim style 59-59 dim -22| "│ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能 │" +22| "│ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果您有任 │" style 0-0 dim style 59-59 dim 23| "├──────────────────────────────────────────────────────────┤" diff --git a/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt b/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt index 67f4ceffad..bfab89be3f 100644 --- a/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt @@ -57,7 +57,7 @@ overlay 80x30 rows=27 15| "│ │" style 0-0 dim style 79-79 dim -16| "│ 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗糙。 │" +16| "│ 目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。 │" style 0-0 dim style 79-79 dim 17| "│ │" @@ -70,7 +70,7 @@ overlay 80x30 rows=27 19| "│ │" style 0-0 dim style 79-79 dim -20| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也 │" +20| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也 │" style 0-0 dim style 79-79 dim 21| "│ 可能促使我们重新审视,甚至推翻已有的设计。 │" diff --git a/apps/cli/tests/tui-first-run-welcome.spec.ts b/apps/cli/tests/tui-first-run-welcome.spec.ts index 1cdea4efbb..245db27bec 100644 --- a/apps/cli/tests/tui-first-run-welcome.spec.ts +++ b/apps/cli/tests/tui-first-run-welcome.spec.ts @@ -124,7 +124,7 @@ describe('TUI first-run welcome composition', () => { expect(createHash('sha256').update(icon).digest('hex')) .toBe('deba5f98a5c1796e20fcac3149bcd7eb8a32f0bdd04d048819400b1f28bd1439') expect(createHash('sha256').update(copy.paragraphs.join('\n')).digest('hex')) - .toBe('78d30473a7e3d7afe3157c2f685fd5fd6c94eb9d9d09ac726a36e922c1ff8725') + .toBe('e1b8ea95d9f8af276c4b2b76fff23fa068bdad61b5d4a10300600ed47572900d') }) it.each([ From 9ca52cbcf00bca00dd83f644390a2d16ab464737 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 31 Jul 2026 13:57:18 +0800 Subject: [PATCH 15/28] 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 16/28] Refresh compaction progress PR head From dbb87f02bf78f53a362c5a919a9dc03c4b36eb12 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 31 Jul 2026 15:15:14 +0800 Subject: [PATCH 17/28] copy(tui): disclose Session Log upload in Chinese welcome --- ...-versioned-tui-first-run-welcome.i18n.yaml | 4 +- ...6-07-30-versioned-tui-first-run-welcome.md | 6 +- ...7-30-versioned-tui-first-run-welcome.zh.md | 6 +- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/src/tui-first-run-welcome-copy.ts | 51 +++++------- apps/cli/src/tui-first-run-welcome.ts | 4 +- .../120-columns.expected.txt | 30 +++---- .../160-columns.expected.txt | 20 ++--- .../60-columns-low-height.expected.txt | 6 +- .../60-columns.expected.txt | 81 ++++++------------ .../80-columns.expected.txt | 83 +++++++++---------- apps/cli/tests/tui-first-run-welcome.spec.ts | 40 +++++++-- apps/cli/tests/tui-keyless-smoke.e2e.ts | 29 ++++--- 15 files changed, 173 insertions(+), 195 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml index c927167d68..cfc68dc8f1 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.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-versioned-tui-first-run-welcome.md -2026-07-30-versioned-tui-first-run-welcome.md: 71790b53ec5816a806952a1e9fcf2079e50af8be -2026-07-30-versioned-tui-first-run-welcome.zh.md: 132899b9485cd784344e54aed0fc933bc50a57bc +2026-07-30-versioned-tui-first-run-welcome.md: d78e7a172198de2c61579dc5faa37a4c9f50fb58 +2026-07-30-versioned-tui-first-run-welcome.zh.md: 1d6a3da63938f6de3dfc9b85ef0548e360de9b3e diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md index 71790b53ec..d78e7a1721 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md @@ -6,7 +6,7 @@ English | [中文](2026-07-30-versioned-tui-first-run-welcome.zh.md) ## Problem -The shipped `dsh` terminal starts directly in the editor and gives first-time internal testers no durable orientation about the product's maturity or feedback channel. The existing one-line `welcome` banner subtitle cannot carry the supplied four-paragraph notice without crowding the normal session header, and putting onboarding in the session log would create a user turn or model-visible context that is unrelated to the user's work. +The shipped `dsh` terminal starts directly in the editor and gives first-time internal testers no durable orientation about the product's maturity or feedback channel. The existing one-line `welcome` banner subtitle cannot carry the supplied notice without crowding the normal session header, and putting onboarding in the session log would create a user turn or model-visible context that is unrelated to the user's work. The notice also needs a recognizable DeepSeek composition without copying another product's startup art or maintaining a hand-drawn approximation that drifts from the official mark. @@ -18,7 +18,7 @@ The marker is launcher state rather than session persistence because eligibility The supplied official `24x24` DeepSeek SVG is committed as the visual source. Static full, compact, and minimal terminal rasters sample that exact path at decreasing square resolutions; they do not redraw the contour. Unicode `▀`/`▄`/`█` cells preserve two vertical source pixels per terminal cell, while an explicitly ASCII-only locale uses the bit-equivalent `'`/`_`/`#` fallback. ANSI styling stays outside both the SVG and editable copy: `ctx.tui` supplies a semantic `brand` role, using the official `#4D6BFE` ink when truecolor is available, standard ANSI blue otherwise, and plain text when color is disabled. The normal startup banner retains its existing gradient. -The overlay is centered and consumes the available terminal width, while its height follows actual content and treats 90% of the viewport only as an upper bound. Wide terminals place the full icon beside the title and prose; medium and narrow terminals stack the compact or minimal icon above them; low height removes the icon before reducing prose space. The prose scrolls while the title and only action remain fixed. The quotation is promoted to its own visual paragraph without changing the centrally owned Chinese string. Closing through Enter returns modal ownership to the existing FIFO manager, which restores the editor and leaves the normal startup banner, transcript, and focus behavior intact. +The overlay is centered and consumes the available terminal width, while its height follows actual content and treats 90% of the viewport only as an upper bound. Wide terminals place the full icon beside the title and prose; medium and narrow terminals stack the compact or minimal icon above them; low height removes the icon before reducing prose space. The prose scrolls while the title and only action remain fixed. Every locale uses the same centrally owned Chinese copy, and the quotation is promoted to its own visual paragraph without changing that string. Closing through Enter returns modal ownership to the existing FIFO manager, which restores the editor and leaves the normal startup banner, transcript, and focus behavior intact. ## Verification @@ -38,6 +38,6 @@ Focused unit coverage pins the supplied SVG and Chinese copy hashes, version bum ## Consequences -Each Harness home receives the notice once per copy version, only after a successful Enter acknowledgement. Maintainers can edit all bilingual wording and the version in one small owner file, and can update the official SVG and derived static rasters in their separate visual owner without chasing snapshots for full prose copies. +Each Harness home receives the notice once per copy version, only after a successful Enter acknowledgement. Maintainers can edit the all-locale Chinese wording and version in one small owner file, and can update the official SVG and derived static rasters in their separate visual owner without chasing snapshots for full prose copies. The terminal cannot display SVG vectors directly, so its faithful representation is resolution-bounded. Smaller tiers preserve the sampled silhouette but necessarily lose fine detail; low-height terminals prefer readable prose and an always-reachable action over brand art. The marker format is intentionally one-file-per-version during the pre-release period; old markers are harmless and no compatibility reader is required. diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md index 132899b948..1d6a3da639 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -已交付的 `dsh` 终端会直接进入编辑器,没有为首次使用的内部测试者提供持久的产品成熟度说明或反馈渠道指引。现有单行 `welcome` banner 副标题无法容纳指定的四段通知,否则会挤占正常的会话 header;如果把 onboarding 写入会话日志,还会创建一个与用户工作无关的用户轮次或模型可见上下文。 +已交付的 `dsh` 终端会直接进入编辑器,没有为首次使用的内部测试者提供持久的产品成熟度说明或反馈渠道指引。现有单行 `welcome` banner 副标题无法容纳指定通知,否则会挤占正常的会话 header;如果把 onboarding 写入会话日志,还会创建一个与用户工作无关的用户轮次或模型可见上下文。 该通知还需要形成具有辨识度的 DeepSeek 视觉构图,同时不能复制其他产品的启动图形,也不能维护一份会偏离官方标志的手绘近似图。 @@ -18,7 +18,7 @@ Status: implemented 指定的官方 `24x24` DeepSeek SVG 作为视觉真源提交。静态的完整、紧凑和最小终端栅格图以逐级降低的方形分辨率对该精确路径取样,不会重新绘制轮廓。Unicode `▀`/`▄`/`█` 单元格让每个终端单元格保留两个垂直方向的源像素;明确仅支持 ASCII 的 locale 则使用位级等价的 `'`/`_`/`#` 回退。ANSI 样式与 SVG 和可编辑文案完全分离:`ctx.tui` 提供语义化 `brand` 角色,在真彩色可用时使用官方 `#4D6BFE` 色值,否则使用标准 ANSI 蓝色;禁用颜色时则使用纯文本。普通启动 banner 保留现有渐变。 -浮层居中显示,并使用可用的终端宽度;高度则跟随实际内容,仅将 viewport 的 90% 作为上限。宽终端将完整图标置于标题与正文旁边;中等和窄终端把紧凑或最小图标堆叠在正文上方;高度不足时,先移除图标,再减少正文空间。正文可以滚动,而标题和唯一操作保持固定。引用内容会提升为独立的视觉段落,但不会改变集中持有的中文字符串。通过 Enter 关闭浮层后,模态所有权会交还给现有 FIFO 管理器;该管理器恢复编辑器,同时保持正常的启动 banner、transcript(文本记录)和焦点行为不变。 +浮层居中显示,并使用可用的终端宽度;高度则跟随实际内容,仅将 viewport 的 90% 作为上限。宽终端将完整图标置于标题与正文旁边;中等和窄终端把紧凑或最小图标堆叠在正文上方;高度不足时,先移除图标,再减少正文空间。正文可以滚动,而标题和唯一操作保持固定。所有 locale 共用同一份集中持有的中文文案,引用内容会提升为独立的视觉段落,但不会改变该字符串。通过 Enter 关闭浮层后,模态所有权会交还给现有 FIFO 管理器;该管理器恢复编辑器,同时保持正常的启动 banner、transcript(文本记录)和焦点行为不变。 ## 验证 @@ -38,6 +38,6 @@ Status: implemented ## 后果 -每个 Harness 主目录在每个文案版本中都会收到一次通知,且仅在用户成功按 Enter 确认后停止展示。维护者可以在一个小型 owner 文件中编辑所有双语文案和版本,也可以在独立的视觉 owner 中更新官方 SVG 与衍生静态栅格图,无需到各个快照中查找完整正文副本。 +每个 Harness 主目录在每个文案版本中都会收到一次通知,且仅在用户成功按 Enter 确认后停止展示。维护者可以在一个小型 owner 文件中编辑所有 locale 共用的中文文案和版本,也可以在独立的视觉 owner 中更新官方 SVG 与衍生静态栅格图,无需到各个快照中查找完整正文副本。 终端无法直接显示 SVG 矢量图,因此其忠实呈现受分辨率限制。较小分级会保留取样后的轮廓,但必然损失精细细节;低高度终端优先保障正文可读和操作始终可达,而非展示品牌图形。在预发布阶段,标记格式有意采用每个版本一个文件;旧标记无害,也无需兼容性读取器。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index c5a38937d2..fb3b8af50f 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: e01989d18d7a1567ea0d62628d207bdebd8e27e1 -README.zh.md: 4df2b0911059c24baa5f6bec315e7fdf523213f7 +README.md: 2f7238b3c05f6b76288c5e26b14efd2e64485677 +README.zh.md: af4004be225307fc3863bf6f227ef95f7ca0c14f diff --git a/apps/cli/README.md b/apps/cli/README.md index e01989d18d..2f7238b3c0 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -12,7 +12,7 @@ The TUI surface: - treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh meta` is the sole exception, below); - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `config.yaml` patches the booted tree, while `.env` there is the credential provider's own store (never hoisted into the environment, so keys stay rotatable). Environment precedence is ambient > project `.env`. -- presents the [versioned first-run welcome](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md) through the mounted TUI overlay service when its immutable marker is absent under `DSH_HOME`; only Enter creates that version's marker, while Escape, disposal, or process exit leaves it eligible. The official DeepSeek icon, responsive terminal rasters, bilingual copy, and notice version are static local owners; the overlay never writes a session event or model context. +- presents the [versioned first-run welcome](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md) through the mounted TUI overlay service when its immutable marker is absent under `DSH_HOME`; only Enter creates that version's marker, while Escape, disposal, or process exit leaves it eligible. The official DeepSeek icon, responsive terminal rasters, all-locale Chinese copy, and notice version are static local owners; the overlay never writes a session event or model context. `dsh meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after the environment is settled, so precedence is unchanged while the session cwd and HMR watch root move together. Meta always starts a fresh session and accepts no default-surface options; use ordinary `dsh --resume ` to resume a persisted session. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 4df2b09110..af4004be22 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -12,7 +12,7 @@ TUI 界面: - 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析(`dsh meta` 是唯一例外,见下文); - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`config.yaml` 修补已启动的树,而那里的 `.env` 是凭据 provider 自己的存储(绝不会被提升进环境,因此密钥始终可轮换)。环境优先级为环境中已有的值 > 项目 `.env`。 -- 当 `DSH_HOME` 下不存在不可变确认标记时,通过已挂载的 TUI overlay 服务呈现[版本化首次运行欢迎页](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md);只有 Enter 会创建该版本的标记,Escape、资源释放或进程退出仍保留展示资格。官方 DeepSeek 图标、响应式终端栅格图、双语文案和通知版本均由静态本地文件持有;overlay 不会写入会话事件或模型上下文。 +- 当 `DSH_HOME` 下不存在不可变确认标记时,通过已挂载的 TUI overlay 服务呈现[版本化首次运行欢迎页](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md);只有 Enter 会创建该版本的标记,Escape、资源释放或进程退出仍保留展示资格。官方 DeepSeek 图标、响应式终端栅格图、所有 locale 共用的中文文案和通知版本均由静态本地文件持有;overlay 不会写入会话事件或模型上下文。 `dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在环境确定之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume `。 diff --git a/apps/cli/src/tui-first-run-welcome-copy.ts b/apps/cli/src/tui-first-run-welcome-copy.ts index ae5ebd8ce3..26ddbd17c2 100644 --- a/apps/cli/src/tui-first-run-welcome-copy.ts +++ b/apps/cli/src/tui-first-run-welcome-copy.ts @@ -1,5 +1,5 @@ /** - * Centrally owned version and bilingual copy for the shipped TUI first-run notice. + * Centrally owned version and all-locale Chinese copy for the shipped TUI first-run notice. * * A material wording change increments {@link TUI_FIRST_RUN_WELCOME_NOTICE_VERSION} * so every Harness home presents the revised notice once. @@ -7,7 +7,7 @@ */ /** Copy version persisted after the user explicitly continues. */ -export const TUI_FIRST_RUN_WELCOME_NOTICE_VERSION = 2 +export const TUI_FIRST_RUN_WELCOME_NOTICE_VERSION = 3 /** Locale-shaped text rendered by the first-run welcome overlay. */ export interface TuiFirstRunWelcomeNoticeCopy { @@ -25,37 +25,24 @@ export interface TuiFirstRunWelcomeNoticeCopy { readonly saveError: string } -/** - * Complete notice copy. The shipped TUI currently presents the supplied - * Simplified Chinese locale; English remains its reviewed locale counterpart. - */ +/** Complete Chinese notice used for every locale. */ +const TUI_FIRST_RUN_WELCOME_CHINESE_COPY = Object.freeze({ + title: 'DeepSeek Harness', + paragraphs: Object.freeze([ + '感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。', + '“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。', + '为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以【关闭方式待补充】。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。', + ]), + continueLabel: '继续', + scrollHint: '↑/↓ 滚动', + saving: '正在保存确认…', + saveError: '无法保存确认,请按 Enter 重试。', +}) + +/** Locale map whose entries deliberately share the single Chinese owner copy. */ export const TUI_FIRST_RUN_WELCOME_NOTICE_COPY = Object.freeze({ - 'zh-CN': Object.freeze({ - title: 'DeepSeek Harness', - paragraphs: Object.freeze([ - '感谢您愿意拨冗试用 DeepSeek Harness。', - '目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。', - '“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。', - '我们尤其希望听见那些失败、困惑与不顺手的时刻——如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。', - ]), - continueLabel: '继续', - scrollHint: '↑/↓ 滚动', - saving: '正在保存确认…', - saveError: '无法保存确认,请按 Enter 重试。', - }), - en: Object.freeze({ - title: 'DeepSeek Harness', - paragraphs: Object.freeze([ - 'Thank you for taking the time to try DeepSeek Harness.', - 'This release is still in internal testing. Features remain unfinished, and the experience will inevitably feel somewhat rough.', - '“As one cuts and files, as one carves and polishes.” A product grows through real encounters and candid feedback. Problems you discover in real use may lead us to re-examine, or even overturn, existing designs.', - 'We especially want to hear about moments of failure, confusion, and friction. If you have any feedback or suggestions, please leave a message in the company WeChat group and let us know. Every piece of feedback helps us refine it.', - ]), - continueLabel: 'Continue', - scrollHint: '↑/↓ Scroll', - saving: 'Saving acknowledgement…', - saveError: 'Could not save the acknowledgement. Press Enter to retry.', - }), + 'zh-CN': TUI_FIRST_RUN_WELCOME_CHINESE_COPY, + en: TUI_FIRST_RUN_WELCOME_CHINESE_COPY, }) /** Locale presented by the shipped first-run notice. */ diff --git a/apps/cli/src/tui-first-run-welcome.ts b/apps/cli/src/tui-first-run-welcome.ts index 8b1bf7e7ed..1b280f84d2 100644 --- a/apps/cli/src/tui-first-run-welcome.ts +++ b/apps/cli/src/tui-first-run-welcome.ts @@ -169,8 +169,8 @@ export function tuiFirstRunWelcomeArtTier( viewportRows: number, ): TuiFirstRunWelcomeArtTier | undefined { if (innerWidth >= 96 && viewportRows >= 23) return 'full' - if (innerWidth >= 64 && viewportRows >= 18) return 'compact' - if (innerWidth >= 48 && viewportRows >= 14) return 'minimal' + if (innerWidth >= 80 && viewportRows >= 34) return 'compact' + if (innerWidth >= 64 && viewportRows >= 14) return 'minimal' return undefined } diff --git a/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt b/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt index fee357976f..2bc2914a52 100644 --- a/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt @@ -10,52 +10,52 @@ overlay 120x30 rows=20 style 0-0 dim style 9-38 fg=blue style 119-119 dim -3| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。 │" +3| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能 │" style 0-0 dim style 4-43 fg=blue style 119-119 dim -4| "│ ▄███████████████████▄ ████████████▀ │" +4| "│ ▄███████████████████▄ ████████████▀ 仍待完善,体验难免有些粗糙。 │" style 0-0 dim style 4-43 fg=blue style 119-119 dim -5| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。 │" +5| "│ ▄██████████████████████▄ ▀█████████▀ │" style 0-0 dim style 4-42 fg=blue style 119-119 dim -6| "│ ▄███▀█████████████████████▄ ████▀▀ │" +6| "│ ▄███▀█████████████████████▄ ████▀▀ “如切如磋,如琢如磨。” │" style 0-0 dim style 6-41 fg=blue - style 119-119 dim -7| "│ ███ ▀▀█████████▀▀▀█████████▀ “如切如磋,如琢如磨。” │" - style 0-0 dim - style 7-40 fg=blue style 49-70 bold style 119-119 dim -8| "│ ███ ▀███████▀█ ▀███████ │" +7| "│ ███ ▀▀█████████▀▀▀█████████▀ │" + style 0-0 dim + style 7-40 fg=blue + style 119-119 dim +8| "│ ███ ▀███████▀█ ▀███████ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的 │" style 0-0 dim style 7-39 fg=blue style 119-119 dim -9| "│ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的 │" +9| "│ ███▄ ▀███████▄ ▀█████▀ 问题,也可能促使我们重新审视,甚至推翻已有的设计。 │" style 0-0 dim style 7-39 fg=blue style 119-119 dim -10| "│ ▀███ ▀██████████████ 问题,也可能促使我们重新审视,甚至推翻已有的设计。 │" +10| "│ ▀███ ▀██████████████ │" style 0-0 dim style 8-39 fg=blue style 119-119 dim -11| "│ ▀███▄ ▀███████████▀ │" +11| "│ ▀███▄ ▀███████████▀ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 │" style 0-0 dim style 8-38 fg=blue style 119-119 dim -12| "│ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果您有任何反馈与建议 │" +12| "│ ▀███▄ ▄▄▄ ▀████████▀ Session Log;如需关闭,可以【关闭方式待补充】。另外,如果您有任何反馈 │" style 0-0 dim style 9-38 fg=blue style 119-119 dim -13| "│ █████▄ ███▄▄ ▀█████▄▄ ,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更 │" +13| "│ █████▄ ███▄▄ ▀█████▄▄ 与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打 │" style 0-0 dim style 9-38 fg=blue style 119-119 dim -14| "│ ▀█████████████▄▄▄▄█▀█████▀ 好。 │" +14| "│ ▀█████████████▄▄▄▄█▀█████▀ 磨得更好。 │" style 0-0 dim style 8-39 fg=blue style 119-119 dim diff --git a/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt b/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt index d2d14a2360..91b6610600 100644 --- a/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt @@ -10,7 +10,7 @@ overlay 160x30 rows=20 style 0-0 dim style 9-38 fg=blue style 159-159 dim -3| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。 │" +3| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。 │" style 0-0 dim style 4-43 fg=blue style 159-159 dim @@ -18,40 +18,40 @@ overlay 160x30 rows=20 style 0-0 dim style 4-43 fg=blue style 159-159 dim -5| "│ ▄██████████████████████▄ ▀█████████▀ 目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。 │" +5| "│ ▄██████████████████████▄ ▀█████████▀ “如切如磋,如琢如磨。” │" style 0-0 dim style 4-42 fg=blue + style 49-70 bold style 159-159 dim 6| "│ ▄███▀█████████████████████▄ ████▀▀ │" style 0-0 dim style 6-41 fg=blue style 159-159 dim -7| "│ ███ ▀▀█████████▀▀▀█████████▀ “如切如磋,如琢如磨。” │" +7| "│ ███ ▀▀█████████▀▀▀█████████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已 │" style 0-0 dim style 7-40 fg=blue - style 49-70 bold style 159-159 dim -8| "│ ███ ▀███████▀█ ▀███████ │" +8| "│ ███ ▀███████▀█ ▀███████ 有的设计。 │" style 0-0 dim style 7-39 fg=blue style 159-159 dim -9| "│ ███▄ ▀███████▄ ▀█████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已 │" +9| "│ ███▄ ▀███████▄ ▀█████▀ │" style 0-0 dim style 7-39 fg=blue style 159-159 dim -10| "│ ▀███ ▀██████████████ 有的设计。 │" +10| "│ ▀███ ▀██████████████ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以【关闭方式待补 │" style 0-0 dim style 8-39 fg=blue style 159-159 dim -11| "│ ▀███▄ ▀███████████▀ │" +11| "│ ▀███▄ ▀███████████▀ 充】。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。 │" style 0-0 dim style 8-38 fg=blue style 159-159 dim -12| "│ ▀███▄ ▄▄▄ ▀████████▀ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反 │" +12| "│ ▀███▄ ▄▄▄ ▀████████▀ │" style 0-0 dim style 9-38 fg=blue style 159-159 dim -13| "│ █████▄ ███▄▄ ▀█████▄▄ 馈,都会帮助我们把它打磨得更好。 │" +13| "│ █████▄ ███▄▄ ▀█████▄▄ │" style 0-0 dim style 9-38 fg=blue style 159-159 dim diff --git a/apps/cli/tests/tui-first-run-snapshots/60-columns-low-height.expected.txt b/apps/cli/tests/tui-first-run-snapshots/60-columns-low-height.expected.txt index 7271f27e79..bdf8a46a6f 100644 --- a/apps/cli/tests/tui-first-run-snapshots/60-columns-low-height.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/60-columns-low-height.expected.txt @@ -8,13 +8,13 @@ overlay 60x12 rows=10 2| "│ │" style 0-0 dim style 59-59 dim -3| "│ 感谢您愿意拨冗试用 DeepSeek Harness。 │" +3| "│ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部 │" style 0-0 dim style 59-59 dim -4| "│ │" +4| "│ 测试阶段,功能仍待完善,体验难免有些粗糙。 │" style 0-0 dim style 59-59 dim -5| "│ 目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些 │" +5| "│ │" style 0-0 dim style 59-59 dim 6| "├──────────────────────────────────────────────────────────┤" diff --git a/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt b/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt index 3737b99fd6..daa9bbfc67 100644 --- a/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt @@ -1,90 +1,61 @@ -overlay 60x30 rows=27 +overlay 60x30 rows=20 0| "╭──────────────────────────────────────────────────────────╮" style 0-59 dim -1| "│ ▄▄▄▄▄▄ ▄▄ │" - style 0-0 dim - style 23-36 fg=blue - style 59-59 dim -2| "│ ▄████████▄ ▀████▀ │" - style 0-0 dim - style 21-38 fg=blue - style 59-59 dim -3| "│ █▀▀▀▀███████▄██▀ │" - style 0-0 dim - style 22-37 fg=blue - style 59-59 dim -4| "│ █▄ ▀███ ▀███ │" - style 0-0 dim - style 22-36 fg=blue - style 59-59 dim -5| "│ ▀█▄ ▀█████ │" - style 0-0 dim - style 23-36 fg=blue - style 59-59 dim -6| "│ ▀█▄▄ █▄▄▀███▄ │" - style 0-0 dim - style 23-36 fg=blue - style 59-59 dim -7| "│ ▀▀▀▀▀▀ │" - style 0-0 dim - style 25-34 fg=blue - style 59-59 dim -8| "│ │" - style 0-0 dim - style 59-59 dim -9| "│ DeepSeek Harness │" +1| "│ DeepSeek Harness │" style 0-0 dim style 22-37 fg=blue bold style 59-59 dim -10| "│ │" +2| "│ │" style 0-0 dim style 59-59 dim -11| "│ 感谢您愿意拨冗试用 DeepSeek Harness。 │" +3| "│ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部 │" style 0-0 dim style 59-59 dim -12| "│ │" +4| "│ 测试阶段,功能仍待完善,体验难免有些粗糙。 │" style 0-0 dim style 59-59 dim -13| "│ 目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些 │" +5| "│ │" style 0-0 dim style 59-59 dim -14| "│ 粗糙。 │" - style 0-0 dim - style 59-59 dim -15| "│ │" - style 0-0 dim - style 59-59 dim -16| "│ “如切如磋,如琢如磨。” │" +6| "│ “如切如磋,如琢如磨。” │" style 0-0 dim style 2-23 bold style 59-59 dim -17| "│ │" +7| "│ │" style 0-0 dim style 59-59 dim -18| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实 │" +8| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实 │" style 0-0 dim style 59-59 dim -19| "│ 使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的 │" +9| "│ 使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的 │" style 0-0 dim style 59-59 dim -20| "│ 设计。 │" +10| "│ 设计。 │" style 0-0 dim style 59-59 dim -21| "│ │" +11| "│ │" style 0-0 dim style 59-59 dim -22| "│ 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果您有任 │" +12| "│ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认 │" style 0-0 dim style 59-59 dim -23| "├──────────────────────────────────────────────────────────┤" +13| "│ 会上传所有 Session Log;如需关闭,可以【关闭方式待补充】 │" + style 0-0 dim + style 59-59 dim +14| "│ 。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉 │" + style 0-0 dim + style 59-59 dim +15| "│ 我们。每一条反馈,都会帮助我们把它打磨得更好。 │" + style 0-0 dim + style 59-59 dim +16| "├──────────────────────────────────────────────────────────┤" style 0-59 dim -24| "│ Enter 继续 │" +17| "│ Enter 继续 │" style 0-0 dim style 24-34 fg=bright-magenta bold style 59-59 dim -25| "│ ↑/↓ 滚动 ↓ │" +18| "│ │" style 0-0 dim - style 24-35 dim style 59-59 dim -26| "╰──────────────────────────────────────────────────────────╯" +19| "╰──────────────────────────────────────────────────────────╯" style 0-59 dim diff --git a/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt b/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt index bfab89be3f..09fb756633 100644 --- a/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt @@ -1,93 +1,86 @@ -overlay 80x30 rows=27 +overlay 80x30 rows=26 0| "╭──────────────────────────────────────────────────────────────────────────────╮" style 0-79 dim -1| "│ ▄▄▄▄▄▄▄██▀ █▄ ▄ │" +1| "│ ▄▄▄▄▄▄ ▄▄ │" style 0-0 dim - style 26-53 fg=blue + style 33-46 fg=blue style 79-79 dim -2| "│ ▄███████████▄▄ ███▄▄████ │" +2| "│ ▄████████▄ ▀████▀ │" style 0-0 dim - style 26-53 fg=blue + style 31-48 fg=blue style 79-79 dim -3| "│ ████████████████▄ ▀██████▀ │" +3| "│ █▀▀▀▀███████▄██▀ │" style 0-0 dim - style 26-52 fg=blue + style 32-47 fg=blue style 79-79 dim -4| "│ ██▀▀▀▀▀████████████▄▄██▀ │" +4| "│ █▄ ▀███ ▀███ │" style 0-0 dim - style 28-51 fg=blue + style 32-46 fg=blue style 79-79 dim -5| "│ ██ ▀█████▄ ▀█████ │" +5| "│ ▀█▄ ▀█████ │" style 0-0 dim - style 28-50 fg=blue + style 33-46 fg=blue style 79-79 dim -6| "│ ██▄ ▀████▄ ▄████ │" +6| "│ ▀█▄▄ █▄▄▀███▄ │" style 0-0 dim - style 28-50 fg=blue + style 33-46 fg=blue style 79-79 dim -7| "│ ██▄ ████████▀ │" +7| "│ ▀▀▀▀▀▀ │" style 0-0 dim - style 29-50 fg=blue + style 35-44 fg=blue style 79-79 dim -8| "│ ██▄ ▄▄ ▀█████▀ │" - style 0-0 dim - style 29-49 fg=blue - style 79-79 dim -9| "│ ▀███▄▄▄███▄ ████▄▄ │" - style 0-0 dim - style 29-50 fg=blue - style 79-79 dim -10| "│ ▀▀▀███████▀▀ │" - style 0-0 dim - style 31-47 fg=blue - style 79-79 dim -11| "│ │" +8| "│ │" style 0-0 dim style 79-79 dim -12| "│ DeepSeek Harness │" +9| "│ DeepSeek Harness │" style 0-0 dim style 32-47 fg=blue bold style 79-79 dim +10| "│ │" + style 0-0 dim + style 79-79 dim +11| "│ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完 │" + style 0-0 dim + style 79-79 dim +12| "│ 善,体验难免有些粗糙。 │" + style 0-0 dim + style 79-79 dim 13| "│ │" style 0-0 dim style 79-79 dim -14| "│ 感谢您愿意拨冗试用 DeepSeek Harness。 │" +14| "│ “如切如磋,如琢如磨。” │" style 0-0 dim + style 2-23 bold style 79-79 dim 15| "│ │" style 0-0 dim style 79-79 dim -16| "│ 目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。 │" +16| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也 │" style 0-0 dim style 79-79 dim -17| "│ │" +17| "│ 可能促使我们重新审视,甚至推翻已有的设计。 │" style 0-0 dim style 79-79 dim -18| "│ “如切如磋,如琢如磨。” │" - style 0-0 dim - style 2-23 bold - style 79-79 dim -19| "│ │" +18| "│ │" style 0-0 dim style 79-79 dim -20| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也 │" +19| "│ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session │" style 0-0 dim style 79-79 dim -21| "│ 可能促使我们重新审视,甚至推翻已有的设计。 │" +20| "│ Log;如需关闭,可以【关闭方式待补充】。另外,如果您有任何反馈与建议,请在企 │" style 0-0 dim style 79-79 dim -22| "│ │" +21| "│ 业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。 │" style 0-0 dim style 79-79 dim -23| "├──────────────────────────────────────────────────────────────────────────────┤" +22| "├──────────────────────────────────────────────────────────────────────────────┤" style 0-79 dim -24| "│ Enter 继续 │" +23| "│ Enter 继续 │" style 0-0 dim style 34-44 fg=bright-magenta bold style 79-79 dim -25| "│ ↑/↓ 滚动 ↓ │" +24| "│ │" style 0-0 dim - style 34-45 dim style 79-79 dim -26| "╰──────────────────────────────────────────────────────────────────────────────╯" +25| "╰──────────────────────────────────────────────────────────────────────────────╯" style 0-79 dim diff --git a/apps/cli/tests/tui-first-run-welcome.spec.ts b/apps/cli/tests/tui-first-run-welcome.spec.ts index 245db27bec..0f040de8d1 100644 --- a/apps/cli/tests/tui-first-run-welcome.spec.ts +++ b/apps/cli/tests/tui-first-run-welcome.spec.ts @@ -60,6 +60,7 @@ function hostFixture(rows: number): { } const copy = TUI_FIRST_RUN_WELCOME_NOTICE_COPY[TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE] +const openingSentence = `${copy.paragraphs[0]!.split('。', 1)[0]}。` const temporaryHomes: string[] = [] function withoutWhitespace(value: string): string { @@ -124,15 +125,17 @@ describe('TUI first-run welcome composition', () => { expect(createHash('sha256').update(icon).digest('hex')) .toBe('deba5f98a5c1796e20fcac3149bcd7eb8a32f0bdd04d048819400b1f28bd1439') expect(createHash('sha256').update(copy.paragraphs.join('\n')).digest('hex')) - .toBe('e1b8ea95d9f8af276c4b2b76fff23fa068bdad61b5d4a10300600ed47572900d') + .toBe('54389347f93109c7cb17baa4312ae55eaefe77cbbf2ffe3e7579a4538e9f5738') + expect(TUI_FIRST_RUN_WELCOME_NOTICE_COPY.en).toBe(copy) }) it.each([ - { columns: 60, inner: 50, rows: 30, tier: 'minimal' }, - { columns: 80, inner: 68, rows: 30, tier: 'compact' }, + { columns: 60, inner: 50, rows: 30, tier: undefined }, + { columns: 80, inner: 68, rows: 30, tier: 'minimal' }, + { columns: 100, inner: 84, rows: 34, tier: 'compact' }, { columns: 120, inner: 104, rows: 30, tier: 'full' }, { columns: 160, inner: 140, rows: 30, tier: 'full' }, - ] as const)('renders the $tier composition at $columns columns without overdraw', ({ inner, rows, tier }) => { + ] as const)('renders the responsive composition at $columns columns without overdraw', ({ inner, rows, tier }) => { const fixture = hostFixture(rows) const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}) const renderWidth = inner + 4 @@ -140,7 +143,17 @@ describe('TUI first-run welcome composition', () => { expect(tuiFirstRunWelcomeArtTier(inner, rows)).toBe(tier) expect(lines.every(line => tuiVisibleWidth(line) <= renderWidth)).toBe(true) - expect(lines.join('\n')).toContain(TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[0]!.trim()) + if (tier === undefined) { + expect(lines.join('\n')).not.toMatch(/[▀▄█]/u) + } else { + expect(lines.join('\n')).toContain(TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[0]!.trim()) + } + const rendered = lines.join('\n') + const placeholder = copy.paragraphs.at(-1)!.match(/【[^】]+】/u)![0] + expect(rendered).not.toContain(copy.scrollHint) + expect(rendered).toContain(copy.paragraphs.at(-1)!.match(/[A-Za-z]+ [A-Za-z]+/u)![0]) + expect(rendered).toContain(placeholder.slice(0, 3)) + expect(rendered).toContain(placeholder.slice(-3)) expect(lines.join('\n')).toContain(`Enter ${copy.continueLabel}`) expect(lines.length).toBeLessThanOrEqual(Math.floor(rows * 0.9)) expect(lines.length).toBeGreaterThan(5) @@ -151,12 +164,12 @@ describe('TUI first-run welcome composition', () => { const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}) const initial = component.render(54).join('\n') expect(tuiFirstRunWelcomeArtTier(50, 10)).toBeUndefined() - expect(initial).toContain(copy.paragraphs[0]) + expect(initial).toContain(openingSentence) expect(initial).toContain(`Enter ${copy.continueLabel}`) component.handleInput('\x1b[F') const end = component.render(54).join('\n') - expect(withoutWhitespace(end)).toContain(withoutWhitespace(copy.paragraphs.at(-1)!.slice(-10))) + expect(withoutWhitespace(end)).toContain(withoutWhitespace(copy.paragraphs.at(-1)!.slice(-7))) expect(end).toContain(`Enter ${copy.continueLabel}`) for (const key of ['\x1b[A', '\x1b[B', '\x1b[5~', '\x1b[6~', '\x1b[H', 'x']) { @@ -173,11 +186,20 @@ describe('TUI first-run welcome composition', () => { expect(lines.every(line => tuiVisibleWidth(line) <= 6)).toBe(true) }) + it('keeps the side-by-side composition aligned when prose outgrows the full raster', () => { + const fixture = hostFixture(40) + const longCopy = { ...copy, paragraphs: [copy.paragraphs.join(' ').repeat(4)] } + const component = new TuiFirstRunWelcomeComponent(fixture.host, longCopy, async () => {}) + const lines = component.render(100) + expect(lines.length).toBeGreaterThan(TUI_FIRST_RUN_WELCOME_WHALE.full.unicode.length) + expect(lines.every(line => tuiVisibleWidth(line) <= 100)).toBe(true) + }) + it('renders the bit-equivalent ASCII icon fallback for an explicitly non-Unicode terminal', () => { const fixture = hostFixture(30) const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}, true) const rendered = component.render(72).join('\n') - expect(rendered).toContain(TUI_FIRST_RUN_WELCOME_WHALE.compact.ascii[0]!.trim()) + expect(rendered).toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.ascii[0]!.trim()) expect(rendered).not.toMatch(/[▀▄█]/u) }) @@ -264,6 +286,6 @@ describe('TUI first-run welcome composition', () => { apply(ctx, { dshHome: home, asciiArt: true }) expect(request?.create(fixture.host).render(72).join('\n')) - .toContain(TUI_FIRST_RUN_WELCOME_WHALE.compact.ascii[0]!.trim()) + .toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.ascii[0]!.trim()) }) }) diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 6007ae69c4..7901a79555 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -172,6 +172,7 @@ function smoke(overrides: Partial & { } const firstRunCopy = TUI_FIRST_RUN_WELCOME_NOTICE_COPY[TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE] +const firstRunOpeningSentence = `${firstRunCopy.paragraphs[0]!.split('。', 1)[0]}。` /** Keep only the overlay rows, excluding platform-specific scrollback and the underlying TUI. */ function overlaySnapshot(snapshot: string, columns: number, rows: number): string { @@ -220,11 +221,11 @@ const SELECT_PRO_MODEL = [ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { it.each([ - { columns: 60, tier: 'minimal' }, - { columns: 80, tier: 'compact' }, + { columns: 60, tier: undefined }, + { columns: 80, tier: 'minimal' }, { columns: 120, tier: 'full' }, { columns: 160, tier: 'full' }, - ] as const)('renders and acknowledges the $tier first-run composition at $columns columns', async ({ columns, tier }) => { + ] as const)('renders and acknowledges the responsive first-run composition at $columns columns', async ({ columns, tier }) => { const output = await smoke({ label: `dsh first-run welcome ${String(columns)} columns`, tempDirPrefix: `dsh-tui-welcome-${String(columns)}-`, @@ -251,9 +252,13 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { } }, }) - await expect(await firstRunFrameSnapshot(output, firstRunCopy.paragraphs[0]!, columns, 30)) + await expect(await firstRunFrameSnapshot(output, firstRunOpeningSentence, columns, 30)) .toMatchFileSnapshot(join(firstRunSnapshots, `${String(columns)}-columns.expected.txt`)) - expect(output).toContain(TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[0]!.trim()) + if (tier === undefined) { + expect(output).not.toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.unicode[0]!.trim()) + } else { + expect(output).toContain(TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[0]!.trim()) + } expect(output).toContain(`Enter ${firstRunCopy.continueLabel}`) }, PTY_SMOKE_TEST_TIMEOUT_MS) @@ -267,7 +272,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { columns: 60, rows: 12, actions: [ - { waitFor: firstRunCopy.paragraphs[0]!, send: '\x1b[F' }, + { waitFor: firstRunOpeningSentence, send: '\x1b[F' }, { waitFor: `Enter ${firstRunCopy.continueLabel}`, occurrence: 2, @@ -276,10 +281,10 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { }, ], }) - await expect(await firstRunFrameSnapshot(output, firstRunCopy.paragraphs[0]!, 60, 12)) + await expect(await firstRunFrameSnapshot(output, firstRunOpeningSentence, 60, 12)) .toMatchFileSnapshot(join(firstRunSnapshots, '60-columns-low-height.expected.txt')) expect(output).toContain(firstRunCopy.title) - expect(output).toContain(firstRunCopy.paragraphs[0]) + expect(output).toContain(firstRunOpeningSentence) expect(output).toContain('企业微信群') expect(output).toContain(`Enter ${firstRunCopy.continueLabel}`) expect(output).not.toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.unicode[0]!.trim()) @@ -310,7 +315,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { expectedExitCode: process.platform === 'win32' ? 0 : -15, actions: [{ waitFor: 'main-session-', signal: 'SIGTERM' }], }) - expect(second).not.toContain(firstRunCopy.paragraphs[0]) + expect(second).not.toContain(firstRunOpeningSentence) expect(second).not.toContain(`Enter ${firstRunCopy.continueLabel}`) } finally { await rm(cwd, { recursive: true, force: true }) @@ -327,7 +332,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { configPath: scriptedConfigPath, showFirstRunWelcome: true, expectedExitCode: -15, - actions: [{ waitFor: firstRunCopy.paragraphs[0]!, signal: 'SIGTERM' }], + actions: [{ waitFor: firstRunOpeningSentence, signal: 'SIGTERM' }], inspect: async (workspace) => { expect(await hasTuiFirstRunWelcomeAcknowledgement(join(workspace, '.dsh'))).toBe(false) }, @@ -344,7 +349,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { { waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r', signalAfterMs: 2_000 }, ], }) - expect(next).toContain(firstRunCopy.paragraphs[0]) + expect(next).toContain(firstRunOpeningSentence) } finally { await rm(cwd, { recursive: true, force: true }) } @@ -557,7 +562,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(appended).not.toContainEqual(expect.objectContaining({ type: 'turn/start' })) }, }) - expect(output).toContain(firstRunCopy.paragraphs[0]) + expect(output).toContain(firstRunOpeningSentence) expect(output).toContain('Resume selector design — DeepSeek Harness') }, PTY_SMOKE_TEST_TIMEOUT_MS) From 85cef9de6f6dc7c2529c4340f4e4fab710ad88ca Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 31 Jul 2026 15:27:10 +0800 Subject: [PATCH 18/28] 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 19/28] 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 346d1e4c74852faddbb2565849d19e2ff8528718 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 31 Jul 2026 17:47:07 +0800 Subject: [PATCH 20/28] fix(tui): address onboarding review feedback --- ...-versioned-tui-first-run-welcome.i18n.yaml | 4 +- ...6-07-30-versioned-tui-first-run-welcome.md | 2 +- ...7-30-versioned-tui-first-run-welcome.zh.md | 2 +- apps/cli/package.json | 1 + .../tui-first-run-welcome-art.ts | 4 +- .../tui-first-run-welcome-copy.ts | 6 +- .../tui-first-run-welcome.ts | 92 ++++++++++------ apps/cli/src/tui.ts | 14 +-- apps/cli/tests/pty-harness.ts | 27 +---- .../120-columns.expected.txt | 6 +- .../160-columns.expected.txt | 6 +- .../60-columns.expected.txt | 19 ++-- .../80-columns.expected.txt | 17 +-- apps/cli/tests/tui-first-run-welcome.spec.ts | 101 ++++++++++++++---- apps/cli/tests/tui-keyless-smoke.e2e.ts | 35 +++--- 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/extension/primitives.ts | 59 ---------- packages/ui/tui/src/index.ts | 7 -- packages/ui/tui/tests/extension.spec.ts | 18 ---- pnpm-lock.yaml | 3 + 23 files changed, 212 insertions(+), 221 deletions(-) rename apps/cli/src/{ => tui-onboarding}/tui-first-run-welcome-art.ts (96%) rename apps/cli/src/{ => tui-onboarding}/tui-first-run-welcome-copy.ts (86%) rename apps/cli/src/{ => tui-onboarding}/tui-first-run-welcome.ts (78%) delete mode 100644 packages/ui/tui/src/extension/primitives.ts diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml index cfc68dc8f1..5a414af614 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.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-versioned-tui-first-run-welcome.md -2026-07-30-versioned-tui-first-run-welcome.md: d78e7a172198de2c61579dc5faa37a4c9f50fb58 -2026-07-30-versioned-tui-first-run-welcome.zh.md: 1d6a3da63938f6de3dfc9b85ef0548e360de9b3e +2026-07-30-versioned-tui-first-run-welcome.md: 5270c239f0bccbf58e68364e195ff2175355a816 +2026-07-30-versioned-tui-first-run-welcome.zh.md: cd132fde5ff0601cef6cd3cd433fdd15dc05f7f0 diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md index d78e7a1721..5270c239f0 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md @@ -12,7 +12,7 @@ The notice also needs a recognizable DeepSeek composition without copying anothe ## Decision -The official `dsh` launcher owns one versioned acknowledgement marker under the resolved `DSH_HOME`. It checks the immutable marker before boot, then mounts an effect-owned consumer of `ctx.tui.openOverlay()` only after the real TUI service is available. Enter is the sole acknowledgement action: the plugin creates and synchronizes the fixed per-version marker before closing. Escape and every other non-navigation input leave the overlay open; disposal or process exit before Enter writes nothing. The version is part of the marker filename, so incrementing the centrally owned notice version presents materially revised copy once without migrating or rewriting an aggregate settings document. +The official `dsh` launcher owns one versioned acknowledgement marker under the resolved `DSH_HOME`. It checks the immutable marker before boot, then mounts an effect-owned consumer of `ctx.tui.openOverlay()` only after the real TUI service is available. Enter is the sole acknowledgement action: the plugin creates and synchronizes the fixed per-version marker before closing. Escape and unrecognized input leave the overlay open; Ctrl+C and Ctrl+D use the normal exit path without acknowledging. Disposal waits for an acknowledgement already started by Enter, while disposal or process exit before Enter writes nothing. The version is part of the marker filename, so incrementing the centrally owned notice version presents materially revised copy once without migrating or rewriting an aggregate settings document. The marker is launcher state rather than session persistence because eligibility spans sessions and workspaces but is scoped to one Harness home. Each Enter syncs a random same-directory file before atomically replacing the fixed marker; concurrent launches publish the same immutable fact, so same-value last-writer-wins replacement has no lost-update shape and needs no lock or dependency on the settings stack. The notice never appends a session event, injects model context, or creates a user turn; resume therefore presents it only when the same Harness home has not acknowledged that version and never replays it from the session log. diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md index 1d6a3da639..cd132fde5f 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -官方 `dsh` 启动器在解析后的 `DSH_HOME` 下持有一个版本化确认标记。它会在启动前检查该不可变标记,并仅在真实 TUI 服务可用后,挂载一个由 effect 持有的 `ctx.tui.openOverlay()` 消费方。Enter 是唯一确认操作:插件先创建并同步固定的逐版本标记,再关闭浮层。Escape 和其他所有非导航输入都会让浮层保持打开;在按 Enter 前进行资源释放或退出进程不会写入任何内容。版本号属于标记文件名的一部分,因此只需递增集中持有的通知版本,即可让有实质修改的文案重新展示一次,无需迁移或改写聚合设置文档。 +官方 `dsh` 启动器在解析后的 `DSH_HOME` 下持有一个版本化确认标记。它会在启动前检查该不可变标记,并仅在真实 TUI 服务可用后,挂载一个由 effect 持有的 `ctx.tui.openOverlay()` 消费方。Enter 是唯一确认操作:插件先创建并同步固定的逐版本标记,再关闭浮层。Escape 和无法识别的输入会让浮层保持打开;Ctrl+C 与 Ctrl+D 通过普通退出路径离开且不确认。资源释放会等待已经由 Enter 启动的确认任务;在按 Enter 前进行资源释放或退出进程不会写入任何内容。版本号属于标记文件名的一部分,因此只需递增集中持有的通知版本,即可让有实质修改的文案重新展示一次,无需迁移或改写聚合设置文档。 该标记属于启动器状态,而非会话持久化,因为展示资格跨越会话与 workspace,但作用域仅限一个 Harness 主目录。每次 Enter 都会先同步一个同目录随机文件,再以原子方式替换固定标记;并发启动发布的是同一个不可变事实,因此同值的最后写入者胜出不会丢失更新,也无需加锁或依赖设置栈。该通知绝不追加会话事件、注入模型上下文或创建用户轮次;因此,恢复会话只会在同一个 Harness 主目录尚未确认该版本时展示通知,也绝不会从会话日志中回放通知。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 127f99a313..a5321ad77b 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -121,6 +121,7 @@ "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", + "@earendil-works/pi-tui": "0.80.7", "commander": "^15.0.0", "cordis": "^4.0.0-rc.7", "js-yaml": "^4.2.0" diff --git a/apps/cli/src/tui-first-run-welcome-art.ts b/apps/cli/src/tui-onboarding/tui-first-run-welcome-art.ts similarity index 96% rename from apps/cli/src/tui-first-run-welcome-art.ts rename to apps/cli/src/tui-onboarding/tui-first-run-welcome-art.ts index eac41d3e19..afaf56db3d 100644 --- a/apps/cli/src/tui-first-run-welcome-art.ts +++ b/apps/cli/src/tui-onboarding/tui-first-run-welcome-art.ts @@ -1,13 +1,13 @@ /** * Static terminal rasters derived from the official 24x24 DeepSeek icon. * - * Source: `../assets/deepseek-color.svg`, whose path data is copied exactly + * Source: `../../assets/deepseek-color.svg`, whose path data is copied exactly * from the supplied official icon (viewBox `0 0 24 24`, fill `#4D6BFE`). Each * tier rasterizes that path into a square binary * mask without redrawing its contour. The Unicode form packs two source rows * into `▀`/`▄`/`█`; the ASCII fallback packs the same two bits into * `'`/`_`/`#`. Assets contain no ANSI and are never generated at runtime. - * @module @deepseek-ai/dsh/tui-first-run-welcome-art + * @module @deepseek-ai/dsh/tui-onboarding/tui-first-run-welcome-art */ /** Responsive official-icon raster tier. */ diff --git a/apps/cli/src/tui-first-run-welcome-copy.ts b/apps/cli/src/tui-onboarding/tui-first-run-welcome-copy.ts similarity index 86% rename from apps/cli/src/tui-first-run-welcome-copy.ts rename to apps/cli/src/tui-onboarding/tui-first-run-welcome-copy.ts index 26ddbd17c2..60cec25c52 100644 --- a/apps/cli/src/tui-first-run-welcome-copy.ts +++ b/apps/cli/src/tui-onboarding/tui-first-run-welcome-copy.ts @@ -3,11 +3,11 @@ * * A material wording change increments {@link TUI_FIRST_RUN_WELCOME_NOTICE_VERSION} * so every Harness home presents the revised notice once. - * @module @deepseek-ai/dsh/tui-first-run-welcome-copy + * @module @deepseek-ai/dsh/tui-onboarding/tui-first-run-welcome-copy */ /** Copy version persisted after the user explicitly continues. */ -export const TUI_FIRST_RUN_WELCOME_NOTICE_VERSION = 3 +export const TUI_FIRST_RUN_WELCOME_NOTICE_VERSION = 4 /** Locale-shaped text rendered by the first-run welcome overlay. */ export interface TuiFirstRunWelcomeNoticeCopy { @@ -31,7 +31,7 @@ const TUI_FIRST_RUN_WELCOME_CHINESE_COPY = Object.freeze { /** Render one visible-width-padded line inside the notice frame. */ function framed(content: string, innerWidth: number, host: TuiOverlayHost): string { - const clipped = truncateTuiText(content, innerWidth) - return `${host.theme.dim('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - tuiVisibleWidth(clipped)))} ${host.theme.dim('│')}` + const clipped = truncateToWidth(content, innerWidth, '') + return `${host.theme.dim('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${host.theme.dim('│')}` } /** Center one line by terminal column width. */ function centered(content: string, width: number): string { - const clipped = truncateTuiText(content, width) - const remaining = Math.max(0, width - tuiVisibleWidth(clipped)) + const clipped = truncateToWidth(content, width, '') + const remaining = Math.max(0, width - visibleWidth(clipped)) return `${' '.repeat(Math.floor(remaining / 2))}${clipped}` } @@ -168,9 +177,10 @@ export function tuiFirstRunWelcomeArtTier( innerWidth: number, viewportRows: number, ): TuiFirstRunWelcomeArtTier | undefined { - if (innerWidth >= 96 && viewportRows >= 23) return 'full' - if (innerWidth >= 80 && viewportRows >= 34) return 'compact' - if (innerWidth >= 64 && viewportRows >= 14) return 'minimal' + const compositionCapacity = Math.max(1, Math.max(7, Math.floor(viewportRows * 0.9)) - 5) + if (innerWidth >= 96 && TUI_FIRST_RUN_WELCOME_WHALE.full.unicode.length <= compositionCapacity) return 'full' + if (innerWidth >= 80 && TUI_FIRST_RUN_WELCOME_WHALE.compact.unicode.length + 4 <= compositionCapacity) return 'compact' + if (innerWidth >= 64 && TUI_FIRST_RUN_WELCOME_WHALE.minimal.unicode.length + 4 <= compositionCapacity) return 'minimal' return undefined } @@ -187,11 +197,11 @@ function proseLines( if (quoteEnd > 0) { const quote = paragraph.slice(0, quoteEnd + 1) const remainder = paragraph.slice(quoteEnd + 1).trimStart() - lines.push(...wrapTuiText(host.theme.bold(host.theme.text(host.display(quote))), width)) + lines.push(...wrapTextWithAnsi(host.theme.bold(host.theme.text(host.display(quote))), width)) lines.push('') - if (remainder !== '') lines.push(...wrapTuiText(host.theme.text(host.display(remainder)), width)) + if (remainder !== '') lines.push(...wrapTextWithAnsi(host.theme.text(host.display(remainder)), width)) } else { - lines.push(...wrapTuiText(host.theme.text(host.display(paragraph)), width)) + lines.push(...wrapTextWithAnsi(host.theme.text(host.display(paragraph)), width)) } } return lines @@ -221,6 +231,7 @@ export class TuiFirstRunWelcomeComponent implements TuiComponent, TuiFocusable { private readonly host: TuiOverlayHost, private readonly copy: TuiFirstRunWelcomeNoticeCopy, private readonly acknowledge: () => Promise, + private readonly exit: () => void, private readonly asciiArt = false, ) {} @@ -234,6 +245,7 @@ export class TuiFirstRunWelcomeComponent implements TuiComponent, TuiFocusable { const availableRows = Math.max(7, Math.floor(viewportRows * 0.9)) const title = this.host.theme.bold(this.host.theme.brand(this.copy.title)) let fixedHeader: string[] = [] + let fullContentHeader: string[] = [] let body: string[] let fullArt: string[] | undefined const fullArtWidth = 44 @@ -241,7 +253,8 @@ export class TuiFirstRunWelcomeComponent implements TuiComponent, TuiFocusable { if (tier === 'full') { fullArt = artLines(tier, fullArtWidth, this.host, this.asciiArt) const contentWidth = Math.max(1, innerWidth - fullArtWidth - 3) - body = [centered(title, contentWidth), '', ...proseLines(this.copy, contentWidth, this.host)] + fullContentHeader = [centered(title, contentWidth), ''] + body = proseLines(this.copy, contentWidth, this.host) } else { const art = tier === undefined ? [] : artLines(tier, innerWidth, this.host, this.asciiArt) fixedHeader = [...art, ...art.length === 0 ? [] : [''], centered(title, innerWidth), ''] @@ -249,7 +262,7 @@ export class TuiFirstRunWelcomeComponent implements TuiComponent, TuiFocusable { } const compositionCapacity = Math.max(1, availableRows - 5) - const bodyLimit = Math.max(1, compositionCapacity - fixedHeader.length) + const bodyLimit = Math.max(1, compositionCapacity - fixedHeader.length - fullContentHeader.length) this.bodyCapacity = Math.min(body.length, bodyLimit) const maxOffset = Math.max(0, body.length - this.bodyCapacity) this.maxScrollOffset = maxOffset @@ -271,12 +284,13 @@ export class TuiFirstRunWelcomeComponent implements TuiComponent, TuiFocusable { ? this.host.theme.dim(this.copy.saving) : this.host.theme.dim(scroll) + const fullContent = [...fullContentHeader, ...visibleBody] const composition = fullArt === undefined ? [...fixedHeader, ...visibleBody] - : Array.from({ length: Math.max(fullArt.length, visibleBody.length) }, (_, index) => { + : Array.from({ length: Math.max(fullArt.length, fullContent.length) }, (_, index) => { const art = fullArt[index] ?? '' - const line = visibleBody[index] ?? '' - const left = `${art}${' '.repeat(Math.max(0, fullArtWidth - tuiVisibleWidth(art)))}` + const line = fullContent[index] ?? '' + const left = `${art}${' '.repeat(Math.max(0, fullArtWidth - visibleWidth(art)))}` return `${left} ${line}` }) @@ -291,17 +305,21 @@ export class TuiFirstRunWelcomeComponent implements TuiComponent, TuiFocusable { } handleInput(data: string): void { - if (matchesTuiKey(data, TuiKey.enter)) { + if (matchesKey(data, Key.ctrl('c')) || matchesKey(data, Key.ctrl('d'))) { + this.exit() + return + } + if (matchesKey(data, Key.enter)) { if (!this.saving) void this.commit() return } - if (this.saving || matchesTuiKey(data, TuiKey.escape)) return - if (matchesTuiKey(data, TuiKey.up)) this.scrollBy(-1) - else if (matchesTuiKey(data, TuiKey.down)) this.scrollBy(1) - else if (matchesTuiKey(data, TuiKey.pageUp)) this.scrollBy(-this.bodyCapacity) - else if (matchesTuiKey(data, TuiKey.pageDown)) this.scrollBy(this.bodyCapacity) - else if (matchesTuiKey(data, TuiKey.home)) this.scrollTo(0) - else if (matchesTuiKey(data, TuiKey.end)) this.scrollTo(this.maxScrollOffset) + if (this.saving || matchesKey(data, Key.escape)) return + if (matchesKey(data, Key.up)) this.scrollBy(-1) + else if (matchesKey(data, Key.down)) this.scrollBy(1) + else if (matchesKey(data, Key.pageUp)) this.scrollBy(-this.bodyCapacity) + else if (matchesKey(data, Key.pageDown)) this.scrollBy(this.bodyCapacity) + else if (matchesKey(data, Key.home)) this.scrollTo(0) + else if (matchesKey(data, Key.end)) this.scrollTo(this.maxScrollOffset) } private scrollBy(delta: number): void { @@ -335,11 +353,23 @@ export class TuiFirstRunWelcomeComponent implements TuiComponent, TuiFocusable { */ export function apply(ctx: Context, config: Config): void { const copy = TUI_FIRST_RUN_WELCOME_NOTICE_COPY[TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE] + const pending = new Set>() + const acknowledge = (): Promise => { + const task = acknowledgeTuiFirstRunWelcome(config.dshHome) + pending.add(task) + const settled = (): void => { pending.delete(task) } + void task.then(settled, settled) + return task + } + ctx.effect(() => async () => { + await Promise.allSettled(pending) + }, 'tui first-run welcome acknowledgement') ctx.tui.openOverlay({ create: host => new TuiFirstRunWelcomeComponent( host, copy, - () => acknowledgeTuiFirstRunWelcome(config.dshHome), + acknowledge, + () => { disposeRootAndExit(ctx, 0) }, config.asciiArt ?? false, ), options: { diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 019e658bc2..207e98e206 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -49,10 +49,10 @@ import { inject as tuiFirstRunWelcomeInject, name as tuiFirstRunWelcomeName, needsTuiFirstRunWelcomeAsciiArt, -} from './tui-first-run-welcome.ts' +} from './tui-onboarding/tui-first-run-welcome.ts' import { TUI_FIRST_RUN_WELCOME_NOTICE_VERSION, -} from './tui-first-run-welcome-copy.ts' +} from './tui-onboarding/tui-first-run-welcome-copy.ts' const NAME = 'dsh' @@ -134,11 +134,6 @@ export async function runTui( process.exit(1) } installFailLoud(NAME) - const dshHome = resolveDshHome() - const showFirstRunWelcome = !await hasTuiFirstRunWelcomeAcknowledgement( - dshHome, - TUI_FIRST_RUN_WELCOME_NOTICE_VERSION, - ) // The bin already loaded the invoking directory's .env, and that is the // whole environment: $DSH_HOME/.env is credentials-local's writable store, // and hoisting it would make every stored key read as a read-only ambient @@ -149,6 +144,11 @@ export async function runTui( // both together. Sessions themselves live under the Harness home so `/resume` // spans every workspace, and are unaffected by this chdir. if (workspace !== undefined) process.chdir(workspace) + const dshHome = resolveDshHome() + const showFirstRunWelcome = !await hasTuiFirstRunWelcomeAcknowledgement( + dshHome, + TUI_FIRST_RUN_WELCOME_NOTICE_VERSION, + ) process.env.DSH_BUNDLED_SKILL_DIR = join(SOURCE_ROOT, 'skills') // The in-place `/resume` handoff re-execs `dsh` with a normalized `--resume` // flag, so the resumed process rehydrates through this same intake. The diff --git a/apps/cli/tests/pty-harness.ts b/apps/cli/tests/pty-harness.ts index 844de8cb0b..07361cf654 100644 --- a/apps/cli/tests/pty-harness.ts +++ b/apps/cli/tests/pty-harness.ts @@ -42,8 +42,6 @@ while time.monotonic() < deadline: if output.count(marker) < actions[action_index].get("occurrence", 1): break action = actions[action_index] - if action.get("delayMs", 0) > 0: - time.sleep(action["delayMs"] / 1000) if "signal" in action: os.kill(pid, getattr(signal, action["signal"])) elif "writeFile" in action: @@ -55,9 +53,6 @@ while time.monotonic() < deadline: os.write(fd, action["send"].encode()) else: os.write(fd, action["send"].encode()) - if "signalAfterMs" in action: - time.sleep(action["signalAfterMs"] / 1000) - os.kill(pid, getattr(signal, action.get("signalAfter", "SIGTERM"))) action_index += 1 waited, candidate = os.waitpid(pid, os.WNOHANG) if waited == pid: @@ -83,19 +78,13 @@ type TuiPtyAction = readonly waitFor: string readonly occurrence?: number readonly send: string - readonly delayMs?: number - /** Terminate the process this many milliseconds after sending input. */ - readonly signalAfterMs?: number - /** Signal used by {@link signalAfterMs}; defaults to `SIGTERM`. */ - readonly signalAfter?: 'SIGTERM' | 'SIGKILL' } - | { readonly waitFor: string; readonly occurrence?: number; readonly signal: 'SIGTERM'; readonly delayMs?: number } + | { readonly waitFor: string; readonly occurrence?: number; readonly signal: 'SIGTERM' } | { readonly waitFor: string readonly occurrence?: number readonly writeFile: { readonly path: string; readonly content: string } readonly send?: string - readonly delayMs?: number } /** Inputs for a keyless real-Loader TUI process smoke. */ @@ -212,19 +201,9 @@ async function runWindowsPtySmoke( mkdirSync(dirname(target), { recursive: true }) writeFileSync(target, action.writeFile.content) const input = action.send - if (input !== undefined) { - if (action.delayMs === undefined) terminal.write(input) - else setTimeout(() => { terminal.write(input) }, action.delayMs) - } + if (input !== undefined) terminal.write(input) } else { - const send = (): void => { - terminal.write(action.send) - if (action.signalAfterMs !== undefined) { - setTimeout(() => { terminal.kill(action.signalAfter ?? 'SIGTERM') }, action.signalAfterMs) - } - } - if (action.delayMs === undefined) send() - else setTimeout(send, action.delayMs) + terminal.write(action.send) } actionIndex += 1 } diff --git a/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt b/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt index 2bc2914a52..9a5051ceb3 100644 --- a/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/120-columns.expected.txt @@ -47,15 +47,15 @@ overlay 120x30 rows=20 style 0-0 dim style 8-38 fg=blue style 119-119 dim -12| "│ ▀███▄ ▄▄▄ ▀████████▀ Session Log;如需关闭,可以【关闭方式待补充】。另外,如果您有任何反馈 │" +12| "│ ▀███▄ ▄▄▄ ▀████████▀ Session Log;如需关闭,请设置环境变量 DSH_TELEMETRY_DISABLED=1。另外 │" style 0-0 dim style 9-38 fg=blue style 119-119 dim -13| "│ █████▄ ███▄▄ ▀█████▄▄ 与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打 │" +13| "│ █████▄ ███▄▄ ▀█████▄▄ ,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈, │" style 0-0 dim style 9-38 fg=blue style 119-119 dim -14| "│ ▀█████████████▄▄▄▄█▀█████▀ 磨得更好。 │" +14| "│ ▀█████████████▄▄▄▄█▀█████▀ 都会帮助我们把它打磨得更好。 │" style 0-0 dim style 8-39 fg=blue style 119-119 dim diff --git a/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt b/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt index 91b6610600..a374d14da8 100644 --- a/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/160-columns.expected.txt @@ -39,15 +39,15 @@ overlay 160x30 rows=20 style 0-0 dim style 7-39 fg=blue style 159-159 dim -10| "│ ▀███ ▀██████████████ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以【关闭方式待补 │" +10| "│ ▀███ ▀██████████████ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,请设置环境变量 │" style 0-0 dim style 8-39 fg=blue style 159-159 dim -11| "│ ▀███▄ ▀███████████▀ 充】。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。 │" +11| "│ ▀███▄ ▀███████████▀ DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我 │" style 0-0 dim style 8-38 fg=blue style 159-159 dim -12| "│ ▀███▄ ▄▄▄ ▀████████▀ │" +12| "│ ▀███▄ ▄▄▄ ▀████████▀ 们把它打磨得更好。 │" style 0-0 dim style 9-38 fg=blue style 159-159 dim diff --git a/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt b/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt index daa9bbfc67..ae53f4bfda 100644 --- a/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/60-columns.expected.txt @@ -1,4 +1,4 @@ -overlay 60x30 rows=20 +overlay 60x30 rows=21 0| "╭──────────────────────────────────────────────────────────╮" style 0-59 dim 1| "│ DeepSeek Harness │" @@ -39,23 +39,26 @@ overlay 60x30 rows=20 12| "│ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认 │" style 0-0 dim style 59-59 dim -13| "│ 会上传所有 Session Log;如需关闭,可以【关闭方式待补充】 │" +13| "│ 会上传所有 Session Log;如需关闭,请设置环境变量 │" style 0-0 dim style 59-59 dim -14| "│ 。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉 │" +14| "│ DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反馈与建议, │" style 0-0 dim style 59-59 dim -15| "│ 我们。每一条反馈,都会帮助我们把它打磨得更好。 │" +15| "│ 请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把 │" style 0-0 dim style 59-59 dim -16| "├──────────────────────────────────────────────────────────┤" +16| "│ 它打磨得更好。 │" + style 0-0 dim + style 59-59 dim +17| "├──────────────────────────────────────────────────────────┤" style 0-59 dim -17| "│ Enter 继续 │" +18| "│ Enter 继续 │" style 0-0 dim style 24-34 fg=bright-magenta bold style 59-59 dim -18| "│ │" +19| "│ │" style 0-0 dim style 59-59 dim -19| "╰──────────────────────────────────────────────────────────╯" +20| "╰──────────────────────────────────────────────────────────╯" style 0-59 dim diff --git a/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt b/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt index 09fb756633..c3a8b5d496 100644 --- a/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt +++ b/apps/cli/tests/tui-first-run-snapshots/80-columns.expected.txt @@ -1,4 +1,4 @@ -overlay 80x30 rows=26 +overlay 80x30 rows=27 0| "╭──────────────────────────────────────────────────────────────────────────────╮" style 0-79 dim 1| "│ ▄▄▄▄▄▄ ▄▄ │" @@ -67,20 +67,23 @@ overlay 80x30 rows=26 19| "│ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session │" style 0-0 dim style 79-79 dim -20| "│ Log;如需关闭,可以【关闭方式待补充】。另外,如果您有任何反馈与建议,请在企 │" +20| "│ Log;如需关闭,请设置环境变量 DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反 │" style 0-0 dim style 79-79 dim -21| "│ 业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。 │" +21| "│ 馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更 │" style 0-0 dim style 79-79 dim -22| "├──────────────────────────────────────────────────────────────────────────────┤" +22| "│ 好。 │" + style 0-0 dim + style 79-79 dim +23| "├──────────────────────────────────────────────────────────────────────────────┤" style 0-79 dim -23| "│ Enter 继续 │" +24| "│ Enter 继续 │" style 0-0 dim style 34-44 fg=bright-magenta bold style 79-79 dim -24| "│ │" +25| "│ │" style 0-0 dim style 79-79 dim -25| "╰──────────────────────────────────────────────────────────────────────────────╯" +26| "╰──────────────────────────────────────────────────────────────────────────────╯" style 0-79 dim diff --git a/apps/cli/tests/tui-first-run-welcome.spec.ts b/apps/cli/tests/tui-first-run-welcome.spec.ts index 0f040de8d1..984ab15a03 100644 --- a/apps/cli/tests/tui-first-run-welcome.spec.ts +++ b/apps/cli/tests/tui-first-run-welcome.spec.ts @@ -4,8 +4,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Context } from 'cordis' +import { visibleWidth } from '@earendil-works/pi-tui' import { - tuiVisibleWidth, type TuiOverlayHost, type TuiOverlayRequest, type TuiTheme, @@ -18,13 +18,19 @@ import { TuiFirstRunWelcomeComponent, tuiFirstRunWelcomeAcknowledgementPath, tuiFirstRunWelcomeArtTier, -} from '../src/tui-first-run-welcome.ts' +} from '../src/tui-onboarding/tui-first-run-welcome.ts' import { TUI_FIRST_RUN_WELCOME_NOTICE_COPY, TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE, TUI_FIRST_RUN_WELCOME_NOTICE_VERSION, -} from '../src/tui-first-run-welcome-copy.ts' -import { TUI_FIRST_RUN_WELCOME_WHALE } from '../src/tui-first-run-welcome-art.ts' +} from '../src/tui-onboarding/tui-first-run-welcome-copy.ts' +import { TUI_FIRST_RUN_WELCOME_WHALE } from '../src/tui-onboarding/tui-first-run-welcome-art.ts' + +const mockDisposeRootAndExit = vi.hoisted(() => vi.fn()) +vi.mock('@deepseek-ai/dsh-tui', async importOriginal => ({ + ...await importOriginal(), + disposeRootAndExit: mockDisposeRootAndExit, +})) const identityTheme: TuiTheme = Object.freeze({ text: (value: string) => value, @@ -63,6 +69,10 @@ const copy = TUI_FIRST_RUN_WELCOME_NOTICE_COPY[TUI_FIRST_RUN_WELCOME_NOTICE_LOCA const openingSentence = `${copy.paragraphs[0]!.split('。', 1)[0]}。` const temporaryHomes: string[] = [] +function artAnchor(tier: keyof typeof TUI_FIRST_RUN_WELCOME_WHALE): string { + return TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[tier === 'full' ? 2 : 0]!.trim() +} + function withoutWhitespace(value: string): string { return value.replace(/\s/gu, '') } @@ -74,6 +84,7 @@ async function temporaryHome(prefix: string): Promise { } afterEach(async () => { + mockDisposeRootAndExit.mockClear() await Promise.all(temporaryHomes.splice(0).map(home => rm(home, { recursive: true, force: true }))) }) @@ -125,7 +136,7 @@ describe('TUI first-run welcome composition', () => { expect(createHash('sha256').update(icon).digest('hex')) .toBe('deba5f98a5c1796e20fcac3149bcd7eb8a32f0bdd04d048819400b1f28bd1439') expect(createHash('sha256').update(copy.paragraphs.join('\n')).digest('hex')) - .toBe('54389347f93109c7cb17baa4312ae55eaefe77cbbf2ffe3e7579a4538e9f5738') + .toBe('99f9a828b4f083b28de21bf5e03f939c00238531e765db78911957c44c6e98da') expect(TUI_FIRST_RUN_WELCOME_NOTICE_COPY.en).toBe(copy) }) @@ -137,31 +148,45 @@ describe('TUI first-run welcome composition', () => { { columns: 160, inner: 140, rows: 30, tier: 'full' }, ] as const)('renders the responsive composition at $columns columns without overdraw', ({ inner, rows, tier }) => { const fixture = hostFixture(rows) - const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}) + const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}, () => {}) const renderWidth = inner + 4 const lines = component.render(renderWidth) expect(tuiFirstRunWelcomeArtTier(inner, rows)).toBe(tier) - expect(lines.every(line => tuiVisibleWidth(line) <= renderWidth)).toBe(true) + expect(lines.every(line => visibleWidth(line) <= renderWidth)).toBe(true) if (tier === undefined) { expect(lines.join('\n')).not.toMatch(/[▀▄█]/u) } else { - expect(lines.join('\n')).toContain(TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[0]!.trim()) + expect(lines.join('\n')).toContain(artAnchor(tier)) } const rendered = lines.join('\n') - const placeholder = copy.paragraphs.at(-1)!.match(/【[^】]+】/u)![0] + const optOut = copy.paragraphs.at(-1)!.match(/[A-Z_]+=1/u)![0] expect(rendered).not.toContain(copy.scrollHint) expect(rendered).toContain(copy.paragraphs.at(-1)!.match(/[A-Za-z]+ [A-Za-z]+/u)![0]) - expect(rendered).toContain(placeholder.slice(0, 3)) - expect(rendered).toContain(placeholder.slice(-3)) + expect(rendered).toContain(optOut) expect(lines.join('\n')).toContain(`Enter ${copy.continueLabel}`) expect(lines.length).toBeLessThanOrEqual(Math.floor(rows * 0.9)) expect(lines.length).toBeGreaterThan(5) }) + it.each([ + { inner: 68, rows: 14, tier: undefined }, + { inner: 68, rows: 17, tier: undefined }, + { inner: 68, rows: 18, tier: 'minimal' }, + { inner: 84, rows: 21, tier: 'minimal' }, + { inner: 84, rows: 22, tier: 'compact' }, + ] as const)('degrades art to preserve the action at $rows rows', ({ inner, rows, tier }) => { + const fixture = hostFixture(rows) + const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}, () => {}) + const lines = component.render(inner + 4) + expect(tuiFirstRunWelcomeArtTier(inner, rows)).toBe(tier) + expect(lines.length).toBeLessThanOrEqual(Math.floor(rows * 0.9)) + expect(lines.join('\n')).toContain(`Enter ${copy.continueLabel}`) + }) + it('drops the whale at low height while keeping prose, scrolling, and Enter reachable', () => { const fixture = hostFixture(10) - const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}) + const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}, () => {}) const initial = component.render(54).join('\n') expect(tuiFirstRunWelcomeArtTier(50, 10)).toBeUndefined() expect(initial).toContain(openingSentence) @@ -181,32 +206,44 @@ describe('TUI first-run welcome composition', () => { it('renders a tiny viewport and a quotation-only paragraph without overdraw', () => { const fixture = hostFixture(5) const quoteOnly = { ...copy, paragraphs: ['“如切如磋,如琢如磨。”'] } - const component = new TuiFirstRunWelcomeComponent(fixture.host, quoteOnly, async () => {}) + const component = new TuiFirstRunWelcomeComponent(fixture.host, quoteOnly, async () => {}, () => {}) const lines = component.render(2) - expect(lines.every(line => tuiVisibleWidth(line) <= 6)).toBe(true) + expect(lines.every(line => visibleWidth(line) <= 6)).toBe(true) }) it('keeps the side-by-side composition aligned when prose outgrows the full raster', () => { const fixture = hostFixture(40) const longCopy = { ...copy, paragraphs: [copy.paragraphs.join(' ').repeat(4)] } - const component = new TuiFirstRunWelcomeComponent(fixture.host, longCopy, async () => {}) + const component = new TuiFirstRunWelcomeComponent(fixture.host, longCopy, async () => {}, () => {}) const lines = component.render(100) expect(lines.length).toBeGreaterThan(TUI_FIRST_RUN_WELCOME_WHALE.full.unicode.length) - expect(lines.every(line => tuiVisibleWidth(line) <= 100)).toBe(true) + expect(lines.every(line => visibleWidth(line) <= 100)).toBe(true) + component.handleInput('\x1b[F') + expect(component.render(100).join('\n')).toContain(copy.title) }) it('renders the bit-equivalent ASCII icon fallback for an explicitly non-Unicode terminal', () => { const fixture = hostFixture(30) - const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}, true) + const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}, () => {}, true) const rendered = component.render(72).join('\n') expect(rendered).toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.ascii[0]!.trim()) expect(rendered).not.toMatch(/[▀▄█]/u) }) + it.each(['full', 'compact', 'minimal'] as const)('keeps the $tier ASCII raster bit-equivalent', (tier) => { + const mapped = TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode.map(line => Array.from(line).map((cell) => { + if (cell === '▀') return "'" + if (cell === '▄') return '_' + if (cell === '█') return '#' + return cell + }).join('')) + expect(mapped).toEqual(TUI_FIRST_RUN_WELCOME_WHALE[tier].ascii) + }) + it('ignores Escape and acknowledges only Enter before closing', async () => { const fixture = hostFixture(30) const acknowledge = vi.fn(async () => {}) - const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, acknowledge) + const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, acknowledge, () => {}) component.render(72) component.handleInput('\x1b') @@ -219,11 +256,23 @@ describe('TUI first-run welcome composition', () => { expect(acknowledge).toHaveBeenCalledOnce() }) + it('keeps the notice eligible when Ctrl+C or Ctrl+D requests a normal exit', async () => { + const fixture = hostFixture(30) + const acknowledge = vi.fn(async () => {}) + const exit = vi.fn() + const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, acknowledge, exit) + component.handleInput('\x03') + component.handleInput('\x04') + expect(exit).toHaveBeenCalledTimes(2) + expect(acknowledge).not.toHaveBeenCalled() + expect(fixture.closed()).toBe(false) + }) + it('does not start a second acknowledgement while the first Enter is pending', async () => { const fixture = hostFixture(30) const pending = Promise.withResolvers() const acknowledge = vi.fn(async () => pending.promise) - const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, acknowledge) + const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, acknowledge, () => {}) component.render(72) component.handleInput('\r') @@ -242,7 +291,7 @@ describe('TUI first-run welcome composition', () => { const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => { attempts += 1 if (attempts === 1) throw new Error('disk unavailable') - }) + }, () => {}) component.render(72) component.handleInput('\r') @@ -260,7 +309,12 @@ describe('TUI first-run welcome composition', () => { it('opens through the TUI extension and uses the launcher-owned acknowledgement closure', async () => { const home = await temporaryHome('dsh-tui-welcome-apply-') let request: TuiOverlayRequest | undefined + let disposePending: (() => Promise) | undefined const ctx = { + effect(register: () => () => Promise) { + disposePending = register() + return () => {} + }, tui: { openOverlay(value: TuiOverlayRequest) { request = value @@ -279,10 +333,11 @@ describe('TUI first-run welcome composition', () => { const fixture = hostFixture(30) const component = request?.create(fixture.host) expect(component).toBeInstanceOf(TuiFirstRunWelcomeComponent) + component?.handleInput?.('\x03') + expect(mockDisposeRootAndExit).toHaveBeenCalledWith(ctx, 0) component?.handleInput?.('\r') - await vi.waitFor(async () => { - expect(await hasTuiFirstRunWelcomeAcknowledgement(home)).toBe(true) - }) + await disposePending?.() + expect(await hasTuiFirstRunWelcomeAcknowledgement(home)).toBe(true) apply(ctx, { dshHome: home, asciiArt: true }) expect(request?.create(fixture.host).render(72).join('\n')) diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 7901a79555..7c7ad10141 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -13,12 +13,12 @@ import { HeadlessTerminal } from '../../../packages/ui/tui/tests/headless-termin import { acknowledgeTuiFirstRunWelcome, hasTuiFirstRunWelcomeAcknowledgement, -} from '../src/tui-first-run-welcome.ts' +} from '../src/tui-onboarding/tui-first-run-welcome.ts' import { TUI_FIRST_RUN_WELCOME_NOTICE_COPY, TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE, -} from '../src/tui-first-run-welcome-copy.ts' -import { TUI_FIRST_RUN_WELCOME_WHALE } from '../src/tui-first-run-welcome-art.ts' +} from '../src/tui-onboarding/tui-first-run-welcome-copy.ts' +import { TUI_FIRST_RUN_WELCOME_WHALE } from '../src/tui-onboarding/tui-first-run-welcome-art.ts' const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url)) // `--config` layers an overlay over the shared base, so the default surface @@ -174,6 +174,10 @@ function smoke(overrides: Partial & { const firstRunCopy = TUI_FIRST_RUN_WELCOME_NOTICE_COPY[TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE] const firstRunOpeningSentence = `${firstRunCopy.paragraphs[0]!.split('。', 1)[0]}。` +function firstRunArtAnchor(tier: keyof typeof TUI_FIRST_RUN_WELCOME_WHALE): string { + return TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[tier === 'full' ? 2 : 0]!.trim() +} + /** Keep only the overlay rows, excluding platform-specific scrollback and the underlying TUI. */ function overlaySnapshot(snapshot: string, columns: number, rows: number): string { const blocks: string[][] = [] @@ -231,15 +235,13 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { tempDirPrefix: `dsh-tui-welcome-${String(columns)}-`, configPath: scriptedConfigPath, showFirstRunWelcome: true, - expectedExitCode: process.platform === 'win32' ? 0 : -9, + expectedExitCode: 0, columns, rows: 30, actions: [ { waitFor: `Enter ${firstRunCopy.continueLabel}`, - send: '\r', - signalAfterMs: 2_000, - signalAfter: 'SIGKILL', + send: '\r\x03', }, ], inspect: async (cwd) => { @@ -257,7 +259,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { if (tier === undefined) { expect(output).not.toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.unicode[0]!.trim()) } else { - expect(output).toContain(TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[0]!.trim()) + expect(output).toContain(firstRunArtAnchor(tier)) } expect(output).toContain(`Enter ${firstRunCopy.continueLabel}`) }, PTY_SMOKE_TEST_TIMEOUT_MS) @@ -268,7 +270,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { tempDirPrefix: 'dsh-tui-welcome-low-', configPath: scriptedConfigPath, showFirstRunWelcome: true, - expectedExitCode: process.platform === 'win32' ? 0 : -15, + expectedExitCode: 0, columns: 60, rows: 12, actions: [ @@ -276,8 +278,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { { waitFor: `Enter ${firstRunCopy.continueLabel}`, occurrence: 2, - send: '\r', - signalAfterMs: 2_000, + send: '\r\x03', }, ], }) @@ -299,9 +300,9 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { cwd, configPath: scriptedConfigPath, showFirstRunWelcome: true, - expectedExitCode: process.platform === 'win32' ? 0 : -15, + expectedExitCode: 0, actions: [ - { waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r', signalAfterMs: 2_000 }, + { waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r\x03' }, ], }) expect(first).toContain(firstRunCopy.title) @@ -344,9 +345,9 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { cwd, configPath: scriptedConfigPath, showFirstRunWelcome: true, - expectedExitCode: -15, + expectedExitCode: 0, actions: [ - { waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r', signalAfterMs: 2_000 }, + { waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r\x03' }, ], }) expect(next).toContain(firstRunOpeningSentence) @@ -534,7 +535,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { binScript: dshBinScript, configArgs: ['--resume', 'resume-target', '--config', scriptedConfigPath], showFirstRunWelcome: true, - expectedExitCode: process.platform === 'win32' ? 0 : -15, + expectedExitCode: 0, prepare: async (cwd) => { await seedResumeSession(cwd) const before = await readFile(logPath( @@ -546,7 +547,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { originalLineCount = before.split('\n').filter(Boolean).length }, actions: [ - { waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r', signalAfterMs: 2_000 }, + { waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r\x03' }, ], inspect: async (cwd) => { const after = await readFile(logPath( diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 5221ecdef0..fa1e4e768c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2336,7 +2336,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:248`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:241`](../../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 f327292d41..d1a1bbb3c5 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: c1ab57410469d649fd589c5038903324c60343cb -README.zh.md: 7c92136398f7a84213712d06f843a29afd8f1277 +README.md: 86e54ab76d07e32fad93965fcbb585d2b5fdfe06 +README.zh.md: a8d072a82c9cff8db7cfe436957a9c1391fb8d8f diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index c1ab574104..86e54ab76d 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -10,7 +10,7 @@ Interactive terminals on macOS, Linux, and Windows are supported. Windows uses p This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `llm`, `systemPrompt`, `tokenMeter`, `tools`, and `userInteraction`, optionally reads a `skills` service (present only when one is mounted), then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries. -After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme (including terminal-safe DeepSeek `brand` treatment), display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. The package exports semantic-key, ANSI-wrap, truncation, and visible-width primitives for extension components, keeping the underlying renderer dependency inside `dsh-tui`. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives. +After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme (including terminal-safe DeepSeek `brand` treatment), display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives. The TUI rebuilds resumed history from the append-origin session events, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes ``. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. A surface replacement never rewrites the rendered transcript: the conversation it shadows stays readable, and a landed compaction checkpoint adds one dim `… earlier context was compacted …` marker at its log position, so the terminal reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies — a pruned tool result, a regenerated assistant message — render nothing. diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 7c92136398..a8d072a82c 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -10,7 +10,7 @@ DeepSeek Harness agent(智能体)的交互式终端入口,基于 [`@earend 本包(package)只持有交互式终端展示和输入。它注入 `agents`、[`commands`](../commands/README.md)、`llm`、`systemPrompt`、`tokenMeter`、`tools` 和 `userInteraction`,可选读取 `skills` 服务(仅在已挂载时存在),然后驱动由 app 或开发者代码创建或恢复的 agent。Agent 生命周期、持久化与模型侧 [`ask_user_question`](../tool-ask-user/README.md) 工具仍是独立组合项。 -终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题(包括终端安全的 DeepSeek `brand` 样式)、显示文本转义、重绘、关闭和生命周期信号,但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。本包还为扩展组件导出语义按键、ANSI 换行、截断和可见宽度原语,使底层 renderer 依赖始终留在 `dsh-tui` 内。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect,因此卸载会移除排队工作,或在清理结算前关闭可见工作;终端关闭会先卸载依赖项,再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。 +终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题(包括终端安全的 DeepSeek `brand` 样式)、显示文本转义、重绘、关闭和生命周期信号,但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect,因此卸载会移除排队工作,或在清理结算前关闭可见工作;终端关闭会先卸载依赖项,再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。 TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 ``。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换从不重写已渲染的 transcript:被它遮蔽的对话仍可阅读,而已落地的压缩(compaction)检查点会在其日志位置添加一行暗色 `… earlier context was compacted …` 标记,因此终端报告的是模型从何处起不再看到那段历史,而不是把它抹掉。仅供模型使用的替换副本——被裁剪的工具结果、重新生成的 assistant 消息——不渲染任何内容。 diff --git a/packages/ui/tui/src/extension/primitives.ts b/packages/ui/tui/src/extension/primitives.ts deleted file mode 100644 index bb4fba8012..0000000000 --- a/packages/ui/tui/src/extension/primitives.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Terminal-safe primitives for components mounted through the TUI extension service. - * - * Extensions use these wrappers instead of importing the underlying renderer, so - * `ctx.tui` remains the owner of key decoding, ANSI wrapping, and column width. - * @module @deepseek-ai/dsh-tui/extension-primitives - */ - -import { - Key, - matchesKey, - truncateToWidth, - visibleWidth, - wrapTextWithAnsi, - type KeyId, -} from '@earendil-works/pi-tui' - -/** Key identifiers accepted by TUI extension components. */ -export const TuiKey = Key - -/** - * Test whether terminal input matches one semantic key. - * @param data - Raw terminal input delivered to the component. - * @param key - Semantic key identifier to match. - * @returns Whether the input encodes the requested key. - */ -export function matchesTuiKey(data: string, key: KeyId): boolean { - return matchesKey(data, key) -} - -/** - * Measure terminal columns after ignoring ANSI control sequences. - * @param value - Styled or plain terminal text. - * @returns Visible terminal-column width. - */ -export function tuiVisibleWidth(value: string): number { - return visibleWidth(value) -} - -/** - * Wrap styled terminal text without splitting ANSI sequences. - * @param value - Styled or plain terminal text. - * @param width - Maximum visible columns per line. - * @returns Wrapped lines preserving active ANSI styling. - */ -export function wrapTuiText(value: string, width: number): string[] { - return wrapTextWithAnsi(value, width) -} - -/** - * Truncate styled terminal text to a visible-column limit. - * @param value - Styled or plain terminal text. - * @param width - Maximum visible columns. - * @param ellipsis - Suffix used when truncation occurs. - * @returns Text whose visible width does not exceed the limit. - */ -export function truncateTuiText(value: string, width: number, ellipsis = ''): string { - return truncateToWidth(value, width, ellipsis) -} diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 16f7bd9938..8448d79f8e 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -141,13 +141,6 @@ import { WorkspaceFileSearch } from './chat/file-autocomplete.ts' export { TuiPromptService } from './prompt.ts' export { renderSkillInvocation } from './chat/skill-invocation.ts' -export { - matchesTuiKey, - truncateTuiText, - TuiKey, - tuiVisibleWidth, - wrapTuiText, -} from './extension/primitives.ts' export type { TuiResumeHost, TuiRuntime } from './runtime.ts' export { resolveTuiConfig, diff --git a/packages/ui/tui/tests/extension.spec.ts b/packages/ui/tui/tests/extension.spec.ts index eb3103f5da..15bfee33f6 100644 --- a/packages/ui/tui/tests/extension.spec.ts +++ b/packages/ui/tui/tests/extension.spec.ts @@ -17,13 +17,6 @@ import { TuiOverlayManager, type TuiOverlayDriver, } from '../src/extension/overlay-manager.ts' -import { - matchesTuiKey, - truncateTuiText, - TuiKey, - tuiVisibleWidth, - wrapTuiText, -} from '../src/index.ts' const theme: TuiTheme = Object.freeze({ text: (value: string) => `text:${value}`, @@ -37,17 +30,6 @@ const theme: TuiTheme = Object.freeze({ bold: (value: string) => `bold:${value}`, }) -describe('TUI extension terminal primitives', () => { - it('owns semantic keys, ANSI-safe wrapping, truncation, and visible width', () => { - expect(matchesTuiKey('\r', TuiKey.enter)).toBe(true) - expect(tuiVisibleWidth('\x1b[34m鲸鱼\x1b[39m')).toBe(4) - const truncated = truncateTuiText('鲸鱼欢迎', 6) - expect(truncated).toContain('鲸鱼欢') - expect(tuiVisibleWidth(truncated)).toBe(6) - expect(wrapTuiText('\x1b[34m鲸鱼欢迎\x1b[39m', 4)).toHaveLength(2) - }) -}) - interface ShownOverlay { component: Component options: TuiOverlayOptions | undefined diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1fd27b86da..03bfb99195 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -435,6 +435,9 @@ importers: '@deepseek-ai/dsh-workspace-context': specifier: workspace:^ version: link:../../packages/context/workspace-context + '@earendil-works/pi-tui': + specifier: 0.80.7 + version: 0.80.7(patch_hash=6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946) commander: specifier: ^15.0.0 version: 15.0.0 From 215bf15f783dc841fe5b9fffafda3613da6b9f95 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:45:19 -0700 Subject: [PATCH 21/28] fix(web): surface terminal turn failures --- ...-21-bounded-llm-request-recovery.i18n.yaml | 4 +- ...2026-06-21-bounded-llm-request-recovery.md | 2 +- ...6-06-21-bounded-llm-request-recovery.zh.md | 2 +- ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 +- .../2026-07-24-web-gui-browser-e2e-lane.md | 3 +- .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 3 +- apps/web/tests/live-interactions.e2e.ts | 32 ++++++------- .../live-interactions/error-auth.expected.md | 3 ++ packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- packages/client/runtime/src/client/index.ts | 2 +- .../src/client/sessions/conversation.ts | 14 ++++++ .../src/client/sessions/failure-display.ts | 10 +++++ .../src/client/sessions/request-inspection.ts | 5 ++- .../runtime/src/client/sessions/session.ts | 17 +++++++ .../runtime/tests/request-inspection.spec.ts | 27 +++++++++++ packages/client/runtime/tests/session.spec.ts | 45 +++++++++++++++++++ .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/MessageItem.module.css | 34 ++++++++++++++ .../src/client/chat/MessageItem.tsx | 33 ++++++++++++-- .../ui-conversation/src/client/locales.ts | 2 + .../ui-conversation/tests/chat-view.spec.tsx | 17 ++++++- 25 files changed, 234 insertions(+), 41 deletions(-) create mode 100644 packages/client/runtime/src/client/sessions/failure-display.ts diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml index 8193e5e839..bf6f030683 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.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/architecture/2026-06-21-bounded-llm-request-recovery.md -2026-06-21-bounded-llm-request-recovery.md: 5c76ed5d754ea40f41dff78cb56ee7fc139a32b1 -2026-06-21-bounded-llm-request-recovery.zh.md: 1fa56f3fe0405cab663c2843d423a78d910170dd +2026-06-21-bounded-llm-request-recovery.md: 24725dcf300cf69e9cc72580d0c8afe937d4e2b9 +2026-06-21-bounded-llm-request-recovery.zh.md: 5f03a65b00be8d3349addce82e4f3faa2af1fe7e diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md index 5c76ed5d75..24725dcf30 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md @@ -84,7 +84,7 @@ Boundary tests prove termination at both actual transports. The hand-written ada A failed attempt may leave `assistant/chunk` events in its closed step, but it never appends `assistant/message` and never dispatches a tool. A retry closes the failed turn, opens the next numbered turn, reconstructs the request from the durable surface, and produces its own chunks. UIs may render live chunks while a step is open, then mark or clear that transient view when `llm/retry` identifies the failed step or `turn/end` records failure. Web validates the complete retry payload contract, clears the failed partial at `llm/retry`, projects consecutive retry-turn events into one stable row updated to the latest attempt, and derives scheduled, started, or cancelled status from subsequent turn facts. Its countdown anchors the scheduled delay to browser receipt rather than the Host event clock, uses ceiling-rounded seconds with a one-second floor, animates only while unresolved, and keeps exact latest failure details collapsed behind the row. Retry nodes anchor their own trajectory turn even when the failed attempt has no assistant node. Message derivation continues to ignore the failed chunks, and Web applies the same projection during history rebuild so refreshing cannot resurrect discarded partials or duplicate retry rows. -If recovery is exhausted, the final failure is stored once on `turn/end.reason` with the structured facts. If transient recovery continues, `llm/retry` is the durable home for that attempt's failure and delay. No standalone final-error event or response-id vocabulary is added. +If recovery is exhausted, the final failure is stored once on `turn/end.reason` with the structured facts. Web derives one `turn-error` node at that sequence position and renders its display-safe message and optional code inline; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. The same fold runs for live events and history replay. If transient recovery continues, `llm/retry` is the durable home for that attempt's failure and delay, so its failed turn does not also gain a terminal error row. No standalone final-error event or response-id vocabulary is added. ## Out of scope diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md index 1fa56f3fe0..5f03a65b00 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md @@ -84,7 +84,7 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 一次失败尝试可以在已关闭的步骤中留下 `assistant/chunk` 事件,但绝不会追加 `assistant/message`,也不会分发工具。重试会关闭失败轮次,开启下一个编号轮次,从持久表层重建请求,并生成自己的分片。步骤仍处于打开状态时,UI 可以渲染实时分片;当 `llm/retry` 标识失败步骤,或 `turn/end` 记录失败时,UI 再标记或清除这份暂时视图。Web 会验证完整的重试载荷契约,在 `llm/retry` 到达时清除失败的部分输出,将连续重试轮次的事件投影为稳定的一行,并用最新一次尝试更新该行,再从后续轮次事实派生 scheduled、started 或 cancelled 状态。倒计时以浏览器收到事件的时刻为计划延迟的起点,而不是使用 Host 事件时钟;它按向上取整且不低于 1 秒的秒数显示,仅在重试尚未结束时显示动画,并把最近一次失败的准确详情折叠在该行之后。即使失败尝试没有 assistant 节点,重试节点也会锚定自身的轨迹轮次。消息派生仍会忽略失败分片;Web 在重建历史时也会应用同一投影,因此刷新页面不会让已丢弃的部分输出重新出现,也不会生成重复的重试行。 -如果恢复预算耗尽,最终失败会连同结构化事实在 `turn/end.reason` 中存储一次。如果暂时性恢复继续,`llm/retry` 就是该次尝试的失败与延迟的持久归属位置。本决策不增加独立的最终错误事件或响应 id 词汇。 +如果恢复预算耗尽,最终失败会连同结构化事实在 `turn/end.reason` 中存储一次。Web 会在该序列位置派生一个 `turn-error` 节点,并内联渲染适合展示的消息与可选错误码;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。实时事件和历史回放使用同一套折叠逻辑。如果暂时性恢复继续,`llm/retry` 就是该次尝试的失败与延迟的持久归属位置,因此该失败轮次不会再获得终态错误行。本决策不增加独立的最终错误事件或响应 id 词汇。 ## 不在范围内 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index 91d77fb803..b219e6245f 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.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/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: cdb7de52c50733d6650202ee2117916319940738 -2026-07-24-web-gui-browser-e2e-lane.zh.md: b850acf026502d054a9d8b2168f0b4f47f58f39b +2026-07-24-web-gui-browser-e2e-lane.md: f05fc7268cfb613d0af8240bbb65cb154252a620 +2026-07-24-web-gui-browser-e2e-lane.zh.md: 3fd3805053a570a32e63601d7b039db41365309c diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index cdb7de52c5..f05fc7268c 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -76,13 +76,12 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot ## Testing -`pnpm run test:web` builds and runs the lane keylessly; `test:web:built` runs it against existing build artifacts. `DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` records a prompting scenario against the live model, and `DSH_SNAPSHOT=refresh pnpm run test:web` rewrites aria goldens keylessly. CI explicitly selects replay mode. The scaffold hermeticity scenario populates distinct entries in all three ambient skill roots and requires none to enter the assembled catalog. `dsh-llm-replay` unit coverage pins pacing, cancellation, consumption diagnostics, sidecar validation, indexed replacement, and the single append position. +`pnpm run test:web` builds and runs the lane keylessly; `test:web:built` runs it against existing build artifacts. `DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` records a prompting scenario against the live model, and `DSH_SNAPSHOT=refresh pnpm run test:web` rewrites aria goldens keylessly. CI explicitly selects replay mode. The live-interactions AUTH scenario pins a non-retryable terminal failure as an inline Chat status carrying the display-safe message and code, verifies that provider-echoed credential fragments stay absent from both Chat and Trajectory, and covers composer recovery plus the `turn/end` error. The scaffold hermeticity scenario populates distinct entries in all three ambient skill roots and requires none to enter the assembled catalog. `dsh-llm-replay` unit coverage pins pacing, cancellation, consumption diagnostics, sidecar validation, indexed replacement, and the single append position. ## Deferred - **Web header-class pin**: web fixtures tokenize `{{system}}`/`{{tools}}` everywhere and no scenario pins the web composition's prompt/tool schemas (`TODO(web-header-pin)` — the scaffold `recordFixture` JSDoc marks it). Following the TUI scrub-everywhere precedent; revisit when the web assembly's header diverges from the repl composition it mirrors. - **Follow-up-prompt-after-resume scenario**: the history/live stitch path over the real wire; add as its own scenario when that code changes or regresses. -- **Web error surface**: the client consumes no `agent/error` frames and a pre-chunk failure freezes no partial, so a non-retryable provider failure renders no error copy — the user sees the send simply stop. The AUTH scenario pins the current contract (no crash, composer recovers, turn logged `error`) and `FIXME(web-error-surface)` marks where visible error text gets asserted once the UI grows an error rendering. - **Composer steering gesture**: the input locks while running (stop-or-wait), so the steering scenario steers over the wire from the page; `TODO(web-steer-composer)` upgrades the drive step to a real composer gesture when the product grows one. - **Drag session reorder**: `workspace.insertSessionBefore` has no browser scenario; it needs two sessions materialized in one workspace plus synthesized HTML5 drag events. Add it when that surface changes or regresses. The inert session Rename/Fork/Delete and workspace Delete menu rows get scenarios when they gain behavior. diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index b850acf026..3fd3805053 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -76,13 +76,12 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ## Testing -`pnpm run test:web` 构建并无密钥运行该车道;`test:web:built` 基于现有构建产物运行。`DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` 对真实模型录制一个发起提示的场景,`DSH_SNAPSHOT=refresh pnpm run test:web` 则无密钥重写 aria 预期输出。CI 显式选择回放模式。scaffold 环境隔离场景会在全部 3 个环境 skill 根目录中分别填入不同条目,并要求这些条目都不得进入组装后的目录。`dsh-llm-replay` 单元覆盖率钉住节奏控制、取消、消费诊断、sidecar 校验、按索引替换与唯一的追加位置。 +`pnpm run test:web` 构建并无密钥运行该车道;`test:web:built` 基于现有构建产物运行。`DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` 对真实模型录制一个发起提示的场景,`DSH_SNAPSHOT=refresh pnpm run test:web` 则无密钥重写 aria 预期输出。CI 显式选择回放模式。live-interactions AUTH 场景会把不可重试的终态失败钉为 Chat 内联状态,其中携带适合展示的消息与错误码,并验证提供方回显的凭据片段不会出现在 Chat 或 Trajectory 中;该场景同时覆盖输入框恢复与 `turn/end` 错误。scaffold 环境隔离场景会在全部 3 个环境 skill 根目录中分别填入不同条目,并要求这些条目都不得进入组装后的目录。`dsh-llm-replay` 单元覆盖率钉住节奏控制、取消、消费诊断、sidecar 校验、按索引替换与唯一的追加位置。 ## 暂缓 - **Web 头类别钉住**:web fixture 处处 token 化 `{{system}}`/`{{tools}}`,没有场景钉住 web 组合的提示词/工具 schema(`TODO(web-header-pin)`——scaffold 的 `recordFixture` JSDoc 有标记)。沿用 TUI 处处脱敏先例;当 web 组装的请求头与其镜像的 repl 组合进一步分叉时重审。 - **恢复后追问场景**:真实 wire 上的历史/实时缝合路径;当该代码变更或回归时作为独立场景补充。 -- **Web 错误表面**:客户端不消费任何 `agent/error` 帧,分片前的失败也没有可冻结的部分输出,因此不可重试的提供方失败不渲染任何错误文案——用户看到的只是发送就此停住。AUTH 场景钉住当前契约(不崩溃、输入框恢复可用、轮次记录为 `error`),`FIXME(web-error-surface)` 标记了待 UI 长出错误渲染后断言可见错误文本的位置。 - **输入框 steering 手势**:输入在运行期间锁定(只能停止或等待),因此 steering 场景从页面走 wire 做 steer;`TODO(web-steer-composer)` 待产品长出真实的输入框手势后,把驱动步骤升级为该手势。 - **拖拽会话重排**:`workspace.insertSessionBefore` 尚无浏览器场景;它需要在同一个工作区里物化两个会话,并合成 HTML5 拖拽事件。当该表面变更或回归时再补充。无行为的会话 Rename/Fork/Delete 和工作区 Delete 菜单行待获得行为后再补充场景。 diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index 186c71be11..387faab3b7 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -28,14 +28,14 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-interactions', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') // One golden pins the stable mid-turn loading state; the other three capture -// what the user is left looking at after cancel, after a non-retryable failure -// (pins the FIXME(web-error-surface) gap as a reviewable artifact: NO error -// copy in the tree), and after retry recovery. +// what the user is left looking at after cancel, after a non-retryable failure, +// and after retry recovery. const CANCEL_EXPECTED = join(SNAPSHOT_DIR, 'cancel.expected.md') const LOADING_EXPECTED = join(SNAPSHOT_DIR, 'loading.expected.md') const ERROR_EXPECTED = join(SNAPSHOT_DIR, 'error-auth.expected.md') const RETRY_EXPECTED = join(SNAPSHOT_DIR, 'retry.expected.md') const MODE = webSnapshotMode() +const AUTH_PROVIDER_MESSAGE = 'Authentication Fails, Your api key: sk-preview-secret is invalid' // The recorded base: one text-only turn whose derived script the sidecars // patch. Kept deliberately tool-free so the derived script is exactly one @@ -158,7 +158,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { it.skipIf(MODE === 'record')('surfaces a non-retryable AUTH failure without retrying', async () => { await launch(() => ({ - patches: [{ at: 0, entry: { kind: 'throw', chunks: [], message: 'invalid api key', code: 'AUTH' } }], + patches: [{ at: 0, entry: { kind: 'throw', chunks: [], message: AUTH_PROVIDER_MESSAGE, code: 'AUTH' } }], })) onTestFailed(() => saveFailureShot(page, 'web-e2e-error-auth')) const { settled } = await sendPrompt() @@ -166,28 +166,28 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { expect(turnEndReasons(sessionEvents).at(-1)).toBe('error') // AUTH is outside llm-retry's retryable set: no retry record. expect(sessionEvents.filter(e => e.type === 'llm/retry').length).toBe(0) - // Product gap found by this lane, pinned as-is: the client consumes no - // agent/error frames and a pre-chunk failure freezes no partial, so THIS - // failure renders no error copy anywhere — the user sees the send simply - // stop. FIXME(web-error-surface): assert visible error text here once the - // web UI grows an error rendering; until then the pinned contract is - // "no crash, composer recovers, turn logged as error". await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true) expect(await page.locator('[data-streaming="true"]').count()).toBe(0) - // The blank workspace also has an enabled composer. Wait for the driven - // session's only visible message before capturing its no-error-copy state. - await expect.poll(() => page.getByText(PROMPT, { exact: true }).first().isVisible(), { timeout: 10_000 }).toBe(true) - // Golden of the same gap: the prompt bubble alone, no error copy in the - // tree — the diff that changes when web-error-surface lands. + const errorStatus = page.getByRole('status').filter({ hasText: 'This turn failed' }) + await errorStatus.waitFor({ timeout: 10_000 }) + expect(await errorStatus.textContent()).toContain('API key is invalid') + expect(await errorStatus.textContent()).toContain('AUTH') + expect(await page.locator('body').textContent()).not.toContain('sk-preview-secret') const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) await compareOrRefreshGolden(ERROR_EXPECTED, snapshot, MODE) + await page.getByRole('tab', { name: 'Trajectory' }).click() + const requestMarker = page.locator('tr[data-request-only="true"]').last() + .getByRole('button', { name: /Request #/ }) + await requestMarker.click() + await page.getByText('API key is invalid', { exact: true }).waitFor({ timeout: 10_000 }) + expect(await page.locator('body').textContent()).not.toContain('sk-preview-secret') expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 120_000) it.skipIf(MODE === 'record')('keeps a terminal request marker inside the trajectory table', async () => { await launch(() => ({ - patches: [{ at: 0, entry: { kind: 'throw', chunks: [], message: 'invalid api key', code: 'AUTH' } }], + patches: [{ at: 0, entry: { kind: 'throw', chunks: [], message: AUTH_PROVIDER_MESSAGE, code: 'AUTH' } }], })) const { settled } = await sendPrompt() await settled diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index c65690aa73..e6a93f2463 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -13,6 +13,9 @@ - img - img - text: Context injection +- status: + - text: This turn failedAPI key is invalid + - code: AUTH - textbox "Message the agent" - button "Commands": - img diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index e3d86bb196..cba74aabd5 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/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/client/runtime/README.md -README.md: e8ba80790307e7123406934c1ab11b86dfc0faf3 -README.zh.md: 6eb69d9cf10959b007f3759378612dbc013a8904 +README.md: d22f119d898de0b8aaa3472f3c2ad35621b1477a +README.zh.md: 26d6f315d02abc931e43cacb696b4c70163b14c0 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index e8ba807903..d22f119d89 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -40,7 +40,7 @@ Because the projection is log-ordered, the node array is seq-monotonic by constr ## Model retry projection -The Session object validates plugin-owned, provider-routed `llm/retry` payloads at the event wire boundary against the producer's complete field contract, including timer, integer, status, provider-delay, and non-empty diagnostic bounds. A valid event removes the matching failed step's streaming partial and inserts a durable retry notice at the event's sequence position. The notice is `scheduled` until a following retry turn starts; an aborted or disposed source turn marks it `cancelled`, while the retry turn marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. Window rebuild and history replay apply the same projection, so logged chunks from the discarded attempt never reappear as an interrupted reply after refresh. A terminal turn without `llm/retry` retains the existing behavior: visible unfinalized output is frozen as an interrupted assistant node. +The Session object validates plugin-owned, provider-routed `llm/retry` payloads at the event wire boundary against the producer's complete field contract, including timer, integer, status, provider-delay, and non-empty diagnostic bounds. A valid event removes the matching failed step's streaming partial and inserts a durable retry notice at the event's sequence position. The notice is `scheduled` until a following retry turn starts; an aborted or disposed source turn marks it `cancelled`, while the retry turn marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. A terminal `turn/end` error without a retry projects one `turn-error` node from its durable message and optional code; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. A retried failure keeps only the retry notice for that attempt. Window rebuild and history replay apply the same projection, so refresh neither resurrects discarded chunks nor loses terminal failure feedback. Visible unfinalized output is frozen as an interrupted assistant node beside the terminal error. ## Session forking diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 6eb69d9cf1..26d6f315d0 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -40,7 +40,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 模型重试投影 -Session 对象会在事件 wire 边界依据生产方的完整字段契约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或被 dispose(资源释放)时,会将该提示标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限;always mode 提示则保持显式无界。窗口重建与历史回放应用相同的投影,因此刷新后,来自已丢弃尝试的日志分片绝不会重新显示为中断回复。没有 `llm/retry` 的终止轮次保留现有行为:可见但尚未定稿的输出会冻结为中断的 assistant 节点。 +Session 对象会在事件 wire 边界依据生产方的完整字段契约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或被 dispose(资源释放)时,会将该提示标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限;always mode 提示则保持显式无界。没有重试的终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。进入重试的失败则只保留该次尝试的重试提示。窗口重建与历史回放应用相同的投影,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 assistant 节点。 ## 会话 fork diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index ef923c9226..2e9b131c77 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -47,7 +47,7 @@ export type { AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase, ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage, RunningToolCall, - SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode, + SteeringMessageNode, TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' export type { ConversationContext, ConversationContextOriginKind, diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 615ac27e77..f34c83689a 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -135,6 +135,19 @@ export type ModelRetryNode = LlmRetryEventData & { retryState: 'scheduled' | 'started' | 'cancelled' } +/** Durable terminal failure for a turn that has no scheduled retry. */ +export interface TurnErrorNode { + kind: 'turn-error' + /** Seq of the owning turn/end event. */ + seq: number + /** Unix epoch ms from the turn/end event. */ + time: number + turn: number + step: number + message: string + code?: string +} + /** A tool result paired (when in-window) with its call head. */ export interface ToolResultNode { kind: 'tool-result' @@ -223,6 +236,7 @@ export type ConversationNode = | SteeringMessageNode | ContextMessageNode | ModelRetryNode + | TurnErrorNode | ToolResultNode | CommandNode | CompactionSummaryNode diff --git a/packages/client/runtime/src/client/sessions/failure-display.ts b/packages/client/runtime/src/client/sessions/failure-display.ts new file mode 100644 index 0000000000..637329772b --- /dev/null +++ b/packages/client/runtime/src/client/sessions/failure-display.ts @@ -0,0 +1,10 @@ +/** + * Convert a durable failure into copy that is safe to expose in the GUI. + * @param failure - Structured failure preserved by the session event. + * @returns Display-safe copy for client projections. + */ +export function displayFailureMessage(failure: { code?: string; message: string }): string { + // Provider AUTH messages may echo a masked or partially preserved credential. + // Keep the raw diagnostic in the session log, but never project it into UI state. + return failure.code === 'AUTH' ? 'API key is invalid' : failure.message +} diff --git a/packages/client/runtime/src/client/sessions/request-inspection.ts b/packages/client/runtime/src/client/sessions/request-inspection.ts index e15ad55c8b..198b3b2bf3 100644 --- a/packages/client/runtime/src/client/sessions/request-inspection.ts +++ b/packages/client/runtime/src/client/sessions/request-inspection.ts @@ -8,6 +8,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { AssistantProvenanceView, AssistantRequestConfig, } from './conversation.ts' +import { displayFailureMessage } from './failure-display.ts' export type { AssistantProvenanceView, AssistantRequestConfig, @@ -319,7 +320,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] const event = sourceEvent as unknown as RetryEvent update(ordinaryByStep.get(requestKey(event.data.turn, event.data.step)), { status: 'error', - error: event.data.failure.message, + error: displayFailureMessage(event.data.failure), retry: event.data.retry, maxRetries: event.data.maxRetries, retryDelayMs: event.data.delayMs, @@ -330,7 +331,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] const reason = sourceEvent.data.reason update(ordinaryByStep.get(requestKey(sourceEvent.data.turn, reason.step)), { status: 'error', - error: 'failure' in reason ? reason.failure.message : reason.message, + error: displayFailureMessage('failure' in reason ? reason.failure : reason), }) continue } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 1e2bc04d9a..02922dbbab 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -19,6 +19,7 @@ import type { import type { PendingInteraction } from './pending.ts' import { PendingWait } from './pending.ts' import { TranscriptAdapter } from './transcript-adapter.ts' +import { displayFailureMessage } from './failure-display.ts' import { Notifier } from './notifier.ts' import { PartialAccumulator } from './partial.ts' import { ProjectionValueStore } from './projection-store.ts' @@ -749,6 +750,22 @@ export class Session implements SessionFace { if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'disposed') { this.settleScheduledRetry('cancelled', event.data.turn) } + if ( + event.data.reason.kind === 'error' + && !this.derivedNodes.some(node => node.kind === 'model-retry' && node.turn === event.data.turn) + ) { + const failure = 'failure' in event.data.reason ? event.data.reason.failure : event.data.reason + this.derivedNodes.push({ + kind: 'turn-error', + seq: event.seq, + time: event.time, + turn: event.data.turn, + step: event.data.reason.step, + message: displayFailureMessage(failure), + ...(failure.code === undefined ? {} : { code: failure.code }), + }) + this.derivedRev++ + } // Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it // into an interrupted terminal node (pulse stops, text survives) instead of deleting it. // Shared by live and window-replay paths, so a refresh reconstructs the same frozen node diff --git a/packages/client/runtime/tests/request-inspection.spec.ts b/packages/client/runtime/tests/request-inspection.spec.ts index a76036c076..8ed5a2ee5a 100644 --- a/packages/client/runtime/tests/request-inspection.spec.ts +++ b/packages/client/runtime/tests/request-inspection.spec.ts @@ -159,6 +159,33 @@ describe('inspectRequests', () => { }) }) + it('keeps provider credential fragments out of projected request errors', () => { + const snapshot = inspectRequests(entriesOf([ + at(0, 'step/start', { turn: 1, step: 1 }), + at(1, 'turn/end', { + turn: 1, + reason: { + kind: 'error', + step: 1, + failure: { + code: 'AUTH', + message: 'Authentication Fails, Your api key: sk-preview-secret is invalid', + }, + }, + }), + at(2, 'step/start', { turn: 2, step: 1 }), + at(3, 'turn/end', { + turn: 2, + reason: { kind: 'error', step: 1, message: 'plugin exploded' }, + }), + ])) + + expect(snapshot.requests).toMatchObject([ + { status: 'error', error: 'API key is invalid' }, + { status: 'error', error: 'plugin exploded' }, + ]) + }) + it('treats a scrubbed durable-fixture tool catalog as unavailable', () => { const snapshot = inspectRequests(entriesOf([ at(0, 'step/start', { turn: 1, step: 1 }), diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index fdb961d6f2..73bf901234 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -211,6 +211,7 @@ describe('live event path', () => { for (const event of retryTurn.slice(7)) feed(event) snapshot = session.getSnapshot() expect(snapshot.nodes.slice(-2).map(node => node.kind)).toEqual(['model-retry', 'assistant']) + expect(snapshot.nodes.some(node => node.kind === 'turn-error')).toBe(false) expect(snapshot.nodes.at(-2)).toMatchObject({ kind: 'model-retry', retryState: 'started' }) expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '完整回复' }] }) @@ -221,6 +222,50 @@ describe('live event path', () => { expect(replay.session.getSnapshot().partial).toBeNull() }) + it('projects unretried terminal failures at turn/end and reproduces them from history', async () => { + const { session } = await opened() + const feed = (event: SessionEvent) => { + session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) + } + const failedTurns = [ + ev.turnStart(6, 1), + ev.user(7, '鉴权失败'), + at(8, { + type: 'turn/end', + data: { + turn: 1, + reason: { + kind: 'error', + step: 0, + failure: { + code: 'AUTH', + message: 'Authentication Fails, Your api key: sk-preview-secret is invalid', + }, + }, + }, + }), + ev.turnStart(9, 2), + ev.user(10, '内部失败'), + at(11, { + type: 'turn/end', + data: { turn: 2, reason: { kind: 'error', step: 1, message: 'plugin exploded' } }, + }), + ] + for (const event of failedTurns) feed(event) + + const errors = session.getSnapshot().nodes.filter(node => node.kind === 'turn-error') + expect(errors).toMatchObject([ + { seq: 8, turn: 1, step: 0, code: 'AUTH', message: 'API key is invalid' }, + { seq: 11, turn: 2, step: 1, message: 'plugin exploded' }, + ]) + expect('code' in errors[1]!).toBe(false) + + const replay = makeSession() + replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...failedTurns]) + await replay.session.open() + expect(replay.session.getSnapshot().nodes).toEqual(session.getSnapshot().nodes) + }) + it('rejects retry payloads outside the producer contract without retracting the current partial', async () => { const { session } = await opened() const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index ac0bd88de1..192d02f405 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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/client/ui-conversation/README.md -README.md: f7279d2c640d447609c4e9804af633d026b35ed9 -README.zh.md: 2e8f3483c160689cdf5ff283f2955a5c0be4fd8e +README.md: 0df6bdf47c39cbb6ea5b939d52e5182fe9b58182 +README.zh.md: 31083683116442df41315fc193f97e18674b6c16 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index f7279d2c64..0df6bdf47c 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -24,7 +24,7 @@ A `read` call declaring the `read` render intent renders the returned file windo A tool call declaring the `diff` render intent (the `write`/`edit` tools) renders its applied change inline through ui-primitives' `DiffBlock`, the same four-layer shape. `contract/diff-card-model.ts` is the single derivation from the `callView`/`resultView` pair; the settled result's hunks replace the call-time diff, and it yields null — the generic path — for any other card tag or a generic result view (write/edit's execution errors). The keyed `FileMutationRow` (registered under both `write` and `edit`) composes the shared `ToolRow`, feeding the diff as ToolRow's `diff` body, so it is the row's collapsed-by-default expanded card; the summary path link still opens the file through the host, and an errored mutation (no diff card) surfaces its error text through ToolRow's Output section with the first line in the collapsed summary. The render-site fallback and the details panel are diff-aware too. Rows cap at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)). -The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds. +The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds. An unretried terminal failure renders as a persistent inline status at its turn boundary, showing the display-safe durable message and optional error code without offering an action the Host cannot fulfill; AUTH copy never echoes provider-supplied credential fragments. A `grep`/`glob` call declaring the `search` render intent renders its result inline, at the same render sites, through ui-primitives' `SearchBlock` — grep's matches grouped by file (each a collapsible header of `lineNumber: line` rows), glob's flat path list. `contract/search-card-model.ts` is the single derivation from the snapshot's `resultView`; unlike the terminal card it reads no `callView`, since a search has no matches or paths before `execute`, so a running search shows its summary alone. It yields null — the generic path — for any non-search result view, a `card` or `kind` this client version does not compile, and (because those ride the untrusted wire frame) a known kind whose `files`/`paths` is malformed. The keyed `SearchRow`, registered under both `grep` and `glob` since the derived `kind` decides the shape, composes the shared `ToolRow`, feeding the card as ToolRow's `search` body, so it is the row's collapsed-by-default expanded card; the render-site fallback routes it the same way. Both cap at `CHAT_SEARCH_MAX_LINES` (8) against the panel's 16. A capped search drops rows from the card, but the locator to the rest — grep/glob's `Full … stored at …` footer — lives only in the result text, so the derivation surfaces that as a recovery footer below the card when (and only when) the result was truncated; a settled call with no card at all (an errored search, a nested `run_code` sub-dispatch, a legacy generic result) surfaces its flattened result text through ToolRow's Output section so nothing is lost behind a bare summary ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 2e8f3483c1..3108368311 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -22,7 +22,7 @@ 声明 `diff` 渲染意图的工具调用(`write`/`edit` 工具),通过 ui-primitives 的 `DiffBlock` 内联渲染其已应用的改动,采用同一套四层结构。`contract/diff-card-model.ts` 是从 `callView`/`resultView` 对推导的唯一位置;已结算 result 的 hunk 替换 call 时 diff,对任何其他 card 标签或 generic result view(write/edit 的执行错误)它返回 null,落回通用路径。键控的 `FileMutationRow`(在 `write` 与 `edit` 下都注册)组合共享的 `ToolRow`,把 diff 作为 ToolRow 的 `diff` body 传入,因此它是该行默认折叠的展开卡片;摘要路径链接仍经 host 打开文件,而出错的改动(没有 diff 卡片)经 ToolRow 的 Output 区呈现其错误文本,首行进入折叠摘要。渲染点兜底行与详情面板同样感知 diff。行的上限是 `CHAT_DIFF_MAX_LINES`(8),面板为 16([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md))。 -聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。 +聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。未进入重试的终态失败会在其轮次边界渲染为持久的内联状态,展示适合显示的持久消息与可选错误码,但不会提供 Host 无法兑现的操作;AUTH 文案绝不会回显提供方给出的凭据片段。 声明 `search` 渲染意图的 `grep`/`glob` 调用,会在同样的渲染点上通过 ui-primitives 的 `SearchBlock` 内联渲染其结果——grep 的匹配按文件分组(每个是一个可折叠的头,下辖 `lineNumber: line` 行),glob 是扁平路径列表。`contract/search-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;与终端卡片不同,它不读 `callView`,因为搜索在 `execute` 前没有匹配或路径,所以运行中的搜索只显示摘要。对任何非搜索的结果视图、当前客户端版本无法编译的 `card` 或 `kind`、以及(因为这些都与不可信的 wire 帧同行)一个 `files`/`paths` 格式错误的已知 kind,它都返回 null,落回通用路径。键控的 `SearchRow` 因推导出的 `kind` 决定形态而同时注册在 `grep` 与 `glob` 下,组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `search` body 传入,因此它是该行默认折叠的展开卡片;渲染点兜底行以同样方式渲染它。两者上限都是 `CHAT_SEARCH_MAX_LINES`(8),面板为 16。被截断的搜索会从卡片里丢掉一些行,但通往其余部分的定位符——grep/glob 的 `Full … stored at …` 脚注——只存在于结果文本里,因此推导在(且仅在)结果被截断时把它作为恢复脚注画在卡片下方;一个完全没有卡片的已结算调用(出错的搜索、嵌套 `run_code` 子派发、旧日志的 generic 结果)则经 ToolRow 的 Output 区呈现其压平后的结果文本,从而不让任何内容丢失在一个光秃秃的摘要之后([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md))。 diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 308101f3c8..1c392a2e82 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -191,6 +191,40 @@ color: var(--dsw-alias-label-secondary); } +.turnErrorRow { + display: grid; + grid-template-columns: 10px minmax(0, 1fr) auto; + gap: 8px; + align-items: start; + padding: 2px 0; + font-size: 13px; + line-height: 20px; +} + +.turnErrorDot { + margin-top: 5px; +} + +.turnErrorCopy { + min-width: 0; + overflow-wrap: anywhere; +} + +.turnErrorTitle { + margin-right: 6px; + color: var(--dsw-alias-state-error-primary); + font-weight: 600; +} + +.turnErrorMessage { + color: var(--dsw-alias-label-secondary); +} + +.turnErrorCode { + color: var(--dsw-alias-label-tertiary); + font: var(--dsw-font-markdown-code-block-small); +} + @keyframes retry-shimmer { from { background-position: 100% 50%; diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 6f55cd3d01..d0d2b50309 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -6,9 +6,9 @@ import { memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import type { CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode, - UnknownSurfaceNode, UserMessageNode, + TurnErrorNode, UnknownSurfaceNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' -import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' import { CompactionItem } from './CompactionItem.tsx' import { ContextInjectionRow } from './ContextInjectionRow.tsx' @@ -16,7 +16,14 @@ import { MessageIconActions } from './MessageIconActions.tsx' import css from './MessageItem.module.css' export interface MessageItemProps { - node: UserMessageNode | SteeringMessageNode | ContextMessageNode | CompactionSummaryNode | ModelRetryNode | UnknownSurfaceNode + node: + | UserMessageNode + | SteeringMessageNode + | ContextMessageNode + | CompactionSummaryNode + | ModelRetryNode + | TurnErrorNode + | UnknownSurfaceNode retryActive?: boolean /** Fork the session through the turn containing this message (user-bubble branch action). */ onFork?: (seq: number) => void @@ -109,6 +116,24 @@ function ModelRetryItem({ node, active, t }: { ) } + +/** Persistent, turn-positioned feedback for a terminal failure. */ +function TurnErrorItem({ node, t }: { + node: TurnErrorNode + t: ChatViewSlotProps['t'] +}) { + return ( +
+ +
+ {t('message.turnError')} + {node.message} +
+ {node.code !== undefined && {node.code}} +
+ ) +} + /** * Display projection of reference forms in a user bubble (free geometry — no * textarea alignment constraint here); everything else stays plain text. The @@ -185,6 +210,8 @@ export const MessageItem = memo(function MessageItem({ return case 'model-retry': return + case 'turn-error': + return default: return (
diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 9263c5d93c..0293b8bc7d 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -63,6 +63,7 @@ export const zh = { 'message.retry.status': '{label}({retry}/{maximum}) · {seconds}s', 'message.retry.delay': '重试延迟:', 'message.retry.failure': '失败原因:', + 'message.turnError': '本轮运行失败', 'command.running': '执行中…', 'command.failed': '命令失败', 'command.done': '已完成', @@ -163,6 +164,7 @@ export const en = { 'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s', 'message.retry.delay': 'Retry delay: ', 'message.retry.failure': 'Failure reason: ', + 'message.turnError': 'This turn failed', 'command.running': 'Running…', 'command.failed': 'Command failed', 'command.done': 'Completed', diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 09affc53e8..110ab6a991 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -8,7 +8,7 @@ import { Profiler } from 'react' import { act, cleanup, fireEvent, render, within } from '@testing-library/react' import type { AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot, - ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode, + ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode, TurnErrorNode, UserMessageNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' @@ -75,6 +75,11 @@ const retry = (seq: number): ModelRetryNode => ({ retry: 1, maxRetries: 2, delayMs: 450, failure: { code: 'TRANSPORT', message: '连接被重置' }, }) +const turnError = (seq: number, code?: string): TurnErrorNode => ({ + kind: 'turn-error', seq, time: seq * 1_000, turn: 1, step: 0, + message: seq === 2 ? 'API key is invalid' : 'plugin exploded', + ...(code === undefined ? {} : { code }), +}) const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({ kind: 'tool-result', seq, time: seq * 1_000, callId, call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` }, @@ -288,6 +293,16 @@ describe('ChatView', () => { expect(within(cancelledDisclosure).getByRole('status').textContent).toContain('重试已取消') }) + it('renders terminal turn failures inline with their durable message and optional code', () => { + const h = makeHarness({ nodes: [user(1, 'try'), turnError(2, 'AUTH'), turnError(3)] }) + const view = render() + const statuses = view.getAllByRole('status') + expect(statuses.map(status => status.textContent)).toEqual([ + '本轮运行失败API key is invalidAUTH', + '本轮运行失败plugin exploded', + ]) + }) + it('the expanded row Inspect pill hands the call id to inspectCall', () => { const h = makeHarness({ nodes: [toolResult(3, 'a')], 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 22/28] 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)不挂载。 ## 配置 From 678ad97f9a39115a477a1a4ab5ca1dbdd52db635 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:35:55 +0800 Subject: [PATCH 23/28] Allow same-basename Workspace paths --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- ...same-basename-workspace-adoption.i18n.yaml | 6 +++ ...-07-31-same-basename-workspace-adoption.md | 37 +++++++++++++++++++ ...-31-same-basename-workspace-adoption.zh.md | 37 +++++++++++++++++++ ...n-list-browsing-and-manual-order.i18n.yaml | 4 +- ...-session-list-browsing-and-manual-order.md | 2 +- ...ssion-list-browsing-and-manual-order.zh.md | 2 +- ...-07-25-workspace-ui-product-flow.i18n.yaml | 4 +- .../2026-07-25-workspace-ui-product-flow.md | 6 +-- ...2026-07-25-workspace-ui-product-flow.zh.md | 6 +-- ...ative-workspace-directory-picker.i18n.yaml | 4 +- ...07-27-native-workspace-directory-picker.md | 6 +-- ...27-native-workspace-directory-picker.zh.md | 6 +-- ...-workspace-registration-deletion.i18n.yaml | 4 +- ...6-07-27-workspace-registration-deletion.md | 4 +- ...7-27-workspace-registration-deletion.zh.md | 4 +- ...-31-one-route-to-add-a-workspace.i18n.yaml | 4 +- ...2026-07-31-one-route-to-add-a-workspace.md | 2 +- ...6-07-31-one-route-to-add-a-workspace.zh.md | 2 +- apps/web/tests/workspace-management.e2e.ts | 26 ++++++++++++- docs/cordis-catalog/services.md | 4 +- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../src/client/WorkspacePicker.tsx | 20 ++-------- .../ui-workspace/src/client/contract/slots.ts | 4 +- .../client/ui-workspace/src/client/locales.ts | 4 -- .../tests/workspace-browser.spec.tsx | 2 +- .../tests/workspace-picker.spec.tsx | 34 +++++------------ .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 12 +++++- packages/host/apiproxy/src/api/workspace.ts | 6 +-- .../tests/api-proxy-workspace.spec.ts | 19 +++++++--- packages/workspace/workspace/README.i18n.yaml | 4 +- packages/workspace/workspace/README.md | 2 +- packages/workspace/workspace/README.zh.md | 2 +- packages/workspace/workspace/src/index.ts | 17 +-------- .../workspace/tests/workspace.spec.ts | 16 ++++---- 43 files changed, 203 insertions(+), 135 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index 27911294a6..6855a0af2b 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.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/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 495062f910785e1bb2f421dbb25c01c399d45567 -2026-07-28-directory-picker-capability-seam.zh.md: 62fc87212ab627ea8819dab55e3a769b4a5afc42 +2026-07-28-directory-picker-capability-seam.md: 9884385cf9e0d51604bab9e4fd3c4bee77448331 +2026-07-28-directory-picker-capability-seam.zh.md: 8c229b9fb08d5052ba8a512f2153a89a9e5fd455 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 495062f910..9884385cf9 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -12,7 +12,7 @@ The web GUI's "Open local folder" flow was hardwired to one interaction: `host.p A three-package capability seam in `packages/host/` — `directory-picker` (interface), `directory-picker-native`, `directory-picker-browse` (backends) — with one contract method: `capability()` returns a **discriminated union**, `{ kind: 'native', pick(signal) }` or `{ kind: 'browse', list(path?), createDirectory(path, name) }`. The gateway (`dsh-host-apiproxy`) injects `directoryPicker`, serves the matching RPCs, and answers `directory-picker-unavailable` for the other kind. The union is discriminated because the backends differ in *interaction shape* — flattening them into one method set would force every backend to fake the other's shape. -**The client side is slot-composed, not advertisement-branched.** ui-workspace's two trigger surfaces each declare a `single` directory-flow hole (`conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`; two keys because a hole has exactly one declaring slot entry — same owner contract, same occupant). Backend packages are **dual-face**: the browser half registers the matching interaction into both holes — `-native` a renderless occupant driving `host.pickDirectory`, `-browse` the in-app Select Workspace Directory dialog. The hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`) carries the whole exchange: ui-workspace keeps the trigger (menu entry rendered only while the hole is occupied) and the adoption (`createWorkspace({path})`, conflict/error dialog, Choose again), the occupant owns everything between `open` and the picked path. One `cordis.yml` row therefore swaps the host capability and the client flow together; a mismatch is impossible by construction, and mounting two flow packages fails at client load (`single` hole). The earlier `host.describe.directoryPicker` advertisement and the client's kind branching are deleted — with composition wiring both sides, a wire fact for the client to branch on had no remaining consumer. The hole registry (`ctx.slots.entries`) replaces it as the per-menu-open occupancy read. +**The client side is slot-composed, not advertisement-branched.** ui-workspace's two trigger surfaces each declare a `single` directory-flow hole (`conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`; two keys because a hole has exactly one declaring slot entry — same owner contract, same occupant). Backend packages are **dual-face**: the browser half registers the matching interaction into both holes — `-native` a renderless occupant driving `host.pickDirectory`, `-browse` the in-app Select Workspace Directory dialog. The hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`) carries the whole exchange: ui-workspace keeps the trigger (menu entry rendered only while the hole is occupied) and the adoption (`createWorkspace({path})`, retryable error dialog, Choose again), the occupant owns everything between `open` and the picked path. One `cordis.yml` row therefore swaps the host capability and the client flow together; a mismatch is impossible by construction, and mounting two flow packages fails at client load (`single` hole). The earlier `host.describe.directoryPicker` advertisement and the client's kind branching are deleted — with composition wiring both sides, a wire fact for the client to branch on had no remaining consumer. The hole registry (`ctx.slots.entries`) replaces it as the per-menu-open occupancy read. Placement and policy rulings folded into this decision: diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 62fc87212a..8c229b9fb0 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -12,7 +12,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick 在 `packages/host/` 落一个三包能力 seam——`directory-picker`(接口)、`directory-picker-native`、`directory-picker-browse`(后端)——唯一契约方法 `capability()` 返回**可辨识联合**:`{ kind: 'native', pick(signal) }` 或 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。网关(`dsh-host-apiproxy`)注入 `directoryPicker`,提供对应的 RPC,另一种 kind 的调用以 `directory-picker-unavailable` 应答。联合之所以可辨识,是因为后端差异在**交互形态**——压平成统一方法集会逼每个后端伪装另一方的形态。 -**client 侧靠 slot 组合,而非按广播分支。** ui-workspace 的两个触发表层各自声明一个 `single` 目录流洞(`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`;之所以是两个 key,是因为一个洞只有一个声明它的 slot entry——owner 契约相同、占用者相同)。后端包是**双面包**:browser half 把匹配的交互注册进两个洞——`-native` 是驱动 `host.pickDirectory` 的无渲染占用者,`-browse` 是应用内的选择工作区目录对话框。洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)承载整个交换:ui-workspace 保留触发(菜单入口仅在洞被占用时渲染)与接纳(`createWorkspace({path})`、冲突/错误对话框、重新选择),占用者持有从 `open` 到所选路径之间的一切。因此一行 `cordis.yml` 同时切换宿主能力与 client 流程;错配在构造上不可能,同时挂两个流程包会在 client 加载期失败(`single` 洞)。早先的 `host.describe.directoryPicker` 广播与客户端 kind 分支被删除——组合已经接好两侧后,供客户端分支用的 wire 事实不再有任何消费者。洞注册表(`ctx.slots.entries`)取而代之,成为每次打开菜单的占用读取。 +**client 侧靠 slot 组合,而非按广播分支。** ui-workspace 的两个触发表层各自声明一个 `single` 目录流洞(`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`;之所以是两个 key,是因为一个洞只有一个声明它的 slot entry——owner 契约相同、占用者相同)。后端包是**双面包**:browser half 把匹配的交互注册进两个洞——`-native` 是驱动 `host.pickDirectory` 的无渲染占用者,`-browse` 是应用内的选择工作区目录对话框。洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)承载整个交换:ui-workspace 保留触发(菜单入口仅在洞被占用时渲染)与接纳(`createWorkspace({path})`、可重试的错误对话框、重新选择),占用者持有从 `open` 到所选路径之间的一切。因此一行 `cordis.yml` 同时切换宿主能力与 client 流程;错配在构造上不可能,同时挂两个流程包会在 client 加载期失败(`single` 洞)。早先的 `host.describe.directoryPicker` 广播与客户端 kind 分支被删除——组合已经接好两侧后,供客户端分支用的 wire 事实不再有任何消费者。洞注册表(`ctx.slots.entries`)取而代之,成为每次打开菜单的占用读取。 并入本决策的位置与策略裁决: diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.i18n.yaml new file mode 100644 index 0000000000..990e7e39bb --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.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/bug-fix/2026-07-31-same-basename-workspace-adoption.md +2026-07-31-same-basename-workspace-adoption.md: ed53804ea64df0d61db16e579c3d65af803dbb97 +2026-07-31-same-basename-workspace-adoption.zh.md: 82cfb7d90afca28f8e666742a758fda0202909f3 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md new file mode 100644 index 0000000000..ed53804ea6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md @@ -0,0 +1,37 @@ +# Agent Note: Same-basename Workspace adoption + +Status: implemented + +English | [中文](2026-07-31-same-basename-workspace-adoption.zh.md) + +## Problem + +A Workspace is identified by its stable id and canonical directory path, while its title is mutable display metadata. The registry nevertheless rejected a new canonical path when its basename-derived title matched another Workspace. Common directory layouts such as `/a/xx` and `/b/xx` therefore could not coexist in the Web UI, even though the [domain design](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md) already permits duplicate titles and every client operation addresses a Workspace by id. + +## Decision + +`ctx.workspace.create(path, title?)` treats canonical path as the only uniqueness key. Repeating the same path remains idempotent and preserves the registered title. Different canonical paths create different Workspace records and may share a title; when no title is supplied, each record still derives its title from `basename(path)` without suffixing or rewriting it. + +The Host's `workspace.create({ path })` adoption route inherits that rule. The Workspace manager, picker, grouping tree, selection, rename, deletion, and Session creation continue to use `WorkspaceId`, so equal labels neither merge records nor redirect an operation. The sidebar hover card exposes each canonical path when the labels need disambiguation. + +Explicit naming remains stricter. `workspace.create({ name })` and `workspace.rename` continue to reject a title already registered, as described by [manual Workspace naming](../feature/2026-07-25-session-list-browsing-and-manual-order.md). This prevents a user from deliberately introducing another ambiguous label while accepting collisions imposed by existing directory names. The path-adoption rule supersedes only the title-conflict clauses in the [Workspace product flow](../feature/2026-07-25-workspace-ui-product-flow.md) and [native directory picker](../feature/2026-07-27-native-workspace-directory-picker.md). + +The durable schema does not change: Workspace records already store id, path, and title independently, bootstrap can derive equal basenames, and startup validates duplicate paths rather than titles. + +## Verification + +Workspace registry and Host API tests create two real directories under different parents with the same final segment and assert distinct ids, paths, and durable order. The picker component renders equal labels as separate id-keyed entries. The keyless Web browser scenario adopts both directories through the composed directory flow and observes two registered and rendered Workspaces. + +## Alternatives considered + +**Keep title uniqueness and reject the second directory.** A display label would remain an accidental identity key and ordinary multi-root layouts would stay impossible to register. + +**Suffix colliding titles automatically.** A generated label such as `xx (2)` would no longer be the directory-derived title, would need stable allocation rules across deletion and reload, and would add state solely to conceal an identity mistake. + +**Use the full path as every Workspace title.** This removes the collision but makes the primary navigation label unnecessarily long. The full path remains available in the hover detail while the concise basename stays useful. + +**Permit collisions from explicit rename and create-by-name operations too.** The registry supports that state, but those operations intentionally ask the user to choose a display name. Retaining their conflict response preserves the existing naming guard without blocking filesystem-selected paths. + +## Consequences + +Two Workspace rows may carry the same visible title. They remain independently selectable and actionable because ids own identity; users can inspect the path or rename either row to disambiguate it. An explicit rename cannot select another row's current title, including a title that arose from same-basename adoption. No storage migration or compatibility path is required. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md new file mode 100644 index 0000000000..82cfb7d90a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 接纳 basename 相同的 Workspace + +Status: implemented + +[English](2026-07-31-same-basename-workspace-adoption.md) | 中文 + +## 问题 + +Workspace 的身份由其稳定 id 和规范目录路径确定,标题则是可变的显示元数据。然而,只要新规范路径按 basename 派生出的标题与另一个 Workspace 相同,注册表就会拒绝该路径。因此,`/a/xx` 和 `/b/xx` 等常见目录布局无法同时出现在 Web UI 中,尽管[领域设计](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md)早已允许标题重复,而且每项客户端操作都通过 id 定位 Workspace。 + +## 决策 + +`ctx.workspace.create(path, title?)` 仅以规范路径作为唯一性键。重复传入同一路径仍保持幂等,并保留已注册的标题。不同的规范路径会创建不同的 Workspace 记录,且可以共用标题;未提供标题时,每条记录仍从 `basename(path)` 派生标题,不添加后缀,也不改写标题。 + +Host 的 `workspace.create({ path })` 接纳入口沿用该规则。Workspace 管理器、选择器、分组树、选择、重命名、删除和 Session 创建仍使用 `WorkspaceId`,因此相同标签既不会合并记录,也不会把操作指向其他记录。需要区分相同标签时,侧边栏悬停详情卡会显示各自的规范路径。 + +显式命名仍采用更严格的规则。`workspace.create({ name })` 和 `workspace.rename` 仍会拒绝已注册的标题,具体见[手动 Workspace 命名](../feature/2026-07-25-session-list-browsing-and-manual-order.md)。这既防止用户主动引入另一个难以区分的标签,又允许既有目录名称造成的重名。路径接纳规则仅取代 [Workspace 产品流](../feature/2026-07-25-workspace-ui-product-flow.md)和[原生目录选择器](../feature/2026-07-27-native-workspace-directory-picker.md)中的标题冲突条款。 + +持久化 schema 未变:Workspace 记录本就分别存储 id、path 和 title,引导初始化可以派生出相同的 basename,启动校验检查的是重复路径而非重复标题。 + +## 验证 + +Workspace 注册表与 Host API 测试会在不同父目录下创建两个末级名称相同的真实目录,并断言其 id 和路径互不相同,且持久顺序正确。选择器组件将相同标签渲染为按 id 区分的独立条目。无密钥 Web 浏览器场景通过组合而成的目录流程接纳这两个目录,并观察到两个 Workspace 均已注册且完成渲染。 + +## 考虑过的替代方案 + +**保持标题唯一,并拒绝第二个目录。** 显示标签仍会意外充当身份键,普通的多根目录布局仍无法注册。 + +**自动为冲突标题添加后缀。** 像 `xx (2)` 这样的生成标签将不再是从目录派生的标题;系统还需要制定跨删除与重载保持稳定的分配规则,并且只为掩盖身份判定错误而增加状态。 + +**将完整路径用作每个 Workspace 的标题。** 这会消除冲突,却使主导航标签不必要地过长。完整路径仍可在悬停详情中查看,而简洁的 basename 仍有价值。 + +**也允许显式重命名和按名称创建操作产生重名。** 注册表支持这种状态,但这些操作本就是明确要求用户选择显示名称。保留冲突响应可维持现有命名防护,同时不阻止从文件系统选取的路径。 + +## 后果 + +两个 Workspace 行可能显示相同的可见标题。id 负责身份,因此两行仍可独立选择和操作;用户可以查看路径或重命名任一行以作区分。显式重命名不能采用另一个行当前使用的标题,即使该标题源自 basename 相同的目录接纳。无需存储迁移或兼容路径。 diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml index 9259180188..fe955d125d 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.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-25-session-list-browsing-and-manual-order.md -2026-07-25-session-list-browsing-and-manual-order.md: 5af9cf026c5a0e32837822b3d8cff827c052b07f -2026-07-25-session-list-browsing-and-manual-order.zh.md: 2cfa4efe2f0e53b419b264c9af0aff878d321cac +2026-07-25-session-list-browsing-and-manual-order.md: bd04e7f74c8a4540d68e60ad68965e05de76bce9 +2026-07-25-session-list-browsing-and-manual-order.zh.md: 8ec5f71943a68a70f46fbd4c7702e4556b1892ec diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md index 5af9cf026c..bd04e7f74c 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md @@ -24,7 +24,7 @@ The group-by menu offers two modes, WorkSpace / In one list. WorkSpace mode rend ### workspace.rename -`workspace.rename({ workspaceId, title })`: the title is trimmed and must be non-blank; both the same-title no-op and the duplicate check evaluate inside the host's serialized workspace-creation chain (shared with create, so concurrent create/rename cannot interleave a duplicate or an out-of-order fake success), and a conflict returns `workspace-name-conflict`. Durability goes through `setTitle`'s mutate path, and the `domain/changed` listener broadcasts the `host/workspace-changed` frame automatically. The UI is a standard modal with a client-side duplicate pre-check. +`workspace.rename({ workspaceId, title })`: the title is trimmed and must be non-blank; both the same-title no-op and the duplicate check evaluate inside the Host's serialized workspace-operation chain (shared with create-by-name, so concurrent explicit naming operations cannot interleave a duplicate or an out-of-order fake success), and a conflict returns `workspace-name-conflict`. Path adoption may derive a title already present because canonical path, not title, owns identity ([decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)). Durability goes through `setTitle`'s mutate path, and the `domain/changed` listener broadcasts the `host/workspace-changed` frame automatically. The UI is a standard modal with a client-side duplicate pre-check. ### Manual order: insertSessionBefore replaces activity pinning diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md index 2cfa4efe2f..8ec5f71943 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md @@ -24,7 +24,7 @@ group-by 菜单提供 WorkSpace / In one list 两种模式。WorkSpace 模式按 ### workspace.rename -`workspace.rename({ workspaceId, title })`:title trim 后非空;同名 no-op 与重名查重都在 host 的 workspace 创建串行链内求值(与 create 共链,并发 create/rename 不能穿插出重名或乱序假成功),冲突回 `workspace-name-conflict`。落盘经 `setTitle` 的 mutate 通道,`domain/changed` 监听自动广播 `host/workspace-changed` 帧。UI 为标准 Modal,client 侧另做重名预检。 +`workspace.rename({ workspaceId, title })`:title trim 后非空;同名 no-op 与重名查重都在 Host 的 Workspace 操作串行链内求值(与按名称创建共链,并发的显式命名操作不能穿插出重名或乱序假成功),冲突返回 `workspace-name-conflict`。按路径收编可以派生出已有 title,因为拥有身份的是 canonical path,而不是 title(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md))。落盘经 `setTitle` 的 mutate 通道,`domain/changed` 监听自动广播 `host/workspace-changed` 帧。UI 为标准 Modal,client 侧另做重名预检。 ### 手动排序:insertSessionBefore 取代活动置顶 diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml index b876fa890c..d8232afa44 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.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-25-workspace-ui-product-flow.md -2026-07-25-workspace-ui-product-flow.md: 7e3338cfcb04c1588bbc97f6e8aa1cd2dac59141 -2026-07-25-workspace-ui-product-flow.zh.md: 8c092151f737b6bc10aa8424281eeda359f07c1d +2026-07-25-workspace-ui-product-flow.md: 7a6a41e19d2930fbcbf7ba5fc9e6809d96e23166 +2026-07-25-workspace-ui-product-flow.zh.md: a40f374fd794b11bff0de72cbc822fd638cd267c diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md index 7e3338cfcb..7a6a41e19d 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md @@ -20,7 +20,7 @@ The Host provides the following GUI wiring on the Workspace entity: | --- | --- | | `workspace.list` | Returns persistent Workspaces in order and filters out Session ids that fail header validation | | `workspace.create({ name })` | Creates a directory and Workspace at `workspaceRoot/name`; fails on a display-name conflict | -| `workspace.create({ path })` | Adopts an existing directory and does not create an arbitrary path | +| `workspace.create({ path })` | Adopts an existing directory by canonical path; basename-derived display titles may repeat | | `workspace.delete({ workspaceId })` | Removes the Workspace registration while retaining its directory and session logs; its Sessions become Ungrouped | | `session.create({ workspaceId, sessionId? })` | Resolves cwd from the Workspace, idempotently creates a Session with an optional preallocated id, and attaches it | | `session.create({ cwd })` | Remains available to non-Workspace callers and creates an Ungrouped Session | @@ -52,7 +52,7 @@ When no Workspace exists, the page creates a frontend Workspace object named `wo Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the most recent Workspace, or the Workspace Intent if no real Workspace exists. The Workspace picker's one Add workspace action ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md); it was a pair of Use-an-existing-folder and create-by-name actions when this was decided) immediately creates a real Workspace when the user confirms a directory, then retargets the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message. -A new Workspace takes its display name from the directory it was created in, and the Host rejects a title already registered (the UI's own duplicate-name pre-check went with the create-by-name dialog). Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow. +A new Workspace takes its display name from the directory it was created in. Distinct canonical paths may share the same basename-derived title ([identity decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)); explicit create-by-name and rename operations retain their duplicate-title checks. Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow. ### First send and recovery @@ -108,7 +108,7 @@ The Sidebar and conversation empty hero receive standardized actions through slo - Workspace list performs one reentrant bootstrap using only headers; an initialized empty registry does not initialize again after restart, and membership reads validate both the index and canonical cwd. - The initial default target is determined exactly once after both baselines are ready; Workspace groups are not reordered as a whole by hydration or Session activity, and an active Session moves only itself to the front. - A frontend Session under a real Workspace temporarily counts toward the sidebar total, while a Workspace Intent remains hidden; neither publication nor refresh leaves duplicate rows or counts. -- Both the UI and Host reject duplicate Workspace names; cwd-only Sessions, Sessions with invalid historical cwd values, and unattached Sessions remain Ungrouped. +- The UI and Host admit distinct same-basename directories as separate Workspaces, while explicit create-by-name and rename operations reject duplicate titles; cwd-only Sessions, Sessions with invalid historical cwd values, and unattached Sessions remain Ungrouped. - Confirmed Workspace deletion removes only the registration, retains the current Session, directory, files, and session log, and survives reload; package tests pin unary/frame/baseline races and failure rollback. - Keyless runnable snapshots cover the zero state, explicit creation, and the first send; package-level tests cover bootstrap, membership validation, ordering, idempotency, failure recovery, and arbitrary frame order. diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md index 8c092151f7..a40f374fd7 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md @@ -20,7 +20,7 @@ Host 在 Workspace entity 上提供以下 GUI 接线: | --- | --- | | `workspace.list` | 返回持久有序的 Workspace,并过滤未通过 header 校验的 Session id | | `workspace.create({ name })` | 在 `workspaceRoot/name` 创建目录和 Workspace;显示名冲突时失败 | -| `workspace.create({ path })` | 收编已经存在的目录,不为任意路径创建目录 | +| `workspace.create({ path })` | 按 canonical path 收编已有目录;由 basename 派生的显示名可以重复 | | `workspace.delete({ workspaceId })` | 移除 Workspace 注册记录,同时保留目录和会话日志;相关 Session 进入 Ungrouped | | `session.create({ workspaceId, sessionId? })` | 从 Workspace 解析 cwd,以可选预分配 id 幂等创建 Session 并 attach | | `session.create({ cwd })` | 保留给非 Workspace 调用方,创建 Ungrouped Session | @@ -52,7 +52,7 @@ Session 自己持有首条输入并驱动一条内部流水线:必要时以预 顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用最近 Workspace,没有真实 Workspace 时使用 Workspace Intent。Workspace picker 的单一 Add workspace 动作(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md);本决策做出时是 Use an existing folder 与按名称创建两个动作)会在用户确认目录时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。 -新建 Workspace 的显示名取自其所在目录,Host 拒绝已注册的同名 title(UI 侧的重名预检随按名称创建的对话框一并删除)。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名和目录名仍不在此动线范围内。 +新建 Workspace 的显示名取自其所在目录。不同 canonical path 可以拥有相同的 basename 派生显示名(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md));显式的按名称创建和重命名操作仍保留显示名重名检查。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名和目录名仍不在此动线范围内。 ### 首次发送与恢复 @@ -108,7 +108,7 @@ Sidebar 与 conversation empty hero 通过 slot 获得标准化动作:`startSe - Workspace list 只读取 header 完成一次可重入 bootstrap;initialized 的空 registry 重启不重复初始化,成员读取同时校验索引与 canonical cwd。 - 初始默认目标只在两份基线 ready 后确定一次;Workspace 组不因 hydration 或 Session 活跃整体重排,单个活跃 Session 只前移自身。 - 真实 Workspace 下的前端 Session 临时计入 sidebar 数量,Workspace Intent 保持隐藏,发布与刷新都不会留下重复行或重复计数。 -- UI 与 Host 两层拒绝同名 Workspace;cwd-only Session、无效历史 cwd 和未 attach Session 保持 Ungrouped。 +- UI 与 Host 会将 canonical path 不同但 basename 相同的目录接纳为独立 Workspace,而显式的按名称创建和重命名操作会拒绝重复显示名;cwd-only Session、无效历史 cwd 和未 attach Session 保持 Ungrouped。 - 经确认的 Workspace 删除只移除注册记录,保留当前 Session、目录、文件和会话日志,并在刷新后保持该状态;包级测试固定一元响应/帧/基线竞态和失败回滚行为。 - keyless runnable snapshot 覆盖零态、显式创建和首次发送;包级测试覆盖 bootstrap、成员校验、排序、幂等、失败恢复及任意 frame 顺序。 diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml index a2b2e940fd..1faf10a4c8 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.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-native-workspace-directory-picker.md -2026-07-27-native-workspace-directory-picker.md: 8dd7084eb8ffa417d9366cbc54bcbc59170c64fe -2026-07-27-native-workspace-directory-picker.zh.md: 9efc6571207b1f6a6996b9990c2a55afb70872bd +2026-07-27-native-workspace-directory-picker.md: 98f9dc9bed5358e816d4324462d5ea7657f9007f +2026-07-27-native-workspace-directory-picker.zh.md: ca765778fae734fd47a05652aea7021328ed4ab6 diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md index 8dd7084eb8..98f9dc9bed 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md @@ -19,7 +19,7 @@ The workspace manager must upsert the returned workspace before the selection ca - The picker accepts one directory on macOS, Windows, and Linux. - Cancelling the system dialog is silent and returns `null`. - A duplicate path selects the existing workspace. -- A different path whose derived title conflicts with another workspace shows a focused error with **Choose again** and **Cancel** actions. +- A different canonical path adopts a separate Workspace even when its derived title matches another Workspace ([identity decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)). - Other picker failures show a compact retryable error. - The create-by-name flow this decision left untouched is gone; picking a directory is now the whole of adding a workspace ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md)). @@ -41,9 +41,9 @@ Platform adapters invoke native tools without a shell: ## Consequences -The current GUI opens one local folder through a native picker on macOS, Windows, and Linux. Cancelling changes no state, failures remain retryable, and duplicate paths are idempotent while title conflicts require an explicit new choice. The selected workspace and its displayed name refresh before a new blank session starts. This picker is now the only route to a workspace ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md)): the operator picks an existing directory, or creates one inside the chooser. +The current GUI opens one local folder through a native picker on macOS, Windows, and Linux. Cancelling changes no state, failures remain retryable, duplicate paths are idempotent, and distinct same-basename paths coexist as separate Workspaces. The selected workspace and its displayed name refresh before a new blank session starts. This picker is now the only route to a workspace ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md)): the operator picks an existing directory, or creates one inside the chooser. -The added host, runtime, component, and GUI tests cover the native boundary, request trust checks, cancellation and failure handling, existing-path reuse, title conflicts, and the immediate visible-name update. The privileged RPC remains specific to the local desktop carrier; a remote Web directory browser is outside this decision. +The added host, runtime, component, and GUI tests cover the native boundary, request trust checks, cancellation and failure handling, existing-path reuse, same-basename adoption, and the immediate visible-name update. The privileged RPC remains specific to the local desktop carrier; a remote Web directory browser is outside this decision. ## Risks diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md index 9efc657120..ca765778fa 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md @@ -19,7 +19,7 @@ Status: implemented - 在 macOS、Windows 和 Linux 上,选择器一次只允许选择一个目录。 - 取消系统对话框不会显示提示,并返回 `null`。 - 路径重复时,选中现有工作区。 -- 如果路径不同,但其派生标题与另一个工作区冲突,则显示明确指出该冲突的错误提示,其中包含 **重新选择** 和 **取消** 操作。 +- 即使派生显示名与另一个 Workspace 相同,不同 canonical path 也会被收编为独立 Workspace(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md))。 - 选择器的其他故障会显示简洁且可重试的错误提示。 - 本决策当时未触碰的按名称创建流程现已删除;选择目录就是添加工作区的全部(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md))。 @@ -41,9 +41,9 @@ Status: implemented ## 后果 -当前 GUI 可以在 macOS、Windows 和 Linux 上通过原生选择器打开一个本地文件夹。取消操作不会改变任何状态,故障仍可重试;重复路径的处理具有幂等性,标题冲突则要求用户明确重新选择。选中的工作区及其显示名称会在启动新的空白会话前完成刷新。该选择器现已是获得工作区的唯一路径(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md)):操作者要么选一个已有目录,要么在选择器内新建一个。 +当前 GUI 可以在 macOS、Windows 和 Linux 上通过原生选择器打开一个本地文件夹。取消操作不会改变任何状态,故障仍可重试;重复路径的处理具有幂等性,basename 相同的不同路径则可作为独立 Workspace 共存。选中的工作区及其显示名称会在启动新的空白会话前完成刷新。该选择器现已是获得工作区的唯一路径(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md)):操作者要么选一个已有目录,要么在选择器内新建一个。 -新增的宿主、运行时、组件和 GUI 测试覆盖原生边界、请求信任校验、取消与故障处理、已有路径复用、标题冲突和可见名称即时更新。该特权 RPC 仍仅面向本地桌面载体;远程 Web 目录浏览器不属于本次决策范围。 +新增的宿主、运行时、组件和 GUI 测试覆盖原生边界、请求信任校验、取消与故障处理、已有路径复用、同 basename 路径收编和可见名称即时更新。该特权 RPC 仍仅面向本地桌面载体;远程 Web 目录浏览器不属于本次决策范围。 ## 风险 diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml index d576fb10e5..5b8cb4481f 100644 --- a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.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-workspace-registration-deletion.md -2026-07-27-workspace-registration-deletion.md: 8168b0832ca39e6023f6981815ffe758b5695361 -2026-07-27-workspace-registration-deletion.zh.md: b0df6982ac81426a5b0ce2f0e2b0e744212e3f5b +2026-07-27-workspace-registration-deletion.md: ae12b09979f20385336eef8d805173dd9c08d887 +2026-07-27-workspace-registration-deletion.zh.md: 2e43b25ec0b68703d68f6346796482d1c5ed3e8a diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md index 8168b0832c..ae12b09979 100644 --- a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md @@ -28,7 +28,7 @@ Create and delete write a durable `pendingMutation` before their record/order pa `WorkspaceManager` treats both `host/workspace-changed` and `host/workspace-removed` as ordered deltas replayed over an in-flight `workspace.list` response. A successful unary delete removes the row immediately instead of waiting for its own stream echo. Removal is idempotent, and a process-local tombstone rejects late changed frames or stale baseline rows for the never-reused Workspace id. A reconnect still refreshes from `workspace.list`; Session state is never pruned by a Workspace delta. -The delete confirmation remains pending until the React Workspace projection has committed the removed id, so the next create gesture cannot observe one stale list frame. During create, duplicate-name validation is suppressed while the request is pending because the committed `host/workspace-changed` frame may publish the newly created Workspace before its unary response; after failure returns the form to editing, validation uses the latest list again. +The delete confirmation remains pending until the React Workspace projection has committed the removed id, so the next Workspace gesture cannot observe or target one stale list frame. ## Confirmation interaction @@ -50,7 +50,7 @@ The menu, Modal, and buttons retain their existing structure and design tokens. ## Verification -Workspace package tests pin successful metadata-only deletion, same-path re-registration, unknown-id idempotence, table-failure rollback, explicit-marker restart recovery, unexplained-corruption rejection, and cache/table invariant behavior. Apiproxy and carrier tests pin the schema, handler, `workspace-not-found`, retained Session/folder, fresh-id re-registration, and committed `host/workspace-removed` frame. Client tests pin unary direct echo, duplicate removal, late changed frames, and deletion racing an in-flight baseline. Component tests pin confirmation, projection-settled closing, pending-state duplicate suppression, success-frame-before-unary ordering, failure, Cancel, Escape, and Close. The browser scenario observes every transient alert, slot error, console error, and page error while reusing a deleted title for a different directory. +Workspace package tests pin successful metadata-only deletion, same-path re-registration, unknown-id idempotence, table-failure rollback, explicit-marker restart recovery, unexplained-corruption rejection, and cache/table invariant behavior. Apiproxy and carrier tests pin the schema, handler, `workspace-not-found`, retained Session/folder, fresh-id re-registration, and committed `host/workspace-removed` frame. Client tests pin unary direct echo, duplicate removal, late changed frames, and deletion racing an in-flight baseline. Component tests pin confirmation, projection-settled closing, success-frame-before-unary ordering, failure, Cancel, Escape, and Close. The browser scenario observes every transient alert, slot error, console error, and page error while reusing a deleted title for a different directory. The assembled keyless Web scenario registers an existing temporary project directory, accounts a persisted Session, makes that Session current, confirms deletion in Chromium, and verifies the Workspace group disappears while Ungrouped retains the current Session. It checks the user file and JSONL log before and after deletion and repeats the UI, directory, and log assertions after reload. diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md index b0df6982ac..2e43b25ec0 100644 --- a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md @@ -28,7 +28,7 @@ Create 与 delete 会在记录/顺序对可能分叉之前写入持久 `pendin `WorkspaceManager` 将 `host/workspace-changed` 与 `host/workspace-removed` 都视为有序增量,并在进行中的 `workspace.list` 响应之上回放。成功的一元删除会立即移除行,无需等待本次操作自己的流回显。移除操作具有幂等性;由于 Workspace id 永不复用,进程本地删除标记会拒绝延迟到达的 changed 帧或陈旧基线行。重连仍从 `workspace.list` 刷新;Workspace 增量绝不会剪除会话状态。 -删除确认框会保持待处理,直到 React Workspace 投影已经提交目标 id 的移除,因此下一次创建操作不会读到一帧陈旧列表。创建请求进行中会暂停重复名称校验,因为已提交的 `host/workspace-changed` 帧可能先于一元响应发布刚创建的 Workspace;如果请求失败并让表单回到可编辑状态,系统会重新使用最新列表执行校验。 +删除确认框会保持待处理,直到 React Workspace 投影已经提交目标 id 的移除,因此下一次 Workspace 操作不会观察或定位到陈旧列表帧中的内容。 ## 确认交互 @@ -50,7 +50,7 @@ Create 与 delete 会在记录/顺序对可能分叉之前写入持久 `pendin ## Verification -Workspace 包测试固定了仅删除元数据的成功路径、同路径重新注册、未知 id 的幂等行为、表操作失败回滚、明确标记的重启恢复、来源不明损坏的拒绝,以及缓存/表不变量行为。Apiproxy 与载体测试固定了 schema、处理器、`workspace-not-found`、保留会话/文件夹、使用新 id 重新注册,以及已提交的 `host/workspace-removed` 帧。客户端测试固定了一元直接回显、重复移除、延迟到达的 changed 帧,以及删除与进行中基线并发的行为。组件测试固定了确认交互、投影稳定后关闭、待处理状态下抑制重复提交、成功帧先于一元响应、失败、Cancel、Escape 与 Close。浏览器场景会在为不同目录复用已删除名称时,观测每一次瞬时 alert、slot error、console error 与 page error。 +Workspace 包测试固定了仅删除元数据的成功路径、同路径重新注册、未知 id 的幂等行为、表操作失败回滚、明确标记的重启恢复、来源不明损坏的拒绝,以及缓存/表不变量行为。Apiproxy 与载体测试固定了 schema、处理器、`workspace-not-found`、保留会话/文件夹、使用新 id 重新注册,以及已提交的 `host/workspace-removed` 帧。客户端测试固定了一元直接回显、重复移除、延迟到达的 changed 帧,以及删除与进行中基线并发的行为。组件测试固定了确认交互、投影稳定后关闭、成功帧先于一元响应、失败、Cancel、Escape 与 Close。浏览器场景会在为不同目录复用已删除名称时,观测每一次瞬时 alert、slot error、console error 与 page error。 组装后的无密钥 Web 场景会注册一个已有临时项目目录,将持久化会话计入账本,把该会话设为当前会话,在 Chromium 中确认删除,并验证 Workspace 分组消失,而 Ungrouped 保留当前会话。该场景在删除前后检查用户文件和 JSONL 日志,并在刷新后重复验证 UI、目录与日志。 diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml index e4491b6c7b..1c0cc5644d 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.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/simplification/2026-07-31-one-route-to-add-a-workspace.md -2026-07-31-one-route-to-add-a-workspace.md: 0d3f4096b11ec2d0bd1339268bfb8567380d3e1a -2026-07-31-one-route-to-add-a-workspace.zh.md: b9b25b15680cb54ae980e217e6218a91cd1e366a +2026-07-31-one-route-to-add-a-workspace.md: 5d002265b5eb1178bb1dbc7bd17f8b364d9b9856 +2026-07-31-one-route-to-add-a-workspace.zh.md: 0a59d3a505eb921b4ec980abaefedfcad8a3c294 diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md index 0d3f4096b1..5d002265b5 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md @@ -31,7 +31,7 @@ The host's `workspace.create` still accepts `{ name }`, and `dsh web --workspace ## Testing -`connectFreshWorkspace` — the helper every web e2e scenario boots through — stages `/workspace` and adopts it through the dialog's path editor, so the produced session cwd stays identical to what create-by-name produced and scenario goldens stay valid. Staging rather than creating in-dialog keeps the helper idempotent across the repeated connects a scenario may make (a second create of the same folder fails, and the create dialog holds the flow open on that failure). Creating a folder from inside the chooser — the other half of the same route — is covered by `workspace-management.e2e.ts`, which owns the focused coverage: two workspaces added on folders the dialog creates, a deleted title reused on a different directory, and the browser-dialog aria golden. +`connectFreshWorkspace` — the helper every web e2e scenario boots through — stages `/workspace` and adopts it through the dialog's path editor, so the produced session cwd stays identical to what create-by-name produced and scenario goldens stay valid. Staging rather than creating in-dialog keeps the helper idempotent across the repeated connects a scenario may make (a second create of the same folder fails, and the create dialog holds the flow open on that failure). Creating a folder from inside the chooser — the other half of the same route — is covered by `workspace-management.e2e.ts`, which owns the focused coverage: two workspaces added on folders the dialog creates, distinct same-basename directories adopted independently, a deleted title reused on a different directory, and the browser-dialog aria golden. `smoke-real.e2e.ts` is the one scenario booting the unpatched shipped tree, where the `-auto` row resolves per host; it now pins `-browse` through a `--config` overlay so the developer's display environment cannot decide whether the picker is drivable at all. diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md index b9b25b1568..0a59d3a505 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md @@ -31,7 +31,7 @@ Host 侧的 `workspace.create` 仍接受 `{ name }`,`dsh web --workspace-root` ## Testing -`connectFreshWorkspace`——所有 web e2e 场景启动时都会走的辅助函数——会预先备好 `/workspace`,再经对话框的路径编辑器接纳它,因此产出的会话 cwd 与按名称创建时完全一致,场景 golden 保持有效。选择预先备好而不是在对话框内新建,是为了让该辅助函数在一个场景可能发生的多次连接之间保持幂等(第二次创建同名文件夹会失败,而创建对话框会在失败时把流程停在原地)。在选择器内新建文件夹——同一条路径的另一半——由 `workspace-management.e2e.ts` 覆盖,它承担针对性覆盖:在对话框自己创建的文件夹上添加两个 workspace、在另一个目录上复用已删除的标题、以及浏览对话框的 aria golden。 +`connectFreshWorkspace`——所有 web e2e 场景启动时都会走的辅助函数——会预先备好 `/workspace`,再经对话框的路径编辑器接纳它,因此产出的会话 cwd 与按名称创建时完全一致,场景 golden 保持有效。选择预先备好而不是在对话框内新建,是为了让该辅助函数在一个场景可能发生的多次连接之间保持幂等(第二次创建同名文件夹会失败,而创建对话框会在失败时把流程停在原地)。在选择器内新建文件夹——同一条路径的另一半——由 `workspace-management.e2e.ts` 覆盖,它承担针对性覆盖:在对话框自己创建的文件夹上添加两个 workspace、接纳 basename 相同的不同目录并保持彼此独立、在另一个目录上复用已删除的标题、以及浏览对话框的 aria golden。 `smoke-real.e2e.ts` 是唯一启动未打补丁的出厂配置树的场景,其中 `-auto` 行会按宿主机解析;它现在通过 `--config` overlay 钉死 `-browse`,使开发机的显示环境无法决定选择器是否可被驱动。 diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index e18664614a..e8d7abd737 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -1,7 +1,8 @@ // Web e2e scenarios: workspace management — adding a workspace through the // composed directory dialog (its own New folder affordance is the product's -// one creation route), the rename round trip over the real wire -// (workspace.rename RPC + durable registry), duplicate-name pre-check, the +// one creation route), same-basename directory adoption, the rename round +// trip over the real wire (workspace.rename RPC + durable registry), the +// duplicate-name pre-check, the // flat "In one list" view with its persisted group-by preference, the session // hover card, and the session archive round trip (row menu → // workspace.archiveSession RPC → durable global set → row hidden across @@ -476,6 +477,27 @@ describe('web e2e: workspace management (create / rename / flat view / hover car expect(tripwire.pageErrors).toEqual([]) }, 90_000) + it('opens folders with identical basenames as distinct workspaces', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-duplicate-basename')) + const firstPath = join(scaffold.workspaceCwd, 'same-basename-a', 'xx') + const secondPath = join(scaffold.workspaceCwd, 'same-basename-b', 'xx') + await mkdir(firstPath, { recursive: true }) + await mkdir(secondPath, { recursive: true }) + + await adoptDirectory(firstPath, { waitForAgent: true }) + await adoptDirectory(secondPath, { waitForAgent: true }) + + const matchingWorkspaces = scaffold.ctx.workspace.list() + .filter(workspace => workspace.title === 'xx') + expect(matchingWorkspaces.map(workspace => workspace.path).sort()) + .toEqual([firstPath, secondPath].sort()) + await expect.poll( + () => page.locator('button[aria-label="Workspace actions for xx"]').count(), + { timeout: 10_000 }, + ).toBe(2) + expect(tripwire.pageErrors).toEqual([]) + }, 90_000) + it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { expect(tripwire.warnings).toEqual([]) // The directory-browser aria golden is this spec's one owned artifact; diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 478a147361..5de8841ca6 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2522,7 +2522,7 @@ Durable workspace registry. Startup waits for `sessionPersistence`, builds one c * original error and a non-directory rejects. Repeated calls for the same * canonical path return the existing entity without changing its title. * A newly created workspace is prepended to the durable registry order. - * A different canonical path cannot create a duplicate display title. + * Different canonical paths may share a display title. * @param path - Existing directory to own, in any path spelling. * @param title - Display title used only when a new record is created. * @returns the existing or newly durable workspace. @@ -2575,7 +2575,7 @@ async resolveByPath(path: string): Promise Types: [SessionId](../core-data-structures/core.md) -Source: [`packages/workspace/workspace/src/index.ts:92`](../../packages/workspace/workspace/src/index.ts) +Source: [`packages/workspace/workspace/src/index.ts:81`](../../packages/workspace/workspace/src/index.ts) ## Inherited `ctx` members (cordis core + loader/hmr/timer) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index c8cc4d5f0f..8dcb27b8f7 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/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/client/ui-workspace/README.md -README.md: 4f12f888d5a18673601ea5a2579f7dd825ca3f5b -README.zh.md: 11a56de5c19650e1d570c860d959a83f4a0681af +README.md: 5428c8dbc6165a39eec46504c775a1645dde91ff +README.zh.md: 76614b5694a493328bc0edc73740be6c9e6dbebb diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 4f12f888d5..5428c8dbc6 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -6,7 +6,7 @@ Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace add/rename and in-Workspace reorder flows. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event. -The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default, under which the sidebar header drops its add button rather than offering a dead one). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands. +The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Distinct canonical paths remain separate id-keyed Workspaces when their basenames and display titles match; the sidebar hover detail exposes the full path. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default, under which the sidebar header drops its add button rather than offering a dead one). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands. The Session row's Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 11a56de5c1..76614b5694 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -6,7 +6,7 @@ 该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 -该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。blank「新会话」行是纯占位:不渲染行菜单和时间标签(其中还没有发生任何事),rename/fork/归档都从首条 prompt 落地后才可用。 +该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。不同的规范路径即使 basename 和显示标题相同,仍会作为由 id 区分的独立 Workspace;侧边栏的悬停详情会显示完整路径。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。blank「新会话」行是纯占位:不渲染行菜单和时间标签(其中还没有发生任何事),rename/fork/归档都从首条 prompt 落地后才可用。 Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。 diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index fe5bc1d92a..c72d8cab39 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -13,9 +13,8 @@ import { useCallback, useEffect, useState } from 'react' import { Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' -import { - WorkspaceCreateError, - type WorkspaceId, type WorkspaceListState, type WorkspaceView, +import type { + WorkspaceId, WorkspaceListState, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from './contract/slots.ts' @@ -80,7 +79,6 @@ export function WorkspacePickFlow({ const [modalError, setModalError] = useState(null) const [flowOpen, setFlowOpen] = useState(false) const [pickingFolder, setPickingFolder] = useState(false) - const [folderConflict, setFolderConflict] = useState(false) // One picking interaction at a time: while the flow is open (native chooser // pending, browse dialog up) or its pick is being adopted, every other // menu action stays disabled — a late outcome must not race a concurrent @@ -130,10 +128,6 @@ export function WorkspacePickFlow({ setFlowOpen(false) onPick(workspace.workspaceId) }).catch((reason: unknown) => { - setFolderConflict( - reason instanceof WorkspaceCreateError - && reason.rpcError.code === 'workspace-name-conflict', - ) setModalError(reason instanceof Error ? reason.message : String(reason)) setFlowOpen(false) setErrorOpen(true) @@ -143,7 +137,6 @@ export function WorkspacePickFlow({ onClose() setErrorOpen(false) setModalError(null) - setFolderConflict(false) setFlowOpen(true) }, [onClose]) @@ -174,7 +167,6 @@ export function WorkspacePickFlow({ onCancel: () => { setFlowOpen(false) }, onError: (message) => { setFlowOpen(false) - setFolderConflict(false) setModalError(message) setErrorOpen(true) }, @@ -208,7 +200,7 @@ export function WorkspacePickFlow({ open={errorOpen} onClose={closeModal} closeLabel={t('close')} - title={folderConflict ? t('conflict.title') : t('folderError.title')} + title={t('folderError.title')} footer={( <> @@ -218,11 +210,7 @@ export function WorkspacePickFlow({ )} > -
- {folderConflict - ? t('conflict.hint') - : modalError} -
+
{modalError}
) diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index fa5a8239b2..e1c41c9c17 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -13,8 +13,8 @@ * picking interaction — a renderless native-chooser driver or an in-app * browsing dialog. ui-workspace owns the trigger (the "Add workspace…" * entry, present only while the hole is occupied) and the adoption - * semantics (`createWorkspace({ path })`, the conflict/error dialog, Choose - * again); the occupant owns everything between `open` and the picked path, + * semantics (`createWorkspace({ path })`, the retryable error dialog, + * Choose again); the occupant owns everything between `open` and the picked path, * including creating a new directory to hand back. That occupant-owned * creation is why adding a workspace has a single route: an unoccupied hole * leaves the surface with no add affordance at all. diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index af8ccf10b7..7bc446c07c 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -26,8 +26,6 @@ export const zh = { 'search.hasMore': '仅显示前 {n} 条结果,请缩小搜索范围。', 'menu.addWorkspace': '添加工作区…', 'picker.loading': '正在加载工作区…', - 'conflict.title': '已存在同名工作区', - 'conflict.hint': '请选择其他名称的文件夹。', 'conflict.named': '已存在名为“{name}”的工作区。', 'folderError.title': '无法打开文件夹', 'folderError.retry': '重新选择', @@ -84,8 +82,6 @@ export const en = { 'search.hasMore': 'Showing the first {n} results. Narrow your search.', 'menu.addWorkspace': 'Add workspace…', 'picker.loading': 'Loading workspaces…', - 'conflict.title': 'A workspace with this name already exists', - 'conflict.hint': 'Choose a folder with a different name.', 'conflict.named': 'A workspace named “{name}” already exists.', 'folderError.title': 'Couldn’t open folder', 'folderError.retry': 'Choose again', diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 82284c1e47..1128434de7 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -725,7 +725,7 @@ describe('WorkspaceBrowser', () => { await act(async () => { resolveDelete() }) // RPC success alone does not close: the component waits until its // useWorkspaces projection has committed the removal, preventing a stale - // duplicate-name frame from leaking into the next create gesture. + // Workspace frame from leaking into the next gesture. expect(screen.getByRole('dialog', { name: '删除工作区' })).toBeTruthy() rerender(browser, { useWorkspaces: hook(workspaceState([])) }) expect(screen.queryByRole('dialog', { name: '删除工作区' })).toBeNull() diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 69cf8b39fb..9ab2128073 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -4,7 +4,6 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra import type { SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' -import { WorkspaceCreateError } from '@deepseek-ai/dsh-client-runtime/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from '../src/client/contract/slots.ts' @@ -115,10 +114,12 @@ function chooseAdd(): void { } describe('WorkspacePicker', () => { - it('lists real Workspaces from useWorkspaces and forwards a selected id', () => { - const b = mount() - fireEvent.click(screen.getByRole('menuitem', { name: 'Alpha' })) - expect(b.onPick).toHaveBeenCalledWith(wid('alpha')) + it('lists same-title Workspaces separately and forwards the selected id', () => { + const b = mount([workspace('alpha', 'Shared'), workspace('beta', 'Shared')]) + const entries = screen.getAllByRole('menuitem', { name: 'Shared' }) + expect(entries).toHaveLength(2) + fireEvent.click(entries[1]!) + expect(b.onPick).toHaveBeenCalledWith(wid('beta')) }) it('opens the composed directory flow, adopts its picked path, and selects the returned Workspace', async () => { @@ -156,26 +157,6 @@ describe('WorkspacePicker', () => { expect(screen.queryByRole('dialog')).toBeNull() }) - it('shows a name conflict and retries by reopening the flow', async () => { - const createWorkspace = vi.fn(async () => { - throw new WorkspaceCreateError({ - code: 'workspace-name-conflict', message: 'project already exists', details: { name: 'project' }, - }) - }) - const b = mount([workspace('alpha', 'Alpha')], createWorkspace) - chooseAdd() - await act(async () => { b.probe.owner!.onPicked('/one/project') }) - await waitFor(() => { - expect(screen.getByRole('dialog', { name: '已存在同名工作区' })).toBeTruthy() - }) - expect(screen.getByRole('alert').textContent).toBe('请选择其他名称的文件夹。') - // The failed adoption withdrew the flow; Choose again reopens it. - expect(b.probe.owner!.open).toBe(false) - fireEvent.click(screen.getByRole('button', { name: '重新选择' })) - expect(b.probe.owner!.open).toBe(true) - expect(b.onPick).not.toHaveBeenCalled() - }) - it('reports a non-Error adoption failure in the folder-error surface', async () => { const b = mount([workspace('alpha', 'Alpha')], vi.fn(async () => { throw 'permission denied' })) chooseAdd() @@ -184,6 +165,9 @@ describe('WorkspacePicker', () => { expect(screen.getByRole('dialog', { name: '无法打开文件夹' })).toBeTruthy() }) expect(screen.getByRole('alert').textContent).toBe('permission denied') + expect(b.probe.owner!.open).toBe(false) + fireEvent.click(screen.getByRole('button', { name: '重新选择' })) + expect(b.probe.owner!.open).toBe(true) expect(b.onPick).not.toHaveBeenCalled() }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 9158256f20..5b6e4abde1 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1152,7 +1152,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'async create(path: string, title?: string): Promise', - jsDoc: '/**\n * Create or reuse a workspace for an existing directory. The path is\n * canonicalized through `fs.realpath`; a nonexistent path rejects with the\n * original error and a non-directory rejects. Repeated calls for the same\n * canonical path return the existing entity without changing its title.\n * A newly created workspace is prepended to the durable registry order.\n * A different canonical path cannot create a duplicate display title.\n * @param path - Existing directory to own, in any path spelling.\n * @param title - Display title used only when a new record is created.\n * @returns the existing or newly durable workspace.\n */', + jsDoc: '/**\n * Create or reuse a workspace for an existing directory. The path is\n * canonicalized through `fs.realpath`; a nonexistent path rejects with the\n * original error and a non-directory rejects. Repeated calls for the same\n * canonical path return the existing entity without changing its title.\n * A newly created workspace is prepended to the durable registry order.\n * Different canonical paths may share a display title.\n * @param path - Existing directory to own, in any path spelling.\n * @param title - Display title used only when a new record is created.\n * @returns the existing or newly durable workspace.\n */', }, { signature: 'get(id: WorkspaceId): Workspace | undefined', diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index ab277b7d79..0962dd7205 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/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/host/apiproxy/README.md -README.md: 94698d02f599e10b1bbe69e2f9b1b058eb3e1647 -README.zh.md: da63ff09d7ca52bdc10c8a8bce81be4c2a0abf0d +README.md: 5af2739ee419d32c93578b37bebc6808431fd003 +README.zh.md: c2729c379bd3c132c94d96134c04a0976a3aca9b diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 94698d02f5..5af2739ee4 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -22,7 +22,7 @@ Session model routing is a session-domain contract. `session.models` returns the Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The operation queries only an attached Agent and never resumes a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events. -Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. +Workspace and Session lists are separate reconnect baselines. `workspace.create({ name })` creates a uniquely titled directory under the configured root, while `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. `session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index da63ff09d7..c2729c379b 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -22,7 +22,7 @@ 待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。该操作只查询当前已挂载的 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。 -Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 +Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 `session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前 surface user、assistant 和 steering(中途引导)匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index fb5b4cce6e..90bbd2c745 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -21,7 +21,7 @@ import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-se import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, - WorkspaceMoveInvalidError, WorkspaceNameConflictError, WorkspaceUnknownSessionError, + WorkspaceMoveInvalidError, WorkspaceUnknownSessionError, } from '@deepseek-ai/dsh-workspace' // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import type {} from '@deepseek-ai/dsh-tools' @@ -522,6 +522,14 @@ class SessionCwdConflict extends Error { /** Host failed before the registry could adopt a name-created directory. */ class WorkspaceDirectoryCreationError extends Error {} +/** An explicit Host naming operation would duplicate another Workspace title. */ +class WorkspaceNameConflictError extends Error { + constructor(readonly workspaceName: string) { + super(`workspace name '${workspaceName}' is already in use`) + this.name = 'WorkspaceNameConflictError' + } +} + /** Shared workspace-not-found error response of the workspace.* mutation rows. */ function workspaceNotFound(request: RpcRequest, workspaceId: string): RpcResponse { return err(request, { @@ -570,7 +578,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const resumes = new Map>() /** Client-chosen identity creation/resume, deduplicated across concurrent retries. */ const sessionCreations = new Map>() - /** Serializes path ownership checks with record creation across spellings. */ + /** Serializes path ownership and explicit title checks with Workspace mutations. */ let workspaceCreationChain = Promise.resolve() const pendingQuestions = new Map() const pendingApprovals = new Map() diff --git a/packages/host/apiproxy/src/api/workspace.ts b/packages/host/apiproxy/src/api/workspace.ts index 957c566bbd..d5307e27a8 100644 --- a/packages/host/apiproxy/src/api/workspace.ts +++ b/packages/host/apiproxy/src/api/workspace.ts @@ -22,7 +22,7 @@ export interface WorkspaceView { workspaceId: WorkspaceId /** Canonical directory path (host-side realpath canon). */ path: string - /** Unique display title (defaults to the path basename at create). */ + /** Display title (defaults to the path basename at create). */ title: string /** * Sessions accounted under this workspace, in manually owned order @@ -53,8 +53,8 @@ export interface WorkspaceApi { * root before registering. Either spelling resolving to a directory already * owned by a workspace returns that workspace (`created: false`) for the * existing-folder spelling. Create-by-name rejects an existing title with - * `workspace-name-conflict`; a new path whose basename duplicates another - * Workspace title is rejected by the registry with the same code. + * `workspace-name-conflict`; path adoption allows distinct canonical paths + * whose basenames produce the same display title. * A new name-created workspace uses `name` as both directory name and title; * a path-created workspace uses the registry's basename title default. */ diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index d9211dde6c..70b29ed18d 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -292,18 +292,25 @@ describe('workspace.create', () => { } }) - it('rejects different paths that derive the same Workspace title', async () => { + it('adopts different paths that derive the same Workspace title', async () => { const { api, workspaceRoot } = await harness() const first = join(workspaceRoot, 'one', 'project') const second = join(workspaceRoot, 'two', 'project') mkdirSync(first, { recursive: true }) mkdirSync(second, { recursive: true }) - expectOk(await api.workspace.create(request({ path: first }))) - const conflict = await api.workspace.create(request({ path: second })) - expect(conflict.result).toMatchObject({ - ok: false, - error: { code: 'workspace-name-conflict', details: { name: 'project' } }, + const firstResult = expectOk(await api.workspace.create(request({ path: first }))) + const secondResult = expectOk(await api.workspace.create(request({ path: second }))) + expect(firstResult).toMatchObject({ + created: true, + workspace: { path: first, title: 'project' }, }) + expect(secondResult).toMatchObject({ + created: true, + workspace: { path: second, title: 'project' }, + }) + expect(secondResult.workspace.workspaceId).not.toBe(firstResult.workspace.workspaceId) + expect(expectOk(await api.workspace.list(request({}))).items.map(workspace => workspace.path)) + .toEqual([second, first]) }) }) diff --git a/packages/workspace/workspace/README.i18n.yaml b/packages/workspace/workspace/README.i18n.yaml index 33f0029093..9ec33b56ca 100644 --- a/packages/workspace/workspace/README.i18n.yaml +++ b/packages/workspace/workspace/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/workspace/workspace/README.md -README.md: 11dc8172392e530ab4ea16f1b60473e5befb8089 -README.zh.md: 5c6e3cefe31759df27b8008861505527648ce3c4 +README.md: bfa044de50fc71dca95637487df50dc551d40e1b +README.zh.md: 1711e4d90c4cc62cf6e18a6868cd362a2c2e9458 diff --git a/packages/workspace/workspace/README.md b/packages/workspace/workspace/README.md index 11dc817239..bfa044de50 100644 --- a/packages/workspace/workspace/README.md +++ b/packages/workspace/workspace/README.md @@ -8,7 +8,7 @@ The entity/storage rationale lives in the [domain Agent Note](../../../.agents/n ## Shape -- `ctx.workspace.create(path, title?)` — canonicalizes `path` via `fs.realpath`, rejects a nonexistent or non-directory path, creates at most one record per canonical path, and prepends a new record to durable workspace order. Repeated calls for that path return the existing workspace without changing its title; a different path cannot create a duplicate title. +- `ctx.workspace.create(path, title?)` — canonicalizes `path` via `fs.realpath`, rejects a nonexistent or non-directory path, creates at most one record per canonical path, and prepends a new record to durable workspace order. Repeated calls for that path return the existing workspace without changing its title; different paths may share a display title. - `ctx.workspace.get(id)` / `list()` / `resolveByPath(path)` — cache-served lookups. `list()` is synchronous and follows durable registry order; `resolveByPath` is async because it applies the same `realpath` canon and rejects a missing path rather than creating it. - `ctx.workspace.delete(id)` — removes only the Workspace registration, its durable order entry, and its session account. Unknown ids return `false`; a removed record returns `true`. The directory, user files, live Sessions, and persisted session logs are never touched, so those Sessions become Ungrouped. A table-write failure restores the prior order and published entity. - `Workspace.attachSession(id)` — validates a live or persisted session header cwd against the workspace path and prepends a new id. Unknown sessions, absent/unresolvable/non-directory cwd values, and mismatches reject without writing. `detachSession` removes only the candidate index entry. diff --git a/packages/workspace/workspace/README.zh.md b/packages/workspace/workspace/README.zh.md index 5c6e3cefe3..1711e4d90c 100644 --- a/packages/workspace/workspace/README.zh.md +++ b/packages/workspace/workspace/README.zh.md @@ -8,7 +8,7 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领 ## 结构 -- `ctx.workspace.create(path, title?)`:规范化 `path` 时使用 `fs.realpath`,拒绝不存在或非目录的路径,每个规范路径最多创建一条记录,并将新记录前置到持久 workspace 顺序。对同一路径重复调用会返回现有 workspace,且不改变其标题;不同路径不能创建重复标题。 +- `ctx.workspace.create(path, title?)`:规范化 `path` 时使用 `fs.realpath`,拒绝不存在或非目录的路径,每个规范路径最多创建一条记录,并将新记录前置到持久 workspace 顺序。对同一路径重复调用会返回现有 workspace,且不改变其标题;不同路径可以共用显示标题。 - `ctx.workspace.get(id)`/`list()`/`resolveByPath(path)`:由缓存提供的查找。`list()` 为同步操作,并遵循持久注册表顺序;`resolveByPath` 为异步操作,因为它采用相同的 `realpath` 规范化方式,并会拒绝缺失路径,而不是创建路径。 - `ctx.workspace.delete(id)`:只移除 Workspace 注册记录、对应的持久顺序条目及会话归属记录。未知 id 返回 `false`,成功移除记录则返回 `true`。目录、用户文件、活跃会话和持久化会话日志绝不受影响,因此相关会话会进入 Ungrouped。表写入失败时会恢复原顺序和此前发布的实体。 - `Workspace.attachSession(id)`:对照 workspace 路径验证实时或已持久化的会话头 cwd,并将新 id 前置。未知会话、缺失/无法解析/非目录的 cwd 值和不匹配情况都会在不写入的前提下被拒绝。`detachSession` 只移除候选索引条目。 diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index 5262043b03..71401862f2 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -38,17 +38,6 @@ export function WorkspaceId(id: string): WorkspaceId { return id as WorkspaceId } -/** A create request would give two Workspaces the same display name. */ -export class WorkspaceNameConflictError extends Error { - /** - * @param workspaceName - Conflicting display name. - */ - constructor(readonly workspaceName: string) { - super(`workspace name '${workspaceName}' is already in use`) - this.name = 'WorkspaceNameConflictError' - } -} - /** * An archiveSession request named a session neither live nor in session * persistence — a definite miss only; storage faults propagate as themselves. @@ -145,7 +134,7 @@ export class WorkspaceRegistry extends Service { * original error and a non-directory rejects. Repeated calls for the same * canonical path return the existing entity without changing its title. * A newly created workspace is prepended to the durable registry order. - * A different canonical path cannot create a duplicate display title. + * Different canonical paths may share a display title. * @param path - Existing directory to own, in any path spelling. * @param title - Display title used only when a new record is created. * @returns the existing or newly durable workspace. @@ -259,10 +248,6 @@ export class WorkspaceRegistry extends Service { } const workspaceName = title ?? basename(canonical) - if ([...this.entities.values()].some(entity => entity.title === workspaceName)) { - throw new WorkspaceNameConflictError(workspaceName) - } - const table = this.requireTable() const state = this.requireState() const id = WorkspaceId(randomUUID()) diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index ae4567b27e..6a562e15bd 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -10,7 +10,7 @@ import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionHeader } from '@deepseek-ai/dsh-session' import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' -import WorkspaceRegistry, { WorkspaceId, WorkspaceMoveInvalidError, WorkspaceNameConflictError } from '../src/index.ts' +import WorkspaceRegistry, { WorkspaceId, WorkspaceMoveInvalidError } from '../src/index.ts' import type { WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts' const DOMAIN_VERSION = 2 @@ -377,17 +377,15 @@ describe('WorkspaceRegistry create and lookup', () => { expect(pool.media.get('workspace')!.tables.get('workspaces')!.size).toBe(1) }) - it('rejects a duplicate display name on a different canonical path', async () => { + it('allows a duplicate display name on a different canonical path', async () => { const firstDir = await makeDir('named-first') const secondDir = await makeDir('named-second') const { registry } = await harness() - await registry.create(firstDir, 'Shared') - await expect(registry.create(secondDir, 'Shared')).rejects.toEqual( - expect.objectContaining>({ - workspaceName: 'Shared', - }), - ) - expect(registry.list()).toHaveLength(1) + const first = await registry.create(firstDir, 'Shared') + const second = await registry.create(secondDir, 'Shared') + expect(first.title).toBe('Shared') + expect(second.title).toBe('Shared') + expect(registry.list()).toEqual([second, first]) }) it('rejects nonexistent and non-directory paths without changing order', async () => { From f9dc4aa7028f4d7ff772ba8a4816286bb37bfc7c Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Fri, 31 Jul 2026 19:12:16 +0800 Subject: [PATCH 24/28] =?UTF-8?q?feat:=20remove=20"=E6=8F=92=E8=AF=9D"=20b?= =?UTF-8?q?adge=20displayed=20in=20user=20message?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...input-machine-and-slash-pipeline.i18n.yaml | 4 +- ...25-web-input-machine-and-slash-pipeline.md | 2 +- ...web-input-machine-and-slash-pipeline.zh.md | 2 +- ...eer-entry-or-interjection-chrome.i18n.yaml | 6 +++ ...i-no-steer-entry-or-interjection-chrome.md | 38 +++++++++++++ ...o-steer-entry-or-interjection-chrome.zh.md | 38 +++++++++++++ .../snapshots/steering/settled.expected.md | 2 +- apps/web/tests/steering.e2e.ts | 53 +++++++++---------- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/MessageItem.module.css | 10 ---- .../src/client/chat/MessageItem.tsx | 9 ++-- .../src/client/input/contract.ts | 8 +-- .../src/client/input/facade.ts | 17 +++--- .../ui-conversation/src/client/input/hub.ts | 6 +-- .../src/client/input/machine.ts | 17 +++--- .../ui-conversation/src/client/locales.ts | 2 - .../ui-conversation/src/client/service.ts | 10 ++-- .../src/client/skeleton/InputBar.tsx | 4 +- .../tests/apply-inject.spec.tsx | 10 ++-- .../tests/chat-branch-tails.spec.tsx | 4 +- .../ui-conversation/tests/input-bar.spec.tsx | 8 +-- .../tests/input-machine.spec.ts | 40 +++++++------- .../tests/input-matrix.spec.tsx | 4 +- .../tests/input-scenarios.spec.tsx | 4 +- .../tests/service-orchestration.spec.ts | 12 ++--- .../ui-conversation/tests/skeleton.spec.tsx | 2 +- 28 files changed, 191 insertions(+), 129 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md create mode 100644 .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml index 98423a8c7d..37b3413f41 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.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/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md -2026-07-25-web-input-machine-and-slash-pipeline.md: 92bb91c3e892d928cedf18ec57c725a116b6ffc8 -2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 5bee6df52f16d935aa4f4ccff8627a2d43d44c8c +2026-07-25-web-input-machine-and-slash-pipeline.md: fbff722279b7f625a650602adce6ab6d21183076 +2026-07-25-web-input-machine-and-slash-pipeline.zh.md: adc3e22334f4115373aa3e48015518a033b26d91 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md index 92bb91c3e8..fbff722279 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md @@ -72,7 +72,7 @@ A trigger/menu/pick pipeline with zero knowledge of "commands": - Each materialized Session has exactly one `SessionInputShell` (the facade), created and torn down with the session scope; with no session, no input machine is built. `ConversationRoot` is itself the `session-maybe` resident shell, holding HeroShell, the Workspace picker, the composer stack, and the chain-fallback frame. - The composer bar is one `session-maybe` slot entry rendered unconditionally: with no session the same InputBar renders inert (machine faces absent, `disabled` owner prop), and once `connectWorkspace` returns a blank session the same instance goes live — the textarea DOM survives the no-session → blank transition and every later phase flip; `ConversationRoot`, the Hero, and the layout skeleton hold throughout. - ConversationRoot's Hero criterion is `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))`. The first submit enters engaging synchronously, and a failure keeps the composer and the error context rather than falling back to the blank Hero; the sidebar's blank bit flips false only after a prompt is successfully accepted. -- Sending unifies in the hub defaultSink: after an optimistic draft clear it goes only through `session.prompt {mode:'queue'|'steer'}`; backfill happens only when it fails and the live draft is still empty — a user who has kept typing is never overwritten. No Draft materialize or attach transaction exists. +- Sending unifies in the hub defaultSink: after an optimistic draft clear it goes only through `session.prompt` with `mode:'queue'` (the Web UI has no steer entry; host-wire `mode:'steer'` remains outside this machine); backfill happens only when it fails and the live draft is still empty — a user who has kept typing is never overwritten. No Draft materialize or attach transaction exists. - When the blank Hero re-picks the Workspace, the shell calls `connectWorkspace`; if the target session differs, the non-empty draft moves from the current shell to the target shell before the new id is opened, and the old blank session survives but is no longer current. - The Notifier's two-bit contract: `dirty` (snapshot freshness, clearable by an `ensureFresh` pull) and `notifyPending` (notification debt, cleared only by a flush) are mutually independent — a pull must not swallow a push, and object-layer push subscribers (watchTransaction) depend on this guarantee. diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md index 5bee6df52f..adc3e22334 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md @@ -72,7 +72,7 @@ occurrence 表与 chip 三投影: - 每个实体 Session 只有一个 `SessionInputShell`(facade),随 session scope 创建和拆除;无 session 时不造 input machine。`ConversationRoot` 自身是 `session-maybe` 常驻外壳,持有 HeroShell、Workspace picker、composer stack 与 chain fallback 外框。 - composer bar 是一个无条件渲染的 `session-maybe` slot entry:无 session 时同一个 InputBar 以惰性态渲染(machine face 缺席、`disabled` owner prop),`connectWorkspace` 返回 blank session 后同一实例转为 live——textarea DOM 在无 session → blank 切换及其后每次 phase 翻转中都不重建;`ConversationRoot`、Hero 与布局骨架全程保持。 - ConversationRoot 的 Hero 判据是 `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))`。首次 submit 同步进入 engaging,失败也保留 composer 与错误上下文,不退回 blank Hero;sidebar 的 blank 位只在 prompt 成功受理后翻 false。 -- 发送统一在 hub defaultSink:乐观清稿后只走 `session.prompt {mode:'queue'|'steer'}`;失败且 live draft 仍为空才回填,用户已经继续输入则不覆盖。不存在 Draft materialize 或 attach 事务。 +- 发送统一在 hub defaultSink:乐观清稿后只走 `session.prompt` 且固定 `mode:'queue'`(Web UI 无 steer 入口;host 线缆上的 `mode:'steer'` 不经此 machine);失败且 live draft 仍为空才回填,用户已经继续输入则不覆盖。不存在 Draft materialize 或 attach 事务。 - blank Hero 改选 Workspace 时,外壳调用 `connectWorkspace`;目标 session 不同时把非空 draft 从当前 shell 搬到目标 shell,再 open 新 id,旧 blank session 留存但不再 current。 - Notifier 双位契约:`dirty`(快照新鲜度,`ensureFresh` 拉取可清)与 `notifyPending`(通知欠账,只有 flush 清)各自独立——拉取不得吞推送,对象层推订阅者(watchTransaction)依赖这一保证。 diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml new file mode 100644 index 0000000000..2059841653 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.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/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md +2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: 6f397668106a6c74f327fc799327752c54824d8c +2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: f127bd57006747465a1ece87406f5f97086b6a34 diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md new file mode 100644 index 0000000000..6f39766810 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md @@ -0,0 +1,38 @@ +# Agent Note: Web UI drops steer entry and interjection chrome + +Status: implemented + +English | [中文](2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md) + +## Problem + +Mid-turn steering is a host/agent-loop capability (`mode:'steer'`, durable `steering/message`). The Web product already locked the composer while a turn runs and never shipped a queue/steer menu, yet the client still threaded `'queue' | 'steer'` through the input machine, `conversation.send`, and locale keys, and rendered consumed steering as a badged 「插话」/「Interjection」 bubble. That left a half-built UI surface: an unused submit mode, a product label for a gesture users cannot perform, and e2e goldens that pinned chrome the product does not own. + +## Decision + +Keep host and runtime steering intact. Remove only the Web UI entry and chrome: + +- `InputMachine` / `SessionInput` / `InputActions.submit` / hub `defaultSink` are queue-only; they always call `session.prompt(..., 'queue')`. +- `ConversationService.send(text)` drops its mode argument and always queues. +- `MessageItem`'s `steering` arm still folds durable `steering/message` content into a plain right-aligned bubble (no badge, no user IconActions) so external/host steers stay visible on replay. +- Delete `message.steering` locale strings and the unused badge CSS. +- The web steering e2e still POSTs `mode:'steer'` over `/api/session.prompt` and asserts durable + model-visible obedience; it no longer expects interjection chrome. Update [web input machine note](../architecture/2026-07-25-web-input-machine-and-slash-pipeline.md) fact lines to match. + +## Alternatives considered + +**Delete host steering entirely.** Out of scope; the user asked only for Web UI display and entry. Agent-loop drain, session events, and the wire mode remain load-bearing for ACP/TUI/automation. + +**Hide `steering/message` from the transcript.** Would lie on replay when an external client steers; rejected in favor of a plain bubble. + +**Keep the mode parameter but only ever pass `'queue'`.** Leaves dead API surface and tests that invent `'steer'` paths the composer cannot reach. + +## Consequences + +- Web users cannot steer from the composer or `ctx.conversation.send`; stop/cancel and Queue remain the only mid-turn controls. +- Host-wire and non-Web clients can still steer; the Web client shows those messages without labeling them as interjections. +- Reintroducing a dedicated steer UI would need a new product decision; do not revive the mode union or badge without one. + +## Testing + +- `packages/client/ui-conversation` unit/jsdom coverage: input machine enter/sink, ConversationService routing, MessageItem steering arm (no 「插话」), InputBar submit. +- `apps/web/tests/steering.e2e.ts` keyless replay plus updated `settled.expected.md` (steer text without badge). diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md new file mode 100644 index 0000000000..f127bd5700 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md @@ -0,0 +1,38 @@ +# Agent Note: Web UI 去掉 steer 入口与插话 chrome + +Status: implemented + +[English](2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md) | 中文 + +## 问题 + +中途 steering 是 host/agent-loop 能力(`mode:'steer'`、持久 `steering/message`)。Web 产品已在 turn 运行中锁定 composer,且从未交付排队/steer 菜单,但客户端仍把 `'queue' | 'steer'` 穿进 input machine、`conversation.send` 与 locale 键,并把已消费的 steering 渲染成带「插话」/「Interjection」徽章的气泡。这留下半成品 UI:用不到的提交 mode、用户做不到的手势却有产品文案,以及把产品并不拥有的 chrome 钉死在 e2e golden 上。 + +## 决策 + +保留 host 与 runtime 的 steering。只去掉 Web UI 入口与 chrome: + +- `InputMachine`/`SessionInput`/`InputActions.submit`/hub `defaultSink` 仅 queue;始终调用 `session.prompt(..., 'queue')`。 +- `ConversationService.send(text)` 去掉 mode 参数,始终排队。 +- `MessageItem` 的 `steering` 分支仍把持久 `steering/message` 内容折成右对齐普通气泡(无徽章、无用户 IconActions),以便外部/host steer 在回放时仍可见。 +- 删除 `message.steering` locale 字符串与未使用的徽章 CSS。 +- web steering e2e 仍通过 `/api/session.prompt` POST `mode:'steer'`,并断言持久化与模型可见服从;不再期望插话 chrome。同步更新 [web input machine note](../architecture/2026-07-25-web-input-machine-and-slash-pipeline.md) 中的事实行。 + +## 曾考虑的替代方案 + +**整段删除 host steering。** 超出范围;用户只要求清 Web UI 展示与入口。agent-loop 排空、session 事件与线缆 mode 对 ACP/TUI/自动化仍是承重能力。 + +**在 transcript 中隐藏 `steering/message`。** 外部客户端 steer 时回放会撒谎;改为普通气泡。 + +**保留 mode 参数但永远只传 `'queue'`。** 留下死 API 面与只会虚构 composer 到不了的 `'steer'` 路径的测试。 + +## 后果 + +- Web 用户无法从 composer 或 `ctx.conversation.send` steer;中途控制只剩停止/取消与 Queue。 +- Host 线缆与非 Web 客户端仍可 steer;Web 客户端展示这些消息时不再标成插话。 +- 若要重新引入专用 steer UI,需要新的产品决策;没有决策就不要复活 mode 联合类型或徽章。 + +## 测试 + +- `packages/client/ui-conversation` unit/jsdom 覆盖:input machine enter/sink、ConversationService 路由、MessageItem steering 分支(无「插话」)、InputBar submit。 +- `apps/web/tests/steering.e2e.ts` 无密钥回放,以及更新后的 `settled.expected.md`(有 steer 正文、无徽章)。 diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 5e61b89b0b..aa16fd5142 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -19,7 +19,7 @@ - img - img - text: Ask question 1/1 answered -- text: "Interjection Interjection: include the word BANANA in your final reply." +- text: "Interjection: include the word BANANA in your final reply." - button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.": - img - img diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 3cbae0b9ee..19ca4bf8c5 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -1,16 +1,15 @@ -// Web e2e scenario: mid-turn steering, end to end. The product composer -// deliberately exposes Queue only, so the steer is POSTed from the page -// itself over the same same-origin /api transport the client uses. -// TODO(web-steer-ui): Drive this through a dedicated steering interaction -// once one exists. Everything downstream is product: the gateway -// routes mode:'steer' to Agent.steer, the loop drains it at the step -// boundary into a durable steering/message event, the SSE mux pushes it, and -// the transcript renders the badged interjection bubble. The question -// composer supplies the deterministic mid-turn window: while ask_user_question -// blocks, the turn is provably running, so record and replay perform the -// identical steer-then-answer sequence with zero timing dependence — and the -// recorded final reply proves the steer reached the MODEL (it obeys an -// instruction that only the steering message carries). +// Web e2e scenario: mid-turn steering over the host wire. The Web UI has no +// steer entry, so the steer is POSTed from the page over the same +// same-origin /api transport the client uses. Everything downstream is +// product: the gateway routes mode:'steer' to Agent.steer, the loop drains +// it at the step boundary into a durable steering/message event, the SSE mux +// pushes it, and the transcript shows the text as a plain bubble (no +// interjection chrome). The question composer supplies the deterministic +// mid-turn window: while ask_user_question blocks, the turn is provably +// running, so record and replay perform the identical steer-then-answer +// sequence with zero timing dependence — and the recorded final reply proves +// the steer reached the MODEL (it obeys an instruction that only the +// steering message carries). import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -29,11 +28,11 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/steering', import.meta.u const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') // Two goldens for the two distinct states this interaction produces: the // mid-turn moment (steer ACCEPTED but deliberately invisible — the loop -// drains steering at the step boundary, so no interjection bubble exists -// while the question still blocks the step) and the settled transcript -// (badged bubble in place, final reply obeying it). The pair pins the -// timing semantics visually: if the client ever starts rendering pending -// steers eagerly, the mid-steer golden flips first. +// drains steering at the step boundary, so no steering text exists while +// the question still blocks the step) and the settled transcript (plain +// bubble in place, final reply obeying it). The pair pins the timing +// semantics visually: if the client ever starts rendering pending steers +// eagerly, the mid-steer golden flips first. const MID_EXPECTED = join(SNAPSHOT_DIR, 'mid-steer.expected.md') const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') const MODE = webSnapshotMode() @@ -80,7 +79,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { await scaffold?.close() }) - it('steers during the blocked step; the interjection is logged, rendered, and obeyed', async () => { + it('steers during the blocked step; the message is logged, rendered, and obeyed', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-steering')) if (MODE !== 'record') { // The steer must NOT be a user/message — it lands as steering/message. @@ -118,10 +117,9 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { if (MODE !== 'record') { // Mid-turn golden: the ACCEPTED steer is durable in the inbox but the // loop drains steering only at the step boundary, so no steering/message - // exists yet and no interjection bubble renders — the composer still - // blocks, alone. The DOM is stable here (no further SSE frames can - // arrive until the question is answered), making this state capturable. - expect(await page.getByText('Interjection', { exact: true }).count()).toBe(0) + // exists yet and no steer text renders — the composer still blocks, + // alone. The DOM is stable here (no further SSE frames can arrive until + // the question is answered), making this state capturable. expect(await page.getByText(STEER, { exact: true }).count()).toBe(0) expect(await page.getByRole('button', { name: 'Edit queued message' }).count()).toBe(0) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) @@ -155,14 +153,13 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { expect(turnEnds).toHaveLength(1) expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed') - // Visible: the badged interjection bubble plus the reply that obeys it + // Visible: the plain steering bubble plus the reply that obeys it // (steer text + final reply each contain the marker word). - await expect.poll(() => page.getByText('Interjection', { exact: true }).count(), { timeout: 15_000 }).toBe(1) - await expect.poll(() => page.getByText('Interjection:', { exact: false }).count(), { timeout: 10_000 }).toBe(1) + await expect.poll(() => page.getByText(STEER, { exact: true }).count(), { timeout: 15_000 }).toBe(1) await expect.poll(() => page.getByText('BANANA', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) expect(await page.locator('[data-question-key]').count()).toBe(0) - // Settled golden: badge + interjection between the question round trip - // and the obeying reply, composer takeover gone. + // Settled golden: steer text between the question round trip and the + // obeying reply, composer takeover gone. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE) expect(tripwire.pageErrors).toEqual([]) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index cc9f5a575b..b3423544ab 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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/client/ui-conversation/README.md -README.md: b4b1e5653705c76bac3e0227e6df77143a11cbbe -README.zh.md: 74e0f3dc0ebaf74e2e065c6b88f3a30fce94b391 +README.md: c2db1fba330a5ba968ba8fbbb5825ee62e699b4c +README.zh.md: bc2c3297ee75bd30c9090d9d4dadf673cc3e0484 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index b4b1e56537..c2db1fba33 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -45,4 +45,4 @@ None; this package neither assembles nor sends a provider request. - **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today. - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. - **Queue edit is text-only** — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete with save and cancel; Enter saves and Escape cancels. QueueDock exposes no send-now control. -- **Web exposes pending Queue only** — the Host omits pending steering from the Queue snapshot until steering has its own interaction. A consumed `steering/message` still renders in the durable transcript so external steering remains truthful on replay. +- **Web exposes pending Queue only** — the composer and `conversation.send` never submit `mode:'steer'`. The Host omits pending steering from the Queue snapshot. A consumed `steering/message` still folds into the durable transcript as a plain bubble (no interjection chrome) so external/host steering remains truthful on replay. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 74e0f3dc0e..bc2c3297ee 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -45,4 +45,4 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 - **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除会替换为保存和取消;Enter 保存,Escape 取消。QueueDock 不提供立即发送控件。 -- **Web 仅暴露待处理 Queue**:在 steering(中途引导)拥有专用交互之前,Host 不会把待处理 steering 纳入 Queue 快照。已消费的 `steering/message` 仍会渲染到持久 transcript(文本记录)中,因此从外部提交的 steering 在回放时仍能如实呈现。 +- **Web 仅暴露待处理 Queue**:composer 与 `conversation.send` 从不提交 `mode:'steer'`。Host 不会把待处理 steering(中途引导)纳入 Queue 快照。已消费的 `steering/message` 仍会折叠进持久 transcript(文本记录),并以无「插话」徽章的普通气泡呈现,因此从外部/Host 提交的 steering 在回放时仍能如实呈现。 diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 2667024bcd..5e2c623f50 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -20,16 +20,6 @@ color: var(--dsw-alias-label-primary); } -.badge { - display: inline-block; - margin-bottom: 4px; - padding: 1px 6px; - border-radius: 6px; - background: var(--dsw-alias-state-warn-primary); - color: var(--dsw-alias-label-primary-foreground); - font-size: 11px; -} - .contextRow { padding: 2px 0; } diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index bb6429470f..20e613dc9e 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -1,8 +1,8 @@ // MessageItem: the four simple node kinds — user bubble (right-aligned, with -// clock + copy / branch / edit IconActions), steering (badged bubble), context -// injection and unknown-surface JSON rows. Props are frozen node slices off -// the snapshot cache; memo holds across streaming because unchanged nodes -// keep their references. +// clock + copy / branch / edit IconActions), steering (same bubble, no +// actions), context injection and unknown-surface JSON rows. Props are frozen +// node slices off the snapshot cache; memo holds across streaming because +// unchanged nodes keep their references. import { memo } from 'react' import type { ReactNode } from 'react' @@ -94,7 +94,6 @@ export const MessageItem = memo(function MessageItem({ node, onFork, t }: Messag return (
- {t('message.steering')} {projectUserText(text)} {rest.map((block, i) => )}
diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index 317adc0ec5..f503381bbb 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -29,7 +29,7 @@ export interface SessionInput extends InputTarget { /** Single write path for draft text (all mutation rides machine events). */ setDraft(text: string): void /** THE complexity sink: enter adjudication, submit transaction, and the default sink live inside. */ - submit(mode?: 'queue' | 'steer'): void + submit(): void /** * Surface a notice outside the machine's own effect stream: detached * command results and business notifications render through here. @@ -60,7 +60,7 @@ export interface InputActions { /** Single public draft write path (full next draft; occurrence math via diff scan). */ setDraft(text: string): void /** Enter submission (adjudication / claim transaction / default sink inside). */ - submit(mode?: 'queue' | 'steer'): void + submit(): void } /** One surfaced notice (command results, adjudication failures). seq keys re-render of repeats. */ @@ -239,7 +239,7 @@ export type InputEvent = | { readonly type: 'paste-upgrade'; readonly attemptId: number; readonly span: TokenSpan; readonly reference: ReferenceInsert } /** Shell-observed attempt killers the machine cannot see itself (caret/selection ops, Slash interaction updates). */ | { readonly type: 'invalidate-paste' } - | { readonly type: 'enter'; readonly mode: 'queue' | 'steer' } + | { readonly type: 'enter' } | { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome } | { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string } | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string } @@ -258,5 +258,5 @@ export type InputEvent = export type InputEffect = | { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string } | { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string } - | { readonly type: 'default-sink'; readonly draft: string; readonly mode: 'queue' | 'steer' } + | { readonly type: 'default-sink'; readonly draft: string } | { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string } diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index d6b2fde80a..79a223a43e 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -39,7 +39,7 @@ export interface SessionInputDeps { /** Queue read face; overlaid onto InputState.queue (absent = empty). */ queue?: ObservableSnapshot | undefined /** The plain-message sink (send choreography / materialize fork — the hub owns it). */ - defaultSink(text: string, mode: 'queue' | 'steer'): void + defaultSink(text: string): void } /** Guard tier from the machine phase. */ @@ -68,7 +68,7 @@ export class SessionInputShell implements SessionInput { /** The public provide-channel action face (one stable identity per session — decision 20). */ readonly actions: InputActions = { setDraft: (text) => { this.setDraft(text) }, - submit: (mode) => { this.submit(mode) }, + submit: () => { this.submit() }, } // Real wall clock: the typing-run merge window must actually expire in @@ -151,10 +151,9 @@ export class SessionInputShell implements SessionInput { * from the machine; this method only feeds the event. Lock entry * (adjudicating/submitting) force-closes the transient layers: the popup * dismisses and the menu tracks frozen. - * @param mode - default-sink mode (queue appends; steer interrupts). */ - submit(mode: 'queue' | 'steer' = 'queue'): void { - this.run(this.core.dispatch({ type: 'enter', mode })) + submit(): void { + this.run(this.core.dispatch({ type: 'enter' })) const phase = this.snapshot.phase if (phase === 'adjudicating' || phase === 'submitting') { this.deps.popup?.()?.dismiss() @@ -339,7 +338,7 @@ export class SessionInputShell implements SessionInput { return } case 'default-sink': { - this.sinkSerialized(fx.draft, fx.mode) + this.sinkSerialized(fx.draft) return } default: @@ -354,10 +353,10 @@ export class SessionInputShell implements SessionInput { * send — notice + draft and chips retained, never a silent downgrade to * the clipboard text. Chip-free drafts skip the async detour. */ - private sinkSerialized(draft: string, mode: 'queue' | 'steer'): void { + private sinkSerialized(draft: string): void { const occurrences = this.core.state.occurrences if (occurrences.length === 0) { - this.deps.defaultSink(draft.trim(), mode) + this.deps.defaultSink(draft.trim()) return } const slash = this.deps.slash?.() @@ -377,7 +376,7 @@ export class SessionInputShell implements SessionInput { cursor = part.offset + 1 } out += draft.slice(cursor) - this.deps.defaultSink(out.trim(), mode) + this.deps.defaultSink(out.trim()) }, (error: unknown) => { controller.abort() diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 2641e0dcc4..599cb85a08 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -56,7 +56,7 @@ export class InputHub implements InputService { slash: () => this.controller(actx), popup: () => this.popup(actx), queue: queueReadFaceOf(session), - defaultSink: (text, mode) => { this.sink(session, text, mode) }, + defaultSink: (text) => { this.sink(session, text) }, }) this.shells.set(id, shell) // The one teardown axis: listeners, shell, and map entries all ride the @@ -112,12 +112,12 @@ export class InputHub implements InputService { * exactly one path; a failed first prompt is an ordinary prompt failure * (error strip via promptError, draft restored only while untouched). */ - private sink(session: SessionFace, text: string, mode: 'queue' | 'steer'): void { + private sink(session: SessionFace, text: string): void { if (text === '') return const shell = this.shells.get(session.sessionId) // Commit, not an editable clear: undo must not resurrect sent content. shell?.commitSend() - void session.prompt([{ type: 'text', text }], mode).then( + void session.prompt([{ type: 'text', text }], 'queue').then( (result) => { if (!result.ok && shell?.snapshot.draft === '') shell.setDraft(text) }, diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index 48f6ddd872..f32d9e11d7 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -112,7 +112,6 @@ export class InputMachine { private inflight: { readonly attempt: SubmitAttempt readonly controller: AbortController - readonly mode: 'queue' | 'steer' } | undefined private log: Transaction[] = [] private redoStack: Transaction[] = [] @@ -163,7 +162,7 @@ export class InputMachine { this.paste = undefined return [] } - case 'enter': return this.onEnter(ev.mode) + case 'enter': return this.onEnter() case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome) case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message) case 'submit-settled': return this.onSubmitSettled(ev) @@ -462,18 +461,18 @@ export class InputMachine { // ---- submit plane ---- /** Mint the next SubmitAttempt and take the in-flight slot. */ - private beginAttempt(mode: 'queue' | 'steer'): SubmitAttempt { + private beginAttempt(): SubmitAttempt { const controller = new AbortController() this.seq += 1 const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: this.draft } - this.inflight = { attempt, controller, mode } + this.inflight = { attempt, controller } return attempt } - private onEnter(mode: 'queue' | 'steer'): InputEffect[] { + private onEnter(): InputEffect[] { if (this.phase === 'adjudicating' || this.phase === 'submitting') return [] if (this.phase === 'claimed' && this.claim !== undefined) { - const attempt = this.beginAttempt(mode) + const attempt = this.beginAttempt() this.phase = 'submitting' this.paste = undefined return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(this.draft, this.claim.token) }] @@ -482,11 +481,11 @@ export class InputMachine { if (trimmed === '') return [] this.paste = undefined if (trimmed.startsWith('/')) { - const attempt = this.beginAttempt(mode) + const attempt = this.beginAttempt() this.phase = 'adjudicating' return [{ type: 'adjudicate', attempt, draft: this.draft }] } - return [{ type: 'default-sink', draft: this.draft, mode }] + return [{ type: 'default-sink', draft: this.draft }] } private onAdjudicated(attempt: SubmitAttempt, outcome: Extract['outcome']): InputEffect[] { @@ -507,7 +506,7 @@ export class InputMachine { this.inflight = undefined this.phase = 'plain' return outcome === undefined - ? [{ type: 'default-sink', draft: attempt.draftSnapshot, mode: flight.mode }] + ? [{ type: 'default-sink', draft: attempt.draftSnapshot }] : [] } diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 737d6dc9f0..976a397b03 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -42,7 +42,6 @@ export const zh = { 'chat.loadOlder': '加载更早', 'chat.toBottom': '回到底部', 'message.extraBlock': '附加内容块', - 'message.steering': '插话', 'message.contextInjection': '上下文注入', 'message.unknownSurface': '未知 surface 事件:{type}', 'message.unknownBlock': '未知内容块', @@ -124,7 +123,6 @@ export const en = { 'chat.loadOlder': 'Load earlier', 'chat.toBottom': 'Back to bottom', 'message.extraBlock': 'Extra content block', - 'message.steering': 'Interjection', 'message.contextInjection': 'Context injection', 'message.unknownSurface': 'Unknown surface event: {type}', 'message.unknownBlock': 'Unknown content block', diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 939073d22a..bbbb13f3ca 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -25,12 +25,11 @@ export interface IConversation { /** The per-session input machine registry (InputService face). */ readonly input: InputService /** - * Send a prompt into the caller scope's session. + * Send a prompt into the caller scope's session (queued turn). * @param text - prompt text, sent verbatim as one text block. - * @param mode - queue after the current turn, or steer into it. * @returns completion; business failures reject (and land in promptError). */ - send(text: string, mode: 'queue' | 'steer'): Promise + send(text: string): Promise /** * Apply one operation to a pending queue occurrence. * @param itemId - agent-owned inbox occurrence identity. @@ -71,11 +70,10 @@ export class ConversationService extends Service implements IConversation { * session snapshot's promptError (object-layer surface); the rejection here * exists for caller choreography (the composer restores the draft on it). * @param text - prompt text, sent verbatim as one text block. - * @param mode - queue after the current turn, or steer into it. */ - async send(text: string, mode: 'queue' | 'steer'): Promise { + async send(text: string): Promise { const session = this.scopedSession('send') - const result = await session.prompt([{ type: 'text', text }], mode) + const result = await session.prompt([{ type: 'text', text }], 'queue') if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`) } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 887c7872b6..bdf28add34 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -172,7 +172,7 @@ export function InputBar({ e.preventDefault() if (e.repeat) return // held-down Enter must not machine-gun sends if (locked || machineBusy) return - inputActions.submit('queue') + inputActions.submit() } const onChange = (e: ChangeEvent): void => { @@ -266,7 +266,7 @@ export function InputBar({ return } /* v8 ignore next -- defensive: the primary button is disabled while empty||disabled, so a click cannot reach the false arm. */ - if (!empty && !disabled && !machineBusy) inputActions.submit('queue') + if (!empty && !disabled && !machineBusy) inputActions.submit() } // The Access seat: the projection-fed permission chip (renders nothing diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 367b3519e9..53cd0df455 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -101,7 +101,7 @@ async function bench() { } const actions = info.props['inputActions'] as { setDraft: (text: string) => void - submit: (mode?: 'queue' | 'steer') => void + submit: () => void } return { state, actions } } @@ -140,25 +140,25 @@ describe('conversation slot inject surface', () => { const { state, actions } = b.inputSurface(ROOT) // Whitespace-only: the machine treats it as empty — no prompt, draft kept. actions.setDraft(' ') - actions.submit('queue') + actions.submit() expect(b.sessionFake.prompt).not.toHaveBeenCalled() expect(state.getSnapshot().draft).toBe(' ') // Success: cleared and stays cleared. actions.setDraft('hello') - actions.submit('queue') + actions.submit() expect(state.getSnapshot().draft).toBe('') await Promise.resolve() expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue') // Failure: restored (draft still empty when the rejection lands). b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } }) actions.setDraft('retry me') - actions.submit('queue') + actions.submit() await vi.waitFor(() => { expect(state.getSnapshot().draft).toBe('retry me') }) // Failure landing after new typing: no clobber (restore fills empty only). b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } }) - actions.submit('queue') + actions.submit() actions.setDraft('typed during flight') await new Promise(r => setTimeout(r, 0)) expect(state.getSnapshot().draft).toBe('typed during flight') diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index a88d1175ee..a9a28e373d 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -99,7 +99,7 @@ describe('MessageItem arms', () => { fireEvent.click(screen.getByRole('button', { name: '复制' })) }) - it('steering bubbles carry the interjection badge and non-text rest blocks, without user actions', () => { + it('steering bubbles render text and non-text rest blocks, without user actions or a badge', () => { const view = render( { } as never} />, ) - expect(view.getByText('插话')).toBeTruthy() + expect(view.queryByText('插话')).toBeNull() expect(view.getByText('steer!')).toBeTruthy() expect(view.getByText(/附加内容块/)).toBeTruthy() expect(view.queryByRole('button', { name: '复制' })).toBeNull() diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index a501ac1a4c..b173c09499 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -127,7 +127,7 @@ describe('Enter semantics', () => { it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => { const { textarea, sink } = bench({ draft: 'hello' }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('hello', 'queue') + expect(sink).toHaveBeenCalledWith('hello') fireEvent.keyDown(textarea, { key: 'Enter', repeat: true }) expect(sink).toHaveBeenCalledTimes(1) const empty = bench({ draft: ' ' }) @@ -195,7 +195,7 @@ describe('running and lock semantics (queue cut 1)', () => { expect(textarea.disabled).toBe(false) // running no longer locks fireEvent.change(textarea, { target: { value: '排队消息2' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('排队消息2', 'queue') + expect(sink).toHaveBeenCalledWith('排队消息2') expect(button.getAttribute('aria-label')).toBe('停止生成') fireEvent.click(button) expect(stop).toHaveBeenCalledTimes(1) @@ -211,7 +211,7 @@ describe('running and lock semantics (queue cut 1)', () => { it('idle primary sends and disables on empty draft', () => { const { button, sink } = bench({ draft: 'go' }) fireEvent.click(button) - expect(sink).toHaveBeenCalledWith('go', 'queue') + expect(sink).toHaveBeenCalledWith('go') const empty = bench() expect(empty.button.disabled).toBe(true) }) @@ -320,7 +320,7 @@ describe('machine pending lock', () => { }, { start: 0, end: 6, draftRev: shell.snapshot.draftRev }, ) - shell.submit('queue') + shell.submit() }) expect(shell.snapshot.phase).toBe('submitting') const textarea = view.container.querySelector('textarea')! diff --git a/packages/client/ui-conversation/tests/input-machine.spec.ts b/packages/client/ui-conversation/tests/input-machine.spec.ts index 9ce23c4a10..510cb28076 100644 --- a/packages/client/ui-conversation/tests/input-machine.spec.ts +++ b/packages/client/ui-conversation/tests/input-machine.spec.ts @@ -40,9 +40,9 @@ function effectAt( } /** Drive plain → adjudicating and hand back the minted attempt. */ -function enterAdjudicating(m: InputMachine, draft: string, mode: 'queue' | 'steer' = 'queue'): SubmitAttempt { +function enterAdjudicating(m: InputMachine, draft: string): SubmitAttempt { m.dispatch({ type: 'draft-changed', draft }) - const fx = m.dispatch({ type: 'enter', mode }) + const fx = m.dispatch({ type: 'enter' }) return effectAt(fx, 0, 'adjudicate').attempt } @@ -52,7 +52,7 @@ function enterSubmitting(m: InputMachine, name: string, args: string): { attempt m.dispatch({ type: 'draft-changed', draft: `/${name.slice(0, 2)}` }) m.dispatch({ type: 'begin-command', claim, span: spanOf(m, 0, m.state.draft.length) }) m.dispatch({ type: 'draft-changed', draft: claim.token + args }) - const fx = m.dispatch({ type: 'enter', mode: 'queue' }) + const fx = m.dispatch({ type: 'enter' }) return { attempt: effectAt(fx, 0, 'begin-submit').attempt, claim } } @@ -63,24 +63,24 @@ function staleAttempt(): SubmitAttempt { describe('input-machine: plain × enter', () => { it('empty and whitespace-only drafts produce nothing', () => { const m = new InputMachine() - expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([]) + expect(m.dispatch({ type: 'enter' })).toEqual([]) m.dispatch({ type: 'draft-changed', draft: ' \n ' }) - expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([]) + expect(m.dispatch({ type: 'enter' })).toEqual([]) expect(m.state.phase).toBe('plain') }) - it('non-command text falls to the default sink with the given mode', () => { + it('non-command text falls to the default sink', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: 'hello world' }) - expect(m.dispatch({ type: 'enter', mode: 'steer' })) - .toEqual([{ type: 'default-sink', draft: 'hello world', mode: 'steer' }]) + expect(m.dispatch({ type: 'enter' })) + .toEqual([{ type: 'default-sink', draft: 'hello world' }]) expect(m.state.phase).toBe('plain') }) it('leading "/" enters adjudicating with a minted attempt carrying the draft snapshot', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: '/goal x' }) - const fx = m.dispatch({ type: 'enter', mode: 'queue' }) + const fx = m.dispatch({ type: 'enter' }) const eff = effectAt(fx, 0, 'adjudicate') expect(eff.draft).toBe('/goal x') expect(eff.attempt.draftSnapshot).toBe('/goal x') @@ -91,14 +91,14 @@ describe('input-machine: plain × enter', () => { it('leading is judged after trim including newlines', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: '\n\n/goal x' }) - expect(m.dispatch({ type: 'enter', mode: 'queue' })[0]?.type).toBe('adjudicate') + expect(m.dispatch({ type: 'enter' })[0]?.type).toBe('adjudicate') }) it('a non-whitespace prefix before "/" is not leading — default sink', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: '第一行\n/goal x' }) - expect(m.dispatch({ type: 'enter', mode: 'queue' })) - .toEqual([{ type: 'default-sink', draft: '第一行\n/goal x', mode: 'queue' }]) + expect(m.dispatch({ type: 'enter' })) + .toEqual([{ type: 'default-sink', draft: '第一行\n/goal x' }]) }) }) @@ -124,11 +124,11 @@ describe('input-machine: adjudication outcomes', () => { expect(effectAt(b.dispatch({ type: 'adjudicated', attempt: attemptB, outcome: { claim: claimOf('goal') } }), 0, 'begin-submit').args).toBe('x') }) - it('undefined outcome falls back to the default sink preserving the enter mode', () => { + it('undefined outcome falls back to the default sink', () => { const m = new InputMachine() - const attempt = enterAdjudicating(m, '/unknown thing', 'steer') + const attempt = enterAdjudicating(m, '/unknown thing') expect(m.dispatch({ type: 'adjudicated', attempt, outcome: undefined })) - .toEqual([{ type: 'default-sink', draft: '/unknown thing', mode: 'steer' }]) + .toEqual([{ type: 'default-sink', draft: '/unknown thing' }]) expect(m.state.phase).toBe('plain') }) @@ -152,7 +152,7 @@ describe('input-machine: adjudication outcomes', () => { it('enter is a no-op while adjudicating (pending lock)', () => { const m = new InputMachine() enterAdjudicating(m, '/goal x') - expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([]) + expect(m.dispatch({ type: 'enter' })).toEqual([]) expect(m.state.phase).toBe('adjudicating') }) @@ -587,7 +587,7 @@ describe('input-machine: paste plane', () => { const b = new InputMachine() b.dispatch({ type: 'paste-begin', text: 'plain text', selection: { start: 0, end: 0 } }) - b.dispatch({ type: 'enter', mode: 'queue' }) + b.dispatch({ type: 'enter' }) expect(b.state.paste).toBeUndefined() }) @@ -755,7 +755,7 @@ describe('input-machine: submitting transaction', () => { it('enter and begin-command are locked; draft-changed is recorded without leaving submitting', () => { const m = new InputMachine() enterSubmitting(m, 'goal', 'x') - expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([]) + expect(m.dispatch({ type: 'enter' })).toEqual([]) expect(m.dispatch({ type: 'draft-changed', draft: '/goal y' })).toEqual([]) expect(m.state).toMatchObject({ phase: 'submitting', draft: '/goal y' }) }) @@ -768,7 +768,7 @@ describe('input-machine: submitting transaction', () => { m.dispatch({ type: 'draft-changed', draft: '/go', editRange: { start: 0, end: 1, insertedLength: 0 } }) m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) }) m.dispatch({ type: 'draft-changed', draft: '/goal go' }) - const attempt = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt + const attempt = effectAt(m.dispatch({ type: 'enter' }), 0, 'begin-submit').attempt const fx = m.dispatch({ type: 'submit-settled', attempt, ok: true, outcome: { kind: 'success', text: 'goal set' } }) expect(fx).toEqual([{ type: 'notice', level: 'info', text: 'goal set' }]) expect(m.state).toMatchObject({ phase: 'plain', draft: '', occurrences: [] }) @@ -809,7 +809,7 @@ describe('input-machine: submitting transaction', () => { const m = new InputMachine() const { attempt: first } = enterSubmitting(m, 'goal', 'x') m.dispatch({ type: 'submit-settled', attempt: first, ok: false, message: 'retry' }) - const second = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt + const second = effectAt(m.dispatch({ type: 'enter' }), 0, 'begin-submit').attempt expect(second.seq).not.toBe(first.seq) expect(m.dispatch({ type: 'submit-settled', attempt: first, ok: true })).toEqual([]) expect(m.state.phase).toBe('submitting') diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index 005aedc693..b4dee5ce8c 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -85,7 +85,7 @@ describe('matrix row: plain', () => { fireEvent.change(textarea, { target: { value: '普通消息' } }) expect(shell.snapshot.claim).toBeUndefined() fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('普通消息', 'queue') + expect(sink).toHaveBeenCalledWith('普通消息') expect(shell.snapshot.phase).toBe('plain') }) }) @@ -184,7 +184,7 @@ describe('matrix row: locked (session disabled)', () => { expect((textarea).disabled).toBe(false) fireEvent.change(textarea, { target: { value: '排队' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('排队', 'queue') + expect(sink).toHaveBeenCalledWith('排队') }) }) diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 2c735071e3..841e5d267f 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -224,7 +224,7 @@ describe('scenario D: execute-kind /compact', () => { act(() => { b2.shell.setDraft('/compact 现在') }) fireEvent.keyDown(b2.textarea, { key: 'Enter' }) // execute with trailing → matchEnter answers undefined → default sink. - await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', 'queue') }) + await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在') }) expect(b2.executed).toHaveLength(0) }) }) @@ -278,7 +278,7 @@ describe('scenario I: unknown /xyz + enter', () => { const b = await bench() act(() => { b.shell.setDraft('/xyz 干点啥') }) fireEvent.keyDown(b.textarea, { key: 'Enter' }) - await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', 'queue') }) + await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥') }) expect(b.shell.snapshot.phase).toBe('plain') expect(b.execute).not.toHaveBeenCalled() }) diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index 209cdee212..41a21b86e3 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -33,11 +33,11 @@ async function bench() { describe('ConversationService', () => { it('routes operations through the public Session binding', async () => { const b = await bench() - await b.scoped.send('hello', 'steer') + await b.scoped.send('hello') await b.scoped.updateQueue('item-1' as never, { kind: 'remove' }) await b.scoped.cancel() await b.scoped.loadOlder() - expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'steer') + expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue') expect(b.updateQueue).toHaveBeenCalledWith('item-1', { kind: 'remove' }) expect(b.cancel).toHaveBeenCalledOnce() expect(b.loadOlder).toHaveBeenCalledOnce() @@ -47,7 +47,7 @@ describe('ConversationService', () => { it('folds Session business failures into callback rejections', async () => { const b = await bench() b.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'busy', details: {} } } as never) - await expect(b.scoped.send('x', 'queue')).rejects.toThrow('conversation.send failed: agent-busy: busy') + await expect(b.scoped.send('x')).rejects.toThrow('conversation.send failed: agent-busy: busy') b.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'nope', details: {} } } as never) await expect(b.scoped.cancel()).rejects.toThrow('conversation.cancel failed: internal: nope') await b.runtime.dispose() @@ -55,9 +55,9 @@ describe('ConversationService', () => { it('fails loudly from the root scope, on an unbound session, or without SessionsService', async () => { const b = await bench() - await expect(b.root.send('x', 'queue')).rejects.toThrow(/requires a session scope/) + await expect(b.root.send('x')).rejects.toThrow(/requires a session scope/) await b.runtime.sessions.remove('s1') - await expect(b.scoped.send('x', 'queue')).rejects.toThrow(/resolved no binding/) + await expect(b.scoped.send('x')).rejects.toThrow(/resolved no binding/) await b.runtime.dispose() // No SessionsService at all: a bare context (the runtime always provides one). const bare = new Context() @@ -65,6 +65,6 @@ describe('ConversationService', () => { input: new InputHub(bare), }).await() const orphan = bare.get('conversation') as ConversationService - await expect(orphan.send('x', 'queue')).rejects.toThrow(/sessions service unavailable/) + await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/) }) }) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index a257688bd3..5698df8c5b 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -206,7 +206,7 @@ describe('ConversationRoot resident composer', () => { fireEvent.change(box, { target: { value: 'ordinary revised' } }) expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised') fireEvent.keyDown(box, { key: 'Enter' }) - expect(b.sink).toHaveBeenCalledWith('ordinary revised', 'queue') + expect(b.sink).toHaveBeenCalledWith('ordinary revised') fireEvent.click(b.view.getByRole('button', { name: 'Root' })) expect(b.open).toHaveBeenCalledWith(sid('root')) }) From 53e7e5b2478089fc43303790b81f85a25025df97 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:14:24 +0800 Subject: [PATCH 25/28] docs(cli): track welcome storage migration --- apps/cli/src/tui-onboarding/tui-first-run-welcome.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/cli/src/tui-onboarding/tui-first-run-welcome.ts b/apps/cli/src/tui-onboarding/tui-first-run-welcome.ts index d18e6d2a0c..41b4c5aca2 100644 --- a/apps/cli/src/tui-onboarding/tui-first-run-welcome.ts +++ b/apps/cli/src/tui-onboarding/tui-first-run-welcome.ts @@ -35,6 +35,9 @@ import { type TuiFirstRunWelcomeArtTier, } from './tui-first-run-welcome-art.ts' +// TODO: Move acknowledgement persistence behind @deepseek-ai/dsh-storage once +// its backend contract supports concurrent host processes. This same-value +// marker must not inherit JSON lost updates or SQLite busy failures. const ACKNOWLEDGEMENT_DIRECTORY = 'notices' const ACKNOWLEDGEMENT_BASENAME = 'tui-first-run-welcome' From 2d73157099bd256ad3f2c08cb1b83a7d509af862 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:18:08 +0800 Subject: [PATCH 26/28] docs(client): refresh runtime README pairing --- packages/client/runtime/README.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index cba74aabd5..0783d0662f 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/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/client/runtime/README.md -README.md: d22f119d898de0b8aaa3472f3c2ad35621b1477a -README.zh.md: 26d6f315d02abc931e43cacb696b4c70163b14c0 +README.md: 0ae71ff17b13be67c16786ff69a0e1626437913a +README.zh.md: 52a443d9df753ba01650b6cbf189c39633f6a461 From 57d6d9ee1cce882ae0937577ca87dc0d4f787148 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Fri, 31 Jul 2026 19:20:12 +0800 Subject: [PATCH 27/28] fix: ci --- .../src/client/chat/MessageItem.tsx | 67 +++++++++++-------- 1 file changed, 39 insertions(+), 28 deletions(-) diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index eab509286f..e4730924fd 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -142,41 +142,52 @@ function projectUserText(text: string): ReactNode { return <>{parts} } +/** Right-aligned bubble shared by user and steering rows (steering has no actions). */ +function UserStyleBubble({ + content, actions, t, +}: { + content: readonly unknown[] + /** Optional IconActions (or similar) below the bubble; receives the joined text. */ + actions?: (text: string) => ReactNode + t: ChatViewSlotProps['t'] +}): ReactNode { + const { text, rest } = contentText(content) + const truncated = (total: number): string => t('json.truncated', { total }) + return ( +
+
+ {projectUserText(text)} + {rest.map((block, i) => )} +
+ {actions?.(text)} +
+ ) +} + export const MessageItem = memo(function MessageItem({ node, retryActive = false, onFork, t, }: MessageItemProps) { const truncated = (total: number): string => t('json.truncated', { total }) switch (node.kind) { - case 'user': { - const { text, rest } = contentText(node.content) + case 'user': return ( -
-
- {projectUserText(text)} - {rest.map((block, i) => )} -
- { onFork(node.seq) }} - className={css.actions} - t={t} - /> -
+ ( + { onFork(node.seq) }} + className={css.actions} + t={t} + /> + )} + /> ) - } - case 'steering': { - const { text, rest } = contentText(node.content) - return ( -
-
- {projectUserText(text)} - {rest.map((block, i) => )} -
-
- ) - } + case 'steering': + return case 'context': return ( From c83159f1c0a54af6b908eb2540bebada693e943e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:48:04 +0800 Subject: [PATCH 28/28] test(cli): acknowledge welcome in composition smoke --- apps/cli/tests/shipped-composition.e2e.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/cli/tests/shipped-composition.e2e.ts b/apps/cli/tests/shipped-composition.e2e.ts index 565aacd269..ac1e9d5456 100644 --- a/apps/cli/tests/shipped-composition.e2e.ts +++ b/apps/cli/tests/shipped-composition.e2e.ts @@ -7,6 +7,7 @@ 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' +import { acknowledgeTuiFirstRunWelcome } from '../src/tui-onboarding/tui-first-run-welcome.ts' const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) @@ -105,6 +106,7 @@ describe('shipped dsh composition (real Loader tree in a PTY)', () => { tsconfigPath, configPath: keylessTail, env: { DEEPSEEK_API_KEY: 'keyless-composition-no-call', DSH_TELEMETRY_DISABLED: '1' }, + prepare: cwd => acknowledgeTuiFirstRunWelcome(join(cwd, '.dsh')), // Artifact CI builds and smokes concurrently on a contended runner. ...(process.env.DSH_EXAMPLE_MODE === 'lib' ? { timeoutMs: 60_000 } : {}), actions: [