diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml similarity index 66% rename from .agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml rename to .agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml index a8472075e4..635e3fca62 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml +++ b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.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-27-tui-diff-card-redundant-path-header.md -2026-07-27-tui-diff-card-redundant-path-header.md: 708e543ff079828b4929d2a50ac697a9c846608a -2026-07-27-tui-diff-card-redundant-path-header.zh.md: 863868ae707f37689bbc202267c5470d8c3163e9 +2026-07-27-tui-diff-card-redundant-path-header.md: 608a11892a20d020087180175eff847021dc0554 +2026-07-27-tui-diff-card-redundant-path-header.zh.md: bf7f1c1eeb994f9940b5f7dfb7db72d422293bd4 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md rename to .agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md index 708e543ff0..608a11892a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md +++ b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md @@ -1,6 +1,7 @@ # Agent Note: TUI diff card dropped the duplicated file path Status: implemented +Archived: 2026-07-31 English | [中文](2026-07-27-tui-diff-card-redundant-path-header.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md rename to .agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md index 863868ae70..bf7f1c1eeb 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md +++ b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md @@ -1,6 +1,7 @@ # Agent Note: TUI diff 卡片重复打印文件路径 Status: implemented +Archived: 2026-07-31 [English](2026-07-27-tui-diff-card-redundant-path-header.md) | 中文 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index e46d7c34cd..1ed77225ae 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -46,6 +46,9 @@ "bug-fix/2026-07-26-intent-draft-same-tick-echo.i18n.yaml": "sha256:c623947c4fa00e6d4b51792c7972ba09582bbcb7605beb373725c0dd666f2c81", "bug-fix/2026-07-26-intent-draft-same-tick-echo.md": "sha256:fa8b1417b2cdd3deecbf8e55bdddd73dd3a8c6e3486fd399b0b8bdf317e56373", "bug-fix/2026-07-26-intent-draft-same-tick-echo.zh.md": "sha256:00ce72552dbaa11562fbc541343a5d33f9449edabbe6dd354eb879a7d4d530f8", + "bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml": "sha256:8613a1cfcf4b9c7fafa78a8d8565e2a65ef0335b7b826af9b2bb32097836af55", + "bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md": "sha256:1bd344aec5454d2a2d6e1e6a32eff035c4a99c3df409f2624b39fd32e23ee402", + "bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md": "sha256:0a1747006efb1a4b67feceb9b627a437a0f023158e90ae86e1fe8aef76485384", "feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918", "feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b", "feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69", diff --git a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml index 629476cf8e..43675cbd56 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.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-consolidated-tui-presentation.md -2026-07-28-consolidated-tui-presentation.md: f87d543a698d6e77abf9120c6579100df4b60b64 -2026-07-28-consolidated-tui-presentation.zh.md: 005e408f0e75207027315546942f9eab57d595d1 +2026-07-28-consolidated-tui-presentation.md: 8200c8e96cdea7cf5ae54c1328623bb03f841d00 +2026-07-28-consolidated-tui-presentation.zh.md: 852bd413b25f93f3b9096b78d69b21ab420df37b diff --git a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md index f87d543a69..8200c8e96c 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md +++ b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md @@ -26,7 +26,7 @@ A tool card has one colored `Tool / ` status header over one dim body. Pre Injected context renders as prose in `ContextCardComponent`, not through the XML tree renderer. Exact matched outer `` lines are stripped, but mismatched, unpaired, or inline tag-like text remains verbatim. Model-facing content is unchanged. Folding uses the shared `preview` helper after body assembly, so it depends only on row count, never parser success or payload characters. -`Ctrl+O` cycles collapsed, expanded, and hidden. Tool cards disappear in the hidden state together with their card-owned leading gap. Context cards participate in collapsed and expanded states but fall back to collapsed while tools are hidden, because injected instructions are not disposable tool traffic. +`Ctrl+O` cycles collapsed, expanded, and hidden. Tool cards disappear in the hidden state together with their card-owned leading gap. Context cards participate in collapsed and expanded states but fall back to collapsed while tools are hidden, because injected instructions are not disposable tool traffic. The hidden phase additionally folds each turn's assistant steps into one message; the [hidden-mode assistant fold Agent Note](../feature/2026-07-29-tui-hidden-mode-assistant-fold.md) owns that rule. ### Cross-workspace resume diff --git a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md index 005e408f0e..852bd413b2 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md @@ -26,7 +26,7 @@ Status: implemented 注入上下文由 `ContextCardComponent` 按普通文本呈现,不经过 XML 树渲染器。仅移除精确配对的外层 `` 行;不匹配、单边或正文内类似标签的文本都原样保留。面向模型的内容不变。折叠在正文组装完成后使用共享 `preview` 辅助函数,因此只取决于行数,不依赖解析是否成功或载荷包含哪些字符。 -`Ctrl+O` 在折叠、展开和隐藏之间循环。隐藏状态会连同卡片自有的前导间距一起移除工具卡片。上下文卡片参与折叠和展开状态,但工具隐藏时回到折叠状态,因为注入指令不是可丢弃的工具流量。 +`Ctrl+O` 在折叠、展开和隐藏之间循环。隐藏状态会连同卡片自有的前导间距一起移除工具卡片。上下文卡片参与折叠和展开状态,但工具隐藏时回到折叠状态,因为注入指令不是可丢弃的工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息;该规则由[隐藏模式 assistant 折叠 Agent Note](../feature/2026-07-29-tui-hidden-mode-assistant-fold.md)负责。 ### 跨工作区恢复 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.i18n.yaml new file mode 100644 index 0000000000..2f06390bdf --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.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-30-multi-select-custom-answer-composition.md +2026-07-30-multi-select-custom-answer-composition.md: 581beec89a0f0018ec2df687f5dfe1b1b5b86d22 +2026-07-30-multi-select-custom-answer-composition.zh.md: 5c9cb59822aca3fbf49fbbdf522c76f963df3480 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md new file mode 100644 index 0000000000..581beec89a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md @@ -0,0 +1,25 @@ +# Agent Note: Multi-select custom answer composition + +Status: implemented + +English | [中文](2026-07-30-multi-select-custom-answer-composition.zh.md) + +## Problem + +The user-interaction result vocabulary carries selected option labels and optional custom text in separate fields, but its original semantics made them mutually exclusive for every question. On a multi-select question, opening or typing the custom answer discarded labels the user had already selected. The TUI returned only the custom text, and the Web host rejected a client response that preserved both fields. + +## Decision + +For a question with `multiSelect: true`, one answer item may contain both a non-empty `selected` array and non-empty `custom` text. Web drafts preserve both values regardless of whether the user selects an option or types custom text first; the TUI retains pending custom text across option/custom mode switches and projects it with checked labels from either submit mode; and the Web host accepts the combined response after applying its existing id, label, uniqueness, batch, and non-empty-text validation. + +Single-select and optionless questions keep exclusive semantics: custom text overrides any selected option. The result shape remains `{ id, selected, custom? }`, so no wire or tool-output schema changes. + +## Alternatives considered + +**Encode custom text as another `selected` label.** Rejected because it would erase the distinction between caller-provided option labels and human-authored text, weakening validation and forcing consumers to infer which value was custom. + +**Allow `selected` and `custom` together for every question.** Rejected because a single-select question represents one answer; permitting a selected option plus custom text would make its cardinality ambiguous. The combined form is limited to questions that explicitly opt into multiple answers. + +## Consequences + +Multi-select UIs can represent the user's complete answer without discarding either source. Providers and consumers retain the existing DTO, while request-aware validators interpret the allowed combination from `multiSelect`. Web component and assembled-browser coverage, TUI coverage, host-response coverage, and tool-projection coverage pin the combined result. Web, TUI, and tool-projection coverage also retain labels-only answers; assembled keyless TUI coverage pins the combined terminal flow, and single-select host coverage pins the remaining exclusivity rule. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.zh.md new file mode 100644 index 0000000000..5c9cb59822 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 多选题自定义答案组合 + +Status: implemented + +[English](2026-07-30-multi-select-custom-answer-composition.md) | 中文 + +## 问题 + +用户交互结果的词汇分别通过不同字段携带选中的选项标签和可选的自定义文本,但最初的语义要求每个问题的这两个字段互斥。对于多选题,打开自定义答案或输入文本会丢弃用户已选中的标签。TUI 只返回自定义文本,而 Web 宿主会拒绝同时保留两个字段的客户端响应。 + +## 决策 + +对于 `multiSelect: true` 的问题,一个回答项可以同时包含非空 `selected` 数组与非空 `custom` 文本。无论用户先选择选项还是先输入自定义文本,Web 草稿都会保留两个值;TUI 在选项与自定义模式之间切换时会保留待提交的自定义文本,并在任一模式提交时将其与已勾选的标签一同投影;Web 宿主则在应用现有的 id、标签、唯一性、批次和非空文本校验后接受组合响应。 + +单选题和无选项问题仍保持互斥语义:自定义文本会覆盖任何已选中的选项。结果形状仍为 `{ id, selected, custom? }`,因此协议或工具输出 schema 均无需变更。 + +## 考虑过的替代方案 + +**把自定义文本编码为另一个 `selected` 标签。** 不予采纳,因为这样会抹去调用方提供的选项标签与用户填写文本之间的区别,削弱校验,并迫使消费方推断哪个值属于自定义内容。 + +**允许所有问题同时使用 `selected` 与 `custom`。** 不予采纳,因为单选题只表示一个回答;允许选中选项与自定义文本并存会使其基数含义模糊。组合形式仅适用于显式选择多项回答的问题。 + +## 后果 + +多选 UI 可以完整表达用户的回答,不会丢弃任一来源。提供方和消费方继续使用现有 DTO,而请求感知的校验器会根据 `multiSelect` 判断是否允许组合。Web 组件与组装浏览器的覆盖率、TUI 覆盖率、宿主响应覆盖率和工具投影覆盖率共同固定组合结果。Web、TUI 与工具投影覆盖率还固定了仅含标签的回答形态;组装后的无密钥 TUI 覆盖率固定终端中的组合回答流程,单选题的宿主覆盖率则固定其余的互斥规则。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml new file mode 100644 index 0000000000..b2bfc991c9 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.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-tui-diff-context-line-accounting.md +2026-07-31-tui-diff-context-line-accounting.md: d465568d5f6cad15ef4647be7ef936c2f5824bba +2026-07-31-tui-diff-context-line-accounting.zh.md: dd1a3eb1acf03477059d48461219700b224b2f96 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md new file mode 100644 index 0000000000..d465568d5f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md @@ -0,0 +1,33 @@ +# Agent Note: TUI diff context lines stay neutral + +Status: implemented + +English | [中文](2026-07-31-tui-diff-context-line-accounting.zh.md) + +## Problem + +Result-time filesystem diffs carry the applied change with three surrounding context lines in each `FileDiff.oldText` and `FileDiff.newText`. The TUI rendered every old-side row as removed and every new-side row as added, including the identical context present on both sides. A one-line edit therefore appeared as seven removals plus seven additions, and the footer repeated those inflated totals. + +## Decision + +The TUI compares each `FileDiff` whose old and new text are both available. Added and removed rows retain their green `+` and red `-` markers; equal context rows use the recessed body tone with a neutral two-space prefix. The footer sums only the rows classified as added or removed. `maxDiffEditLength` bounds the exact comparison by its combined added and removed line count; the default is 1000. Exceeding the bound renders the complete old side as removed and the complete new side as added, marks the footer approximate, and caches that result so redraws do not repeat the comparison. A tool result clears the pending-view cache before deriving the settled view, including when a presenter mutates and reuses the same view object. + +When `oldText` is `null`, the renderer cannot distinguish a create from a pending overwrite or an argument fallback whose prior text is unavailable. It therefore shows every non-empty new-side row as added, without claiming those rows were absent from an existing file. Empty new content renders no synthetic added row. + +This remains a consumer-side interpretation of the existing `FileDiff` contract. Filesystem tools continue to persist contextual before/after snippets, so other consumers keep their placement context and existing session logs replay with corrected TUI presentation. The TUI uses the same maintained `diff` package as `dsh-tool-fs` instead of introducing a second line-diff implementation. + +## Alternatives considered + +**Remove context from filesystem result metadata.** Rejected: contextual applied hunks are intentional producer output used by capable editors, and changing them would weaken every consumer while leaving old session logs misleading in the TUI. + +**Extend `FileDiff` with persisted per-line tags.** Rejected: the tags can be derived deterministically from the existing before/after pair; persisting them would widen the cross-package and session-log contract solely for one renderer. + +**Match equal lines by position without a diff algorithm.** Rejected: insertions and deletions shift subsequent context, so positional pairing would misclassify valid hunks. + +**Run every comparison to completion.** Rejected: pending tool views can contain unrestricted model-authored old and new strings, and an unbounded Myers comparison can block the synchronous terminal renderer. + +## Consequences + +TUI diff cards distinguish evidence-bearing context from the mutation itself, and an exact `+A -R` footer reports the actual line delta. Replaying an existing contextual diff gains the corrected rendering without a migration. Result-time filesystem hunks are context-bounded; unrestricted pending views either complete within the configured edit-length budget or degrade to an explicitly approximate linear rendering. + +The focused TUI tests cover neutral context, exact totals, an empty create, bounded fallback, result-time cache invalidation, and redraw cache reuse. The assembled `advanced-cards` terminal snapshots pin the neutral context style, semantic change colors, exact footer, and approximate fallback through collapsed and expanded card states. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md new file mode 100644 index 0000000000..dd1a3eb1ac --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md @@ -0,0 +1,33 @@ +# Agent Note: TUI diff 上下文行保持中性 + +Status: implemented + +[English](2026-07-31-tui-diff-context-line-accounting.md) | 中文 + +## 问题 + +文件系统 diff 返回结果时,每个 `FileDiff.oldText` 和 `FileDiff.newText` 都会包含已应用的变更及其前后各 3 行上下文。TUI 将旧侧的每一行都渲染为删除行,将新侧的每一行都渲染为新增行,其中包括两侧相同的上下文。因此,一行编辑会显示为删除 7 行并新增 7 行,页脚还会重复这些虚高的合计值。 + +## 决策 + +TUI 会比较每个变更前后文本均可用的 `FileDiff`。新增行和删除行仍分别使用绿色 `+` 和红色 `-` 标记;相同的上下文行则使用弱化的正文色调,并带有由两个空格构成的中性前缀。页脚只汇总归类为新增或删除的行。`maxDiffEditLength` 以新增行与删除行的合计数为精确比较设置上限,默认值为 1000。超过上限时,TUI 会把完整旧侧渲染为删除内容、把完整新侧渲染为新增内容,将页脚标记为近似结果,并缓存该结果,避免后续重绘重复比较。工具结果会在派生已结算视图前清除待处理视图缓存,即使 presenter 修改并复用同一个视图对象也一样。 + +当 `oldText` 为 `null` 时,渲染器无法区分文件创建、待处理覆写,以及旧文本不可用的参数回退。因此,它会把新侧的每个非空行显示并计作新增行,但不会声称这些行原先不存在于已有文件中。新内容为空时,不会渲染虚构的新增行。 + +该行为仍然只是消费方对现有 `FileDiff` 契约的解释。文件系统工具仍会持久化带上下文的变更前后片段,因此其他消费方仍能获得定位上下文,已有会话日志在回放时也会采用修正后的 TUI 呈现。TUI 与 `dsh-tool-fs` 共用同一个受维护的 `diff` 包(package),无需引入第二套逐行 diff 实现。 + +## 考虑过的替代方案 + +**从文件系统结果元数据中移除上下文。** 不予采纳:带上下文的已应用 hunk 是有意保留的生产方输出,供具备相应能力的编辑器使用;更改这些内容会让所有消费方丢失信息,同时旧会话日志在 TUI 中仍会产生误导。 + +**为 `FileDiff` 扩展持久化的逐行标签。** 不予采纳:这些标签可以根据现有的变更前后文本对确定性派生;仅为一个渲染器持久化标签,会扩大跨包契约和会话日志契约。 + +**不使用 diff 算法,按位置匹配相同行。** 不予采纳:插入和删除会使后续上下文发生位移,因此按位置配对会把有效 hunk 错误分类。 + +**让所有比较都运行至完成。** 不予采纳:待处理工具视图可能包含由模型生成且长度不受限制的新旧字符串,无界的 Myers 比较可能阻塞同步终端渲染器。 + +## 后果 + +TUI diff 卡片会区分用于佐证的上下文与变更本身,精确的 `+A -R` 页脚会报告实际的行变更量。回放已有的上下文 diff 无需迁移即可获得修正后的渲染。结果时刻的文件系统 hunk 受上下文范围限制;不受限制的待处理视图要么在配置的编辑长度预算内完成比较,要么降级为明确标注为近似结果的线性渲染。 + +聚焦的 TUI 测试覆盖中性上下文、精确合计值、空文件创建、有界回退、结果到达时的缓存失效和重绘缓存复用。组装后的 `advanced-cards` 终端快照在卡片折叠和展开状态下固定了中性上下文样式、变更行的语义色彩、精确结果页脚和近似回退。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.i18n.yaml new file mode 100644 index 0000000000..9ac8e49fe6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.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-08-02-message-fork-actions-require-completed-turn-tail.md +2026-08-02-message-fork-actions-require-completed-turn-tail.md: f2e7fd67b65a6ce4a86ba3f4405f78842be8f234 +2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md: 2c3feeaa3ef01dbde67faa73257520918996f9c8 diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md new file mode 100644 index 0000000000..f2e7fd67b6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md @@ -0,0 +1,29 @@ +# Agent Note: Message fork actions require a completed turn tail + +Status: implemented + +English | [中文](2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md) + +## Problem + +The Web conversation attached branch to the last assistant node with nonempty text in each turn. A later tool result, interrupted reasoning node, or terminal error did not take ownership because those rows have no content-text IconActions. The branch icon could therefore appear beneath an assistant response while more rows from the same turn remained below it. The Host correctly expanded that message anchor through the containing `turn/end`, but the placement made the action look like a message-level cut and the child visibly inherited the same-turn suffix. + +## Decision + +`ConversationSnapshot.turnEnds` retains the completed turn boundaries present in the raw event window. The conversation view walks transcript nodes through each boundary and enables branch only when the boundary's last node is a user message, a durable steering message, or a content-bearing assistant message. Open turns have no eligible message, and a later tool result, reasoning-only interruption, turn error, or other transcript node leaves branch unavailable on earlier messages. The unavailable control stays visible, focusable, and hoverable; `aria-disabled`, a tooltip, and `aria-describedby` explain the completed-tail requirement without sending a Host request. Copy and clock remain available under their existing message chrome, and the Host's completed-turn fork semantics remain unchanged. + +This narrows the message eligibility established by the earlier [Web session fork action decision](../feature/2026-07-27-web-session-fork-actions.md). Session-row forking still selects the latest completed turn, and eligible message actions still pass their event seq through the shared client runtime operation. + +## Alternatives considered + +**Cut the event log at the clicked assistant message.** Rejected because an assistant message can sit inside an open step and can contain tool calls whose results occur later. A raw prefix at that seq is not a balanced turn and may not be a valid provider transcript. + +**Infer completion from `running` or the next user message.** Rejected because retry and steering turns need not align with the next visible user bubble, and a paged window may omit that later bubble. The durable `turn/end` event is the authoritative completion fact. + +**Hide branch from every interrupted turn.** Rejected because an aborted turn is durably closed and its final interrupted text can be the true transcript tail. Eligibility depends on the completed boundary and node order, not the outcome kind. + +**Hide ineligible message controls.** Rejected because a disappearing control does not explain the boundary requirement and shifts otherwise stable message chrome. A focusable unavailable control preserves the affordance while preventing the request. + +## Consequences + +An enabled branch icon denotes the same completed-turn boundary that the Host will copy. In the reported response → tool → interrupted Think shape, the response keeps copy, clock, and a disabled branch control that explains why it cannot act. This change deliberately does not provide same-turn transcript editing or a retry-before-turn operation; the Session-row action remains available when a reader wants to copy the latest completed turn in full. Runtime tests pin boundary projection and reference stability, while conversation tests cover assistant, user-only, and durable-steering tails plus unavailable controls caused by later tool and interrupted reasoning rows. diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md new file mode 100644 index 0000000000..2c3feeaa3e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 消息 fork 操作要求消息位于已完成轮次尾部 + +Status: implemented + +[English](2026-08-02-message-fork-actions-require-completed-turn-tail.md) | 中文 + +## 问题 + +Web 会话把分支操作挂到每个轮次中最后一个文本非空的 assistant 节点上。如果后面还有工具结果、被中断的推理(reasoning)节点或终态错误,这些行也不会接管操作,因为它们没有内容文本 IconActions。因此,分支图标可能出现在 assistant 响应下方,而同一轮次的更多行仍位于其后。Host 会正确地把该消息锚点扩展到其所在的 `turn/end`,但图标位置使操作看起来像在消息级截断,子会话又会明显继承同轮次的后缀。 + +## 决策 + +`ConversationSnapshot.turnEnds` 保留原始事件窗口中的已完成轮次边界。会话视图按各边界遍历 transcript(文本记录)节点,仅当边界的最后一个节点是用户消息、持久 steering(中途引导)消息或含内容的 assistant 消息时才启用分支操作。开放轮次没有符合条件的消息;如果后面还有工具结果、只有推理内容的中断、轮次错误或其他 transcript 节点,较早消息上的分支操作会保持不可用。不可用的控件仍然可见、可聚焦、可悬停;`aria-disabled`、tooltip 与 `aria-describedby` 会说明已完成尾部这一要求,且不会发送 Host 请求。复制和时钟仍可在既有消息 chrome 下使用,Host 按已完成轮次 fork 的语义保持不变。 + +本决策收紧了较早的 [Web 会话 fork 操作决策](../feature/2026-07-27-web-session-fork-actions.md)所定义的消息资格。Session 行 fork 仍选择最新的已完成轮次;符合条件的消息操作仍通过共享 client 运行时操作传递其事件 seq。 + +## 考虑过的替代方案 + +**在点击的 assistant 消息处截断事件日志。** 不予采纳:assistant 消息可能位于尚未结束的步骤内,也可能包含结果随后才出现的工具调用。以该 seq 截取的原始前缀并不是结构完整的轮次,也可能不是有效的提供方 transcript。 + +**从 `running` 或下一条用户消息推断完成状态。** 不予采纳:重试轮次与 steering 轮次不一定和下一个可见用户气泡对齐,分页窗口也可能省略该气泡。持久 `turn/end` 事件才是权威的完成事实。 + +**对每个被中断轮次隐藏分支。** 不予采纳:已中止的轮次会持久关闭,其最终的中断文本可能正是真正的 transcript 尾部。资格取决于已完成边界与节点顺序,而非结果类别。 + +**隐藏不符合条件的消息控件。** 不予采纳:消失的控件无法说明边界要求,还会让本应稳定的消息 chrome 发生位移。保留可聚焦但不可用的控件,既能维持操作提示,也能阻止请求。 + +## 后果 + +启用的分支图标现在表示的已完成轮次边界与 Host 实际复制的边界一致。在所报告的「响应 → 工具 → 被中断的 Think」形态中,响应仍保留复制、时钟,以及一个说明无法操作原因的禁用分支控件。本变更刻意不提供同轮次 transcript 编辑,也不提供轮次前重试操作;当读者希望完整复制最新的已完成轮次时,仍可使用 Session 行操作。运行时测试固定边界投影和引用稳定性,会话测试则覆盖 assistant 尾部、纯用户消息尾部、持久 steering 尾部,以及后续工具行和被中断推理行导致的不可用控件。 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml index 1f52492649..656a52d300 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.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-23-web-assistant-markdown.md: 38d193271d88b3a8f32ba1b191e8a6d432176281 -2026-07-23-web-assistant-markdown.zh.md: be3cd041c6012af142fc27934fda125dfc4cf6de +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md +2026-07-23-web-assistant-markdown.md: d5074e6090699229f5c43dd93eef0fdfbfedab76 +2026-07-23-web-assistant-markdown.zh.md: 31f0fd6835c9921f544f4b6217a0c834dff79859 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md index 38d193271d..d5074e6090 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md @@ -20,7 +20,7 @@ The dependency is explicit in `ui-primitives`; because that pure library is seed ## Untrusted output policy -Assistant-authored destinations are restricted to absolute HTTP, HTTPS, and mailto URLs. HTTP(S) links open in a new tab with `rel="noopener noreferrer"`; relative destinations and other protocols render as non-navigable text. Markdown images render only their alt text, so model output cannot initiate a remote image request. Raw HTML remains inert source text because no HTML parser enters the pipeline. Shiki output is a static span tree generated from the fence text (no scripts or user HTML). +Assistant-authored link destinations are restricted to absolute HTTP, HTTPS, and mailto URLs. HTTP(S) links open in a new tab with `rel="noopener noreferrer"`; relative destinations and other protocols render as non-navigable text. Markdown images follow the separate [remote-image policy](2026-07-30-web-remote-markdown-images.md). Raw HTML remains inert source text because no HTML parser enters the pipeline. Shiki output is a static span tree generated from the fence text (no scripts or user HTML). Fenced code and GFM tables own horizontal overflow so long content cannot widen the conversation column. @@ -32,7 +32,7 @@ Fenced code and GFM tables own horizontal overflow so long content cannot widen **Parse Markdown into session snapshots.** This would make React nodes or presentation ASTs durable runtime state and reintroduce a final-versus-streaming mode boundary. Parsing stays at the presentation leaf instead. -**Enable raw HTML or remote images with sanitization.** Neither capability has a current product need, while both enlarge the executable or network privacy boundary. They remain disabled rather than adding sanitizer and image-policy dependencies. +**Enable raw HTML with sanitization.** Raw HTML has no current product need and would enlarge the executable-content boundary, so it remains disabled rather than adding a sanitizer dependency. Remote images are governed by the later [image policy](2026-07-30-web-remote-markdown-images.md). **Port deepsuite Prism `highlight.css` and the mdast pipeline.** Appearance parity is owned by CSS Modules and shared `--dsw-*` tokens; highlighting stays on the existing shiki allowlist so the client does not take a second highlighter or Prism class contract. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md index be3cd041c6..31f0fd6835 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md @@ -20,7 +20,7 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd ## 不受信任输出策略 -assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S) 链接会在新标签页中打开,并带有 `rel="noopener noreferrer"`;相对目标地址与其他协议会渲染为不可导航的文本。Markdown 图片仅渲染替代文本,因此模型输出无法发起远程图片请求。由于管线中未引入 HTML 解析器,原始 HTML 仍是不会生效的源文本。Shiki 输出是由围栏文本生成的静态 span 树(不含脚本或用户 HTML)。 +assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S) 链接会在新标签页中打开,并带有 `rel="noopener noreferrer"`;相对目标地址与其他协议会渲染为不可导航的文本。Markdown 图片遵循独立的[远程图片策略](2026-07-30-web-remote-markdown-images.md)。由于流水线中未引入 HTML 解析器,原始 HTML 仍是不会生效的源文本。Shiki 输出是由围栏文本生成的静态 span 树(不含脚本或用户 HTML)。 围栏代码与 GFM 表格各自处理横向溢出,因此较长内容无法撑宽对话栏。 @@ -32,7 +32,7 @@ assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S **将 Markdown 解析为会话快照。**这会让 React 节点或呈现层 AST 成为持久的运行时状态,并重新引入最终输出与流式输出之间的模式边界。解析仍留在呈现层的叶节点中。 -**通过净化启用原始 HTML 或远程图片。**当前产品并不需要这两项功能,但二者都会扩大可执行行为或网络隐私边界。因此它们保持禁用,无需增加净化器与图片策略依赖。 +**通过净化启用原始 HTML。** 原始 HTML 当前没有产品需求,并且会扩大可执行内容边界,因此保持禁用,无需增加净化器依赖。远程图片由后续的[图片策略](2026-07-30-web-remote-markdown-images.md)约束。 **移植 deepsuite 的 Prism `highlight.css` 与 mdast 管线。**外观一致性由 CSS Modules 与共享的 `--dsw-*` token 负责;高亮仍走现有的 shiki 允许列表,使客户端不必引入第二套高亮器或 Prism class 契约。 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.i18n.yaml new file mode 100644 index 0000000000..8cc4d64238 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.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-24-tui-question-dialog-multiline.md +2026-07-24-tui-question-dialog-multiline.md: fc6e9bceeee4abc46a69a23124d09fcd4f3c7224 +2026-07-24-tui-question-dialog-multiline.zh.md: a56821921bad1016009687bde63eae5f4d893cdf diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md new file mode 100644 index 0000000000..fc6e9bceee --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md @@ -0,0 +1,41 @@ +# Agent Note: TUI QuestionDialog renders options across multiple lines + +Status: implemented + +English | [中文](2026-07-24-tui-question-dialog-multiline.zh.md) + +## Problem + +`ctx.userInteraction.ask()` must keep question text, supporting `detail`, option labels, descriptions, validation, and controls readable inside configured width and height bounds. The question panel also belongs directly above the editor: placing it at the terminal edge separates the pending decision from both the transcript that prompted it and the input that follows it. + +## Decision + +The TUI renders a pending question as an inline modal between the transcript/status area and the editor while retaining the shared FIFO with model and plugin overlays: + +- `InlineModalComponent` applies `questionDialogWidth` and `questionDialogMaxHeight` inside the normal component flow. The effective question height is additionally clamped to the current viewport after reserving the editor, so the editor remains below the question during resize. +- `renderOptionBlock` wraps each label beneath its cursor/number prefix and renders the muted description on separately wrapped, equally indented lines. The progress header, question, custom-answer hint, validation text, and final rows are width-bounded as well; the final ellipsis clamp is only a safety boundary for prefixes or other indivisible content. The explicit `↑ N lines hidden` fallback is reserved for a viewport below the configured minimum, where the whole semantic layout cannot fit. +- When question text or `detail` exceeds the header allocation, the header becomes a paged line viewport with its own `… lines A-B/N • PgUp/PgDn` status row. Page Up and Page Down traverse both line viewports: forward navigation exhausts the header/detail pages before entering oversized selected-option pages, and backward navigation reverses that order. This keeps plan-review detail reachable rather than leaving it behind the height clamp. +- The option-line budget subtracts padding, header, position, and footer rows before `windowBlocks` runs. The window obeys both `maxQuestionOptions` and the remaining row budget, keeps the selected option visible, and renders omitted options as `↑ N more` / `↓ N more` markers. If fixed chrome would leave fewer than four option rows, the compact header becomes the line pager so selected content, paging status, and both option markers still fit. +- When one selected block exceeds its allocation, it becomes a line viewport with a `lines A-B/N • PgUp/PgDn` status row. Page Up and Page Down expose every wrapped line without allowing the block to hide the option markers, validation, or controls. + +Package tests pin count and height bounds, header and selected-block paging order, narrow-width wrapping, selection behavior, and placement relative to retained editor input. Semantic TUI snapshots pin the assembled terminal layout, header/detail and selected-option page transitions, and validation state. + +## Alternatives considered + +**Ellipsis-only horizontal truncation.** Keeping one option per row would signal lost text without making the description readable and would not address vertical bounds. The implementation wraps readable content and retains an ellipsis only as a final safety boundary. + +**Wrap the combined label and description.** A composite row couples their widths, so either side can starve the other. Separate lines keep both widths predictable. + +**Keep the question as a bottom-edge overlay.** A terminal-edge anchor can place the panel after the editor or cover lower chrome, depending on transcript and viewport height. The inline modal preserves ordering while the modal manager retains focus and FIFO ownership. + +**Push the bounds into pi-tui.** Generic overlay slicing cannot identify option boundaries, selected content, controls, or the inline editor relationship. The owning dialog therefore applies semantic count, row, and paging rules. + +**Use only the option-count cap.** `maxQuestionOptions` remains a public count bound, but it cannot contain wrapped blocks by itself. The dialog enforces the count and row bounds together. + +## Consequences + +- Descriptions consume additional rows, so fewer options can be visible than `maxQuestionOptions`; markers state the omitted option counts. +- Long question text and plan-review detail remain reachable inside a height-bounded panel, at the cost of sharing Page Up and Page Down with selected-option paging. +- An oversized selected block reserves one status row and requires Page Up or Page Down to read beyond the current line page. +- The inline question can displace older transcript rows from a short viewport. Below the configured minimum height, the final fallback can collapse upper rows behind an explicit hidden-line marker so the input controls and editor remain available. +- The model-facing schema, selected labels, abort/cancel behavior, and ACP elicitation path are unchanged. diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.zh.md b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.zh.md new file mode 100644 index 0000000000..a56821921b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.zh.md @@ -0,0 +1,41 @@ +# Agent Note: TUI QuestionDialog 以多行方式渲染选项 + +Status: implemented + +[English](2026-07-24-tui-question-dialog-multiline.md) | 中文 + +## 问题 + +`ctx.userInteraction.ask()` 必须确保问题正文、`detail` 补充内容、选项标签、描述、校验信息和控件在已配置的宽度与高度边界内均可读。问题面板也直接位于编辑器上方:若将其置于终端边缘,待处理决策就会同时脱离触发该决策的 transcript(文本记录)和后续输入。 + +## 决策 + +TUI 将待处理问题渲染为位于 transcript/状态区域与编辑器之间的内联模态框,同时仍与模型浮层和插件浮层共享 FIFO: + +- `InlineModalComponent` 在正常组件流内应用 `questionDialogWidth` 和 `questionDialogMaxHeight`。系统在为编辑器预留空间后,还会根据当前视口限制问题的实际高度,因此调整窗口大小时,编辑器仍位于问题下方。 +- `renderOptionBlock` 将每个标签换行到光标/编号前缀下方,并在另行换行且缩进相同的行上渲染弱化的描述。进度标题、问题、自定义答案提示、校验文本和末尾行也受宽度边界约束;最终的省略号截断仅作为前缀或其他不可拆分内容的安全边界。明确的 `↑ N lines hidden` 回退仅用于低于已配置最小值、无法容纳完整语义布局的视口。 +- 当问题正文或 `detail` 超出头部分配的空间时,头部会成为带有独立 `… lines A-B/N • PgUp/PgDn` 状态行的分页行视口。Page Up 和 Page Down 会遍历这两个行视口:向前导航先翻完问题正文/`detail` 页面,再进入超大选中选项页面;向后导航则采用相反顺序。这样可确保计划评审的 `detail` 内容始终可达,而不会被高度边界挡住。 +- 在 `windowBlocks` 运行前,选项行预算会扣除内边距、标题行、位置行和页脚行。窗口同时遵守 `maxQuestionOptions` 和剩余行预算,保持选中项可见,并将省略的选项渲染为 `↑ N more`/`↓ N more` 标记。若固定界面元素会使选项行少于四行,紧凑头部会转为行分页器,从而容纳选中内容、分页状态和上下两个选项标记。 +- 当一个选中块超出分配空间时,它会成为带有 `lines A-B/N • PgUp/PgDn` 状态行的行视口。Page Up 和 Page Down 可展示每一行已换行内容,同时防止该块遮住选项标记、校验信息或控件。 + +包(package)测试固定数量和高度边界、头部与选中块的分页顺序、窄宽度换行、选择行为,以及问题相对于保留的编辑器输入的位置。语义 TUI 快照固定组装后的终端布局、头部/详情与选中选项的分页转换,以及校验状态。 + +## 备选方案 + +**仅用省略号进行横向截断。** 保持每个选项占一行,只能提示文本有所丢失,无法使描述变得可读,也无法处理纵向边界。该实现会对可读内容换行,仅将省略号保留为最终安全边界。 + +**将标签与描述合并后换行。** 组合行会将两者的宽度耦合在一起,任一方都可能挤占另一方的空间。分行渲染可使二者的宽度保持可预测。 + +**将问题保留为终端底边浮层。** 根据 transcript 和视口高度,锚定在终端边缘的面板可能出现在编辑器之后,也可能遮盖下方界面元素。内联模态框可保留顺序,同时由模态管理器继续负责焦点和 FIFO 所有权。 + +**将边界处理下推至 pi-tui。** 通用浮层切片无法识别选项边界、选中内容、控件或内联编辑器关系。因此,负责该语义的对话框会应用数量、行数和分页规则。 + +**仅使用选项数量上限。** `maxQuestionOptions` 仍是公开的数量边界,但仅靠它无法容纳已换行的块。对话框会同时执行数量边界和行数边界。 + +## 后果 + +- 描述会占用额外行,因此可见选项数可能少于 `maxQuestionOptions`;标记会说明省略的选项数量。 +- 较长的问题正文和计划评审 `detail` 在受高度约束的面板内仍然可达,代价是 Page Up 和 Page Down 需要与选中选项分页共用。 +- 超出空间的选中块会预留一行状态信息;若要阅读当前页面之外的行,必须使用 Page Up 或 Page Down。 +- 在较矮的视口内,内联问题可能将较早的 transcript 行挤出可见区域。低于已配置最小高度时,最终回退可能将上部行折叠到明确的隐藏行标记之后,从而让输入控件和编辑器仍然可用。 +- 面向模型的 schema、选中的标签、中止/取消行为,以及 ACP(Agent Client Protocol)的 elicitation 路径均保持不变。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.i18n.yaml index 21eea20254..5268ea62f9 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.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-web-session-fork-actions.md -2026-07-27-web-session-fork-actions.md: 58960169a2e499d953840e5769e7689b5cd48047 -2026-07-27-web-session-fork-actions.zh.md: ea2f9030f672f00fb91bce3546836689a7d41004 +2026-07-27-web-session-fork-actions.md: 578e59ec92e003fe5c8cdfe951a595f3a7371ecf +2026-07-27-web-session-fork-actions.zh.md: d90124f6e6e164b0a1e0fce734f52976630cd848 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md index 58960169a2..578e59ec92 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md @@ -10,7 +10,9 @@ The Session store already provides a fork primitive that creates a child session ## Decision -The Web Session-row menu and message IconActions share the client runtime's `sessions.fork` action. A Session row passes `{ sessionId, increaseTitle: true }`, so it forks at the source session's last completed turn; a user message or settled assistant content message passes `{ sessionId, atSeq: node.seq, increaseTitle: true }`, so it forks at the turn containing that event. Only the client consumes `increaseTitle`: after adding the child session to its local list, the client increments a trailing `(N)` or `(N)` in the source session's persisted title without changing bracket style, appends ` (1)` to an unnumbered title, and skips the rename when no persisted title exists; the Host fork request still contains only `sessionId` and the optional `atSeq`. The caller opens the child only after the rename succeeds; a fork or rename failure leaves the source session and current selection unchanged, while a child created before a rename failure remains in the list. +The message-eligibility portion of this decision is narrowed by the [completed-turn-tail decision](../bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md); the shared runtime action, injection ownership, title handling, and peer-list decisions remain current. + +The Web Session-row menu and message IconActions share the client runtime's `sessions.fork` action. A Session row passes `{ sessionId, increaseTitle: true }`, so it forks at the source session's last completed turn; an eligible completed-turn-tail message passes `{ sessionId, atSeq: node.seq, increaseTitle: true }`, so it forks at the turn ending at that message. Only the client consumes `increaseTitle`: after adding the child session to its local list, the client increments a trailing `(N)` or `(N)` in the source session's persisted title without changing bracket style, appends ` (1)` to an unnumbered title, and skips the rename when no persisted title exists; the Host fork request still contains only `sessionId` and the optional `atSeq`. The caller opens the child only after the rename succeeds; a fork or rename failure leaves the source session and current selection unchanged, while a child created before a rename failure remains in the list. `forkAt(seq)` touches the session service only in ui-conversation's apply injection layer; message components report only the event `seq`. Session rows likewise initiate the operation only through ui-workspace's injected callback. Neither presentation package owns session mutation state or duplicates the host's boundary evaluation. @@ -28,6 +30,6 @@ Session lineage is not projected into a list hierarchy. WorkSpace mode displays ## Consequences -Users can create forks from Session rows, user messages, or settled assistant content messages; all three entry points ultimately use the same runtime/host operation. Message entry points preserve the exact event boundary, while the list entry point preserves the “latest completed turn” shortcut. Successive fork titles increment through `(1)`, `(2)`, and so on instead of repeatedly appending `(1)`; titles with fullwidth parentheses retain that style. Every fork child immediately appears as an ordinary peer row, so the list no longer needs session expansion state, recursive nodes, or twist controls. +Users can create forks from Session rows or eligible completed-turn-tail messages; both entry points ultimately use the same runtime/host operation. Message entry points preserve the exact event boundary, while the list entry point preserves the “latest completed turn” shortcut. Successive fork titles increment through `(1)`, `(2)`, and so on instead of repeatedly appending `(1)`; titles with fullwidth parentheses retain that style. Every fork child immediately appears as an ordinary peer row, so the list no longer needs session expansion state, recursive nodes, or twist controls. -Fork and child-rename failures stay silent and preserve the source selection, preventing a derivation action from disrupting the current reading position; this tradeoff also means the UI does not yet expose a failure reason or retry entry point. Package tests separately pin the two message `seq` paths, title increments, and the peer-list derivation; `apps/web/tests/message-actions.e2e.ts` exercises assistant-message branching and Session-row menu branching through the assembled application. +Fork and child-rename failures stay silent and preserve the source selection, preventing a derivation action from disrupting the current reading position; this tradeoff also means the UI does not yet expose a failure reason or retry entry point. Package tests pin eligible message `seq` forwarding, title increments, and the peer-list derivation; `apps/web/tests/message-actions.e2e.ts` exercises assistant-message branching and Session-row menu branching through the assembled application. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md index ea2f9030f6..d90124f6e6 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md @@ -10,7 +10,9 @@ Session store 已提供按完成轮前缀创建子会话的 fork 原语,但 We ## Decision -Web 的 session 行菜单与消息 IconActions 共用 client runtime 的 `sessions.fork` 操作。Session 行传 `{ sessionId, increaseTitle: true }`,因此在源会话最后一个已完成轮次处分支;用户消息与已定稿 assistant 内容消息传 `{ sessionId, atSeq: node.seq, increaseTitle: true }`,因此在包含该事件的轮次处分支。`increaseTitle` 只由 client 消费:子会话进入本地列表后,client 把源会话持久化标题尾部的 `(N)` 或 `(N)` 递增并保留括号样式,无编号时追加 ` (1)`,没有持久化标题时不改名;Host fork 请求仍只有 `sessionId` 与可选的 `atSeq`。改名成功后调用方才打开子会话;fork 或改名失败时保持源会话与当前选择不变,改名失败时已创建的子会话仍留在列表中。 +本决策中的消息资格部分由[已完成轮次尾部决策](../bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)收紧;共享运行时操作、注入归属、标题处理和同级列表决策仍然有效。 + +Web 的 session 行菜单与消息 IconActions 共用 client runtime 的 `sessions.fork` 操作。Session 行传 `{ sessionId, increaseTitle: true }`,因此在源会话最后一个已完成轮次处分支;符合条件且位于已完成轮次尾部的消息传 `{ sessionId, atSeq: node.seq, increaseTitle: true }`,因此在以该消息结束的轮次处分支。`increaseTitle` 只由 client 消费:子会话进入本地列表后,client 把源会话持久化标题尾部的 `(N)` 或 `(N)` 递增并保留括号样式,无编号时追加 ` (1)`,没有持久化标题时不改名;Host fork 请求仍只有 `sessionId` 与可选的 `atSeq`。改名成功后调用方才打开子会话;fork 或改名失败时保持源会话与当前选择不变,改名失败时已创建的子会话仍留在列表中。 `forkAt(seq)` 只在 ui-conversation 的 apply 注入层接触 session 服务,消息组件只回传事件 `seq`。Session 行同理只通过 ui-workspace 的注入回调发起操作;两个呈现包都不持有 session mutation 状态,也不复制 host 的边界求值。 @@ -28,6 +30,6 @@ Session lineage 不投影成列表层级。WorkSpace 模式按 `WorkspaceView.se ## Consequences -用户可从 session 行、用户消息或已定稿 assistant 内容消息创建分支,三处最终走同一个 runtime/host 操作;消息点位保留精确事件边界,列表点位保留「最新完成轮」快捷语义。连续 fork 的标题按 `(1)`、`(2)` 递增,而不是重复追加 `(1)`;全角括号标题保持全角样式。所有 fork 子会话立即作为普通同级行出现,列表不再需要 session 展开状态、递归节点或 twist 控件。 +用户可从 session 行或符合条件的已完成轮次尾部消息创建分支,两处最终走同一个 runtime/host 操作;消息点位保留精确事件边界,列表点位保留「最新完成轮」快捷语义。连续 fork 的标题按 `(1)`、`(2)` 递增,而不是重复追加 `(1)`;全角括号标题保持全角样式。所有 fork 子会话立即作为普通同级行出现,列表不再需要 session 展开状态、递归节点或 twist 控件。 -Fork 与子会话改名失败都保持静默并保留源选择,避免一个派生操作破坏当前阅读位置;该取舍也意味着 UI 暂不提供失败原因或重试入口。Package tests 分别钉住两种消息 `seq`、标题递增与同级列表派生,`apps/web/tests/message-actions.e2e.ts` 通过装配后的应用执行 assistant 消息分支与 session 行菜单分支。 +Fork 与子会话改名失败都保持静默并保留源选择,避免一个派生操作破坏当前阅读位置;该取舍也意味着 UI 暂不提供失败原因或重试入口。包级测试固定符合条件的消息 `seq` 转发、标题递增与同级列表派生,`apps/web/tests/message-actions.e2e.ts` 通过装配后的应用执行 assistant 消息分支与 session 行菜单分支。 diff --git a/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.i18n.yaml new file mode 100644 index 0000000000..338f90664d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.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-29-tui-hidden-mode-assistant-fold.md +2026-07-29-tui-hidden-mode-assistant-fold.md: e2bae1d4669fd0a4c8f9278c58f641704b425109 +2026-07-29-tui-hidden-mode-assistant-fold.zh.md: 583d6099f9cd5edfc592a9d510d260d7a4172013 diff --git a/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.md b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.md new file mode 100644 index 0000000000..e2bae1d466 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.md @@ -0,0 +1,25 @@ +# Agent Note: TUI hidden mode folds a turn's assistant steps into one message + +Status: implemented + +English | [中文](2026-07-29-tui-hidden-mode-assistant-fold.zh.md) + +## Problem + +Ctrl+O's hidden phase ([consolidated TUI presentation](../architecture/2026-07-28-consolidated-tui-presentation.md)) drops tool cards so the transcript reads as a conversation, but each model step still rendered its own `Assistant` header. A multi-step turn (text → tools → text) therefore showed several consecutive `Assistant` blocks with nothing between them — the removed tool cards were the only thing that had justified the repeated headers. Codex-style conversation-only reading wants one assistant message per turn. + +## Decision + +Hidden mode is also a fold rule, applied purely as TUI presentation: per turn, the first step whose rendered content is visible (text, or reasoning while reasoning display is on) owns the turn's single `Assistant` header; every other step renders as a headerless continuation, and a step with no visible body renders nothing at all — a tool-only step neither consumes the header nor leaves a blank segment. Collapsed and expanded phases keep per-step headers; leaving hidden restores them. + +Mechanics: `StreamingAssistantComponent` carries its `StepPosition` and a `setFoldedContinuation` presentation flag; `createTuiChat` keeps a per-turn list of step components and re-derives the fold on Ctrl+O, on each streamed text/reasoning chunk, on message settle, and on retraction of a failed stream (which may hand the header to the next step). Transcript rebuild clears the map and replays the log, so resume, compaction replacement, resize, and theme swaps converge on the same fold. Step timing footers keep their per-step ownership and are unaffected. + +## Alternatives considered + +- **Merge steps into one component** — collides with per-step streaming lifecycle, retry retraction, and timing footers; the flag on existing components changes only the header/spacer. +- **Fold in the session log or `deriveMessages`** — mutates durable/model-visible history for a UI reading mode; the log stays step-shaped. +- **Always fold (all visibility phases)** — collapsed/expanded interleave tool cards between steps, where per-step headers delimit which output belongs to which step. + +## Consequences + +Hidden mode now reads as one assistant message per turn; turns stay separated by their headers. The fold is recomputed state, never stored, so no session or persistence format changes. Coverage: TUI unit specs for the Ctrl+O cycle header counts, tool-only first step header handoff, per-turn separation, and live streaming + rebuild convergence; keyless snapshot `tool-cards-hidden-folded` pins the folded frame. diff --git a/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md new file mode 100644 index 0000000000..583d6099f9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md @@ -0,0 +1,25 @@ +# Agent Note: TUI 隐藏模式把一个轮次的 assistant 步骤折叠为一条消息 + +Status: implemented + +[English](2026-07-29-tui-hidden-mode-assistant-fold.md) | 中文 + +## 问题 + +Ctrl+O 的隐藏阶段([整合的 TUI 展示](../architecture/2026-07-28-consolidated-tui-presentation.md))去掉工具卡片,让 transcript(文本记录)读作一段对话,但每个模型步骤仍渲染自己的 `Assistant` 标题。因此一个多步骤轮次(文本 → 工具 → 文本)会显示多个连续、之间空无一物的 `Assistant` 区块——被移除的工具卡片正是重复标题曾经的唯一理由。Codex 风格的纯对话阅读需要每轮次一条 assistant 消息。 + +## 决定 + +隐藏模式同时也是一条折叠规则,且纯粹作为 TUI 展示实现:在每个轮次内,第一个渲染内容可见(有文本,或在 reasoning 显示开启时有 reasoning)的步骤拥有该轮次唯一的 `Assistant` 标题;其余步骤渲染为无标题的续段,没有可见正文的步骤则完全不渲染——仅有工具调用的步骤既不占用标题,也不留下空白段。折叠与展开阶段保留每步各自的标题;离开隐藏阶段会恢复它们。 + +机制:`StreamingAssistantComponent` 携带自己的 `StepPosition` 和一个 `setFoldedContinuation` 展示标志;`createTuiChat` 维护每轮次的步骤组件列表,并在 Ctrl+O、每个流式 text/reasoning chunk、消息结算,以及失败流被撤回(可能把标题移交给下一个步骤)时重新推导折叠。transcript 重建会清空该映射并重放日志,因此恢复、压缩替换、调整尺寸和主题切换收敛到同一折叠结果。步骤计时页脚保持按步骤归属,不受影响。 + +## 考虑过的替代方案 + +- **把多个步骤合并为一个组件**——与按步骤的流式生命周期、重试撤回和计时页脚冲突;在现有组件上加标志只改变标题与前导间距。 +- **在会话日志或 `deriveMessages` 中折叠**——为一种 UI 阅读模式改变持久 / 模型可见的历史;日志保持按步骤的形状。 +- **所有可见性阶段都折叠**——折叠 / 展开阶段在步骤之间穿插工具卡片,此时每步的标题用来划分哪段输出属于哪个步骤。 + +## 后果 + +隐藏模式现在每轮次读作一条 assistant 消息;轮次之间仍由各自的标题分隔。折叠是重新计算的状态,从不存储,因此会话与持久化格式没有变化。覆盖:TUI 单元测试覆盖 Ctrl+O 循环的标题计数、仅工具的首步骤标题移交、按轮次分隔,以及实时流式 + 重建收敛;无密钥快照 `tool-cards-hidden-folded` 固定折叠后的帧。 diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml new file mode 100644 index 0000000000..5a1b64f6b3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.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-tui-details-command.md +2026-07-30-tui-details-command.md: fb7c4dfaedeff27c9cafd0ba82daf4739665f19c +2026-07-30-tui-details-command.zh.md: 5f9e033311d1999998ef08b8f340d71f751b11f6 diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md new file mode 100644 index 0000000000..fb7c4dfaed --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md @@ -0,0 +1,34 @@ +# Agent Note: /details command for transcript detail state + +Status: implemented + +English | [中文](2026-07-30-tui-details-command.zh.md) + +## Problem + +The TUI's transcript detail state — tool-card visibility (`collapsed`/`expanded`/`hidden`, per the [consolidated TUI presentation](../architecture/2026-07-28-consolidated-tui-presentation.md)) and reasoning-block display — was reachable only through the Ctrl+O cycle and the Ctrl+R toggle. A user who wants a specific mode must cycle through the others, cannot set both dimensions in one action, and has no way to query the current state; a terminal that swallows those control keys has no fallback at all. + +## Decision + +`dsh-tui` registers `/details` beside its other agent-scoped commands. Bare `/details` opens `DetailsDialog`, a centered keyboard toggle with one entry per dimension — `Tool cards` and `Reasoning` — showing the live values: Tab cycles the highlighted entry and applies the change immediately, so the transcript behind the dialog is the preview, and Enter, Esc, or Ctrl+C closes; its width is the `detailsDialogWidth` config key and a second `/details` replaces an open selector, mirroring the `/model` overlay. Arguments name target states directly: `collapsed|expanded|hidden` jumps tool cards to that phase, `reasoning on|off` sets reasoning display, bare `reasoning` toggles it, and directives combine in one invocation. An unknown token returns a command error carrying the usage line. Every entry mutates the same closure state as the shortcuts, refactored so the cycle and toggle are thin wrappers over `setToolsVisibility`/`setReasoning`; the shortcuts and their notices are unchanged. + +A combined invocation applies reasoning before visibility because `setReasoning` rebuilds the transcript from session events, which drops non-durable notice components; applying it last would erase the just-appended visibility notice. + +The reasoning rebuild exposed a replay defect that this change fixes in `renderEvent`: the live path cleared a settled `StreamingAssistantComponent` before a later `assistant/message` of the same step (so the second message got a fresh component), but `rebuildTranscript` replay reused the settled component and `settle()` overwrote its content, silently dropping the earlier message's text. The settled check now lives in `renderEvent`'s `assistant/message` case — one home for both paths — and the previously wrong `untrusted-controls` snapshot (an empty `Assistant` header where reasoning and text had been dropped) was re-recorded with the content present. + +## Alternatives considered + +**Cycle on bare `/details`, mirroring Ctrl+O.** Rejected: the command's value over the shortcut is naming an absolute state; a cycling command is the shortcut with more keystrokes, and bare invocation is more useful as the selector, which shows the current state while offering every target. + +**Bare `/details` as a text-only state report.** Shipped first, replaced by the selector: the report answered "where am I" but still required a second, argument-spelling invocation to change anything, while the selector shows the same state and applies a change in one interaction. The textual grammar remains for scripts, muscle memory, and combined two-dimension changes. + +**Separate `/tools` and `/reasoning` commands.** Rejected: both dimensions are one presentation concern ("how much detail does the transcript show"), and a single command keeps the registry and `/help` list small while allowing one combined invocation. + +**Config-key defaults per mode.** Out of scope: `showReasoning` already exists as config; the command is runtime state on top of it, matching the shortcuts. + +## Consequences + +- A user can jump to any detail mode, set both dimensions at once, and see the current state in the selector — including on terminals that intercept Ctrl+O/Ctrl+R. +- The parser accepts order-free tokens, so `/details reasoning expanded` toggles reasoning and expands cards; last directive wins per dimension. This leniency is deliberate and documented in the README. +- The selector has no pending state or cancel: every Tab is a real, already-notified change, and closing never reverts. A user who over-cycles simply Tabs on to the wanted value. +- Transcript rebuilds no longer lose assistant messages when a step carries more than one `assistant/message` event; the `details-command` snapshot pins the argument surface and the fixed replay, and `details-selector` pins the open toggle right after a Tab applied `hidden` -> `collapsed`, including the restored tool card behind it. diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md new file mode 100644 index 0000000000..5f9e033311 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md @@ -0,0 +1,34 @@ +# Agent Note: 用于 transcript 细节状态的 /details 命令 + +Status: implemented + +[English](2026-07-30-tui-details-command.md) | 中文 + +## Problem + +TUI 的 transcript(文本记录)细节状态——工具卡片可见性(`collapsed`/`expanded`/`hidden`,见[整合的 TUI 展示](../architecture/2026-07-28-consolidated-tui-presentation.md))与 reasoning 块显示——过去只能通过 Ctrl+O 循环和 Ctrl+R 切换来触达。想要某个特定模式的用户必须循环经过其他模式,无法一次操作同时设置两个维度,也无法查询当前状态;吞掉这些控制键的终端更是完全没有替代途径。 + +## Decision + +`dsh-tui` 在其他 agent 作用域命令旁注册 `/details`。裸 `/details` 打开 `DetailsDialog`:一个居中的键盘开关,每个维度一个条目——`Tool cards` 与 `Reasoning`——显示实时值:Tab 循环高亮条目并立即应用变更,对话框背后的 transcript 即是预览,Enter、Esc 或 Ctrl+C 关闭;其宽度由配置键 `detailsDialogWidth` 决定,选择器打开时再次执行 `/details` 会替换它,与 `/model` 浮层一致。参数直接命名目标状态:`collapsed|expanded|hidden` 让工具卡片跳到该阶段,`reasoning on|off` 设置 reasoning 显示,裸 `reasoning` 切换它,且指令可在一次调用中组合。未知 token 返回携带用法行的命令错误。每个入口改动的都是与快捷键相同的闭包状态,重构后循环与切换成为 `setToolsVisibility`/`setReasoning` 之上的薄封装;快捷键及其通知保持不变。 + +组合调用先应用 reasoning 再应用可见性,因为 `setReasoning` 会从会话事件重建 transcript,而重建会丢弃非持久的通知组件;若最后才应用它,会抹掉刚追加的可见性通知。 + +reasoning 重建暴露了一个重放缺陷,本变更在 `renderEvent` 中修复:实时路径会在同一步骤的后续 `assistant/message` 之前清除已结算的 `StreamingAssistantComponent`(因此第二条消息获得新组件),但 `rebuildTranscript` 重放复用了已结算组件,`settle()` 覆盖其内容,静默丢掉了前一条消息的文本。已结算检查现在位于 `renderEvent` 的 `assistant/message` 分支——两条路径共用一个归属地——此前错误的 `untrusted-controls` 快照(reasoning 与文本被丢弃后只剩空 `Assistant` 标题)已重录为包含内容的版本。 + +## Alternatives considered + +**裸 `/details` 像 Ctrl+O 一样循环。** 否决:命令相对快捷键的价值在于命名绝对状态;循环命令只是按键更多的快捷键,裸调用作为选择器更有用——它在展示当前状态的同时提供所有目标。 + +**裸 `/details` 仅输出文本状态报告。** 首版如此实现,后被选择器取代:报告回答了“我在哪”,但改变任何东西仍需第二次、拼写参数的调用;选择器展示同样的状态并在一次交互中应用变更。文本语法保留给脚本、肌肉记忆和两维组合变更。 + +**拆分 `/tools` 与 `/reasoning` 两个命令。** 否决:两个维度同属一个展示关注点(“transcript 显示多少细节”),单一命令让注册表与 `/help` 列表更小,同时允许一次组合调用。 + +**按模式提供配置键默认值。** 超出范围:`showReasoning` 已作为配置存在;命令是其上的运行时状态,与快捷键一致。 + +## Consequences + +- 用户可以跳到任意细节模式、一次设置两个维度,并在选择器中看到当前状态——包括在拦截 Ctrl+O/Ctrl+R 的终端上。 +- 解析器接受无序 token,因此 `/details reasoning expanded` 会切换 reasoning 并展开卡片;每个维度以最后一个指令为准。这一宽松是刻意的,并记录在 README 中。 +- 选择器没有待定状态与取消:每次 Tab 都是已生效、已通知的真实变更,关闭从不回退。循环过头的用户继续 Tab 到想要的值即可。 +- 当一个步骤携带多条 `assistant/message` 事件时,transcript 重建不再丢失 assistant 消息;`details-command` 快照固定参数表面与修复后的重放,`details-selector` 固定 Tab 将 `hidden` 应用为 `collapsed` 后仍打开的开关,包括其背后恢复显示的工具卡片。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml index f9b2cfa908..388a8de9cd 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.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-web-diff-card.md -2026-07-30-web-diff-card.md: 396bdbc2843c1bbed5c6a913be436d8b9e96a81c -2026-07-30-web-diff-card.zh.md: afdeafa6e94b46b4f0fbd4a065afdac8a93ac57d +2026-07-30-web-diff-card.md: eb43e09d6173ca2270df97cecaab6da36c70a679 +2026-07-30-web-diff-card.zh.md: 669cd49abc8eba8637705cd7c9f331cc51607755 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md index 396bdbc284..eb43e09d61 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md @@ -16,13 +16,13 @@ This is the [terminal card](2026-07-28-web-terminal-card.md) done for the `diff` `DiffBlock` is a `ui-primitives` component that renders a file mutation as an inline diff surface, and both Web render sites for a write/edit call consume the diff render intent through it: the chat tool row's body and the details panel's Output section. `ui-conversation/src/client/contract/diff-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a change. It returns null — the generic path — whenever neither side declares `card: 'diff'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how write/edit keep their execution errors on the generic path. The result side is authoritative once the call settles: the applied hunks replace the call-time diff derived from the arguments alone. A paging window that drops the call head still renders, because the result view carries the whole change. -The component's contract follows the TUI's `diffLines` (`packages/ui/tui/src/components/transcript.ts`) so a diff reads the same shape across front ends: +The component shares the TUI's single-column framing, line-terminator rule, and distinct-path file count. Line classification differs: Web renders the complete old and new sides, while the TUI derives neutral context and exact changed rows when its bounded comparison completes and labels its whole-side fallback approximate. -- **One path header per file.** A new file opens a bold path header; a same-file second hunk (a scattered edit, or a `replace_all`) opens with a `⋯` gap instead of repeating the path. The `N file(s)` footer counts DISTINCT paths on both front ends — this PR moved the TUI footer off `diffs.length` onto the distinct-path count, so two hunks in one file read as `1 file` in both. -- **The change in the diff's own colors.** A removed line is `- ` on the error token, an added line is `+ ` on the success token, drawn verbatim with `white-space: pre` inside a horizontally scrolling box — a source line is read by its indentation, so it scrolls rather than folds. A create (`oldText: null`) has no removed side. +- **Path grouping.** A new file opens a bold path header; a same-file second hunk (a scattered edit, or a `replace_all`) opens with a `⋯` gap instead of repeating the path. The TUI keeps a path header on every hunk, but both front ends count distinct paths in the `N file(s)` footer, so two hunks in one file read as `1 file`. +- **Whole-side change colors.** Every old-side line is `- ` on the error token and every new-side line is `+ ` on the success token, drawn verbatim with `white-space: pre` inside a horizontally scrolling box — a source line is read by its indentation, so it scrolls rather than folds. A create (`oldText: null`) has no removed side. - **Height cap with an expand control.** A diff longer than `DEFAULT_DIFF_MAX_LINES` (16) shows `ceil(max/2)` head rows plus the remaining tail rows, with a button between reporting the hidden count. The split arithmetic matches `TerminalBlock` and the TUI's collapsed card, so a long diff's head and tail slices agree across front ends. -- **Line terminator.** A side's content splits on `\n` under the terminator rule `TerminalBlock` uses: empty text is zero lines (a full deletion's `newText`, a create's absent `oldText` side), a single trailing newline terminates its last line rather than adding a phantom empty one, and an interior blank line survives. This PR applied the same rule to the TUI diff branch, so the `+A -R` footer counts agree on both front ends for the newline-terminated content real write/edit calls carry. -- **Footer and copy.** A dim `└ +A -R · N file(s)` footer summarizes the change; `+A -R` are the added/removed line counts, the same per-side counts the TUI footer draws. The copy control copies the prefixed diff text (path headers, `- `/`+ ` lines, the `⋯` gap), so a multi-file copy stays attributable. +- **Line terminator.** A side's content splits on `\n` under the terminator rule `TerminalBlock` and the TUI use: empty text is zero lines (a full deletion's `newText`, a create's absent `oldText` side), a single trailing newline terminates its last line rather than adding a phantom empty one, and an interior blank line survives. +- **Footer and copy.** A dim `└ +A -R · N file(s)` footer reports the Web card's complete new- and old-side line counts. The TUI footer instead reports exact changed rows when available and marks a bounded whole-side fallback approximate; both use the same distinct-path file count. The copy control copies the prefixed Web diff text (path headers, `- `/`+ ` lines, the `⋯` gap), so a multi-file copy stays attributable. Geometry, radius, and fonts mirror `CodeBlock`/`TerminalBlock` so a diff card, a terminal card, and a fenced block read as one family; `white-space: pre` plus horizontal scroll is the deliberate divergence. The copy control floats in the card's top-right corner rather than on a banner row of its own, because a banner carrying only a copy button drew an empty band above the first diff line — the TUI diff card has no banner either, only the footer. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md index afdeafa6e9..669cd49abc 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md @@ -16,13 +16,13 @@ Web 客户端忽略了它。write/edit 调用落到 `GenericToolCard`,其行 `DiffBlock` 是一个 `ui-primitives` 组件,把文件改动渲染为内联 diff 表面,write/edit 调用的两个 Web 渲染点都通过它消费 diff 渲染意图:chat 工具行的行体和详情面板的 Output 区。`ui-conversation/src/client/contract/diff-card-model.ts` 是唯一把快照的 `callView`/`resultView` 对转成组件 props 的地方,因此两个渲染点不会对一次改动产生分歧。当两侧都未声明 `card: 'diff'` 时它返回 null —— 走通用路径 —— 包括本客户端版本不认识的 `card` 值,以及已结算调用的 result view 是 generic 的情况(write/edit 的执行错误正是这样留在通用路径上的)。调用结算后 result 侧是权威:已应用的 hunk 替换仅从参数推导的 call 时 diff。分页窗口丢弃了 call 头也仍能渲染,因为 result view 携带完整改动。 -组件的契约遵循 TUI 的 `diffLines`(`packages/ui/tui/src/components/transcript.ts`),使 diff 在两个前端读起来是同一形态: +该组件与 TUI 共用单栏框架、行终止符规则和去重路径计数。两者的行分类不同:Web 渲染完整的变更前后两侧,而 TUI 会在有界比较完成时派生中性上下文和精确变更行,并把整侧回退标记为近似结果。 -- **每个文件一个路径头。** 新文件开启一个粗体路径头;同文件的第二个 hunk(分散编辑,或 `replace_all`)以一个 `⋯` gap 开启,而非重复路径。`N file(s)` 页脚在两个前端都统计**去重后的路径数** —— 本 PR 把 TUI 页脚从 `diffs.length` 改为去重路径计数,因此同文件两个 hunk 在两端都读作 `1 file`。 -- **改动用 diff 自身的颜色。** 删除行是 error token 上的 `- `,新增行是 success token 上的 `+ `,在横向滚动的盒子里以 `white-space: pre` 逐字绘制 —— 源码行靠缩进阅读,所以滚动而不折行。新建(`oldText: null`)没有删除侧。 +- **路径分组。** 新文件开启一个粗体路径头;同文件的第二个 hunk(分散编辑,或 `replace_all`)以一个 `⋯` gap 开启,而非重复路径。TUI 在每个 hunk 上都保留路径头,但两个前端的 `N file(s)` 页脚都按去重路径计数,因此同文件两个 hunk 在两端都读作 `1 file`。 +- **整侧改动配色。** 旧侧每一行都以 error token 上的 `- ` 显示,新侧每一行都以 success token 上的 `+ ` 显示,并在横向滚动的盒子里以 `white-space: pre` 逐字绘制:源码行靠缩进阅读,因此滚动而不折行。新建(`oldText: null`)没有删除侧。 - **高度上限带展开控件。** 长于 `DEFAULT_DIFF_MAX_LINES`(16)的 diff 显示 `ceil(max/2)` 个头部行加剩余尾部行,中间一个按钮报告隐藏行数。分割算术与 `TerminalBlock` 和 TUI 的折叠卡片一致,因此长 diff 的头尾切片在两个前端一致。 -- **行终止符。** 每一侧的内容按 `TerminalBlock` 的终止符规则在 `\n` 上切分:空文本是零行(整文件删除的 `newText`、新建缺失的 `oldText` 侧),单个结尾换行终止其最后一行而非新增一条幻影空行,内部空行保留。本 PR 把同一规则应用到了 TUI diff 分支,因此对于真实 write/edit 调用携带的以换行结尾的内容,两个前端的 `+A -R` 页脚计数一致。 -- **页脚与复制。** 暗色 `└ +A -R · N file(s)` 页脚概括改动;`+A -R` 是新增/删除行数,与 TUI 页脚绘制的每侧计数相同。复制控件复制带前缀的 diff 文本(路径头、`- `/`+ ` 行、`⋯` gap),使多文件复制保持可归属。 +- **行终止符。** 每一侧的内容按 `TerminalBlock` 与 TUI 共用的终止符规则在 `\n` 上切分:空文本是零行(整文件删除的 `newText`、新建缺失的 `oldText` 侧),单个结尾换行终止其最后一行而非新增一条幻影空行,内部空行保留。 +- **页脚与复制。** 暗色 `└ +A -R · N file(s)` 页脚报告 Web 卡片完整新侧与旧侧的行数。TUI 页脚则在可用时报告精确变更行数,并把有界整侧回退标记为近似结果;两者使用相同的去重路径计数。复制控件复制带前缀的 Web diff 文本(路径头、`- `/`+ ` 行、`⋯` gap),使多文件复制保持可辨别归属。 几何、圆角、字体镜像 `CodeBlock`/`TerminalBlock`,使 diff 卡片、terminal 卡片、代码块读起来是一家;`white-space: pre` 加横向滚动是刻意的分歧。复制控件浮在卡片右上角,而非占据自己的 banner 行,因为只放一个复制按钮的 banner 会在第一行 diff 上方画出一条空带 —— TUI 的 diff 卡片也没有 banner,只有页脚。 @@ -32,7 +32,7 @@ chat 行把 diff 常驻渲染在路径链接摘要之下,上限 `CHAT_DIFF_MAX **并排(双栏)diff。** owner 目前拒绝:它更密但不适合狭窄的 chat 行,目标是与 TUI 单栏统一形式对齐。详情面板里的双栏模式是后续的 props 改动,不是重设计。 -**git 式行号槽。** `FileDiff` 契约只携带 `{ path, oldText, newText }` —— `structuredPatch` 的 hunk 起始行在 `diff.ts` 里被丢弃,所以没有行号抵达客户端。渲染行号槽需要后端契约改动(携带 `oldStart`/`newStart`)并同步升级 TUI 以保持一致;推迟,使本 PR 保持为对既有契约的纯 Web 消费。 +**git 式行号槽。** `FileDiff` 契约只携带 `{ path, oldText, newText }` —— `structuredPatch` 的 hunk 起始行在 `diff.ts` 里被丢弃,所以没有行号抵达客户端。渲染行号槽需要后端契约改动(携带 `oldStart`/`newStart`)并同步升级 TUI 以保持一致;推迟,使本 PR(Pull Request)保持为对既有契约的纯 Web 消费。 **复用 `CodeBlock`。** 因与 terminal 卡片相同的理由拒绝:`CodeBlock` 会折行,且没有每行 `+`/`-` 角色、没有路径头、没有页脚。两者共享几何与字体 token,那是唯一一处一个实现对两者都正确的部分。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.i18n.yaml new file mode 100644 index 0000000000..afe776d402 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.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-web-remote-markdown-images.md +2026-07-30-web-remote-markdown-images.md: 23dc699aab87597b7b5bfee83d0d745d4798303e +2026-07-30-web-remote-markdown-images.zh.md: 54db9b25e9f558d77c84bb67c05fadea2c9086ac diff --git a/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.md b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.md new file mode 100644 index 0000000000..23dc699aab --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.md @@ -0,0 +1,29 @@ +# Agent Note: Remote Web Markdown images + +Status: implemented + +English | [中文](2026-07-30-web-remote-markdown-images.zh.md) + +## Problem + +Assistant Markdown can name diagrams and screenshots with standard image syntax, but the Web renderer replaces every image with italic alt text. Even absolute HTTP(S) destinations therefore lose ordinary Markdown behavior. + +## Decision + +`MarkdownText` renders absolute HTTP(S) image destinations as lazy, responsive `` elements with asynchronous decoding and `referrerPolicy="no-referrer"`. Relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain the existing alt-text fallback. Raw HTML stays disabled, so an assistant cannot bypass the Markdown image component with a hand-authored ``. + +The image component reuses the renderer's absolute-URL policy without adding a host proxy, local-file route, Session dependency, sanitizer, or image fetcher. Finalized history, streaming output, interrupted partials, and every other `MarkdownText` consumer receive the same behavior. + +## Alternatives considered + +**Keep all images as alt text.** This preserves the smallest network boundary but defeats the product need to inspect network-hosted visual artifacts inline. + +**Proxy remote images through the host.** A proxy could hide the browser's network address from the image origin, but it would make the host perform arbitrary outbound fetches and require a separate redirect, DNS, size, and content policy. Direct HTTP(S) loading keeps that request visible to browser controls; omitting the referrer limits conversation-origin disclosure. + +**Support local paths in the same change.** Web origins cannot directly load host files. A safe implementation needs a separately reviewed authority boundary, so relative paths, absolute local paths, and `file:` URLs remain disabled. + +**Allow `data:` images.** Large data URLs duplicate binary content into durable transcript text. The HTTP(S)-only policy covers the current need without expanding session logs. + +## Consequences + +Assistant replies display remote images during streaming and replay without changing session events or host protocols. Remote origins still observe the image request, client network address, and any credentials that browser policy permits for that origin. Local and unsupported destinations remain inert alt text. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.zh.md new file mode 100644 index 0000000000..54db9b25e9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Web 中的远程 Markdown 图片 + +Status: implemented + +[English](2026-07-30-web-remote-markdown-images.md) | 中文 + +## 问题 + +assistant Markdown 可以使用标准图片语法引用图表和截图,但 Web 渲染器会把每张图片替换为斜体替代文本。因此,即使目标地址是绝对 HTTP(S) URL,也无法获得普通的 Markdown 图片行为。 + +## 决策 + +`MarkdownText` 将绝对 HTTP(S) 图片目标地址渲染为延迟加载的响应式 `` 元素,并使用异步解码与 `referrerPolicy="no-referrer"`。相对路径、绝对本地路径、`file:` URL 与不支持的协议继续沿用现有的替代文本回退。原始 HTML 保持禁用,因此 assistant 无法通过手写 `` 绕过 Markdown 图片组件。 + +图片组件复用渲染器的绝对 URL 策略,不新增主机代理、本地文件路由、Session 依赖、净化器或图片抓取器。已完成的历史消息、流式输出、被中断的部分输出以及其他所有 `MarkdownText` 消费方均获得同一行为。 + +## 考虑过的替代方案 + +**将所有图片都保留为替代文本。** 这种方案维持了最小的网络边界,但无法满足在行内查看网络托管的视觉产物这一产品需求。 + +**通过主机代理远程图片。** 代理可以向图片源站隐藏浏览器的网络地址,但这会让主机执行任意出站请求,并且需要单独制定重定向、DNS、大小与内容策略。直接加载 HTTP(S) 图片可让浏览器控制机制继续观察该请求;不发送 referrer 可减少对话来源信息的暴露。 + +**在同一变更中支持本地路径。** Web 源无法直接加载主机文件。安全的实现需要单独评审的权限边界,因此相对路径、绝对本地路径与 `file:` URL 保持禁用。 + +**允许 `data:` 图片。** 大型 data URL 会将二进制内容以文本形式重复写入持久化的 transcript(文本记录)。仅允许 HTTP(S) 的策略足以满足当前需求,且不会扩大会话日志。 + +## 后果 + +assistant 回复会在流式输出与回放期间显示远程图片,且不改变会话事件或主机协议。远程源站仍可观察到图片请求、客户端网络地址,以及浏览器策略允许发送给该源站的任何凭据。本地及不支持的目标地址仍只显示不会发起请求的替代文本。 diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md new file mode 100644 index 0000000000..9427dc1d52 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.md @@ -0,0 +1,22 @@ +--- +name: Bug +about: 记录现有预期行为的失效 +title: '' +labels: '' +assignees: '' +type: Bug +--- + + +一句话说明错误结果。 + +
+复现、预期与验收 + +- 复现步骤: +- 实际结果: +- 预期结果: +- 环境: +- 验收条件: + +
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..8005e32267 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,2 @@ +blank_issues_enabled: false +contact_links: [] diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md new file mode 100644 index 0000000000..2c65f5544d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.md @@ -0,0 +1,20 @@ +--- +name: Feature +about: 新增或有意改变可观察行为 +title: '' +labels: '' +assignees: '' +type: Feature +--- + + +一句话说明预期结果。 + +
+验收与细节 + +- 验收条件: +- 用户或模型可见变化: +- 测试证据: + +
diff --git a/.github/ISSUE_TEMPLATE/idea.md b/.github/ISSUE_TEMPLATE/idea.md new file mode 100644 index 0000000000..c8bf80402d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/idea.md @@ -0,0 +1,20 @@ +--- +name: Idea +about: 记录尚未承诺实施、但具有行动可能的想法 +title: '' +labels: '' +assignees: '' +type: Idea +--- + + +一句话说明价值假设。 + +
+价值与细节 + +- 价值假设: +- 需要验证: +- 可能的后续工作: + +
diff --git a/.github/ISSUE_TEMPLATE/research.md b/.github/ISSUE_TEMPLATE/research.md new file mode 100644 index 0000000000..8acfe2cc6e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/research.md @@ -0,0 +1,21 @@ +--- +name: Research +about: 形成结论、证据或决策 +title: '' +labels: '' +assignees: '' +type: Research +--- + + +一句话说明待回答的问题。 + +
+问题与证据标准 + +- 核心问题: +- 证据标准: +- 交付结论: +- 可能的后续工作: + +
diff --git a/.github/ISSUE_TEMPLATE/task.md b/.github/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000000..376855c27c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/task.md @@ -0,0 +1,20 @@ +--- +name: Task +about: 明确的非 Feature、非 Bug 工作 +title: '' +labels: '' +assignees: '' +type: Task +--- + + +一句话说明要完成的工作。 + +
+验收与细节 + +- 验收条件: +- 交付物: +- 测试证据: + +
diff --git a/.github/issue-management/config.json b/.github/issue-management/config.json new file mode 100644 index 0000000000..41019f0aa2 --- /dev/null +++ b/.github/issue-management/config.json @@ -0,0 +1,17 @@ +{ + "organization": "deepseek-harness", + "repository": "deepseek-harness", + "projectNumber": 1, + "projectTitle": "DSH Issue Management", + "priorityField": "Priority", + "allowUnassignedOwner": true, + "statuses": [ + "Inbox", + "Backlog", + "Ready", + "In progress", + "In review", + "Done", + "No action" + ] +} diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs new file mode 100644 index 0000000000..bc8c881eda --- /dev/null +++ b/.github/issue-management/policy.mjs @@ -0,0 +1,545 @@ +#!/usr/bin/env node + +import fs from 'node:fs' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +import config from './config.json' with { type: 'json' } + +const API_VERSION = '2026-03-10' +const BODY_LIMIT = 50 +const AUDIT_MARKER = '' +const OWNER_LINE = /^Owner: @([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)$/ +const TYPES = new Set(['Idea', 'Feature', 'Bug', 'Research', 'Task']) +const PRIORITIES = ['p0', 'p1', 'p2', 'p3'] + +/** + * Return Markdown outside balanced details elements. + * @param {string} body Markdown body. + * @returns {{text: string, balanced: boolean, detailsCount: number, allCollapsed: boolean}} Visible source and details shape. + */ +export function extractOutsideDetails(body) { + const source = body.replace(//g, '') + const tag = /<\/?details\b[^>]*>/gi + let depth = 0 + let cursor = 0 + let balanced = true + let text = '' + let detailsCount = 0 + let allCollapsed = true + + for (const match of source.matchAll(tag)) { + const index = match.index ?? 0 + if (depth === 0) text += source.slice(cursor, index) + if (/^<\//.test(match[0])) { + if (depth === 0) balanced = false + else depth -= 1 + } else { + depth += 1 + detailsCount += 1 + if (/\sopen(?:\s|=|>)/i.test(match[0])) allCollapsed = false + } + cursor = index + match[0].length + } + + if (depth === 0) text += source.slice(cursor) + if (depth !== 0) balanced = false + return { text, balanced, detailsCount, allCollapsed } +} + +/** + * Count Chinese characters and contiguous Latin, numeric, or code tokens. + * @param {string} body Markdown body. + * @returns {{units: number, balanced: boolean, detailsCount: number, allCollapsed: boolean}} Visible unit count and details shape. + */ +export function countVisibleUnits(body) { + const outside = extractOutsideDetails(body) + const visible = outside.text + .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/\[([^\]]+)\]\[[^\]]*\]/g, '$1') + .replace(/<((?:https?:\/\/|mailto:)[^>]+)>/gi, '$1') + .replace(/<[^>]+>/g, ' ') + .replace(/&(?:[A-Za-z]+|#\d+|#x[0-9A-Fa-f]+);/g, ' ') + .replace(/[\u0060*~\[\]{}()<>#!|]/g, ' ') + const han = visible.match(/\p{Script=Han}/gu)?.length ?? 0 + const tokens = visible.match(/[\p{Script=Latin}\p{Number}_./:@+-]+/gu)?.length ?? 0 + return { + units: han + tokens, + balanced: outside.balanced, + detailsCount: outside.detailsCount, + allCollapsed: outside.allCollapsed, + } +} + +function firstNonblankLine(body) { + return body + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) +} + +/** + * Validate body shape and Owner against assignees. + * @param {{body: string, assignees: string[], allowUnassignedOwner?: boolean}} input Body input. + * @returns {string[]} Validation errors. + */ +export function validateBody({ + body, + assignees, + allowUnassignedOwner = config.allowUnassignedOwner ?? false, +}) { + const errors = [] + const count = countVisibleUnits(body) + const owner = firstNonblankLine(body)?.match(OWNER_LINE)?.[1] ?? null + const normalized = [...new Set(assignees.map((login) => login.toLowerCase()))] + + if (!count.balanced) errors.push('details 标签必须成对闭合') + if (count.detailsCount === 0) errors.push('正文必须包含默认收起的
区域') + if (!count.allCollapsed) errors.push('details 必须默认收起,不得设置 open') + if (count.units > BODY_LIMIT) { + errors.push(`正文外露部分为 ${count.units} 单位,超过 50 单位`) + } + if (normalized.length >= 2 && !owner) { + errors.push('多个 Assignees 时首个非空行必须是 Owner: @login') + } else if (normalized.length >= 2 && !normalized.includes(owner.toLowerCase())) { + errors.push('Owner 必须属于 Assignees') + } else if ( + normalized.length < 2 && + owner && + !(normalized.length === 0 && allowUnassignedOwner) + ) { + errors.push('零或一个 Assignee 时不得写 Owner 行') + } + return errors +} + +/** + * Decide whether a PR has entered the human-review enforcement boundary. + * @param {{isDraft: boolean, authorType: string, reviewRequestCount: number, reviewCount: number}} input PR state. + * @returns {boolean} Whether the PR policy is mandatory. + */ +export function requiresPullRequestPolicy({ + isDraft, + authorType, + reviewRequestCount, + reviewCount, +}) { + const automated = authorType === 'Bot' || authorType === 'App' + return !isDraft && !automated && (reviewRequestCount > 0 || reviewCount > 0) +} + +function stripIgnoredMarkdown(body) { + const lines = body.replace(//g, '').split(/\r?\n/) + const kept = [] + let fence = null + for (const line of lines) { + const marker = line.match(/^\s*([\u0060~]{3,})/) + if (marker) { + if (fence === null) fence = marker[1][0] + else if (marker[1][0] === fence) fence = null + continue + } + if (fence === null) kept.push(line) + } + return kept.join('\n').replace(/\u0060[^\u0060]*\u0060/g, ' ') +} + +/** + * Parse same-repository resolving and informational references. + * @param {{body: string, repository: string}} input PR body and repository. + * @returns {{all: number[], resolving: number[], related: number[]}} References. + */ +export function parseReferences({ body, repository }) { + const source = stripIgnoredMarkdown(body) + const expected = repository.toLowerCase() + const all = new Set() + const resolving = new Set() + const reference = + /(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#|#)(\d+)|https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/issues\/(\d+)/gi + const closing = + /\b(?:close(?:s|d)?|fix(?:es|ed)?|resolve(?:s|d)?)\s*:?\s+(?:(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#|#)(\d+)|https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/issues\/(\d+))/gi + + for (const match of source.matchAll(reference)) { + const explicit = (match[1] ?? match[3] ?? '').toLowerCase() + const number = Number(match[2] ?? match[4]) + if (!explicit || explicit === expected) all.add(number) + } + for (const match of source.matchAll(closing)) { + const explicit = (match[1] ?? match[3] ?? '').toLowerCase() + const number = Number(match[2] ?? match[4]) + if (!explicit || explicit === expected) { + all.add(number) + resolving.add(number) + } + } + return { + all: [...all].sort((left, right) => left - right), + resolving: [...resolving].sort((left, right) => left - right), + related: [...all].filter((number) => !resolving.has(number)).sort((a, b) => a - b), + } +} + +/** + * Validate one Issue with its Project status. + * @param {{title: string, body: string, assignees: string[], labels: string[], type: string|null, priority: string|null, status: string|null, state: string, stateReason: string|null}} issue Issue snapshot. + * @returns {string[]} Validation errors. + */ +export function validateIssue(issue) { + const errors = validateBody(issue) + const status = issue.status + + if (!/\p{Script=Han}/u.test(issue.title)) errors.push('Issue 标题必须包含中文') + if ( + /^\s*(?:\[(?:Idea|Feature|Bug|Research|Task|P[0-3]|Inbox|Backlog|Ready|In progress|In review|Done|No action|Owner|area\/[^\]]+)[^\]]*\]|(?:Idea|Feature|Bug|Research|Task|P[0-3]|Inbox|Backlog|Ready|In progress|In review|Done|No action|Owner|area\/[^:: ]+)\s*[::-])/iu.test( + issue.title, + ) + ) { + errors.push('Issue 标题不得带 Type、Priority、Status、area 或 Owner 前缀') + } + if (!TYPES.has(issue.type ?? '')) errors.push('Type 必须是五种原生英文 Type 之一') + if (!status || !config.statuses.includes(status)) errors.push('Issue 必须在 Project 中且具有合法 Status') + if (issue.priority !== null && !PRIORITIES.includes(issue.priority.toLowerCase())) { + errors.push('Priority 必须为空或为 P0–P3') + } + if (status === 'Done' && (issue.state !== 'closed' || issue.stateReason !== 'completed')) { + errors.push('Done 必须对应 Completed 关闭原因') + } + if ( + status === 'No action' && + (issue.state !== 'closed' || issue.stateReason !== 'not_planned') + ) { + errors.push('No action 必须对应 Not planned 关闭原因') + } + if (!['Done', 'No action'].includes(status ?? '') && issue.state !== 'open') { + errors.push(`${status} 必须对应开放 Issue`) + } + return errors +} + +/** + * Validate PR metadata and its referenced Issues. + * @param {{authorType: string, labels: string[], references: ReturnType, issues: Map}} input PR snapshot. + * @returns {string[]} Validation errors. + */ +export function validatePullRequest(input) { + if (!requiresPullRequestPolicy(input)) return [] + const errors = [] + const kinds = input.labels.filter((label) => label.startsWith('kind/')) + const priorities = input.labels.filter((label) => PRIORITIES.includes(label)) + const areas = input.labels.filter((label) => label.startsWith('area/')) + + if (input.references.all.length === 0) errors.push('PR 正文必须引用至少一个同仓库 Issue') + if (kinds.length !== 1) errors.push(`PR 必须恰好有一个 kind/*,当前为 ${kinds.length}`) + if (priorities.length > 1) errors.push(`PR 最多有一个 p0–p3,当前为 ${priorities.length}`) + if (areas.length === 0) errors.push('PR 必须至少有一个 area/*') + for (const number of input.references.all) { + if (!input.issues.has(number)) errors.push(`#${number} 不是同仓库 Issue`) + } + + const resolving = input.references.resolving + .map((number) => [number, input.issues.get(number)]) + .filter((entry) => entry[1]) + if (resolving.length === 0) return errors + + const issuePriorities = resolving + .map(([, issue]) => issue.priority?.toLowerCase()) + .filter((priority) => PRIORITIES.includes(priority)) + if (priorities.length === 0 && issuePriorities.length > 0) { + const highest = issuePriorities.sort( + (left, right) => PRIORITIES.indexOf(left) - PRIORITIES.indexOf(right), + )[0] + errors.push(`PR Priority 应为 ${highest}`) + } else if (priorities.length === 1 && issuePriorities.length !== resolving.length) { + errors.push('有 Priority 的解决型 PR 要求每个被解决 Issue 都设置 Priority') + } else if (priorities.length === 1) { + const highest = issuePriorities.sort( + (left, right) => PRIORITIES.indexOf(left) - PRIORITIES.indexOf(right), + )[0] + if (priorities[0] !== highest) errors.push(`PR Priority 应为 ${highest}`) + } + return errors +} + +function token() { + const value = process.env.GH_TOKEN || process.env.GITHUB_TOKEN + if (!value) throw new Error('GH_TOKEN 或 GITHUB_TOKEN 未设置') + return value +} + +async function api(path, options = {}) { + const response = await fetch(`${process.env.GITHUB_API_URL ?? 'https://api.github.com'}${path}`, { + ...options, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token()}`, + 'X-GitHub-Api-Version': API_VERSION, + 'User-Agent': 'dsh-issue-policy', + ...options.headers, + }, + }) + if (options.allow404 && response.status === 404) return null + if (!response.ok) { + const body = await response.text() + throw new Error(`${options.method ?? 'GET'} ${path}: ${response.status} ${body}`) + } + if (response.status === 204) return null + return response.json() +} + +async function graphql(query, variables) { + const result = await api('/graphql', { + method: 'POST', + body: JSON.stringify({ query, variables }), + headers: { 'Content-Type': 'application/json' }, + }) + if (result.errors?.length) throw new Error(result.errors.map((error) => error.message).join('; ')) + return result.data +} + +async function issueSnapshot(number, status = undefined) { + const issue = await api(`/repos/${config.organization}/${config.repository}/issues/${number}`) + if (issue.pull_request) return null + const values = await api( + `/repos/${config.organization}/${config.repository}/issues/${number}/issue-field-values?per_page=100`, + ) + const field = (name) => values.find((value) => value.issue_field_name === name) + return { + number, + nodeId: issue.node_id, + title: issue.title, + body: issue.body ?? '', + assignees: issue.assignees.map((assignee) => assignee.login), + labels: issue.labels.map((label) => label.name), + type: issue.type?.name ?? null, + priority: field(config.priorityField)?.single_select_option?.name ?? null, + status: status === undefined ? await projectStatus(number) : status, + state: issue.state, + stateReason: issue.state_reason ?? null, + } +} + +async function projectContext(number) { + const data = await graphql( + `query($organization: String!, $repository: String!, $number: Int!, $project: Int!) { + organization(login: $organization) { + projectV2(number: $project) { + id + title + fields(first: 50) { + nodes { + ... on ProjectV2SingleSelectField { id name options { id name } } + } + } + } + } + repository(owner: $organization, name: $repository) { + issue(number: $number) { + id + projectItems(first: 20, includeArchived: true) { + nodes { + id + project { id } + fieldValueByName(name: "Status") { + ... on ProjectV2ItemFieldSingleSelectValue { name optionId } + } + } + } + } + } + }`, + { + organization: config.organization, + repository: config.repository, + number, + project: config.projectNumber, + }, + ) + const project = data.organization?.projectV2 + const issue = data.repository?.issue + if (!project || project.title !== config.projectTitle) throw new Error('目标 Project 不存在或标题不匹配') + if (!issue) throw new Error(`#${number} 不存在`) + const statusField = project.fields.nodes.find((field) => field?.name === 'Status') + if (!statusField) throw new Error('Project 缺少 Status 字段') + const item = issue.projectItems.nodes.find((candidate) => candidate.project.id === project.id) + return { project, issue, statusField, item } +} + +async function projectStatus(number) { + const context = await projectContext(number) + return context.item?.fieldValueByName?.name ?? null +} + +async function ensureProjectItem(number) { + const context = await projectContext(number) + if (context.item) return context + const data = await graphql( + `mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { + item { id } + } + }`, + { projectId: context.project.id, contentId: context.issue.id }, + ) + return { + ...context, + item: { id: data.addProjectV2ItemById.item.id, fieldValueByName: null }, + } +} + +async function setStatus(number, status) { + const context = await ensureProjectItem(number) + const option = context.statusField.options.find((candidate) => candidate.name === status) + if (!option) throw new Error(`Status 不存在:${status}`) + if (context.item.fieldValueByName?.name === status) return + await graphql( + `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, + itemId: $itemId, + fieldId: $fieldId, + value: {singleSelectOptionId: $optionId} + }) { projectV2Item { id } } + }`, + { + projectId: context.project.id, + itemId: context.item.id, + fieldId: context.statusField.id, + optionId: option.id, + }, + ) +} + +async function upsertAudit(number, errors) { + const comments = await api( + `/repos/${config.organization}/${config.repository}/issues/${number}/comments?per_page=100`, + ) + const existing = comments.find( + (comment) => comment.user?.type === 'Bot' && comment.body?.includes(AUDIT_MARKER), + ) + if (errors.length === 0) { + if (existing) { + await api(`/repos/${config.organization}/${config.repository}/issues/comments/${existing.id}`, { + method: 'DELETE', + }) + } + return + } + const body = `${AUDIT_MARKER}\n⚠️ Issue policy 未通过:\n\n${errors.map((error) => `- ${error}`).join('\n')}` + if (existing) { + if (existing.body === body) return + await api(`/repos/${config.organization}/${config.repository}/issues/comments/${existing.id}`, { + method: 'PATCH', + body: JSON.stringify({ body }), + headers: { 'Content-Type': 'application/json' }, + }) + } else { + await api(`/repos/${config.organization}/${config.repository}/issues/${number}/comments`, { + method: 'POST', + body: JSON.stringify({ body }), + headers: { 'Content-Type': 'application/json' }, + }) + } +} + +async function auditIssue(number, extraErrors = [], status = undefined) { + const issue = await issueSnapshot(number, status) + if (!issue) return [] + const errors = [...extraErrors, ...validateIssue(issue)] + await upsertAudit(number, errors) + return errors +} + +async function pullRequestSnapshot(number) { + const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`) + const [reviewRequests, reviews] = await Promise.all([ + api(`/repos/${config.organization}/${config.repository}/pulls/${number}/requested_reviewers`), + api(`/repos/${config.organization}/${config.repository}/pulls/${number}/reviews?per_page=100`), + ]) + const references = parseReferences({ + body: pull.body ?? '', + repository: `${config.organization}/${config.repository}`, + }) + const issues = new Map() + for (const issueNumber of references.all) { + const issue = await issueSnapshot(issueNumber, null) + if (issue) issues.set(issueNumber, issue) + } + return { + number, + isDraft: pull.draft, + authorType: pull.user?.type ?? 'User', + reviewRequestCount: reviewRequests.users.length + reviewRequests.teams.length, + reviewCount: reviews.length, + labels: pull.labels.map((label) => label.name), + references, + issues, + } +} + +async function moveResolvingIssues(pull, from, to) { + for (const number of pull.references.resolving) { + const current = await issueSnapshot(number) + if (!current || current.status !== from) continue + await setStatus(number, to) + await auditIssue(number) + } +} + +async function runPullRequestCheck(event) { + const pull = await pullRequestSnapshot(event.pull_request.number) + const errors = validatePullRequest(pull) + if (errors.length > 0) { + for (const error of errors) process.stdout.write(`::error::${error}\n`) + throw new Error(`Issue policy 未通过,共 ${errors.length} 项`) + } + process.stdout.write( + requiresPullRequestPolicy(pull) ? 'Issue policy 通过。\n' : 'PR 尚未进入 Issue policy 强制范围。\n', + ) +} + +async function runLifecycle(eventName, event) { + if (eventName === 'issues') { + const number = event.issue.number + if (event.action === 'opened') await setStatus(number, 'Inbox') + if (event.action === 'closed') { + const target = event.issue.state_reason === 'not_planned' ? 'No action' : 'Done' + await setStatus(number, target) + } + if (event.action === 'reopened') { + await setStatus(number, 'Inbox') + } + await ensureProjectItem(number) + await auditIssue(number) + return + } + + if (eventName === 'pull_request' || eventName === 'pull_request_review') { + const pull = await pullRequestSnapshot(event.pull_request.number) + const errors = validatePullRequest(pull) + if (errors.length > 0) return + await moveResolvingIssues(pull, 'Ready', 'In progress') + if (pull.reviewRequestCount > 0 || pull.reviewCount > 0) { + await moveResolvingIssues(pull, 'In progress', 'In review') + } + } +} + +function readEvent() { + if (!process.env.GITHUB_EVENT_PATH) throw new Error('GITHUB_EVENT_PATH 未设置') + return JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')) +} + +async function main(argv) { + const [command] = argv + if (command === 'pr') await runPullRequestCheck(readEvent()) + else if (command === 'lifecycle') await runLifecycle(process.env.GITHUB_EVENT_NAME, readEvent()) + else throw new Error('用法:policy.mjs pr|lifecycle') +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)).catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 + }) +} diff --git a/.github/issue-management/policy.test.mjs b/.github/issue-management/policy.test.mjs new file mode 100644 index 0000000000..890247db29 --- /dev/null +++ b/.github/issue-management/policy.test.mjs @@ -0,0 +1,240 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + countVisibleUnits, + parseReferences, + requiresPullRequestPolicy, + validateBody, + validateIssue, + validatePullRequest, +} from './policy.mjs' + +const withDetails = (summary) => + `${summary}\n\n
验收与细节待补充。
` + +const legalIssue = { + title: '完成议题管理校验', + body: withDetails('完成议题管理校验。'), + assignees: [], + labels: [], + type: 'Idea', + priority: null, + status: 'In review', + state: 'open', + stateReason: null, +} + +test('counts only text outside details', () => { + assert.deepEqual(countVisibleUnits('支持 GitHub Project。
隐藏文字
'), { + units: 4, + balanced: true, + detailsCount: 1, + allCollapsed: true, + }) +}) + +test('requires a balanced default-collapsed details region', () => { + assert.deepEqual(validateBody({ body: '完成工作。', assignees: [] }), [ + '正文必须包含默认收起的
区域', + ]) + assert.deepEqual( + validateBody({ + body: '完成工作。\n\n
细节待补充。
', + assignees: [], + }), + ['details 必须默认收起,不得设置 open'], + ) + assert.deepEqual( + validateBody({ body: '完成工作。\n\n
细节', assignees: [] }), + ['details 标签必须成对闭合'], + ) +}) + +test('requires Owner for multiple assignees', () => { + assert.deepEqual( + validateBody({ + body: withDetails('完成工作。'), + assignees: ['tianyicui', 'tianyicui-bot'], + }), + ['多个 Assignees 时首个非空行必须是 Owner: @login'], + ) +}) + +test('accepts an intended Owner while assignment permission is pending', () => { + assert.deepEqual( + validateBody({ + body: withDetails('Owner: @octocat\n\n完成工作。'), + assignees: [], + }), + [], + ) + assert.deepEqual( + validateBody({ + body: withDetails('Owner: @octocat\n\n完成工作。'), + assignees: ['hubot'], + }), + ['零或一个 Assignee 时不得写 Owner 行'], + ) +}) + +test('allows optional metadata in every open Status', () => { + assert.deepEqual(validateIssue(legalIssue), []) + for (const status of ['Inbox', 'Backlog', 'Ready', 'In progress', 'In review']) { + assert.deepEqual(validateIssue({ ...legalIssue, status }), []) + } +}) + +test('rejects metadata prefixes in an Issue title', () => { + const errors = validateIssue({ ...legalIssue, title: '[Bug] 修复恢复错误' }) + assert.ok(errors.includes('Issue 标题不得带 Type、Priority、Status、area 或 Owner 前缀')) +}) + +test('keeps terminal Status aligned with the native close reason', () => { + assert.deepEqual( + validateIssue({ ...legalIssue, status: 'Done', state: 'closed', stateReason: 'completed' }), + [], + ) + assert.deepEqual( + validateIssue({ + ...legalIssue, + status: 'No action', + state: 'closed', + stateReason: 'not_planned', + }), + [], + ) + assert.ok(validateIssue({ ...legalIssue, status: 'Done' }).includes('Done 必须对应 Completed 关闭原因')) +}) + +test('separates resolving and informational references', () => { + assert.deepEqual( + parseReferences({ + body: 'Fixes #12\nRelated to #4\nRefs deepseekharness/dsh-test#7', + repository: 'deepseekharness/dsh-test', + }), + { all: [4, 7, 12], resolving: [12], related: [4, 7] }, + ) +}) + +test('allows informational references without cross-object constraints', () => { + const errors = validatePullRequest({ + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + labels: ['kind/cleanup', 'area/infra'], + references: { all: [4], resolving: [], related: [4] }, + issues: new Map([[4, { type: 'Bug', priority: 'P0', labels: ['area/web'] }]]), + }) + assert.deepEqual(errors, []) +}) + +test('enforces highest resolving Priority without Type or area synchronization', () => { + const pull = { + isDraft: false, + authorType: 'User', + reviewRequestCount: 0, + reviewCount: 1, + labels: ['kind/cleanup', 'p0', 'area/web'], + references: { all: [2, 3], resolving: [2, 3], related: [] }, + issues: new Map([ + [2, { type: 'Feature', priority: 'P2', labels: ['area/web'] }], + [3, { type: 'Bug', priority: 'P0', labels: ['area/session'] }], + ]), + } + assert.deepEqual(validatePullRequest(pull), []) + assert.ok( + validatePullRequest({ ...pull, labels: ['kind/cleanup', 'p2', 'area/web'] }).includes( + 'PR Priority 应为 p0', + ), + ) +}) + +test('requires policy only after a human PR enters review', () => { + assert.equal( + requiresPullRequestPolicy({ + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + }), + true, + ) + assert.equal( + requiresPullRequestPolicy({ + isDraft: false, + authorType: 'User', + reviewRequestCount: 0, + reviewCount: 0, + }), + false, + ) +}) + +test('exempts Draft, Bot, and App PRs', () => { + const invalid = { + isDraft: false, + labels: [], + references: { all: [], resolving: [], related: [] }, + issues: new Map(), + reviewRequestCount: 1, + reviewCount: 0, + } + assert.deepEqual(validatePullRequest({ ...invalid, authorType: 'Bot' }), []) + assert.deepEqual(validatePullRequest({ ...invalid, authorType: 'App' }), []) + assert.deepEqual(validatePullRequest({ ...invalid, authorType: 'User', isDraft: true }), []) + assert.ok(validatePullRequest({ ...invalid, authorType: 'User' }).length > 0) +}) + +test('requires repository PR labels in the enforcement scope', () => { + const errors = validatePullRequest({ + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + labels: [], + references: { all: [2], resolving: [], related: [2] }, + issues: new Map([[2, { priority: null }]]), + }) + assert.ok(errors.includes('PR 必须恰好有一个 kind/*,当前为 0')) + assert.ok(errors.includes('PR 必须至少有一个 area/*')) +}) + +test('accepts repository-extensible kind labels', () => { + assert.deepEqual( + validatePullRequest({ + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + labels: ['kind/dependency', 'area/infra'], + references: { all: [2], resolving: [], related: [2] }, + issues: new Map([[2, { priority: null }]]), + }), + [], + ) +}) + +test('allows missing Priority only when resolving Issues are also unprioritized', () => { + const pull = { + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + labels: ['kind/feature', 'area/web'], + references: { all: [2], resolving: [2], related: [] }, + issues: new Map([[2, { priority: null }]]), + } + assert.deepEqual(validatePullRequest(pull), []) + assert.ok( + validatePullRequest({ ...pull, issues: new Map([[2, { priority: 'P2' }]]) }).includes( + 'PR Priority 应为 p2', + ), + ) + assert.ok( + validatePullRequest({ ...pull, labels: [...pull.labels, 'p2'] }).includes( + '有 Priority 的解决型 PR 要求每个被解决 Issue 都设置 Priority', + ), + ) +}) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..e960016ae8 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ + + + + +关联 Issue: + +
+变更与验证 + +- 变更: +- 验证: + +
diff --git a/apps/cli/tests/fixtures/tui-scripted-llm.ts b/apps/cli/tests/fixtures/tui-scripted-llm.ts index 79c4f0504a..c9bd2e1fe6 100644 --- a/apps/cli/tests/fixtures/tui-scripted-llm.ts +++ b/apps/cli/tests/fixtures/tui-scripted-llm.ts @@ -117,8 +117,19 @@ class ScriptedTuiAdapter extends LlmAdapter { const blocks = lastMessage?.content ?? [] if (blocks.some(block => block.type === 'tool-result')) { - const answered = blocks.some(block => block.type === 'tool-result' && block.toolCallId === BASH_FAILURE_CALL_ID) - for (const chunk of textChunks(answered ? BASH_FAILURE_TEXT : FINAL_TEXT)) yield chunk + const answeredBash = blocks.some(block => + block.type === 'tool-result' && block.toolCallId === BASH_FAILURE_CALL_ID) + if (answeredBash) { + for (const chunk of textChunks(BASH_FAILURE_TEXT)) yield chunk + return + } + const toolResultText = blocks.flatMap(block => block.type === 'tool-result' + ? block.content.flatMap(content => content.type === 'text' ? [content.text] : []) + : []).join('\n') + if (toolResultText !== '{"answers":[{"id":"mode","selected":["Safe"],"custom":"Release notes"}]}') { + throw new Error(`the scripted TUI request received an unexpected question answer: ${toolResultText}`) + } + for (const chunk of textChunks(FINAL_TEXT)) yield chunk return } if (lastText.includes(BASH_FAILURE_PROBE)) { @@ -140,6 +151,7 @@ class ScriptedTuiAdapter extends LlmAdapter { id: 'mode', header: 'Execution mode', question: 'How should the scripted run proceed?', + multi_select: true, options: [ { label: 'Safe', description: 'Use the guarded path.' }, { label: 'Fast', description: 'Use the shorter path.' }, diff --git a/apps/cli/tests/snapshots/queued-manual-compact/terminal.expected.txt b/apps/cli/tests/snapshots/queued-manual-compact/terminal.expected.txt index 0df46a7018..1d9c7048c6 100644 --- a/apps/cli/tests/snapshots/queued-manual-compact/terminal.expected.txt +++ b/apps/cli/tests/snapshots/queued-manual-compact/terminal.expected.txt @@ -1,7 +1,7 @@ -terminal 100x36 buffer=normal length=66 base=30 viewport=30 +terminal 100x36 buffer=normal length=68 base=32 viewport=32 lifecycle started=1 stopped=0 progress=inactive title "Reply with exactly the word: — DSH TUI snapshot" -cursor hidden column=7 viewportRow=35 bufferRow=65 +cursor hidden column=7 viewportRow=35 bufferRow=67 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -73,56 +73,60 @@ buffer style 0-64 dim 37| "/compact — Compact older conversation history " style 0-44 dim -38| "/exit — Exit after the active turn reaches idle " +38| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Select tool-card visibility and " + style 0-99 dim +39| "reasoning display " + style 0-16 dim +40| "/exit — Exit after the active turn reaches idle " style 0-46 dim -39| "/help — Show keyboard shortcuts and commands " +41| "/help — Show keyboard shortcuts and commands " style 0-43 dim -40| "/model [[provider/]model] — Show or switch this session's model " +42| "/model [[provider/]model] — Show or switch this session's model " style 0-62 dim -41| "/palette — Show every color and attribute role this terminal renders " +43| "/palette — Show every color and attribute role this terminal renders " style 0-67 dim -42| "/quit — Exit after the active turn reaches idle " +44| "/quit — Exit after the active turn reaches idle " style 0-46 dim -43| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " +45| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " style 0-87 dim -44| "/resume — List this workspace's resumable sessions " +46| "/resume — List this workspace's resumable sessions " style 0-49 dim -45| "/status — Show session diagnostics, system prompt, and registered tools " +47| "/status — Show session diagnostics, system prompt, and registered tools " style 0-70 dim -46| "/skill: [instructions] — load a skill into the conversation " +48| "/skill: [instructions] — load a skill into the conversation " style 0-64 dim -47| -48| "Context · snapshot-injector" +49| +50| "Context · snapshot-injector" style 0-26 dim -49| "Injected while compaction was running. " +51| "Injected while compaction was running. " style 0-37 dim -50| -51| "… earlier context was compacted … " - style 0-32 dim 52| -53| "You " +53| "… earlier context was compacted … " + style 0-32 dim +54| +55| "You " style 0-2 fg=bright-magenta bold underline -54| "Reply with exactly the word: TWO. No tools. " -55| -56| "Compacted 2 history items (~387 tokens). " - style 0-39 dim +56| "Reply with exactly the word: TWO. No tools. " 57| -58| "Assistant " +58| "Compacted 2 history items (~387 tokens). " + style 0-39 dim +59| +60| "Assistant " style 0-8 fg=bright-magenta bold underline -59| "Reasoning " +61| "Reasoning " style 0-8 dim italic -60| "The user wants me to reply with exactly the word \"TWO\" and no tools. " +62| "The user wants me to reply with exactly the word \"TWO\" and no tools. " style 0-67 dim italic -61| "TWO " -62| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " +63| "TWO " +64| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim -63| -64| "/workspace/project deepseek-v4-flash ↑2.9k ↓41 cache 49% 3% cont" +65| +66| "/workspace/project deepseek-v4-flash ↑2.9k ↓41 cache 49% 3% cont" style 0-49 fg=bright-magenta bold style 52-68 dim style 71-90 dim style 93-99 dim -65| " dsh ◍ " +67| " dsh ◍ " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 70c9c0fa65..17b33f37ce 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -259,6 +259,7 @@ const SELECT_PRO_MODEL = [ { waitFor: 'scripted TUI ready.', send: '/model\r' }, { waitFor: 'Select model', send: '\x1b[B\x1b[Z\r' }, ] as const +const ANSWER_MULTI_WITH_CUSTOM = ' \tRelease notes\r' describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { it.each([ @@ -450,7 +451,10 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { // The question text first appears in the streamed tool-call card. Wait // for the dialog's input legend so Enter cannot arrive before it owns // terminal input when pre-dispatch policy yields. - { waitFor: 'Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt', send: '\r' }, + { + waitFor: 'Tab custom answer • ↑/↓ navigate • Space toggle • Enter submit • Esc interrupt', + send: ANSWER_MULTI_WITH_CUSTOM, + }, { waitFor: 'Decision received. Scripted TUI run complete.', send: '' }, // Session title: the first user message drives the first-message-llm // provider's tool-less title call; the scripted adapter answers it, the @@ -476,6 +480,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { expect(output).not.toContain('\u001B[999CMODEL_CURSOR') expect(output).not.toContain('\u009B31mMODEL_C1') expect(output).toContain('Safe') + expect(output).toContain('Release notes') expect(output).toContain('\u001B]0;scripted session title — DeepSeek Harness\u0007') expect(output).toContain('Session status') expect(output).toContain('Title') @@ -861,7 +866,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { actions: [ ...SELECT_PRO_MODEL, { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: 'exercise the TUI\r' }, - { waitFor: 'How should the scripted run proceed?', send: '\r' }, + { waitFor: 'How should the scripted run proceed?', send: ANSWER_MULTI_WITH_CUSTOM }, { waitFor: 'Decision received. Scripted TUI run complete.', send: '/exit\r' }, ], inspect: async (cwd) => { context = await readLoggedRequestContext(cwd) }, diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 707d7b72a0..bdf0d4f7d1 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -6,10 +6,10 @@ // layers, per-plugin CSS injection, and a rendered journey reaching chat // content from the keyless FixtureApiClient transport. // -// Behavior assertions do NOT belong here: component and wiring behavior is -// pinned by the per-package suites (SlotTestRuntime benches over src), which -// this smoke's plugin set cannot influence — bundling, module-table -// resolution, and boot layering are the only failure modes left to it. +// Component behavior remains owned by per-package suites (SlotTestRuntime +// benches over src). This smoke additionally pins the resident approval +// fixture's cross-plugin projection because only the built connection/runtime/ +// workspace graph can prove that transport-to-row path end to end. import { readFileSync } from 'node:fs' import { join } from 'node:path' import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' @@ -105,8 +105,17 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) await within(tree).findByText('4 sessions') + // The resident approval fixture proves the assembled workspace plugin + // distinguishes a blocked running session from an ordinarily busy one. + const waitingTitle = await within(tree).findByText('Fixture 历史会话') + const waitingRow = waitingTitle.closest('[role="treeitem"]') + if (waitingRow === null) throw new Error('fixture Session title must belong to a tree row') + expect(waitingRow.querySelector('[data-state="warning"]')).not.toBeNull() + expect(waitingRow.querySelector('[data-state="ongoing"]')).toBeNull() + within(waitingRow).getByText('Waiting for approval') + // Opening a session reaches chat content through the fixture transport. - fireEvent.click(await within(tree).findByText('Fixture 历史会话')) + fireEvent.click(waitingTitle) await waitFor(() => { expect(document.querySelector('[data-sample="bash"]')).not.toBeNull() }, { timeout: 10_000 }) diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts new file mode 100644 index 0000000000..8dce7f405b --- /dev/null +++ b/apps/web/tests/markdown-images.e2e.ts @@ -0,0 +1,205 @@ +// Web e2e scenario: absolute HTTP(S) Markdown images. A validated session +// assembled through the Session API is seeded cold into the real web +// composition, then a separate image origin proves that the browser receives +// a real network image while local-path Markdown remains inert alt text. +import { createServer, type Server } from 'node:http' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { + SESSION_FORMAT_VERSION, + Session, + SessionId, +} from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-title' +import { + assertFixtureInventory, + captureStableAria, + compareOrRefreshGolden, + launchWebScaffold, + seedSession, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/markdown-images', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./snapshots/markdown-images/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'markdown-images-web-e2e' +const REMOTE_ALT = 'Remote test image' +const LOCAL_ALT = 'Local test image' +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', +) + +interface ImageOrigin { + server: Server + url: string + requests: Array<{ path: string | undefined; referer: string | undefined }> +} + +/** Start the deterministic remote image origin used by this browser scenario. */ +async function startImageOrigin(): Promise { + const requests: ImageOrigin['requests'] = [] + const server = createServer((request, response) => { + requests.push({ path: request.url, referer: request.headers.referer }) + response.writeHead(200, { + 'cache-control': 'no-store', + 'content-length': PNG.length, + 'content-type': 'image/png', + }) + response.end(PNG) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('image origin did not expose an IP socket') + } + return { + server, + url: `http://127.0.0.1:${String(address.port)}/image.png`, + requests, + } +} + +/** Stop one image origin after the browser and host release their requests. */ +async function stopServer(server: Server): Promise { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error === undefined) resolve() + else reject(error) + }) + }) +} + +/** Build one closed, invariant-checked session fixture with remote and local image Markdown. */ +function markdownImageFixture(remoteUrl: string): string { + const session = new Session(SessionId('markdown-image-source')) + session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Show the Markdown image policy.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('session/title', { + title: 'Markdown image policy', + messageSeqs: [user.seq], + source: { kind: 'fallback' }, + }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ + type: 'text', + text: [ + '## Markdown images', + '', + `![${REMOTE_ALT}](${remoteUrl})`, + '', + `![${LOCAL_ALT}](./local-image.png)`, + '', + 'REMOTE_IMAGE_DONE', + ].join('\n'), + }], + source: { kind: 'model', provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + const header = { + type: 'session', + version: SESSION_FORMAT_VERSION, + id: '{{sessionId}}', + createdAt: 0, + cwd: '{{cwd}}', + } + return [ + JSON.stringify(header), + ...session.events.map(event => JSON.stringify(event)), + '', + ].join('\n') +} + +describe('web e2e: remote Markdown image rendering', () => { + let scaffold: WebScaffold + let imageOrigin: ImageOrigin + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + imageOrigin = await startImageOrigin() + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, markdownImageFixture(imageOrigin.url), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + await stopServer(imageOrigin.server) + }) + + it.skipIf(MODE === 'record')('loads only the remote image and matches the conversation golden', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-images')) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await expect.poll(() => page.getByText('REMOTE_IMAGE_DONE', { exact: true }).count(), { + timeout: 15_000, + }).toBe(1) + + const image = page.getByRole('img', { name: REMOTE_ALT }) + await image.waitFor({ timeout: 10_000 }) + await expect.poll(() => image.evaluate(element => (element as HTMLImageElement).naturalWidth), { + timeout: 10_000, + }).toBeGreaterThan(0) + expect(await image.evaluate((element) => { + const computed = getComputedStyle(element) + return { + borderRadius: computed.borderRadius, + decoding: element.getAttribute('decoding'), + loading: element.getAttribute('loading'), + maxWidth: computed.maxWidth, + referrerPolicy: element.getAttribute('referrerpolicy'), + } + })).toEqual({ + borderRadius: '8px', + decoding: 'async', + loading: 'lazy', + maxWidth: '100%', + referrerPolicy: 'no-referrer', + }) + expect(await page.getByRole('img', { name: LOCAL_ALT }).count()).toBe(0) + expect(await page.getByText(LOCAL_ALT, { exact: true }).count()).toBe(1) + expect(imageOrigin.requests).toEqual([{ path: '/image.png', referer: undefined }]) + + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }, 60_000) +}) diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index 238257a9ae..aac2c4806c 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -1,7 +1,7 @@ -// Web e2e scenario: message IconActions + clocks. Cold-seeds the seeded-history -// fixture (zero model calls) and pins the settled conversation aria after the -// user/assistant footers are focus-revealed — the surface package jsdom tests -// cannot substitute for (docs/testing.md snapshot rule). +// Web e2e scenario: message IconActions + clocks. Cold-seeds a deterministic +// completed-turn-tail fork case (zero model calls) and pins the settled +// conversation aria after the footers are focus-revealed — the surface package +// jsdom tests cannot substitute for (docs/testing.md snapshot rule). import { mkdir, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -25,6 +25,48 @@ const MODE = webSnapshotMode() const SEED_ID = 'message-actions-web-e2e' const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.' +const MID_TURN_TEXT = 'I will read both files before answering.' +const SECOND_PROMPT = 'Now give the final answer.' + +/** + * Adapt the borrowed recording into response -> tools -> interrupted Think, + * followed by one ordinary completed response. The first response keeps + * copy/clock but is not a legal branch point; the second is the real turn tail. + * @param raw - Recorded seeded-history JSONL. + * @returns A contiguous, closed two-turn fixture. + */ +function completedTailFixture(raw: string): string { + const kept: string[] = [] + for (const line of raw.trimEnd().split('\n')) { + const row = JSON.parse(line) as { + type: string + seq?: number + seq0?: number + data?: { content?: unknown[] } + } + const firstSeq = row.seq ?? row.seq0 + if (firstSeq !== undefined && firstSeq >= 101) break + if (row.type === 'assistant/message' && row.seq === 64) { + const content = row.data?.content + if (!Array.isArray(content)) throw new Error('borrowed step-one assistant message has no content') + content.splice(1, 0, { type: 'text', text: MID_TURN_TEXT }) + kept.push(JSON.stringify(row)) + } else { + kept.push(line) + } + } + const tail = [ + { type: 'step/end', seq: 101, time: 1784974102749, data: { turn: 1, step: 2 } }, + { type: 'turn/end', seq: 102, time: 1784974102750, data: { turn: 1, reason: { kind: 'aborted' } } }, + { type: 'turn/start', seq: 103, time: 1784974103000, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user', rpcId: '{{rpcId}}' } } } }, + { type: 'user/message', seq: 104, time: 1784974103001, data: { content: [{ type: 'text', text: SECOND_PROMPT }], source: { kind: 'user', rpcId: '{{rpcId}}' } }, surfaceOp: 'append' }, + { type: 'step/start', seq: 105, time: 1784974103002, data: { turn: 2, step: 1 } }, + { type: 'assistant/message', seq: 106, time: 1784974103003, data: { turn: 2, step: 1, content: [{ type: 'text', text: 'DONE' }], provenance: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }, sourceEventSeqs: [], surfaceOp: 'append' }, + { type: 'step/end', seq: 107, time: 1784974103004, data: { turn: 2, step: 1 } }, + { type: 'turn/end', seq: 108, time: 1784974103005, data: { turn: 2, reason: { kind: 'completed' } } }, + ] + return `${[...kept, ...tail.map(row => JSON.stringify(row))].join('\n')}\n` +} describe('web e2e: message IconActions and clocks on settled history', () => { let scaffold: WebScaffold @@ -38,8 +80,8 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await mkdir(sessionCwd, { recursive: true }) await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n') await writeFile(join(sessionCwd, 'b.txt'), 'beta\n') - const raw = await readFile(SEED, 'utf8') - expect(fixtureUserPrompts(raw), 'borrowed seed must carry the drive prompt').toEqual([PROMPT]) + const raw = completedTailFixture(await readFile(SEED, 'utf8')) + expect(fixtureUserPrompts(raw), 'adapted seed must carry both prompts').toEqual([PROMPT, SECOND_PROMPT]) await seedSession(scaffold, raw, SEED_ID) browser = await chromium.launch() page = await newEnglishPage(browser) @@ -53,7 +95,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await scaffold?.close() }) - it.skipIf(MODE === 'record')('lists the seeded session and reveals user/assistant IconActions', async () => { + it.skipIf(MODE === 'record')('enables branch only on the completed transcript tail', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions')) const groupRow = page.locator('[role="treeitem"]').first() await groupRow.waitFor({ timeout: 15_000 }) @@ -61,16 +103,24 @@ describe('web e2e: message IconActions and clocks on settled history', () => { const sessionRow = page.locator('[role="treeitem"]').nth(1) await sessionRow.waitFor({ timeout: 10_000 }) await sessionRow.click() + await expect.poll(() => page.getByText(MID_TURN_TEXT, { exact: true }).count(), { timeout: 15_000 }).toBe(1) await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) // Focus-reveal the footers (hover:hover keeps them opacity-hidden until - // hover/focus-within). User and each turn's last content assistant both - // have copy + branch. + // hover/focus-within). Every durable message footer keeps branch visible, + // but only the final assistant at a completed transcript tail enables it. const copyButtons = page.getByRole('button', { name: 'Copy' }) - await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) + await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(4) await copyButtons.first().focus() - await expect.poll(() => page.getByRole('button', { name: 'Branch into a new conversation' }).count(), { timeout: 5_000 }) - .toBeGreaterThanOrEqual(2) + const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' }) + await expect.poll(() => branchButtons.count(), { timeout: 5_000 }).toBe(4) + await expect.poll( + () => branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled'))), + { timeout: 5_000 }, + ).toEqual(['true', 'true', 'true', null]) + await branchButtons.first().focus() + await expect.poll(() => page.getByRole('tooltip').textContent(), { timeout: 5_000 }) + .toBe('Available only on the last message of a completed turn') await expect.poll(() => page.getByRole('button', { name: 'Edit' }).count(), { timeout: 5_000 }).toBe(0) }, 60_000) @@ -88,8 +138,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { it.skipIf(MODE === 'record')('forks through the settled-message and session-row actions', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-fork')) - // Exercise the assistant action specifically; package coverage pins the - // user action separately at its own event seq. + // The last message action belongs to the completed second-turn assistant. await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click() await expect.poll( () => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SEED_ID)), diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 37f6964c73..ebbaf759c4 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -23,15 +23,16 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/question-composer', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') -// Second golden: the answered transcript — the question resolved into its -// tool round trip and the final reply, the state the waiting golden cannot see. +const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md') +// Final golden: the answered transcript — the question resolved into its tool +// round trip and the final reply, the state the composer goldens cannot see. const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md') const MODE = webSnapshotMode() // The options carry long descriptions on purpose: the squeeze assertion below // needs option copy that WRAPS, which is the only shape that reproduces a // collapsed row painting its copy outside its own box. -const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." After I answer, reply with the single word DONE and stop.' +const PROMPT = 'Use the ask_user_question tool to ask me exactly one multi-select question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." Set multi_select to true. After I answer, reply with the single word DONE and stop.' describe('web e2e: resident question composer round trip', () => { let scaffold: WebScaffold @@ -124,9 +125,17 @@ describe('web e2e: resident question composer round trip', () => { await page.setViewportSize(original) } - await composer.getByRole('radio', { name: 'Blue' }).click() - // Submit: Enter on the focused option (the composer's documented submit). - await composer.getByRole('radio', { name: 'Blue' }).press('Enter') + const blue = composer.getByRole('checkbox', { name: 'Blue' }) + await blue.click() + const custom = composer.getByRole('textbox') + await custom.fill('Include accessibility notes') + expect(await blue.getAttribute('aria-checked')).toBe('true') + expect(await custom.inputValue()).toBe('Include accessibility notes') + if (MODE !== 'record') { + const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd) + await compareOrRefreshGolden(COMPOSED_EXPECTED, snapshot, MODE) + } + await custom.press('Enter') const sessionId = await settled if (MODE === 'record') { @@ -135,7 +144,14 @@ describe('web e2e: resident question composer round trip', () => { } // World state: the tool result carries the chosen answer, and DONE lands. const results = sessionEvents.filter(e => e.type === 'tool/result') - expect(JSON.stringify(results.at(-1))).toContain('Blue') + const answerText = results.flatMap(event => event.data.message.content.flatMap(block => + block.type === 'tool-result' + ? block.content.filter(item => item.type === 'text').map(item => item.text) + : [], + )).at(-1) + expect(JSON.parse(answerText ?? '')).toEqual({ + answers: [{ id: 'color', selected: ['Blue'], custom: 'Include accessibility notes' }], + }) await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) // Composer gone; regular input restored. expect(await page.locator('[data-question-key]').count()).toBe(0) @@ -149,6 +165,11 @@ describe('web e2e: resident question composer round trip', () => { }, 200_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md', 'answered.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'session.jsonl', + 'ui.expected.md', + 'composed.expected.md', + 'answered.expected.md', + ]) }) }) diff --git a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md index f68c68c9f4..e35ccea200 100644 --- a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md +++ b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md @@ -7,8 +7,9 @@ - text: "Run two shell commands: wait for cancellation, then write skipped.txt. {{date}} {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 8f4c7e5bf2..c6799a2247 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -7,8 +7,9 @@ - text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index e4d5ac8426..fc6d312821 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -7,8 +7,9 @@ - text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index facb7b58cc..17df480cd9 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -7,8 +7,9 @@ - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 5965797c69..5e0b4f73bb 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -7,8 +7,9 @@ - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index d1e4d2bbef..d18e061a1a 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -7,8 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img 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 fb9337e978..1a4aec678c 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -7,8 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/loading.expected.md b/apps/web/tests/snapshots/live-interactions/loading.expected.md index a5dfd08fb5..c50b440f86 100644 --- a/apps/web/tests/snapshots/live-interactions/loading.expected.md +++ b/apps/web/tests/snapshots/live-interactions/loading.expected.md @@ -7,8 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 6380eaf5c6..d5df08c05e 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -7,8 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/markdown-images/ui.expected.md b/apps/web/tests/snapshots/markdown-images/ui.expected.md new file mode 100644 index 0000000000..58b72e0d65 --- /dev/null +++ b/apps/web/tests/snapshots/markdown-images/ui.expected.md @@ -0,0 +1,31 @@ +- banner: + - navigation "Session hierarchy": + - button "Markdown image policy" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Show the Markdown image policy. {{clock}} +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn +- heading "Markdown images" [level=2] +- paragraph: + - img "Remote test image" +- paragraph: Local test image +- paragraph: REMOTE_IMAGE_DONE +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current deepseek-v4-flash": + - text: deepseek-v4-flash + - img +- button "Send message" [disabled] +- text: 1 turns · 1 steps Input 0 tok · Output 0 tok diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index 962c3b8dbe..79b981f37f 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -8,12 +8,19 @@ - button "Copy": - img - tooltip "Copy" -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img - img - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel. +- paragraph: I will read both files before answering. +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn 7/25 {{clock}} - button "Read a.txt": - img - img @@ -28,6 +35,12 @@ - img - img - text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed. +- text: Stopped Now give the final answer. 7/25 {{clock}} +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - paragraph: DONE - button "Copy": - img @@ -42,4 +55,4 @@ - text: Select model - img - button "Send message" [disabled] -- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok +- text: 2 turns · 3 steps Tool call {{duration}} Cache hit 98% Input 7.8K tok · Output 103 tok diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index 5be3f83247..108ca47986 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -8,8 +8,9 @@ - text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 28297569ab..7fdddfa8a8 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -4,11 +4,12 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop. {{clock}}" +- text: "Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/question-composer/composed.expected.md b/apps/web/tests/snapshots/question-composer/composed.expected.md new file mode 100644 index 0000000000..c18e6225c6 --- /dev/null +++ b/apps/web/tests/snapshots/question-composer/composed.expected.md @@ -0,0 +1,17 @@ +- region "Which color do you prefer?": + - text: Pick one + - heading "Which color do you prefer?" [level=2] + - button "Dismiss all questions": + - img + - group: + - checkbox "Blue" [checked]: Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. + - checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. + - textbox "Type your answer": Include accessibility notes + - button "Previous question" [disabled]: + - img + - text: 1 / 1 + - button "Next question" [disabled]: + - img + - status + - button "Skip this question" + - button "Submit" diff --git a/apps/web/tests/snapshots/question-composer/session.jsonl b/apps/web/tests/snapshots/question-composer/session.jsonl index 2ac86783c2..7dac5f236d 100644 --- a/apps/web/tests/snapshots/question-composer/session.jsonl +++ b/apps/web/tests/snapshots/question-composer/session.jsonl @@ -1,20 +1,20 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785150167878,"cwd":"{{cwd}}/workspace"} {"type":"turn/start","seq":0,"time":1785150167924,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} -{"type":"user/message","seq":1,"time":1785150167925,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1785150167925,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785150167927,"data":{"title":"Use the ask_user_question tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1785150167928,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1785150167929,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1785150168452,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"reasoning-chunks","seq0":6,"time0":1785150168452,"data":{"turn":1,"step":1,"index":0,"dt":[87,26,1,0,0,0,38,0,0,0,0,1,12,27,0,27,0,0,1,25,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," ask","_user","_","question"," tool"," with"," specific"," parameters","."," Let"," me"," do"," exactly"," that","."]}} {"type":"assistant/chunk","seq":28,"time":1785150168775,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":29,"time0":1785150168776,"data":{"turn":1,"step":1,"index":1,"dt":[25,1,0,0,0,25,0,0,0,26,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,1,25,0,0,0,0,1,25,1,0,0,0,0,25,1,0,0,26,0,0,1,0,24,1,0,0,0,1,26,1,0,0,0,0,25,0,1,0,0,0,25,1,0,0,25,0,0,0,0,1,25,0,0,1,0,0,25,0,0,0,1,0,26,1,24],"id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\","," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\","," \"","description","\":"," \"","A"," cool"," recessive"," hue"," that"," reads"," as"," calm"," and"," trustworthy"," in"," long"," reading"," sessions"," and"," dense"," dash","boards",".\"","},"," {\"","label","\":"," \"","Green","\","," \"","description","\":"," \"","A"," rest","ful"," mid","-spect","rum"," hue"," with"," the"," highest"," perceived"," brightness",","," easiest"," on"," the"," eye"," over"," long"," sessions",".\"","}]","}]","}"]}} +{"type":"tool-call-chunks","seq0":29,"time0":1785150168776,"data":{"turn":1,"step":1,"index":1,"dt":[25,1,0,0,0,25,0,0,0,26,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,1,25,0,0,0,0,1,25,1,0,0,0,0,25,1,0,0,26,0,0,1,0,24,1,0,0,0,1,26,1,0,0,0,0,25,0,1,0,0,0,25,1,0,0,25,0,0,0,0,1,25,0,0,1,0,0,25,0,0,0,1,0,26,1,24],"id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\", \"multi_select\": true,"," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\","," \"","description","\":"," \"","A"," cool"," recessive"," hue"," that"," reads"," as"," calm"," and"," trustworthy"," in"," long"," reading"," sessions"," and"," dense"," dash","boards",".\"","},"," {\"","label","\":"," \"","Green","\","," \"","description","\":"," \"","A"," rest","ful"," mid","-spect","rum"," hue"," with"," the"," highest"," perceived"," brightness",","," easiest"," on"," the"," eye"," over"," long"," sessions",".\"","}]","}]","}"]}} {"type":"assistant/chunk","seq":127,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."}}}} -{"type":"assistant/chunk","seq":128,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}}} +{"type":"assistant/chunk","seq":128,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}}} {"type":"assistant/chunk","seq":129,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}}}} {"type":"assistant/chunk","seq":130,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"} -{"type":"tool/call","seq":132,"time":1785150169312,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}} -{"type":"tool/result","seq":133,"time":1785150169787,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"]}]}"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"} +{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"} +{"type":"tool/call","seq":132,"time":1785150169312,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}} +{"type":"tool/result","seq":133,"time":1785150169787,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"],\"custom\":\"Include accessibility notes\"}]}"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"} {"type":"step/end","seq":134,"time":1785150169790,"data":{"turn":1,"step":1}} {"type":"step/start","seq":135,"time":1785150169790,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":136,"time":1785150170605,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/apps/web/tests/snapshots/question-composer/ui.expected.md b/apps/web/tests/snapshots/question-composer/ui.expected.md index 894f84d9ba..c2ee767319 100644 --- a/apps/web/tests/snapshots/question-composer/ui.expected.md +++ b/apps/web/tests/snapshots/question-composer/ui.expected.md @@ -3,9 +3,9 @@ - heading "Which color do you prefer?" [level=2] - button "Dismiss all questions": - img - - radiogroup: - - radio "Blue": 1 Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. - - radio "Green": 2 Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. + - group: + - checkbox "Blue": Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. + - checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. - textbox "Type your answer" - button "Previous question" [disabled]: - img diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index b9dee060ac..bd44e33ad0 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -7,8 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index bfff0cd9f0..8df2ea2940 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -7,8 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md index fe6f5b6087..74de289ee9 100644 --- a/apps/web/tests/snapshots/queue-actions/preserved.expected.md +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -7,8 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img @@ -22,8 +23,9 @@ - text: {{clock}} Edited queue item {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - paragraph: partial - status: Deep diving... - list: diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index db860c30d1..7afdf3c8b4 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -7,8 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index 7c1e150bc4..d6b20b9180 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -7,8 +7,9 @@ - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img - img diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index 03c7ef157d..df3f72856a 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -7,8 +7,9 @@ - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img - img diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 61f48ffbab..5b316f4efb 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -7,8 +7,9 @@ - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index be28e218d3..4d8d35a085 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -7,8 +7,9 @@ - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img @@ -24,8 +25,9 @@ - text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - 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/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index 14b397d9b4..9497cc36d3 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -12,8 +12,9 @@ - text: Explain event sourcing in one sentence. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img @@ -30,8 +31,9 @@ - text: {{clock}} Now give the same explanation to a human reader. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": - img - img diff --git a/apps/web/tests/snapshots/web-search-round/ui.expected.md b/apps/web/tests/snapshots/web-search-round/ui.expected.md index 4ff674462e..97ca405141 100644 --- a/apps/web/tests/snapshots/web-search-round/ui.expected.md +++ b/apps/web/tests/snapshots/web-search-round/ui.expected.md @@ -7,8 +7,9 @@ - text: Use web_search to search exactly "DeepSeek Harness snapshot search". Then reply exactly SEARCH_DONE and stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 6f339cf9a9..0049cb2791 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -400,6 +400,17 @@ describe('web e2e: persisted subagent conversation and human continuation', () = const tree = page.getByRole('tree', { name: 'Subagent sessions' }) const nestedRow = tree.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }) expect(await nestedRow.locator(':scope > *').count()).toBe(1) + const clickArea = nestedRow.locator(':scope > *') + const [treeBox, clickAreaBox] = await Promise.all([ + tree.boundingBox(), + clickArea.boundingBox(), + ]) + expect(treeBox).not.toBeNull() + expect(clickAreaBox).not.toBeNull() + expect([ + Math.round(clickAreaBox!.x - treeBox!.x), + Math.round(treeBox!.x + treeBox!.width - clickAreaBox!.x - clickAreaBox!.width), + ]).toEqual([5, 5]) await compareOrRefreshGolden( BRANCHLESS_EXPECTED, await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd), diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 5128d01da8..e224bb6f91 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -46,6 +46,7 @@ "tests/cordis-tool-round.e2e.ts", "tests/web-search-round.e2e.ts", "tests/message-actions.e2e.ts", + "tests/markdown-images.e2e.ts", "tests/queue-actions.e2e.ts", "tests/skill-invocation-policy.e2e.ts", "tests/permission-policy-context.e2e.ts", diff --git a/docs/config-catalog.md b/docs/config-catalog.md index cc85531d4d..617cfb82be 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2067,6 +2067,8 @@ export interface TuiConfig { showReasoning?: boolean /** Maximum tool-card body lines retained in its collapsed head/tail preview. */ maxToolOutputLines?: number + /** Maximum added and removed lines explored while deriving an exact line diff. */ + maxDiffEditLength?: number /** Maximum options visible at once in a user-question panel. */ maxQuestionOptions?: number /** Maximum models visible at once in the model selector. */ @@ -2081,6 +2083,8 @@ export interface TuiConfig { modelDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number + /** Transcript-details selector width in terminal columns. */ + detailsDialogWidth?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ fileSearchMaxResults?: number /** Maximum paths retained in one `@` workspace index. */ @@ -2112,7 +2116,7 @@ export interface TuiThemeConfig { } ``` -Source: [`packages/ui/tui/src/config.ts:117`](../packages/ui/tui/src/config.ts) +Source: [`packages/ui/tui/src/config.ts:125`](../packages/ui/tui/src/config.ts) ## `@deepseek-ai/dsh-typert-loader` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index cf18c6f73c..b518f37876 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2451,7 +2451,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:242`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:245`](../../packages/ui/tui/src/index.ts) ## `ctx.typert` — `TypertRegistry` diff --git a/docs/core-data-structures/user-interaction.i18n.yaml b/docs/core-data-structures/user-interaction.i18n.yaml index bc281d9bb7..36352bb747 100644 --- a/docs/core-data-structures/user-interaction.i18n.yaml +++ b/docs/core-data-structures/user-interaction.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 docs/core-data-structures/user-interaction.md -user-interaction.md: 2483382eee07d379b13d456149096c8afab70e4b -user-interaction.zh.md: 57f57277975597fff712705cc741ae8e78643642 +user-interaction.md: 16478168c8fcfeafbd3fdbbf30e732de81a3a8dd +user-interaction.zh.md: 7c8baaf189d759ca609c1d51652120f973ae48b6 diff --git a/docs/core-data-structures/user-interaction.md b/docs/core-data-structures/user-interaction.md index 2483382eee..16478168c8 100644 --- a/docs/core-data-structures/user-interaction.md +++ b/docs/core-data-structures/user-interaction.md @@ -86,14 +86,14 @@ interface AskUserQuestionRequest { ## Answer -Providers return one answer item per question id. `selected` contains selected option labels, and `custom` carries a free-form "Other" answer when the user typed one. When `custom` is present, `selected` is empty; custom text is an answer override, not a supplement to selected choices. A UI may also use an item with empty `selected` and no `custom` to preserve a skipped question in an otherwise completed batch. +Providers return one answer item per question id. `selected` contains selected option labels, and `custom` carries a free-form "Other" answer when the user typed one. For a single-select question, `custom` overrides the selected choice and `selected` is empty. For a multi-select question, `custom` may supplement the labels in `selected`. A UI may also use an item with empty `selected` and no `custom` to preserve a skipped question in an otherwise completed batch. ```ts type-equiv /** Answer to one question. */ interface AskUserQuestionAnswerItem { /** The answered question id. */ id: string - /** Selected option labels. Empty for custom or unanswered choices. */ + /** Selected option labels. May accompany custom text for a multi-select question. */ selected: string[] /** Optional free-text "Other" answer. */ custom?: string diff --git a/docs/core-data-structures/user-interaction.zh.md b/docs/core-data-structures/user-interaction.zh.md index 57f5727797..7c8baaf189 100644 --- a/docs/core-data-structures/user-interaction.zh.md +++ b/docs/core-data-structures/user-interaction.zh.md @@ -86,14 +86,14 @@ interface AskUserQuestionRequest { ## 回答 -提供方为每个问题 id 返回一个回答项。`selected` 包含选中的选项标签,`custom` 在用户输入自由文本时携带「其他」回答。当 `custom` 存在时,`selected` 为空;自定义文本是对选中项的覆盖,而非补充。UI 也可以使用 `selected` 为空且不含 `custom` 的回答项,在其余问题均已完成的批次中保留被跳过的问题。 +提供方为每个问题 id 返回一个回答项。`selected` 包含选中的选项标签,`custom` 在用户输入自由文本时携带「其他」回答。对于单选题,`custom` 会覆盖选中的选项,且 `selected` 为空。对于多选题,`custom` 可以补充 `selected` 中的标签。UI 也可以使用 `selected` 为空且不含 `custom` 的回答项,在其余问题均已完成的批次中保留被跳过的问题。 ```ts type-equiv /** Answer to one question. */ interface AskUserQuestionAnswerItem { /** The answered question id. */ id: string - /** Selected option labels. Empty for custom or unanswered choices. */ + /** Selected option labels. May accompany custom text for a multi-select question. */ selected: string[] /** Optional free-text "Other" answer. */ custom?: string diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 1ab6c96659..c3850804ff 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: 82f1bc95a6128245f88f01a0de0849494cb98359 -README.zh.md: f3aba75d18671fe377ec8305693ea5025d51e0de +README.md: 89e58f967f852bb0786a5b7d73fa8e924fa282e0 +README.zh.md: 960e2fceede1b500af9ee2063ec9283e2b7b271a diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 82f1bc95a6..89e58f967f 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -26,7 +26,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and ## The human transcript -`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally. +`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally. Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index f3aba75d18..960e2fceed 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -26,7 +26,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 面向人的 transcript(文本记录) -`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。 +`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。 由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。 diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 31872356c8..46cc5b0391 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -331,6 +331,8 @@ export interface ConversationSnapshot { sessionId: SessionId /** Human transcript plus retry notices and interrupted-turn terminal nodes in event order. */ nodes: readonly ConversationNode[] + /** In-window completed turn number -> its `turn/end` event seq. */ + turnEnds: ReadonlyMap partial: PartialAssistant | null runningCalls: readonly RunningToolCall[] /** diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 58d3028fba..cce554f3ad 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -113,6 +113,11 @@ export class Session implements SessionFace { private pendingCache: { rev: number; value: PendingInteraction[] } | null = null private derivedRev = 0 private nodesCache: { projected: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null + /** Completed turn boundaries retained from the raw window so presentation + * actions never infer a safe fork point from transcript content alone. */ + private turnEnds = new Map() + private turnEndsRev = 0 + private turnEndsCache: { rev: number; value: ReadonlyMap } | null = null /** Authoritative stream-only inbox snapshot; pending work never hits history. */ private queued: QueuedMessage[] = [] private queueRev = 0 @@ -821,6 +826,8 @@ export class Session implements SessionFace { return } case 'turn/end': { + this.turnEnds.set(event.data.turn, event.seq) + this.turnEndsRev++ if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'disposed') { this.settleScheduledRetry('cancelled', event.data.turn) } @@ -911,6 +918,8 @@ export class Session implements SessionFace { this.callsRev++ this.derivedNodes = [] this.derivedRev++ + this.turnEnds = new Map() + this.turnEndsRev++ this.codeDispatches = new Map() this.dispatchesRev++ for (let i = 0; i < this.events.length; i++) { @@ -942,6 +951,9 @@ export class Session implements SessionFace { if (this.callsCache === null || this.callsCache.rev !== this.callsRev) { this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] } } + if (this.turnEndsCache === null || this.turnEndsCache.rev !== this.turnEndsRev) { + this.turnEndsCache = { rev: this.turnEndsRev, value: new Map(this.turnEnds) } + } if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) { this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] } } @@ -955,6 +967,7 @@ export class Session implements SessionFace { return { sessionId: this.sessionId, nodes, + turnEnds: this.turnEndsCache.value, partial, runningCalls: this.callsCache.value, pending: this.pendingCache.value, diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 7f5f793948..6e75593e3a 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -426,6 +426,7 @@ describe('live event path', () => { feed(ev.turnEnd(10, 1, 'aborted')) // no assistant/message ever arrives const snapshot = session.getSnapshot() expect(snapshot.partial).toBeNull() + expect(snapshot.turnEnds.get(1)).toBe(10) const frozen = snapshot.nodes.at(-1) expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'text', text: '说到一半' }] }) // Ordered inside the flow: after the user message (seq 7), before any later turn. @@ -1215,6 +1216,7 @@ describe('reference stability (the memo contract)', () => { expect(after).not.toBe(before) expect(after.runningCalls).toBe(before.runningCalls) expect(after.pending).toBe(before.pending) + expect(after.turnEnds).toBe(before.turnEnds) // And a mutation on the tracked domain swaps that array. feed(ev.toolResult(11, 1, 'c1', 'ECHO')) const resolved = session.getSnapshot() diff --git a/packages/client/test-runtime/src/fixtures.ts b/packages/client/test-runtime/src/fixtures.ts index 2544870ace..d033669215 100644 --- a/packages/client/test-runtime/src/fixtures.ts +++ b/packages/client/test-runtime/src/fixtures.ts @@ -46,6 +46,7 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot return { sessionId, nodes: [], + turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 1dc3761c5f..da6ea1a597 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: 728d67aaccee609d5f28ee8744c1f11ca74b040a -README.zh.md: eec1d36b9758dddcf63eb75c3a74c51b4afb0a72 +README.md: 78572ba0ab3ce9475dba31dee8844017564e2a18 +README.zh.md: 7708e980e24f4ea4365fbbacd641a5be6c61b138 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 728d67aacc..78572ba0ab 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -10,7 +10,7 @@ The resident conversation shell survives no-session and session transitions. Wit The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. -Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. +Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager tracks this approval wait through the `waitingApproval` list bit even for uninstantiated sessions; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. @@ -38,7 +38,7 @@ The todo surfaces are two registrations over that shape, both plain registrant p `QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `" 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible ordinary-session row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; addressed subagents retain the rows as a read-only projection because their continuation transport does not expose queue mutation. If strict steer loses to a closed window, the original occurrence remains queued for normal delivery; if the driver already claimed it, normal delivery is already underway. Neither converged race displays a failure, while transport and unknown failures do. -The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `steering/message` has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, restores Copy and Fork from the durable node, and survives reconnect from the same authority. +The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `steering/message` has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the branch control from the durable node, enables branch only when that node is the completed turn's transcript tail, and survives reconnect from the same authority. Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. @@ -63,8 +63,8 @@ None; this package neither assembles nor sends a provider request. - **Compaction markers show no scale** — the row does not yet report how many messages or which range the checkpoint replaced. - **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted. - **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly. -- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch forks through the turn containing that message, increments the inherited title on the client, and then opens the child, while a fork or rename failure leaves the source selected. -- **Sent user messages cannot be edited** — the user bubble's IconActions row carries clock / copy / branch only, and branching from the message is the nearest gesture. The control returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)). +- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock / branch) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch stays disabled unless that message is also the last transcript node of a completed turn; when enabled, it forks through that turn, increments the inherited title on the client, and opens the child. A fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)). +- **Sent user messages cannot be edited** — user bubbles retain clock, copy, and branch; branch stays disabled unless a completed turn's transcript ends at that user message. Editing returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)). - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. - **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. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index eec1d36b97..7708e980e2 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -14,7 +14,7 @@ 已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。 -Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理吞吐:当 reasoning block 是流式尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整 reasoning 进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 +Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 @@ -32,13 +32,13 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);bash 示例是第三方姿态的范例。Trajectory/waterfall(瀑布式事件)工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 -审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 +审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 通过 `waitingApproval` 列表位跟踪这种审批等待,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 `QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `" 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering(中途引导)操作;已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。 -Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;消息尚未进入持久轮次,因此不显示 fork。Host 会等持久 `steering/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会从持久节点恢复复制与 fork 操作,并能在重连后从同一权威恢复。 +Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;消息尚未进入持久轮次,因此不显示 fork。Host 会等持久 `steering/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与分支控件,仅当该节点是已完成轮次的 transcript 尾部时才启用分支,并能在重连后从同一权威恢复。 键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 @@ -63,8 +63,8 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu - **压缩标记不显示规模**:该行尚不报告检查点替换了多少条消息或哪段范围。 - **统计行的耗时只覆盖窗口内消息流**:LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 - **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。 -- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。分支会 fork 到包含该消息的轮次末尾,在 client 端递增继承标题后打开子会话,而 fork 或改名失败时源会话保持选中。 -- **已发送的 user 消息无法编辑**:user 气泡的 IconActions 行只有时钟/复制/分支,从该消息分支是最接近的手势。该控件要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。 +- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟/分支)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。除非该消息同时也是已完成轮次的最后一个 transcript 节点,否则分支保持禁用;启用后,它会 fork 到该轮次末尾,在 client 端递增继承标题并打开子会话。fork 或改名失败时源会话保持选中([决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md))。 +- **已发送的 user 消息无法编辑**:user 气泡保留时钟、复制和分支;除非已完成轮次的 transcript 结束于该 user 消息,否则分支保持禁用。编辑功能要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index f5cbadef8a..3f87e40e8b 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -4,9 +4,10 @@ // view groups them into tool rows through its keyed toolview slot (figma // step-summary flow). Shared by finalized nodes and the streaming partial; // the turn-level loading dots live in the chat view's tail, not here. -// Finalized turn-tail content (text) nodes append IconActions once streaming -// ends (`time` is omitted for mid-turn narration); Think / tool-head-only -// nodes stay chrome-free. +// Finalized content (text) nodes append IconActions once streaming ends +// (`time` is omitted for mid-turn narration); their branch action is enabled +// only when the node is also the completed turn's transcript tail. Think / +// tool-head-only nodes stay chrome-free. import { memo, useMemo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' @@ -28,8 +29,10 @@ export interface AssistantMarkdownProps { time?: number | undefined /** Event sequence used as the fork boundary; omitted while streaming. */ seq?: number | undefined - /** Fork the session through the turn containing this finalized message. */ + /** Fork the session through this finalized message's completed turn when eligible. */ onFork?: ((seq: number) => void) | undefined + /** The message is not the transcript tail of a completed turn. */ + forkUnavailable?: boolean | undefined /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } @@ -76,7 +79,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass } export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, time, seq, onFork, t, + blocks, streaming, interrupted, time, seq, onFork, forkUnavailable, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. @@ -120,6 +123,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ time={time} clock="end" onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }} + branchUnavailable={forkUnavailable} className={css.actions} t={t} /> diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 989f56bba2..076da6c6ab 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -30,7 +30,7 @@ import type { import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { assistantActionsSeqs, deriveChatFlow, type ChatFlowItem } from './chat-flow.ts' +import { assistantActionsSeqs, deriveChatFlow, messageBranchSeqs, type ChatFlowItem } from './chat-flow.ts' import { AssistantMarkdown } from './AssistantMarkdown.tsx' import { GenericCommandCard } from './GenericCommandCard.tsx' import { GenericToolCard } from './GenericToolCard.tsx' @@ -236,6 +236,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t, }: ChatViewSlotProps) { const nodes = useSession(s => s.nodes) + const turnEnds = useSession(s => s.turnEnds) const inbox = useSession(s => s.queue) // Workspace root off the session list row: path summaries display relative to it. const cwd = useSessions(s => s.byId[sessionId]?.cwd) @@ -257,6 +258,7 @@ export function ChatView({ // Only the last content assistant of each turn owns IconActions; mid-turn // text (before tools) omits `time` so AssistantMarkdown stays chrome-free. const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes]) + const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds]) const listRef = useRef(null) const atBottomRef = useRef(true) @@ -413,6 +415,7 @@ export function ChatView({ time={actionSeqs.has(node.seq) ? node.time : undefined} seq={node.seq} onFork={forkAt} + forkUnavailable={!branchSeqs.has(node.seq)} t={t} /> ) @@ -428,6 +431,7 @@ export function ChatView({ node={node} retryActive={node.kind === 'model-retry' && node.seq === activeRetry} onFork={forkAt} + forkUnavailable={!branchSeqs.has(node.seq)} t={t} /> ) diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css b/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css index b247b7e2bf..8a43b42f83 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css @@ -43,3 +43,23 @@ background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); } + +/* Unavailable stays focusable and hoverable so Tooltip can explain why. */ +.action[data-unavailable] { + cursor: default; + opacity: 0.4; +} + +.action[data-unavailable]:hover { + background: transparent; + color: var(--dsw-alias-label-tertiary); +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx index 91ba066544..6d77d4db68 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx @@ -1,7 +1,7 @@ // Shared IconActions chrome for user, steering, and assistant messages: copy // live, optional branch wiring, and an optional date-aware clock. -import { useCallback } from 'react' +import { useCallback, useId } from 'react' import { IconBranchOutline16, IconCopyOutline16, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -17,9 +17,11 @@ export interface MessageIconActionsProps { time?: number | undefined /** Clock before icons (user) or after (assistant). */ clock: 'start' | 'end' - /** Fork the session at this message. */ + /** Fork the session at this message; omission hides the branch action. */ onBranch?: (() => void) | undefined - /** Whether to render the branch action; defaults to true. */ + /** The message is not a completed transcript tail, so branch stays visible but unavailable. */ + branchUnavailable?: boolean | undefined + /** Additional branch visibility gate for transient message chrome; defaults to true. */ showBranch?: boolean | undefined /** Parent layout class composed onto the actions row. */ className?: string | undefined @@ -33,9 +35,10 @@ export interface MessageIconActionsProps { * @returns The actions row element. */ export function MessageIconActions({ - text, time, clock, onBranch, showBranch = true, className, t, + text, time, clock, onBranch, branchUnavailable = false, showBranch = true, className, t, }: MessageIconActionsProps) { const day = useCalendarDay() + const reasonId = useId() const onCopy = useCallback(() => { void writeClipboard(text) }, [text]) @@ -52,13 +55,25 @@ export function MessageIconActions({ - {showBranch && ( - - )} + {showBranch && onBranch !== undefined && branchUnavailable && ( + {t('message.branchUnavailable')} + )} {clock === 'end' ? clockEl : null} ) diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 6e542bf468..df04ac19c2 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -26,8 +26,10 @@ export interface MessageItemProps { | TurnErrorNode | UnknownSurfaceNode retryActive?: boolean - /** Fork the session through the turn containing this message (user-bubble branch action). */ + /** Fork through this message's completed turn when eligible. */ onFork?: (seq: number) => void + /** The message is not the transcript tail of a completed turn. */ + forkUnavailable?: boolean /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } @@ -220,7 +222,7 @@ export function PendingSteeringBubble({ content, t }: { } export const MessageItem = memo(function MessageItem({ - node, retryActive = false, onFork, t, + node, retryActive = false, onFork, forkUnavailable = false, t, }: MessageItemProps) { const truncated = (total: number): string => t('json.truncated', { total }) switch (node.kind) { @@ -236,6 +238,7 @@ export const MessageItem = memo(function MessageItem({ time={node.time} clock="start" onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }} + branchUnavailable={forkUnavailable} className={css.actions} t={t} /> diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index 4958894154..9de9876d89 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -5,8 +5,8 @@ * reuse the first notice's row while projecting the latest retry turn. * Item identity keys are stable across snapshots so the list parent can * subscribe to keys only while rows subscribe to content. IconActions ownership - * (last content assistant per turn) is derived here too so ChatView and the - * flow share one gate. + * and completed-turn branch points are derived here too so ChatView and the + * flow share their gates. */ import type { AssistantBlock, ConversationNode, ToolResultNode, @@ -47,6 +47,39 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon return new Set(lastByTurn.values()) } +/** + * Seq set of message rows that may fork: the last transcript node of a + * completed turn, when that node owns message chrome. A later tool, reasoning, + * error, or other transcript node leaves the earlier message's branch action + * unavailable because the Host would include the whole turn. + * @param nodes - snapshot nodes in event order. + * @param turnEnds - completed turn boundaries retained from the event window. + * @returns Message seq values whose visible position matches the fork boundary. + */ +export function messageBranchSeqs( + nodes: readonly ConversationNode[], + turnEnds: ReadonlyMap, +): ReadonlySet { + const result = new Set() + const boundaries = [...turnEnds].sort((a, b) => a[1] - b[1]) + let nodeIndex = 0 + for (const [turn, endSeq] of boundaries) { + let tail: ConversationNode | undefined + while (nodeIndex < nodes.length) { + const candidate = nodes[nodeIndex] + if (candidate === undefined || candidate.seq > endSeq) break + tail = candidate + nodeIndex++ + } + if (tail?.kind === 'user' + || (tail?.kind === 'steering' && tail.turn === turn) + || (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks))) { + result.add(tail.seq) + } + } + return result +} + /** * Group finalized nodes into the step-summary flow. * @param nodes - snapshot nodes in human-transcript and durable-notice order. diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 52dc8dd2d9..20a791c556 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -461,7 +461,7 @@ export interface ChatViewInjected { /** Last recorded offset, or null when pinned or never recorded. */ read: () => number | null } - /** Fork the session through the turn containing the message at `seq`, then open the child. */ + /** Fork through the completed turn ending at the eligible message `seq`, then open the child. */ forkAt: (seq: number) => void } diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 87252fbc77..7bc01b4d95 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -59,6 +59,7 @@ export const zh = { 'message.unknownBlock': '未知内容块', 'message.stopped': '已停止', 'message.branch': '在新对话中分支', + 'message.branchUnavailable': '仅可从已完成轮次的最后一条消息分支', 'message.retry.active': '正在重试模型请求', 'message.retry.cancelled': '模型请求重试已取消', 'message.retry.started': '已重试模型请求', @@ -166,6 +167,7 @@ export const en = { 'message.unknownBlock': 'Unknown content block', 'message.stopped': 'Stopped', 'message.branch': 'Branch into a new conversation', + 'message.branchUnavailable': 'Available only on the last message of a completed turn', 'message.retry.active': 'Retrying model request', 'message.retry.cancelled': 'Model request retry cancelled', 'message.retry.started': 'Retried model request', 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 4074996063..89a09ca28d 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -36,12 +36,14 @@ describe('MessageItem arms', () => { // Same-day clock: construct "today at 14:24" so the label stays `HH:mm`. const now = new Date() const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime() + const onFork = vi.fn() render( , ) expect(screen.getByText('14:24')).toBeTruthy() @@ -50,6 +52,8 @@ describe('MessageItem arms', () => { expect(screen.queryByRole('button', { name: '编辑' })).toBeNull() fireEvent.click(screen.getByRole('button', { name: '复制' })) expect(writeText).toHaveBeenCalledWith('hello bubble') + fireEvent.click(screen.getByRole('button', { name: '在新对话中分支' })) + expect(onFork).toHaveBeenCalledWith(1) }) it('user copy falls back to execCommand when clipboard.writeText is unavailable', () => { @@ -74,6 +78,30 @@ describe('MessageItem arms', () => { expect(exec).toHaveBeenCalledWith('copy') }) + it('keeps an unavailable branch focusable and explains why without sending a fork', () => { + const onFork = vi.fn() + render( + , + ) + const branch = screen.getByRole('button', { name: '在新对话中分支' }) as HTMLButtonElement + expect(branch.disabled).toBe(false) + expect(branch.getAttribute('aria-disabled')).toBe('true') + const reasonId = branch.getAttribute('aria-describedby') + expect(reasonId).not.toBeNull() + expect(document.getElementById(reasonId!)?.textContent).toBe('仅可从已完成轮次的最后一条消息分支') + fireEvent.click(branch) + expect(onFork).not.toHaveBeenCalled() + fireEvent.focus(branch) + expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支') + }) + it('user copy stays quiet when execCommand throws or is absent', () => { Object.defineProperty(navigator, 'clipboard', { configurable: true, @@ -420,12 +448,15 @@ describe('small branch tails', () => { }) const now = new Date() const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime() + const onFork = vi.fn() const settled = render( , ) expect(settled.getByText('14:24')).toBeTruthy() @@ -433,6 +464,8 @@ describe('small branch tails', () => { expect(settled.getByRole('button', { name: '在新对话中分支' })).toBeTruthy() fireEvent.click(settled.getByRole('button', { name: '复制' })) expect(writeText).toHaveBeenCalledWith('answer body') + fireEvent.click(settled.getByRole('button', { name: '在新对话中分支' })) + expect(onFork).toHaveBeenCalledWith(3) settled.unmount() const thinkOnly = render( diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index e1366721c8..6708cdb759 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -67,7 +67,7 @@ function snapshotWith( runningCalls: RunningToolCall[] = [], ): ConversationSnapshot { return { - sessionId: SID, nodes, partial: null, runningCalls, codeDispatches, + sessionId: SID, nodes, turnEnds: new Map(), partial: null, runningCalls, codeDispatches, pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index a02f8ccb6f..95da35f267 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -32,7 +32,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 04630cf1f6..a041aca214 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -20,7 +20,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts import { createChatStore } from '../src/client/stores.ts' import { ChatView } from '../src/client/chat/ChatView.tsx' import { zh } from '../src/client/locales.ts' -import { assistantActionsSeqs, deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts' +import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs } from '../src/client/chat/chat-flow.ts' afterEach(cleanup) // Keyless create() persists under the bare declared key; clear between cases @@ -33,7 +33,7 @@ const SID = 's1' as SessionId function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } @@ -211,6 +211,29 @@ describe('chat-flow derivation', () => { ]) expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7]) }) + + it('messageBranchSeqs keeps only message rows at completed transcript tails', () => { + const interruptedThink: AssistantMessageNode = { + kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2, + blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true, + } + const nodes: ConversationNode[] = [ + user(1, 'first'), + assistant(2, 'answer before tools'), + toolResult(3, 'a'), + interruptedThink, + user(6, 'second'), + assistant(7, 'clean tail', 2), + user(10, 'user-only tail'), + { + kind: 'steering', messageId: 'steering-tail' as never, + seq: 13, time: 13_000, turn: 4, + content: [{ type: 'text', text: 'steering tail' }], source: null, + }, + ] + const seqs = messageBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11], [4, 14]])) + expect([...seqs]).toEqual([7, 10, 13]) + }) }) describe('ChatView', () => { @@ -302,8 +325,18 @@ describe('ChatView', () => { expect(view.getAllByText('interrupt now')).toHaveLength(1) expect(view.container.querySelector('[data-pending-steering]')).toBeNull() expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2) + const durableBubble = view.getByText('interrupt now').closest('[class*="userRow"]') as HTMLElement + const unavailable = within(durableBubble).getByRole('button', { name: '在新对话中分支' }) + expect(unavailable.getAttribute('aria-disabled')).toBe('true') + fireEvent.click(unavailable) + expect(h.forkAt).not.toHaveBeenCalled() + + act(() => { + h.set({ running: false, turnEnds: new Map([[1, 3]]) }) + }) const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' }) expect(branchButtons).toHaveLength(2) + expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null]) fireEvent.click(branchButtons[1]!) expect(h.forkAt).toHaveBeenCalledWith(2) }) @@ -404,21 +437,47 @@ describe('ChatView', () => { user(5, 'next'), assistant(6, 'second turn', 2), ], + turnEnds: new Map([[1, 4], [2, 6]]), }) const view = render() - // 2 user + 2 turn-tail assistants; mid-turn text at seq 2 stays chrome-free. + // Every message footer keeps branch visible; only completed assistant tails enable it. expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4) - expect(view.getAllByRole('button', { name: '在新对话中分支' })).toHaveLength(4) + const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' }) + expect(branchButtons).toHaveLength(4) + expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null, 'true', null]) }) - it('forks from both user and finalized assistant message actions at their event seq', () => { - const h = makeHarness({ nodes: [user(1, 'question'), assistant(2, 'answer')] }) + it('enables fork only on the finalized assistant at the completed transcript tail', () => { + const h = makeHarness({ + nodes: [user(1, 'question'), assistant(2, 'answer')], + turnEnds: new Map([[1, 3]]), + }) const view = render() const buttons = view.getAllByRole('button', { name: '在新对话中分支' }) expect(buttons).toHaveLength(2) + expect(buttons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null]) fireEvent.click(buttons[0]!) fireEvent.click(buttons[1]!) - expect(h.forkAt.mock.calls).toEqual([[1], [2]]) + expect(h.forkAt.mock.calls).toEqual([[2]]) + }) + + it('keeps branch visible but unavailable when tool and interrupted Think follow the response', () => { + const interruptedThink: AssistantMessageNode = { + kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2, + blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true, + } + const h = makeHarness({ + nodes: [user(1, 'question'), assistant(2, 'answer'), toolResult(3, 'a'), interruptedThink], + turnEnds: new Map([[1, 5]]), + }) + const view = render() + expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2) + const buttons = view.getAllByRole('button', { name: '在新对话中分支' }) + expect(buttons).toHaveLength(2) + expect(buttons.every(button => button.getAttribute('aria-disabled') === 'true')).toBe(true) + fireEvent.click(buttons[0]!) + fireEvent.click(buttons[1]!) + expect(h.forkAt).not.toHaveBeenCalled() }) it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => { diff --git a/packages/client/ui-conversation/tests/diff-card.spec.tsx b/packages/client/ui-conversation/tests/diff-card.spec.tsx index 1722b9628e..f7de4e45f9 100644 --- a/packages/client/ui-conversation/tests/diff-card.spec.tsx +++ b/packages/client/ui-conversation/tests/diff-card.spec.tsx @@ -335,7 +335,7 @@ describe('DetailsPanel diff Output section', () => { function snapshot(over: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 4496423efa..8350b318e9 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -24,7 +24,7 @@ const SID = 's1' as SessionId function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 180b41b73b..002704ce4c 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -23,7 +23,7 @@ const SID = 's1' as SessionId function snapshotOf(overrides: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index af8dbb8e66..13c519e10d 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -26,7 +26,7 @@ const SID = 's1' as SessionId /** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) { const session = createSnapshotStore({ - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active', removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index f3ef3410ff..180318a846 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -112,7 +112,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined) const wiring = shell const sessionStore = createSnapshotStore({ - sessionId, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index 371766db5c..52387ae397 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -32,7 +32,7 @@ function row(id: string, text: string | null, preview = text ?? '[image]'): Queu function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } diff --git a/packages/client/ui-conversation/tests/read-card.spec.tsx b/packages/client/ui-conversation/tests/read-card.spec.tsx index 700c8c3d75..bf9898cff9 100644 --- a/packages/client/ui-conversation/tests/read-card.spec.tsx +++ b/packages/client/ui-conversation/tests/read-card.spec.tsx @@ -283,7 +283,7 @@ describe('DetailsPanel Output section (read)', () => { function snapshot(over: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/ui-conversation/tests/search-card.spec.tsx b/packages/client/ui-conversation/tests/search-card.spec.tsx index b4306d064d..c50c308edf 100644 --- a/packages/client/ui-conversation/tests/search-card.spec.tsx +++ b/packages/client/ui-conversation/tests/search-card.spec.tsx @@ -397,7 +397,7 @@ describe('DetailsPanel Output section (search)', () => { function snapshot(over: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 1642425930..5fc6f106b5 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -68,7 +68,7 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => function conversationSnapshot(overrides: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/terminal-card.spec.tsx b/packages/client/ui-conversation/tests/terminal-card.spec.tsx index bcc5ff102f..6250fd0764 100644 --- a/packages/client/ui-conversation/tests/terminal-card.spec.tsx +++ b/packages/client/ui-conversation/tests/terminal-card.spec.tsx @@ -477,7 +477,7 @@ describe('DetailsPanel Output section', () => { function snapshot(over: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/ui-conversation/tests/web-card.spec.tsx b/packages/client/ui-conversation/tests/web-card.spec.tsx index a60c16f77c..974df2264a 100644 --- a/packages/client/ui-conversation/tests/web-card.spec.tsx +++ b/packages/client/ui-conversation/tests/web-card.spec.tsx @@ -232,7 +232,7 @@ describe('DetailsPanel web Output section', () => { function snapshot(over: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index f79b5073be..7bdae1f209 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: 8bdc3f043488631424c85a4319020c3d8ba5437b -README.zh.md: 52b507bb924ca4c05e92ed09d819339621c3bc20 +README.md: 7318acd9b9a6047b1144789bcd2655132237f6c5 +README.zh.md: e326846dc2099472bc0a81dff093ff24b614559b diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 8bdc3f0434..7318acd9b9 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ ## Markdown rendering -`MarkdownText` renders GFM and `$…$` / `$$…$$` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). +`MarkdownText` renders GFM and `$…$` / `$$…$$` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). ## Terminal output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 52b507bb92..e326846dc2 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -10,7 +10,7 @@ ## Markdown 渲染 -`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$` / `$$…$$` TeX 公式,公式由 KaTeX 排版并禁用受信任命令。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 +`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$` / `$$…$$` TeX 公式,公式由 KaTeX 排版并禁用受信任命令。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 ## 终端输出 diff --git a/packages/client/ui-primitives/src/DiffBlock.tsx b/packages/client/ui-primitives/src/DiffBlock.tsx index 23c498b1d1..41af796ab3 100644 --- a/packages/client/ui-primitives/src/DiffBlock.tsx +++ b/packages/client/ui-primitives/src/DiffBlock.tsx @@ -1,13 +1,12 @@ // DiffBlock: the inline-diff surface for a file mutation (write/edit) — a copy // control over one or more per-file hunks, each a bold path header followed by // the removed block (`-`, error color) and the added block (`+`, success -// color), with a dim `└ +A -R · N file(s)` footer. The +/- block form mirrors -// the TUI transcript's diff card (packages/ui/tui: diffLines) so a diff reads -// the same across front ends: the removed side is the old text in full, the -// added side the new text in full, both split on the same terminator rule, and -// the footer counts distinct paths on both ends. Output never soft-wraps — an -// aligned source line keeps its indentation and scrolls horizontally instead of -// folding. Colors resolve through --dsw-* tokens; geometry mirrors CodeBlock. +// color), with a dim `└ +A -R · N file(s)` footer. Unlike the TUI's exact +// changed-row comparison, this block renders the old and new sides in full. +// Both front ends share the line-terminator rule and distinct-path file count. +// Output never soft-wraps — an aligned source line keeps its indentation and +// scrolls horizontally instead of folding. Colors resolve through --dsw-* +// tokens; geometry mirrors CodeBlock. import { useCallback, useMemo, useState } from 'react' import clsx from 'clsx' diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index 116c7c4976..7e31857053 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -230,3 +230,14 @@ color: var(--dsw-alias-label-tertiary); font-style: italic; } + +.image { + display: block; + width: auto; + max-width: 100%; + height: auto; + margin: 0; + border-radius: 8px; + background: var(--dsw-alias-bg-base); + object-fit: contain; +} diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index a14f2c8a69..72e9168661 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -37,6 +37,15 @@ export interface MarkdownCodeLabels { copiedLabel?: string | undefined } +function remoteImageUrl(url: string): string | undefined { + try { + const protocol = new URL(url).protocol + return protocol === 'http:' || protocol === 'https:' ? url : undefined + } catch { + return undefined + } +} + /** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */ function buildComponents(streaming: boolean, codeLabels?: MarkdownCodeLabels): Components { return { @@ -53,7 +62,20 @@ function buildComponents(streaming: boolean, codeLabels?: MarkdownCodeLabels): C ) }, - img: ({ alt = '' }) => {alt}, + img: ({ alt = '', src = '' }) => { + const imageSrc = remoteImageUrl(src) + if (imageSrc === undefined) return {alt} + return ( + {alt} + ) + }, table: ({ children }) => (
{children}
@@ -98,7 +120,9 @@ const streamingComponents = buildComponents(true) * pass a reference-stable object (memoized per locale revision), because the * component table memoizes on its identity and a fresh literal per render * would rebuild it every streaming chunk. - * @returns A GFM document with TeX math rendered through KaTeX and raw HTML, relative links, unsafe protocols, and remote images disabled. + * @returns A GFM document with TeX math rendered through KaTeX; raw HTML, + * relative links, and unsafe protocols are disabled, while absolute HTTP(S) + * images render directly. */ export function MarkdownText({ text, streaming = false, codeLabels }: { text: string diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index 030856a261..10af104e5a 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -99,13 +99,35 @@ describe('MarkdownText', () => { expect(screen.getByRole('button', { name: 'Copy code' })).toBeTruthy() }) - it('neutralizes raw HTML, unsafe or relative links, and remote images', () => { + it('renders absolute HTTP(S) images with bounded presentation', () => { + const markdown = [ + '![secure diagram](https://example.com/secure.png)', + '![plain diagram](http://example.com/plain.png)', + ].join('\n\n') + const { container } = render() + const images = [...container.querySelectorAll('img')] + expect(images.map(image => image.getAttribute('src'))).toEqual([ + 'https://example.com/secure.png', + 'http://example.com/plain.png', + ]) + for (const image of images) { + expect(image.getAttribute('loading')).toBe('lazy') + expect(image.getAttribute('decoding')).toBe('async') + expect(image.getAttribute('referrerpolicy')).toBe('no-referrer') + } + }) + + it('neutralizes raw HTML, unsafe or relative links, and unsupported images', () => { const markdown = [ '', '', '[script](javascript:alert(1)) [relative](/settings)', '[mail](mailto:dev@example.com) [web](http://example.com) [upper](HTTPS://example.com)', - '![remote diagram](https://example.com/private.png)', + '![relative diagram](private.png)', + '![absolute diagram](/workspace/private.png)', + '![file diagram](file:///workspace/private.png)', + '![script diagram](javascript:alert(1))', + '![mail diagram](mailto:dev@example.com)', ].join('\n\n') const { container } = render() @@ -117,7 +139,11 @@ describe('MarkdownText', () => { expect(screen.getByRole('link', { name: 'mail' }).getAttribute('target')).toBeNull() expect(screen.getByRole('link', { name: 'web' }).getAttribute('rel')).toBe('noopener noreferrer') expect(screen.getByRole('link', { name: 'upper' }).getAttribute('target')).toBe('_blank') - expect(screen.getByText('remote diagram')).toBeTruthy() + expect(screen.getByText('relative diagram')).toBeTruthy() + expect(screen.getByText('absolute diagram')).toBeTruthy() + expect(screen.getByText('file diagram')).toBeTruthy() + expect(screen.getByText('script diagram')).toBeTruthy() + expect(screen.getByText('mail diagram')).toBeTruthy() }) it('keeps incomplete streaming Markdown renderable', () => { diff --git a/packages/client/ui-question/README.i18n.yaml b/packages/client/ui-question/README.i18n.yaml index 914485fbbb..f1ef187514 100644 --- a/packages/client/ui-question/README.i18n.yaml +++ b/packages/client/ui-question/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-question/README.md -README.md: 5ebba2a1da6e6108b82e9deb235b84f987600345 -README.zh.md: 0aa6428a9b6472fc5b525c11b4716ebc50c378c3 +README.md: 72d94396771eec0a90b96008b1fd5e4a736a398c +README.zh.md: 3c2b12b30dd2858c7b8f99193829c3274f3f8228 diff --git a/packages/client/ui-question/README.md b/packages/client/ui-question/README.md index 5ebba2a1da..72d9439677 100644 --- a/packages/client/ui-question/README.md +++ b/packages/client/ui-question/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user` only when the Web feature is selected; its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot. -The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`. +The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`. A request whose single question declares a presentation intent renders as that intent's own surface instead. `plan-review` — set by `dsh-plan-mode` on the `exit_plan_mode` review — takes the waiting-approval card shape: a `Plan review` strip, the plan as the scrolling markdown body, the question text as the card's accessible name, and one decision row of `Chat about it` / `Refuse` / `Approve`. Approve and Refuse answer with the asker's own option labels (the intent names which label approves, so the verdict never rides option order) and keep the asker's descriptions as tooltips; `Chat about it` rejects the wait as `ASK_CANCELLED`, returning the composer so the user can say what they want instead. The card claims a request only when it can send every answer that request allows: one question, the intent declared, the plan present as `detail`, the named approve label offered, and a binary single choice (at most one option besides approve, not multi-select). Anything else — no intent, a batch of several questions, a missing plan, an approve label naming no option, a third option, a multi-select decision — stays on the generic flow, which can express it. An intent changes the layout, never which answers are reachable. diff --git a/packages/client/ui-question/README.zh.md b/packages/client/ui-question/README.zh.md index 0aa6428a9b..3c2b12b30d 100644 --- a/packages/client/ui-question/README.zh.md +++ b/packages/client/ui-question/README.zh.md @@ -4,7 +4,7 @@ Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧才会挂载 `dsh-tool-ask-user`;浏览器侧会把 `question` 配置项注册到会话拥有的 `conversation.composer` 键控 slot 中。 -组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信内容策略。封顶卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。 +组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信内容策略。封顶卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。 若某个请求的唯一问题声明了呈现意图,则改为渲染该意图自己的界面。`plan-review` —— 由 `dsh-plan-mode` 在 `exit_plan_mode` 审阅上设置 —— 采用等待审批卡片的形状:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体、问题文本作为卡片的无障碍名称,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答(意图指名哪个标签表示批准,因此裁决绝不依赖选项顺序),并把提问方的描述保留为 tooltip;`Chat about it` 以 `ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。卡片只在能够发出该请求允许的每一个答案时才接管:只有一个问题、声明了意图、计划以 `detail` 存在、提供了被指名的批准标签,且是二元单选(除批准外最多一个选项,且非多选)。其他任何情形 —— 没有意图、一批含多个问题、缺少计划、批准标签未命中任何选项、出现第三个选项、多选决定 —— 都留在能够表达它的通用流程上。意图改变的只是布局,从不改变可达的答案。 diff --git a/packages/client/ui-question/src/client/QuestionComposer.tsx b/packages/client/ui-question/src/client/QuestionComposer.tsx index abe87f567e..596e1ec727 100644 --- a/packages/client/ui-question/src/client/QuestionComposer.tsx +++ b/packages/client/ui-question/src/client/QuestionComposer.tsx @@ -98,12 +98,13 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick { updateDraft((current) => { - const selected = question.multiSelect === true - ? current.selected.includes(label) + if (question.multiSelect === true) { + const selected = current.selected.includes(label) ? current.selected.filter(item => item !== label) : [...current.selected, label] - : [label] - return { selected, custom: '', skipped: false } + return { ...current, selected, skipped: false } + } + return { selected: [label], custom: '', skipped: false } }) if (question.multiSelect !== true && index < questions.length - 1) { setIndex(current => current + 1) @@ -129,7 +130,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick): void => { const value = event.target.value updateDraft(current => ({ - ...current, selected: [], custom: value, skipped: false, + ...current, + selected: question.multiSelect === true ? current.selected : [], + custom: value, + skipped: false, })) } diff --git a/packages/client/ui-question/tests/question-composer.spec.tsx b/packages/client/ui-question/tests/question-composer.spec.tsx index e31d756a9f..91475f43e4 100644 --- a/packages/client/ui-question/tests/question-composer.spec.tsx +++ b/packages/client/ui-question/tests/question-composer.spec.tsx @@ -104,13 +104,19 @@ describe('QuestionComposer', () => { fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' })) fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' })) fireEvent.click(screen.getByRole('checkbox', { name: '代码质量' })) - fireEvent.keyDown(screen.getByRole('checkbox', { name: '代码质量' }), { key: 'Enter' }) + const multiCustom = screen.getByPlaceholderText('输入你的答案') + fireEvent.change(multiCustom, { target: { value: '沟通能力' } }) + fireEvent.click(screen.getByRole('checkbox', { name: '产品判断' })) + expect(screen.getByRole('checkbox', { name: '系统设计' }).getAttribute('aria-checked')).toBe('true') + expect(screen.getByRole('checkbox', { name: '代码质量' }).getAttribute('aria-checked')).toBe('true') + expect((multiCustom as HTMLInputElement).value).toBe('沟通能力') + fireEvent.keyDown(multiCustom, { key: 'Enter' }) // The domain face encoded the whole batch into one carrier envelope. expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [ { id: 'profile', selected: ['工程落地型 (Recommended)'] }, { id: 'detail', selected: [], custom: '要能独立排查线上问题' }, - { id: 'signals', selected: ['系统设计', '代码质量'] }, + { id: 'signals', selected: ['系统设计', '代码质量', '产品判断'], custom: '沟通能力' }, ])) expect(screen.getByRole('button', { name: '正在提交…' }).disabled).toBe(true) }) @@ -233,6 +239,11 @@ describe('QuestionComposer', () => { fireEvent.keyDown(custom, { key: 'Enter' }) fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' })) fireEvent.click(screen.getByRole('button', { name: '提交' })) + expect(respond).toHaveBeenNthCalledWith(1, answeredEnvelope('second', [ + { id: 'profile', selected: ['工程落地型 (Recommended)'] }, + { id: 'detail', selected: [], custom: 'x' }, + { id: 'signals', selected: ['系统设计'] }, + ])) expect(await screen.findByText('网络中断')).toBeTruthy() expect(screen.getByRole('button', { name: '提交' }).disabled).toBe(false) diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 6c5f1735e3..c1f5d5df03 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/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-sidebar/README.md -README.md: 93a1f15a5802f94a0ebe930dda1dbd4fbc7343c9 -README.zh.md: 8c8545a5d7d8cb4d58772abf867d7ee82c31bf1d +README.md: 19c2d1033de4475816249aa8429f4a589eeb6481 +README.zh.md: b8c154586570cf1b9fd4bf776bc09b36ab5ee7d2 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 93a1f15a58..19c2d1033d 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -22,6 +22,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **State dots have two live data states (running/none)** — the done/error/amber sources arrive with P-II approvals and notifications; the four-color primitive is already wired. +- **Session state-dot rendering is owned by [ui-workspace](../ui-workspace/README.md)** — done/error notification sources remain deferred. - **Group-by menu ships by-workspace only** — Update/Status grouping strategies are drawn without specs and deferred. - **"New task completed" unread marking is local viewing state** — completion-time > last-seen never reaches the host. diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 8c8545a5d7..b8c1545865 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -22,6 +22,6 @@ New Session 会启动运行时的页面局部前端 Session Intent;真实 Work ## 已知限制与暂缓事项 -- **状态点只有两种实时数据状态(running/none)**:done/error/amber 的数据源将随 P-II 审批与通知功能一并提供;四色原语已接入。 +- **Session 状态点渲染由 [ui-workspace](../ui-workspace/README.md) 持有**:done/error 的通知数据源仍暂缓实现。 - **分组选单只提供按 Workspace 分组**:Update/Status 分组策略只有图稿而没有规范,暂缓实现。 - **「New task completed」未读标记是本地查看状态**:完成时间 > 上次查看时间这一事实永远不会到达宿主。 diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css b/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css index 239081c59c..fc3ddfea46 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css @@ -67,7 +67,7 @@ min-width: 0; } .menu > .node { - margin-left: -8px; + margin-left: -3px; } .row { diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 0b1cbf8030..69d9d620e3 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -100,17 +100,12 @@ } .table tbody tr[data-request-only='true'] td { - height: 1px; + height: 0; padding-top: 0; padding-bottom: 0; border-bottom: 0; } -.table tbody tr[data-request-only='true']:has(+ tr[data-request-only='true']) td { - /* Keep consecutive boundary markers from painting their halos over one another. */ - height: 9px; -} - .table tbody tr[data-request-only='true']:last-child td { /* Retain the lower half of the 16px boundary marker at the table's end. */ height: 9px; @@ -130,10 +125,12 @@ } .requestBoundaryControl { + --request-boundary-base-left: 12px; + position: absolute; z-index: 6; top: -8px; - left: 12px; + left: calc(var(--request-boundary-base-left) + var(--request-boundary-offset, 0px)); width: 16px; height: 16px; padding: 0; @@ -198,6 +195,12 @@ box-shadow: 0 0 0 1.5px var(--dsw-alias-brand-primary-new-colorprimary-new-color); } +.requestBoundaryControl[data-request-status='error']::before, +.requestBoundaryControl[data-request-status='error']:hover::before, +.requestBoundaryControl[data-request-status='error']:focus-visible::before { + background: var(--dsw-alias-state-error-primary); +} + .requestBoundaryControl:hover::after, .requestBoundaryControl:focus-visible::after { opacity: 1; @@ -402,7 +405,7 @@ } .requestBoundaryControl { - left: 6px; + --request-boundary-base-left: 6px; } .kindSlot { @@ -1245,9 +1248,19 @@ background: transparent; cursor: pointer; font: 600 12px/18px var(--dsw-font-family); + gap: 2px; user-select: none; } +.thinkingChevron { + flex: none; + transition: transform 120ms var(--ds-ease-in-out); +} + +.thinkingToggle[aria-expanded='true'] .thinkingChevron { + transform: rotate(90deg); +} + .thinkingToggle:hover { color: var(--dsw-alias-label-secondary); } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 7973649cf5..6f37c852c5 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -191,6 +191,10 @@ type TrajectorySplitStyle = CSSProperties & { '--trajectory-tool-request-width': string } +type RequestBoundaryStyle = CSSProperties & { + '--request-boundary-offset': string +} + function clampDetailsWidth(width: number, splitWidth: number): number { const maxWidth = Math.max( DETAILS_MIN_WIDTH, @@ -453,6 +457,22 @@ function indexRequestNumbers( return numbers } +function indexRequestBoundaryRuns(records: readonly TableRecord[]): ReadonlyMap { + const indexes = new Map() + let runLength = 0 + for (const record of records) { + if (record.cell.requestOnly === true) { + indexes.set(record.cell.index, runLength++) + continue + } + if (runLength > 0 && record.groupStart && requestStep(record.group) !== undefined) { + indexes.set(record.cell.index, runLength) + } + runLength = 0 + } + return indexes +} + function summarizeTurn(records: readonly TableRecord[]): string { const steps = new Set( records @@ -1212,7 +1232,8 @@ function MarkdownRecordContent({ aria-expanded={thinkingExpanded} onClick={() => { onThinkingExpandedChange(!thinkingExpanded) }} > - {thinkingExpanded ? 'Thinking' : 'Thinking ...'} + Thinking + {thinkingExpanded && ( record.cell.index === selectedIndex) const selectedPrompt = selected?.cell.kind === 'system' ? selected.cell.promptDetail @@ -1791,6 +1813,12 @@ export function TrajectoryTable({ const requestInfo = request === undefined ? undefined : sessionRequestNumbers?.find(candidate => candidate.number === request) + const requestStatus = requestInfo?.status + ?? (record.cell.isError === true ? 'error' : undefined) + const requestRunIndex = requestBoundaryRuns.get(record.cell.index) ?? 0 + const requestBoundaryStyle: RequestBoundaryStyle = { + '--request-boundary-offset': `${requestRunIndex * 8}px`, + } const requestLabel = request === undefined ? undefined : `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}` @@ -1882,6 +1910,9 @@ export function TrajectoryTable({ aria-label={requestLabel} aria-pressed={requestSelected} data-label={requestLabel} + data-request-run-index={requestRunIndex} + data-request-status={requestStatus} + style={requestBoundaryStyle} onClick={(event) => { event.stopPropagation() selectRequest({ @@ -1930,36 +1961,36 @@ export function TrajectoryTable({ - - - - {KIND_LABEL[record.cell.kind]} - + + + {KIND_LABEL[record.cell.kind]} - + )}
@@ -2497,7 +2528,7 @@ export function TrajectoryTable({ )} {selectedAssistantRequestTarget !== undefined && ( { selectRequest(selectedAssistantRequestTarget, 'timing') }} diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.spec.tsx index 65c3da3255..b9f5e5d7b7 100644 --- a/packages/client/ui-trajectory/tests/table.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.spec.tsx @@ -64,7 +64,7 @@ describe('TrajectoryTable', () => { it('shows assistant timing facts after keyboard selection', () => { render() fireEvent.keyDown(screen.getByRole('row', { name: /ASSISTANT/ }), { key: 'Enter' }) - fireEvent.click(screen.getByRole('button', { name: 'Timing' })) + fireEvent.click(screen.getByRole('button', { name: 'Request Timing' })) expect(screen.getByText('500 ms')).toBeTruthy() expect(screen.getByText('1.00 s')).toBeTruthy() @@ -101,10 +101,13 @@ describe('TrajectoryTable', () => { render() fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ })) - const toggle = screen.getByRole('button', { name: 'Thinking ...' }) + const toggle = screen.getByRole('button', { name: 'Thinking' }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') expect(screen.queryByText(thinking)).toBeNull() fireEvent.click(toggle) + expect(screen.getByRole('button', { name: 'Thinking' })).toBe(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('true') expect(toggle.parentElement?.textContent?.length).toBeGreaterThan(thinking.length) }) @@ -222,19 +225,79 @@ describe('TrajectoryTable', () => { expect(errorResult.closest('[class*="errorPayload"]')).toBeTruthy() }) - it('renders responsive role icons with a custom tooltip', () => { + it('marks failed requests and lays coincident request markers left to right', () => { + const turns: readonly TrajectoryTurnModel[] = [ + { + turn: 1, + groups: [{ + title: 'Step 1', + cells: [{ + index: 1, + kind: 'message', + text: '', + requestOnly: true, + isError: true, + timeSeconds: 0.1, + }], + }], + }, + { + turn: 2, + groups: [{ + title: 'Step 1', + cells: [{ + index: 2, + kind: 'message', + text: '', + requestOnly: true, + isError: true, + timeSeconds: 0.1, + }], + }], + }, + { + turn: 3, + groups: [{ + title: 'Step 1', + cells: [{ + index: 3, + kind: 'message', + text: 'Recovered response', + timeSeconds: 0.1, + }], + }], + }, + ] + render() + + const failed = screen.getByRole('button', { name: 'Request #1' }) + const retry = screen.getByRole('button', { name: 'Request #2' }) + const recovered = screen.getByRole('button', { name: 'Request #3' }) + expect(failed.getAttribute('data-request-status')).toBe('error') + expect(failed.getAttribute('data-request-run-index')).toBe('0') + expect(failed.style.getPropertyValue('--request-boundary-offset')).toBe('0px') + expect(retry.getAttribute('data-request-run-index')).toBe('1') + expect(retry.style.getPropertyValue('--request-boundary-offset')).toBe('8px') + expect(recovered.getAttribute('data-request-run-index')).toBe('2') + expect(recovered.style.getPropertyValue('--request-boundary-offset')).toBe('16px') + }) + + it('shows the custom role tooltip only from the responsive icon', () => { const view = render() const toolTag = view.container.querySelector('[data-role-kind="tool"]') + const toolIcon = toolTag?.querySelector('[data-role-icon="wrench"]') expect(toolTag).not.toBeNull() expect(toolTag?.getAttribute('title')).toBeNull() - expect(toolTag?.querySelector('[data-role-icon="wrench"]')).toBeTruthy() + expect(toolIcon).toBeTruthy() fireEvent.mouseEnter(toolTag as HTMLElement) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.mouseEnter(toolIcon as HTMLElement) const tooltip = screen.getByRole('tooltip') expect(tooltip.textContent).toBe('TOOL') expect(tooltip.getAttribute('data-side')).toBe('right') - fireEvent.mouseLeave(toolTag as HTMLElement) + fireEvent.mouseLeave(toolIcon as HTMLElement) expect(screen.queryByRole('tooltip')).toBeNull() }) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 4023983d6d..374efd0f58 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: 2670bdfa2fb1a223bf0c0ea65fbacc1cfb30c607 -README.zh.md: 1e68cfb1a94c240c32949059ca6a3c6adc25208b +README.md: 17105f9d70ab5fa0c0472c4b3fb39b759107f469 +README.zh.md: b40b9469271e539501a8f6fc0b70a84f8961f7ab diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 2670bdfa2f..17105f9d70 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -12,6 +12,8 @@ Workspace and Session hover cards copy the value their row clips: activating a W 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. +Session rows distinguish the runtime's live `waitingApproval` approval-request fact from an otherwise blue in-flight Session: an amber warning dot takes precedence over the running indicator, and the hover card reports **Waiting for approval** until the request is resolved. Every lit state carries a visually hidden label (`Waiting for approval` or `Running`) for assistive technology; an idle row leaves the reserved status slot empty. Question waits do not set a list-level status bit such as `waitingApproval`. + Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. The shared sidebar projection hides rows whose durable Session summary has `origin: 'subagent'`; users enter those conversations through the selected parent's subagent header catalog. Ordinary forks remain visible because lineage alone does not set that origin. The runtime keeps hidden rows available for conversation, title, and addressed transport state. @@ -28,4 +30,5 @@ None; this package neither assembles nor sends a provider request. - **No fuzzy content search or event deep links** — the content backend uses literal token/phrase matching, and selecting a result opens the Session rather than the matching event. - **No Session deletion or unarchive control** — archiving replaces the former Delete placeholder; archived sessions have no viewing or unarchive surface yet, and Workspace registration deletion does not delete Sessions. +- **Approval waiting is not aggregated into collapsed groups** — a waiting row inside a collapsed group lights no group-header indicator and becomes visible only after that group is expanded. - **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 1e68cfb1a9..b40b946927 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -12,6 +12,8 @@ Workspace 和 Session 悬浮卡片会复制对应行被截断的值:激活 Wor Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。 +Session 行会把 runtime 的实时 `waitingApproval` 审批请求状态与原本显示为蓝色的进行中 Session 区分开:琥珀色警告点优先于运行指示器,hover 卡片则在请求解决前显示**等待审批**。每种点亮状态都带有面向辅助技术的视觉隐藏标签(等待审批或进行中,随词典本地化);空闲行会保留空的状态槽位。问题等待不会设置如 `waitingApproval` 这样的列表级状态位。 + 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 共享侧边栏投影会隐藏持久化 Session 摘要中带有 `origin: 'subagent'` 的行;用户从所选 parent 的 subagent 页头目录进入这些对话。普通 fork 仍然可见,因为仅有谱系不会设置该 origin。运行时仍保留隐藏行,供对话、标题与已寻址传输状态使用。 @@ -28,4 +30,5 @@ Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork, - **没有模糊内容搜索或事件深链接**:内容后端采用字面 token/短语匹配,选择结果会打开 Session,而不是匹配的事件。 - **没有 Session 删除与取消归档控件**:归档取代了原先的 Delete 占位;已归档会话尚无查看或取消归档入口;删除 Workspace 注册记录不会删除 Session。 +- **待审批状态不会聚合到折叠的分组上**:折叠分组内正在等待的行不会点亮分组头指示,只有展开该分组后才可见。 - **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。可远程的选取是 `-browse` 组合的应用内流程。 diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index 3368f7b3e7..e64127a70d 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -46,6 +46,7 @@ export const zh = { 'actions.newSession.aria': '在“{name}”中新建会话', 'status.running': '进行中', 'status.idle': '空闲', + 'status.waitingApproval': '等待审批', 'hover.created': '创建于 {time}', 'hover.copied': '已复制', 'date.ymd': '{y}年{m}月{d}日', @@ -103,6 +104,7 @@ export const en = { 'actions.newSession.aria': 'New session in {name}', 'status.running': 'Running', 'status.idle': 'Idle', + 'status.waitingApproval': 'Waiting for approval', 'hover.created': 'Created {time}', 'hover.copied': 'Copied', 'date.ymd': '{y}-{m}-{d}', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index ccbbabb9ce..612eb3e306 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -124,6 +124,15 @@ color: var(--dsw-alias-label-tertiary); } +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + .folderActive { color: var(--dsw-alias-state-business-primary); diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index ab92e671ea..e961919498 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -12,6 +12,7 @@ import { IconEllipsisOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' +import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' import type { WorkspaceBrowserProps } from '../contract/slots.ts' import type { GroupNode, SearchResultNode, SessionNode } from '../tree.ts' import { relativeTime } from '../tree.ts' @@ -165,16 +166,16 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: { ) } -/** - * One top-level 34px session row with running dot and relative time. - * @param props.node - derived session node. - * @param props.currentId - selected session id (row highlight). - * @param props.now - epoch ms for relative-time formatting. - * @param props.onOpen - open a session by id. - * @returns the session row. - */ -/** Hover-card body: full title, relative time, and the status line (running/idle until wire status lands). */ +/** Session status presentation; approval waiting outranks the underlying running state. */ +function sessionStatus(node: SessionNode, t: RowTranslate): { state: StateDotState; label: string } { + if (node.waitingApproval) return { state: 'warning', label: t('status.waitingApproval') } + if (node.running) return { state: 'ongoing', label: t('status.running') } + return { state: 'done', label: t('status.idle') } +} + +/** Hover-card body: full title, relative time, and approval/running/idle status. */ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) { + const status = sessionStatus(node, t) return (
{displayTitle(node, t)}
@@ -182,8 +183,8 @@ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; before the first prompt. */} {!node.blank &&
{hoverTimeLabel(node.updatedAt, now, t)}
}
- - {node.running ? t('status.running') : t('status.idle')} + + {status.label}
) @@ -248,6 +249,20 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' } +/** + * One top-level 34px session row: status dot (approval waiting outranks + * running), title, relative time, and the row actions menu. + * @param props.node - derived session node. + * @param props.currentId - selected session id (row highlight). + * @param props.now - epoch ms for relative-time formatting. + * @param props.onOpen - open a session by id. + * @param props.onRename - open the session rename dialog (id + current title). + * @param props.onFork - fork a session at its last completed turn. + * @param props.onArchive - archive a session by id. + * @param props.drag - optional draggable-row wiring. + * @param props.t - the browser root's locale seat. + * @returns the session row. + */ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, t }: { node: SessionNode currentId: string | undefined @@ -266,6 +281,7 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork const row = node const title = displayTitle(node, t) const selected = node.id === currentId + const status = sessionStatus(node, t) const [menuOpen, setMenuOpen] = useState(false) // Archive replaces the former Delete placeholder: it hides the row through // the registry-global archive set and never touches the session log, so it @@ -310,7 +326,14 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork drag.drop(rowHalf(e)) }} > - {row.running && } + + {status.state !== 'done' && ( + <> + + {status.label} + + )} + {title} {/* A blank New Session row is a provisional placeholder: nothing has happened in it yet, so a "now" timestamp and the row verbs diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 6010a1030e..8c72608eaf 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -20,6 +20,8 @@ export interface SessionNode { title: string /** The provisional blank session (renderer shows the localized New Session title). */ blank: boolean + /** The runtime Session list reports a pending approval request for this Session. */ + waitingApproval: boolean running: boolean updatedAt: number } @@ -169,6 +171,7 @@ function sessionNode(s: SessionSummary): SessionNode { id: s.id, title: sessionTitle(s), blank: s.blank, + waitingApproval: s.waitingApproval, running: s.running, updatedAt: s.updatedAt, } diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index 7bef820d4a..1fdfbed0b9 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -96,7 +96,7 @@ describe('workspace browser rows', () => { it('renders and opens a selected running Session row', () => { const node: SessionNode = { - id: sid('session'), title: 'Session', blank: false, running: true, updatedAt: 0, + id: sid('session'), title: 'Session', blank: false, waitingApproval: false, running: true, updatedAt: 0, } const onOpen = vi.fn() render( @@ -180,7 +180,7 @@ describe('workspace browser rows', () => { vi.useFakeTimers() try { const node: SessionNode = { - id: sid('s-blank'), title: 'ignored', blank: true, running: false, updatedAt: 0, + id: sid('s-blank'), title: 'ignored', blank: true, waitingApproval: false, running: false, updatedAt: 0, } render() @@ -206,7 +206,7 @@ describe('workspace browser rows', () => { const onFork = vi.fn() const onArchive = vi.fn() const node: SessionNode = { - id: sid('s1'), title: 'One', blank: false, running: false, updatedAt: 0, + id: sid('s1'), title: 'One', blank: false, waitingApproval: false, running: false, updatedAt: 0, } render() @@ -234,11 +234,12 @@ describe('workspace browser rows', () => { expect(screen.queryByRole('menu')).toBeNull() }) + it('shows the hover card after the dwell and suppresses it while the row menu is open', () => { vi.useFakeTimers() try { const node: SessionNode = { - id: sid('s1'), title: 'Hovered', blank: false, running: true, updatedAt: 0, + id: sid('s1'), title: 'Hovered', blank: false, waitingApproval: false, running: true, updatedAt: 0, } render() @@ -248,7 +249,7 @@ describe('workspace browser rows', () => { // Card body: full title + relative time + running status. expect(screen.getAllByText('Hovered')).toHaveLength(2) expect(screen.getByText('1分钟前')).toBeTruthy() - expect(screen.getByText('进行中')).toBeTruthy() + expect(screen.getAllByText('进行中')).toHaveLength(2) fireEvent.pointerLeave(wrapper) // Menu open (disabled=true) suppresses the card for the same hover. fireEvent.click(screen.getByRole('button', { name: '会话“Hovered”的操作' })) @@ -260,11 +261,38 @@ describe('workspace browser rows', () => { } }) + it('shows approval waiting as warning ahead of the running state', () => { + vi.useFakeTimers() + try { + const node: SessionNode = { + id: sid('approval'), title: 'Needs approval', blank: false, + waitingApproval: true, running: true, updatedAt: 0, + } + const view = render() + const row = screen.getByRole('treeitem') + expect(row.querySelector('[data-state="warning"]')).toBeTruthy() + expect(row.querySelector('[data-state="ongoing"]')).toBeNull() + expect(screen.getByText('等待审批')).toBeTruthy() + + view.rerender() + expect(screen.getByRole('treeitem').querySelector('[data-state="warning"]')).toBeTruthy() + + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getAllByText('等待审批')).toHaveLength(2) + expect(document.querySelectorAll('[data-state="warning"]')).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) + it('idle hover card shows the Idle status line', () => { vi.useFakeTimers() try { const node: SessionNode = { - id: sid('s1'), title: 'Quiet', blank: false, running: false, updatedAt: 0, + id: sid('s1'), title: 'Quiet', blank: false, waitingApproval: false, running: false, updatedAt: 0, } render() @@ -279,7 +307,7 @@ describe('workspace browser rows', () => { it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => { const node: SessionNode = { - id: sid('s1'), title: 'Drag me', blank: false, running: false, updatedAt: 0, + id: sid('s1'), title: 'Drag me', blank: false, waitingApproval: false, running: false, updatedAt: 0, } const inactive = dragProps() const { rerender } = render( diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts index 2d0055ce7f..249c3629a2 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -38,6 +38,14 @@ describe('deriveGroups', () => { expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')]) }) + it('projects approval-waiting state into grouped and flat rows', () => { + const awaiting = { ...summary('awaiting', 10), waitingApproval: true, running: true } + const sessions = list(awaiting) + const grouped = deriveGroups(sessions, [workspace('project', ['awaiting'])], noArchive, view(['project'])) + expect(grouped[0]!.sessions[0]).toMatchObject({ waitingApproval: true, running: true }) + expect(deriveFlat(sessions, noArchive)[0]).toMatchObject({ waitingApproval: true, running: true }) + }) + it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => { const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other')) const groups = deriveGroups(sessions, [workspace('first', ['owned'])], noArchive, view([UNGROUPED_KEY])) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index ef81c56ef9..0041585475 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: 8e2cb4ef6a4f465cf23922a61f50e9f383135de3 -README.zh.md: 87bfaf2ca6b47e877a7e39c9a16cd44f50441ae3 +README.md: 7a5e18990792d55f6eaabaf92df8d1cc0cb7c0ba +README.zh.md: d5f98f4cd7827a8c8db67b8707bad76f984c999d diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 8e2cb4ef6a..7a5e189907 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -10,6 +10,8 @@ Wire messages form a four-quadrant discriminated union — who initiates × requ The layering/protocol decisions are recorded in the [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md); the browser-side consumption architecture in the [web client architecture RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md). +Question responses are validated against their pending request before the first answer claims it. A multi-select item may carry both requested option labels in `selected` and non-empty `custom` text; a single-select item must use one or the other. Duplicate labels, unknown labels, mismatched ids, incomplete batches, and empty custom text are rejected as `bad-response`. + `session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message`, `assistant/message`, and `steering/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it. `session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 87bfaf2ca6..d5f98f4cd7 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -10,6 +10,8 @@ 分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中;浏览器侧消费架构记录在 [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)中。 +首个回答认领待处理请求之前,系统会对照该请求校验问题响应。多选题的回答项可以同时携带 `selected` 中的请求选项标签与非空 `custom` 文本;单选题的回答项必须二选一。标签重复、标签未知、id 不匹配、批次不完整以及自定义文本为空都会以 `bad-response` 拒绝。 + `session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent(智能体),然后按追加来源的消息边界分页。`maxMessages` 统计以追加方式进入 surface 的 `user/message`、`assistant/message` 和 `steering/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。 `session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元铸造一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 24bcdee3ff..804007cb4e 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -395,8 +395,10 @@ function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQues if (new Set(answer.selected).size !== answer.selected.length) return false const custom = answer.custom?.trim() if (custom !== undefined && custom === '') return false - if (custom !== undefined && answer.selected.length > 0) return false - if (question.multiSelect !== true && answer.selected.length > 1) return false + if (question.multiSelect !== true) { + if (custom !== undefined && answer.selected.length > 0) return false + if (answer.selected.length > 1) return false + } const labels = new Set(question.options?.map(option => option.label) ?? []) return answer.selected.every(label => labels.has(label)) }) diff --git a/packages/host/apiproxy/tests/api-proxy-question.spec.ts b/packages/host/apiproxy/tests/api-proxy-question.spec.ts new file mode 100644 index 0000000000..e8eaae813f --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-question.spec.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import type { ApiProxy, MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' +import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { createApiProxy } from '../src/api-proxy.ts' + +async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(UserInteractionService) + return { + ctx, + api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }), + } +} + +function agent(id: string): Agent { + return { id } as unknown as Agent +} + +function openMux(api: ApiProxy, abort: AbortController): { + envelopes: RpcRequest[] + waitForQuestion(): Promise>> +} { + const envelopes: RpcRequest[] = [] + let resolveQuestion!: (value: RpcRequest>) => void + const question = new Promise>>((resolve) => { + resolveQuestion = resolve + }) + void (async () => { + for await (const envelope of api.events.mux({ rpcId: RpcId('question-mux'), payload: {} }, abort.signal)) { + envelopes.push(envelope) + if (envelope.payload.type === 'question/requested') { + resolveQuestion(envelope as RpcRequest>) + } + } + })() + return { envelopes, waitForQuestion: () => question } +} + +function answer( + envelope: RpcRequest>, + selected: string[], + custom?: string, +): Parameters[0] { + return { + type: 'client-response', + rpcId: envelope.rpcId, + result: { + ok: true, + value: { + sessionId: envelope.payload.sessionId, + answer: { + answers: [{ + id: envelope.payload.questions[0]?.id, + selected, + ...custom === undefined ? {} : { custom }, + }], + }, + }, + }, + } +} + +describe('question response validation', () => { + it('accepts selected options with custom text for multi-select questions', async () => { + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const asked = ctx.userInteraction.ask({ + agent: agent('session-multi'), + questions: [{ + id: 'targets', + question: 'Choose targets and add another', + multiSelect: true, + options: [{ label: 'Code' }, { label: 'Docs' }], + }], + }) + const envelope = await mux.waitForQuestion() + + expect(await api.respond(answer(envelope, ['Code', 'Docs'], 'Release notes'))) + .toEqual({ accepted: true }) + await expect(asked).resolves.toEqual({ + answers: [{ id: 'targets', selected: ['Code', 'Docs'], custom: 'Release notes' }], + }) + expect(mux.envelopes.some(item => item.payload.type === 'question/resolved')).toBe(true) + abort.abort() + }) + + it('keeps selected options and custom text mutually exclusive for single-select questions', async () => { + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const asked = ctx.userInteraction.ask({ + agent: agent('session-single'), + questions: [{ + id: 'target', + question: 'Choose one target', + options: [{ label: 'Code' }, { label: 'Docs' }], + }], + }) + const envelope = await mux.waitForQuestion() + + expect(await api.respond(answer(envelope, ['Code'], 'Release notes'))) + .toEqual({ accepted: false, reason: 'bad-response' }) + expect(await api.respond(answer(envelope, [], 'Release notes'))) + .toEqual({ accepted: true }) + await expect(asked).resolves.toEqual({ + answers: [{ id: 'target', selected: [], custom: 'Release notes' }], + }) + abort.abort() + }) +}) diff --git a/packages/ui/tool-ask-user/README.i18n.yaml b/packages/ui/tool-ask-user/README.i18n.yaml index 7b8e667b58..85beed8474 100644 --- a/packages/ui/tool-ask-user/README.i18n.yaml +++ b/packages/ui/tool-ask-user/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/tool-ask-user/README.md -README.md: 8e779f4025c20cd200344efb7cb8cd6bc09ba64d -README.zh.md: acaffec0764404a0e0e842ffc2b4efdee8869c4f +README.md: 64da4d75d01a0df0ae51b1557ed1c796317b906f +README.zh.md: 48a5b5d0d1aadff8a522d0b6100c6d0479b948a5 diff --git a/packages/ui/tool-ask-user/README.md b/packages/ui/tool-ask-user/README.md index 8e779f4025..64da4d75d0 100644 --- a/packages/ui/tool-ask-user/README.md +++ b/packages/ui/tool-ask-user/README.md @@ -15,7 +15,7 @@ Model-facing `ask_user_question` tool over `ctx.userInteraction`. It lets the mo - `options` — optional choices with `label` and `description`. If recommending a choice, put it first and append `(Recommended)` to that label. - `multi_select` — whether that question may return more than one selected option. -The tool calls `ctx.userInteraction.ask()` and returns canonical `{ answers: [{ id, selected, custom? }] }`. `selected` contains option labels; `custom` is present only for a free-form answer and overrides selected choices. The Native renderer preserves the compact JSON text shape `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`. +The tool calls `ctx.userInteraction.ask()` and returns canonical `{ answers: [{ id, selected, custom? }] }`. `selected` contains option labels; `custom` carries a free-form answer, supplementing `selected` for a multi-select question and overriding it for a single-select question. The Native renderer preserves the compact JSON text shape `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`. ## Role diff --git a/packages/ui/tool-ask-user/README.zh.md b/packages/ui/tool-ask-user/README.zh.md index acaffec076..48a5b5d0d1 100644 --- a/packages/ui/tool-ask-user/README.zh.md +++ b/packages/ui/tool-ask-user/README.zh.md @@ -15,7 +15,7 @@ - `options`:可选选项,包含 `label` 和 `description`。如需推荐某个选项,请将其置于首位,并在该标签末尾追加 `(Recommended)`。 - `multi_select`:该问题是否可以返回多个选中的选项。 -工具调用 `ctx.userInteraction.ask()`,并返回规范的 `{ answers: [{ id, selected, custom? }] }`。`selected` 包含选项标签;仅当用户自由填写回答时才会出现 `custom`,并覆盖选中的选项。Native 渲染器会保留紧凑的 JSON 文本形式 `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`。 +工具调用 `ctx.userInteraction.ask()`,并返回规范的 `{ answers: [{ id, selected, custom? }] }`。`selected` 包含选项标签;`custom` 携带自由填写的回答,对于多选题会补充 `selected`,对于单选题则会覆盖它。Native renderer 会保留紧凑的 JSON 文本形式 `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`。 ## 职责 diff --git a/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts b/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts index 395986aed1..0c55e33ed7 100644 --- a/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts +++ b/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts @@ -140,7 +140,8 @@ describe('ask_user_question tool', () => { async ask() { return { answers: [ - { id: 'targets', selected: ['tests', 'docs'] }, + { id: 'targets', selected: ['tests', 'docs'], custom: 'release notes' }, + { id: 'labels-only', selected: ['tests'] }, { id: 'notes', selected: [], custom: 'ship today' }, ], } @@ -159,6 +160,12 @@ describe('ask_user_question tool', () => { options: [{ label: 'tests' }, { label: 'docs' }], multi_select: true, }, + { + id: 'labels-only', + question: 'Which labels should I keep?', + options: [{ label: 'tests' }, { label: 'docs' }], + multi_select: true, + }, { id: 'notes', question: 'Any note?' }, ], }, @@ -168,13 +175,14 @@ describe('ask_user_question tool', () => { if (result.isError) throw new Error('expected ask_user_question success') expect(result.value).toEqual({ answers: [ - { id: 'targets', selected: ['tests', 'docs'] }, + { id: 'targets', selected: ['tests', 'docs'], custom: 'release notes' }, + { id: 'labels-only', selected: ['tests'] }, { id: 'notes', selected: [], custom: 'ship today' }, ], }) expect(result.content).toEqual([{ type: 'text', - text: '{"answers":[{"id":"targets","selected":["tests","docs"]},{"id":"notes","selected":[],"custom":"ship today"}]}', + text: '{"answers":[{"id":"targets","selected":["tests","docs"],"custom":"release notes"},{"id":"labels-only","selected":["tests"]},{"id":"notes","selected":[],"custom":"ship today"}]}', }]) }) diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index 930bbf2c27..de0cf52845 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: c81cac891403e5294c4456ce4d4048ecd74666ce -README.zh.md: 01055619f4df460284564f0a1816de366d809e01 +README.md: 5199e75d732f384497b28518920cc2c775c600c2 +README.zh.md: 02d4431fdab575b5596152c985b72594a948b081 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index c81cac8914..5199e75d73 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -12,7 +12,7 @@ This package owns interactive terminal presentation and input only. It injects ` 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. +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 inline between the transcript/status area and the editor. The question panel shows progress, numbered options, wrapped labels, and separately indented descriptions; it obeys both `maxQuestionOptions` and `questionDialogMaxHeight`, marks hidden options with `↑ N more` / `↓ N more`, and uses Page Up / Page Down to page long question/detail content before an individually oversized selected block while keeping the editor visible. 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. An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`. @@ -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 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. +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`, `/details`, `/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. The hidden phase also folds each turn's assistant steps into one message: the first step with visible text or reasoning keeps the turn's single `Assistant` header, later steps render as headerless continuations, and a step without a visible body renders nothing; leaving the hidden phase restores the per-step headers. 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. `/details` names the same state those two shortcuts cycle: bare it opens a centered keyboard toggle with one entry per dimension — `Tool cards` and `Reasoning` — showing the live values, where Tab cycles the highlighted entry and applies the change immediately (the transcript behind the dialog is the preview), and Enter, Esc, or Ctrl+C closes; `/details collapsed|expanded|hidden` jumps tool cards to that phase directly, and `/details reasoning [on|off]` sets — or bare `reasoning` toggles — reasoning-block display; arguments combine in one invocation, an unknown argument fails with the usage line, and a combined invocation applies reasoning first so its transcript rebuild never drops the card notice. `/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. @@ -50,13 +50,15 @@ A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY | `sessionId` | `main` | Exact shared agent/session identity driven by the terminal | | `showReasoning` | `true` | Render reasoning blocks | | `maxToolOutputLines` | `6` | Output lines retained across a collapsed tool card's head/tail preview | -| `maxQuestionOptions` | `8` | Visible options in a question panel | +| `maxDiffEditLength` | `1000` | Maximum added and removed lines explored for an exact diff before whole-side fallback | +| `maxQuestionOptions` | `8` | Maximum option blocks visible at once; the row bound may reduce this further | | `maxModelOptions` | `8` | Visible models in the model selector | | `maxResumeOptions` | `8` | Visible sessions in the resume selector | | `questionDialogWidth` | `200` | Question-panel width in columns, clamped to the terminal | -| `questionDialogMaxHeight` | `20` | Question-panel maximum rows | +| `questionDialogMaxHeight` | `20` | Maximum question-panel rows, further bounded to retain the editor | | `modelDialogWidth` | `76` | Model-selector width in columns | | `modelDialogMaxHeight` | `20` | Model-selector maximum rows | +| `detailsDialogWidth` | `72` | Transcript-details selector width in columns | | `fileSearchMaxResults` | `20` | Maximum file and directory candidates shown for one `@` query | | `fileSearchMaxEntries` | `10000` | Maximum paths retained in the bounded workspace index used by bare fuzzy queries | | `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | Directory basenames omitted from traversal and direct completion | @@ -72,6 +74,7 @@ A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY sessionId: main-session-123 showReasoning: true maxToolOutputLines: 6 + maxDiffEditLength: 1000 fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist'] ``` @@ -83,7 +86,7 @@ Every general-purpose SGR code the TUI emits lives in one table, `paletteSpec` i 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. +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 with both sides available colors and counts exact added `+` and removed `-` lines, while unchanged context stays dim and uncounted. If exact comparison exceeds `maxDiffEditLength`, the card renders each old-side row as removed and each new-side row as added, marks the footer approximate, and caches that fallback for later redraws. When `oldText` is unavailable, including pending writes and replay fallbacks as well as creates, every non-empty new-side row is shown and counted as added; that count does not prove the rows were absent from an existing file. Empty new content produces no synthetic `+ ` row. A `[signal …]` marker remains 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. ## Model Experience @@ -153,7 +156,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -When a consumer calls `ctx.userInteraction.ask()`, this provider presents each question in order and returns selected option labels or `custom` text. Abort, cancellation, or UI disposal becomes `Error: ask_user_question was interrupted before the user answered` through `dsh-tool-ask-user`. +When a consumer calls `ctx.userInteraction.ask()`, this provider presents each question in order and returns selected option labels, `custom` text, or both for a multi-select question. Pending custom text survives switching back to options and joins checked labels on a later options-mode submit. Abort, cancellation, or UI disposal becomes `Error: ask_user_question was interrupted before the user answered` through `dsh-tool-ask-user`. #### Token effect diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 01055619f4..02d4431fda 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -12,7 +12,7 @@ DeepSeek Harness agent(智能体)的交互式终端入口,基于 [`@earend 终端成功启动后,本包会提供终端本地的 `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 消息——不渲染任何内容。 +TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在 transcript/状态区域与编辑器之间内联展示 `ctx.userInteraction` 问题。问题面板会显示进度、编号选项、换行标签和另行缩进的描述;它同时遵守 `maxQuestionOptions` 和 `questionDialogMaxHeight`,用 `↑ N more`/`↓ N more` 标记隐藏选项,并在保持编辑器可见的同时,通过 Page Up 和 Page Down 先分页浏览过长的问题/详情内容,再分页浏览单个超大的选中块。最新记录的会话标题成为 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 消息——不渲染任何内容。 如果逻辑工作区标签与会话宿主目录不同,嵌入方可以提供 `TuiRuntime.formatCwd`。该覆盖只改变 footer 标签;工具仍使用会话 `cwd`。 @@ -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)标记对处于开启状态期间,提示词上方会显示固定的 `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 在空闲时退出。 +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`、`/details`、`/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 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息:第一个有可见文本或 reasoning 的步骤保留该轮次唯一的 `Assistant` 标题,之后的步骤渲染为无标题的续段,没有可见正文的步骤则不渲染任何内容;离开隐藏阶段会恢复每步各自的标题。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。`/details` 命名的正是这两个快捷键循环的同一份状态:不带参数时打开一个居中的键盘开关,每个维度一个条目——`Tool cards` 与 `Reasoning`——显示实时值,Tab 循环高亮条目并立即应用变更(对话框背后的 transcript 即是预览),Enter、Esc 或 Ctrl+C 关闭;`/details collapsed|expanded|hidden` 让工具卡片直接跳到该阶段,`/details reasoning [on|off]` 设置——或裸 `reasoning` 切换——reasoning 块显示;参数可在一次调用中组合,未知参数会以用法行报错,组合调用先应用 reasoning,使其 transcript 重建不会丢掉卡片通知。 `/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 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。 @@ -50,13 +50,15 @@ Footer 将会话报告的用量汇总为 `↑`;任 | `sessionId` | `main` | 由终端驱动的精确共享 agent/会话身份 | | `showReasoning` | `true` | 渲染 reasoning 块 | | `maxToolOutputLines` | `6` | 折叠工具卡片的头尾预览所保留的输出行数 | -| `maxQuestionOptions` | `8` | 问题面板中可见的选项数 | +| `maxDiffEditLength` | `1000` | 回退到整侧展示前,精确 diff 最多探索的新增与删除行总数 | +| `maxQuestionOptions` | `8` | 一次最多可见的选项块数;行数边界可能进一步减少可见数量 | | `maxModelOptions` | `8` | 模型选择器中可见的模型数 | | `maxResumeOptions` | `8` | 恢复选择器中可见的会话数 | | `questionDialogWidth` | `200` | 问题面板宽度(列数),以终端宽度为上限 | -| `questionDialogMaxHeight` | `20` | 问题面板最大行数 | +| `questionDialogMaxHeight` | `20` | 问题面板最大行数,会进一步受限以保留编辑器 | | `modelDialogWidth` | `76` | 模型选择器宽度(列数) | | `modelDialogMaxHeight` | `20` | 模型选择器最大行数 | +| `detailsDialogWidth` | `72` | transcript 细节选择器宽度(列数) | | `fileSearchMaxResults` | `20` | 一次 `@` 查询显示的最大文件和目录候选数 | | `fileSearchMaxEntries` | `10000` | 无路径模糊查询使用的有界工作区索引最多保留的路径数 | | `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | 遍历和直接补全时忽略的目录 basename | @@ -72,6 +74,7 @@ Footer 将会话报告的用量汇总为 `↑`;任 sessionId: main-session-123 showReasoning: true maxToolOutputLines: 6 + maxDiffEditLength: 1000 fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist'] ``` @@ -83,7 +86,7 @@ TUI 发出的所有通用 SGR 代码都集中在一个表中,即 `components/t 每种视觉语义只对应一个角色:`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` 可移除所有样式。 +成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。在工具卡片内部,整个正文——presenter 标题、终端 `$` 命令与 cwd,以及工具自身的输出——统一以同一种暗色渲染,因此只有带状态色的表头携带颜色,正文读作一个整体弱化的区块,而不是一串互相竞争的色调;注入上下文卡片的正文与其表头也是同一种色调。当前后两侧文本均可用时,diff 卡片会为精确识别出的新增 `+` 行和删除 `-` 行着色并计数;未变更的上下文保持暗色且不纳入计数。如果精确比较超出 `maxDiffEditLength`,卡片会把旧侧每一行渲染为删除行、把新侧每一行渲染为新增行,将页脚标记为近似结果,并缓存该回退结果供后续重绘使用。当 `oldText` 不可用时(包括待处理写入、回放回退以及文件创建),新侧的每个非空行都会显示并计作新增行;该计数不能证明这些行原先不存在于已有文件中。新内容为空时,不会补出虚构的 `+ ` 行。`[signal …]` 标记仍保留颜色,因为那里的颜色本身就是语义,而非强调。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。 ## 模型体验 @@ -153,7 +156,7 @@ Paths prefixed with @ are files explicitly referenced by the user. Use the read #### 模型看到的内容 -消费方调用 `ctx.userInteraction.ask()` 时,此提供方会按顺序显示各个问题,并返回选中选项标签或 `custom` 文本。中止、取消或 UI dispose 会变为 `Error: ask_user_question was interrupted before the user answered`;该转换由 `dsh-tool-ask-user` 完成。 +消费方调用 `ctx.userInteraction.ask()` 时,此提供方会按顺序显示各个问题,并返回选中选项标签、`custom` 文本,或为多选题同时返回两者。切回选项后,待提交的自定义文本仍会保留,并在之后从选项模式提交时与已勾选的标签一同返回。中止、取消或 UI dispose 会变为 `Error: ask_user_question was interrupted before the user answered`;该转换由 `dsh-tool-ask-user` 完成。 #### Token 影响 diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index c3506ea338..a68fe7968a 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -66,6 +66,7 @@ }, "dependencies": { "@earendil-works/pi-tui": "0.80.7", + "diff": "^9.0.0", "saxes": "6.0.0", "schemastery": "^3.18.0" }, diff --git a/packages/ui/tui/src/chat/questions.ts b/packages/ui/tui/src/chat/questions.ts index e5f2c8b806..5d96282860 100644 --- a/packages/ui/tui/src/chat/questions.ts +++ b/packages/ui/tui/src/chat/questions.ts @@ -29,7 +29,10 @@ interface PendingQuestion { } /** Collaborators the question queue needs from the chat channel. */ -export type QuestionQueueDeps = ChatChannelDeps +export interface QuestionQueueDeps extends ChatChannelDeps { + /** Current row budget after reserving the editor. */ + questionMaxHeight(): number +} /** Ask-user-question controller for one chat channel. */ export interface QuestionQueue { @@ -85,6 +88,7 @@ export function createQuestionQueue(deps: QuestionQueueDeps): QuestionQueue { pending.request.questions.length, pending.request.questions.length - pending.answers.length, resolved.maxQuestionOptions, + () => deps.questionMaxHeight(), palette, (selection) => { pending.overlay = undefined @@ -102,10 +106,8 @@ export function createQuestionQueue(deps: QuestionQueueDeps): QuestionQueue { options: { width: resolved.questionDialogWidth, maxHeight: resolved.questionDialogMaxHeight, - anchor: 'bottom-left', - margin: { bottom: 1 }, }, - }) + }, 'inline') pending.overlay = session void session.closed.then((result) => { if (pending.overlay !== session) return diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index bad6a5458b..6867a30240 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -34,6 +34,7 @@ import type { import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction' import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, displayText, sanitizePastedText } from './text.ts' import { dialogSelectTheme, type Palette } from './theme.ts' +import type { ToolCardVisibility } from './transcript.ts' import { renderTuiPromptTemplate, type TuiPromptTemplateToken, @@ -432,6 +433,79 @@ export class ModelDialog implements Component { } } +/** Both transcript-detail dimensions, applied immediately on each Tab. */ +export interface DetailsSelection { + readonly visibility: ToolCardVisibility + readonly showReasoning: boolean +} + +const TOOL_CARD_PHASES: readonly ToolCardVisibility[] = ['collapsed', 'expanded', 'hidden'] + +/** + * Keyboard toggle over the two transcript-detail entries — tool-card + * visibility and reasoning display. Tab cycles the highlighted entry's value + * and applies it immediately, so the transcript behind the dialog is the live + * preview; Enter, Esc, or Ctrl+C closes. + */ +export class DetailsDialog implements Component { + private readonly list: SelectList + private readonly toolsItem: SelectItem + private readonly reasoningItem: SelectItem + + constructor( + private visibility: ToolCardVisibility, + private showReasoning: boolean, + private readonly palette: Palette, + private readonly apply: (selection: DetailsSelection) => void, + private readonly close: () => void, + ) { + this.toolsItem = { value: 'tools', label: 'Tool cards', description: visibility } + this.reasoningItem = { value: 'reasoning', label: 'Reasoning', description: this.reasoningLabel() } + this.list = new SelectList([this.toolsItem, this.reasoningItem], 2, dialogSelectTheme(palette)) + this.list.onSelect = close + } + + private reasoningLabel(): string { + return this.showReasoning ? 'shown' : 'hidden' + } + + /** Cycle the highlighted entry one step and apply the new state. */ + private cycle(): void { + const selected = this.list.getSelectedItem() + /* v8 ignore next -- the two-entry list always has a selection. */ + if (selected === null) return + if (selected.value === 'tools') { + const index = TOOL_CARD_PHASES.indexOf(this.visibility) + this.visibility = TOOL_CARD_PHASES[(index + 1) % TOOL_CARD_PHASES.length] as ToolCardVisibility + this.toolsItem.description = this.visibility + } else { + this.showReasoning = !this.showReasoning + this.reasoningItem.description = this.reasoningLabel() + } + this.apply({ visibility: this.visibility, showReasoning: this.showReasoning }) + } + + invalidate(): void { + this.list.invalidate() + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) this.close() + else if (matchesKey(data, Key.tab)) this.cycle() + else this.list.handleInput(data) + this.invalidate() + } + + render(width: number): string[] { + const innerWidth = Math.max(1, width - 4) + return renderDialog('Transcript details', [ + ...this.list.render(innerWidth), + '', + this.palette.dim('↑/↓ move • Tab toggle • Enter/Esc close'), + ], width, this.palette) + } +} + /** The provider/model route recovered from a resume candidate's log. */ export interface ResumeRoute { provider: string @@ -748,10 +822,18 @@ export class ResumePicker implements Component, Focusable { } } -/** Bottom-anchored dialog for one user question with option or custom-answer modes. */ +interface SelectedBlockPage { + offset: number + size: number + maxOffset: number +} + +/** Inline dialog for one user question with option or custom-answer modes. */ export class QuestionDialog implements Component, Focusable { private selectedIndex = 0 private selected = new Set() + private headerPage: SelectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } + private selectedBlockPage: SelectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } private mode: 'options' | 'custom' private error = '' private readonly input = new Input() @@ -764,6 +846,7 @@ export class QuestionDialog implements Component, Focusable { private readonly total: number, private readonly unanswered: number, private readonly maxVisible: number, + private readonly maxHeight: () => number, private readonly palette: Palette, private readonly done: (selection: QuestionSelection) => void, private readonly cancel: () => void, @@ -787,6 +870,14 @@ export class QuestionDialog implements Component, Focusable { handleInput(data: string): void { this.invalidate() + if (matchesKey(data, Key.pageUp)) { + this.pageBackward() + return + } + if (matchesKey(data, Key.pageDown)) { + this.pageForward() + return + } if (this.mode === 'custom') { this.input.focused = this.focused this.input.handleInput(data) @@ -794,21 +885,27 @@ export class QuestionDialog implements Component, Focusable { } const options = this.options if (matchesKey(data, Key.up)) { + this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } this.selectedIndex = this.selectedIndex === 0 ? options.length - 1 : this.selectedIndex - 1 } else if (matchesKey(data, Key.down)) { + this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } this.selectedIndex = this.selectedIndex === options.length - 1 ? 0 : this.selectedIndex + 1 } else if (matchesKey(data, Key.space) && this.question.multiSelect) { if (this.selected.has(this.selectedIndex)) this.selected.delete(this.selectedIndex) else this.selected.add(this.selectedIndex) } else if (matchesKey(data, Key.enter)) { - const indices = this.question.multiSelect ? [...this.selected].sort((a, b) => a - b) : [this.selectedIndex] - if (indices.length === 0) { + const selected = this.question.multiSelect + ? this.selectedOptionLabels() + : [options[this.selectedIndex]?.label].filter((label): label is string => label !== undefined) + const custom = this.question.multiSelect ? this.input.getValue().trim() : '' + if (selected.length === 0 && custom === '') { this.error = 'Select at least one option, or press Tab for a custom answer.' return } - this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) }) + this.done({ selected, ...(custom === '' ? {} : { custom }) }) } else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') { this.mode = 'custom' + this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } this.error = '' } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { this.cancel() @@ -821,78 +918,362 @@ export class QuestionDialog implements Component, Focusable { this.error = 'Enter an answer before submitting.' return } - this.done({ selected: [], custom }) + this.done({ + selected: this.question.multiSelect ? this.selectedOptionLabels() : [], + custom, + }) + } + + private selectedOptionLabels(): string[] { + return [...this.selected] + .sort((a, b) => a - b) + .map(index => this.options[index]?.label) + .filter((label): label is string => label !== undefined) + } + + /** Page backward through an oversized option, then through question detail. */ + private pageBackward(): void { + if (this.mode === 'options' && this.selectedBlockPage.offset > 0) { + this.selectedBlockPage = { + ...this.selectedBlockPage, + offset: Math.max(0, this.selectedBlockPage.offset - this.selectedBlockPage.size), + } + return + } + this.headerPage = { + ...this.headerPage, + offset: Math.max(0, this.headerPage.offset - this.headerPage.size), + } + } + + /** Page forward through question detail, then through an oversized option. */ + private pageForward(): void { + if (this.headerPage.offset < this.headerPage.maxOffset) { + this.headerPage = { + ...this.headerPage, + offset: Math.min( + this.headerPage.maxOffset, + this.headerPage.offset + this.headerPage.size, + ), + } + return + } + if (this.mode === 'custom') return + this.selectedBlockPage = { + ...this.selectedBlockPage, + offset: Math.min( + this.selectedBlockPage.maxOffset, + this.selectedBlockPage.offset + this.selectedBlockPage.size, + ), + } } render(width: number): string[] { this.input.focused = this.focused - const innerWidth = Math.max(1, width - 4) + const horizontalPadding = Math.min(2, Math.max(0, Math.floor((width - 1) / 2))) + const innerWidth = Math.max(1, width - horizontalPadding * 2) const header = `Question ${this.position}/${this.total} (${this.unanswered} unanswered)${this.question.header === undefined ? '' : ` · ${displayText(this.question.header)}`}` - const lines = [ - this.palette.dim(header), - ...wrapTextWithAnsi(this.palette.text(displayText(this.question.question)), innerWidth), + const questionLines = wrapTextWithAnsi( + this.palette.text(displayText(this.question.question)), + innerWidth, + ) + const contentLines = [...questionLines] + const headerLines: string[] = [ + ...wrapTextWithAnsi(this.palette.dim(header), innerWidth), + ...questionLines, ] - const push = (line: string): void => { lines.push(line) } // Supporting detail (e.g. the full plan under review) renders between the // question and the answer surface, kept out of option labels. if (this.question.detail !== undefined) { - push('') - for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) push(line) - } - push('') - if (this.mode === 'custom') { - for (const line of this.input.render(innerWidth)) push(line) - push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel')) - } else { - const options = this.options - const start = Math.max(0, Math.min( - this.selectedIndex - Math.floor(this.maxVisible / 2), - options.length - this.maxVisible, - )) - const end = Math.min(options.length, start + this.maxVisible) - const optionRows = options.slice(start, end).map((option, offset) => { - const index = start + offset - const mark = this.question.multiSelect - ? this.selected.has(index) ? '[x] ' : '[ ] ' - : '' - return `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}` - }) - const descriptionColumn = Math.min( - Math.max(...optionRows.map(row => visibleWidth(row))) + 2, - Math.max(1, Math.floor(innerWidth * 0.55)), - ) - for (let index = start; index < end; index += 1) { - // `index < end <= options.length`; the options array is borrowed immutably for this dialog. - const option = options[index] as NonNullable[number] - const mark = this.question.multiSelect - ? this.selected.has(index) ? '[x] ' : '[ ] ' - : '' - const left = `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}` - const leftStyled = index === this.selectedIndex - ? this.palette.bold(this.palette.accent(left)) - : left - const description = option.description === undefined - ? '' - : `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.dim(displayText(option.description))}` - push(`${leftStyled}${description}`) + headerLines.push('') + contentLines.push('') + for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) { + headerLines.push(line) + contentLines.push(line) } - if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`)) + } + headerLines.push('') + + const customControls = [ + ...(this.options.length > 0 && this.question.multiSelect ? [`${this.selected.size} selected`] : []), + 'Enter submit', + this.options.length > 0 ? 'Esc options' : 'Esc cancel', + ] + const customHint = this.palette.dim(customControls.join(' • ')) + const footerLines: string[] = [] + if (this.mode === 'custom') { + for (const line of this.input.render(innerWidth)) footerLines.push(line) + for (const line of wrapTextWithAnsi(customHint, innerWidth)) footerLines.push(line) + } else { const controls = [ 'Tab custom answer', - ...(options.length > 1 ? ['↑/↓ navigate'] : []), + ...(this.options.length > 1 ? ['↑/↓ navigate'] : []), ...(this.question.multiSelect ? ['Space toggle'] : []), 'Enter submit', 'Esc interrupt', ] const hint = this.palette.dim(controls.join(' • ')) - for (const line of wrapTextWithAnsi(hint, innerWidth)) push(line) + for (const line of wrapTextWithAnsi(hint, innerWidth)) footerLines.push(line) } if (this.error) { - for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) push(line) + for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) footerLines.push(line) } - return ['', ...lines, ''].map((line) => { - const clipped = truncateToWidth(line, innerWidth, '') - return ` ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ` + const positionLines = this.mode === 'options' && this.options.length > this.maxVisible + ? [this.palette.dim(`${this.selectedIndex + 1}/${this.options.length}`)] + : [] + + // Options receive only the rows left after fixed chrome and outer padding. + // The final height window handles fixed chrome that cannot fit even alone. + const paddingRows = 2 + const maxHeight = this.maxHeight() + const availableForOptions = Math.max( + this.mode === 'options' ? 4 : 1, + maxHeight - paddingRows - headerLines.length - positionLines.length - footerLines.length, + ) + + const body: string[] = [...headerLines] + const optionLines: string[] = [] + if (this.mode === 'custom') { + for (const line of footerLines) body.push(line) + } else { + const optionBlocks = this.options.map((option, index) => this.renderOptionBlock(option, index, innerWidth)) + const { visibleBlocks, hiddenBefore, hiddenAfter } = this.windowBlocks(optionBlocks, availableForOptions, innerWidth) + if (hiddenBefore > 0) optionLines.push(this.palette.dim(`↑ ${hiddenBefore} more`)) + for (const block of visibleBlocks) { + for (const line of block) optionLines.push(line) + } + if (hiddenAfter > 0) optionLines.push(this.palette.dim(`↓ ${hiddenAfter} more`)) + for (const line of optionLines) body.push(line) + for (const line of positionLines) body.push(line) + for (const line of footerLines) body.push(line) + } + + const rows = ['', ...body, ''] + let visibleRows = rows + if (rows.length <= maxHeight) this.headerPage = { offset: 0, size: 1, maxOffset: 0 } + if (rows.length > maxHeight && this.mode === 'options' && maxHeight >= 6) { + const headerBudget = Math.max( + 0, + maxHeight - optionLines.length - (this.error === '' ? 1 : 2), + ) + const compactFooter = [ + ...this.error === '' + ? [] + : [truncateToWidth(this.palette.error(`Error: ${this.error}`), innerWidth, '…')], + this.compactOptionControls( + innerWidth, + headerBudget === 1 && contentLines.length > headerBudget, + ), + ] + const compactHeader = this.compactQuestionHeader(contentLines, headerBudget, innerWidth) + visibleRows = [...compactHeader, ...optionLines, ...compactFooter] + } else if (rows.length > maxHeight && this.mode === 'custom' && maxHeight >= 2) { + const compactFooterSource = [ + ...this.input.render(innerWidth), + this.compactCustomControls(innerWidth), + ...this.error === '' + ? [] + : [truncateToWidth(this.palette.error(this.error), innerWidth, '…')], + ] + const footerBudget = Math.max(1, maxHeight - 1) + const compactFooter = compactFooterSource.length <= footerBudget + ? compactFooterSource + : footerBudget === 1 + ? compactFooterSource.slice(0, 1) + : [ + ...compactFooterSource.slice(0, 1), + ...compactFooterSource.slice(-(footerBudget - 1)), + ] + const compactHeader = this.compactQuestionHeader( + contentLines, + Math.max(0, maxHeight - compactFooter.length), + innerWidth, + ) + visibleRows = [...compactHeader, ...compactFooter] + } + if (visibleRows.length > maxHeight) { + visibleRows = maxHeight === 1 + ? [this.palette.dim(`↑ ${visibleRows.length} lines hidden`)] + : [ + this.palette.dim(`↑ ${visibleRows.length - maxHeight + 1} lines hidden`), + ...visibleRows.slice(-(maxHeight - 1)), + ] + } + return visibleRows.map((line) => { + const bounded = truncateToWidth(line, innerWidth, '…') + const pad = ' '.repeat(Math.max(0, innerWidth - visibleWidth(bounded))) + const outerPad = ' '.repeat(horizontalPadding) + return `${outerPad}${bounded}${pad}${outerPad}` }) } + + /** Render one option as wrapped label and indented description lines. */ + private renderOptionBlock( + option: NonNullable[number], + index: number, + innerWidth: number, + ): string[] { + const cursor = index === this.selectedIndex ? '›' : ' ' + const number = `${index + 1}. ` + const mark = this.question.multiSelect + ? this.selected.has(index) ? '[x] ' : '[ ] ' + : '' + const labelPrefixPlain = ` ${cursor} ${number}${mark}` + const labelPrefixWidth = visibleWidth(labelPrefixPlain) + const labelBodyWidth = Math.max(1, innerWidth - labelPrefixWidth) + const labelLines = wrapTextWithAnsi(displayText(option.label), labelBodyWidth) + const continuation = ' '.repeat(labelPrefixWidth) + const lines: string[] = [] + for (const [lineIndex, labelLine] of labelLines.entries()) { + const prefix = lineIndex === 0 ? labelPrefixPlain : continuation + const composed = `${prefix}${labelLine}` + lines.push(index === this.selectedIndex ? this.palette.bold(this.palette.accent(composed)) : composed) + } + if (option.description !== undefined) { + const descIndent = ' '.repeat(labelPrefixWidth) + const descBodyWidth = Math.max(1, innerWidth - labelPrefixWidth) + const descLines = wrapTextWithAnsi(displayText(option.description), descBodyWidth) + for (const descLine of descLines) lines.push(`${descIndent}${this.palette.dim(descLine)}`) + } + return lines + } + + /** Keep the question visible when fixed chrome must be compacted. */ + private compactQuestionHeader( + contentLines: readonly string[], + budget: number, + innerWidth: number, + ): string[] { + if (budget <= 0) return [] + if (contentLines.length <= budget) { + this.headerPage = { offset: 0, size: 1, maxOffset: 0 } + return [...contentLines] + } + const pageSize = Math.max(1, budget - 1) + const maxOffset = Math.max(0, contentLines.length - pageSize) + const offset = Math.min(this.headerPage.offset, maxOffset) + this.headerPage = { offset, size: pageSize, maxOffset } + const keptLines = contentLines.slice(offset, offset + pageSize) + if (budget === 1) { + // A page is non-empty because pageSize is one and offset is clamped inside contentLines. + return [keptLines[0] as string] + } + return [ + ...keptLines, + this.pagerStatus(offset + 1, offset + keptLines.length, contentLines.length, innerWidth), + ] + } + + /** Keep Page Up / Page Down discoverable when a full pager status cannot fit. */ + private pagerStatus(first: number, last: number, total: number, innerWidth: number): string { + const full = `… lines ${first}-${last}/${total} • PgUp/PgDn` + const compact = `PgUp/PgDn ${first}/${total}` + return this.palette.dim(truncateToWidth( + visibleWidth(full) <= innerWidth ? full : compact, + innerWidth, + '…', + )) + } + + /** Render custom-mode controls on one row when the header must compact. */ + private compactCustomControls(innerWidth: number): string { + const controls = this.options.length > 0 + ? 'Enter submit • Esc options' + : 'Enter submit • Esc cancel' + const fallback = this.options.length > 0 ? '↵ Esc options' : 'Enter Esc cancel' + const line = visibleWidth(controls) <= innerWidth ? controls : fallback + return this.palette.dim(truncateToWidth(line, innerWidth, '…')) + } + + /** Render a one-row option footer that retains every mode-specific control. */ + private compactOptionControls(innerWidth: number, showPager = false): string { + const controls = [ + ...(this.options.length > 1 ? ['↑/↓'] : []), + 'Tab custom', + ...(this.question.multiSelect ? ['Space toggle'] : []), + 'Enter', + 'Esc interrupt', + ...(showPager ? ['PgUp/PgDn'] : []), + ].join(' • ') + const optionNavigation = this.options.length > 1 ? '↑↓ ' : '' + const fallback = showPager + ? `P↑↓ ${optionNavigation}Tab${this.question.multiSelect ? ' S' : ''}↵Esc` + : this.question.multiSelect ? `${optionNavigation}Tab Sp ↵Esc` : `${optionNavigation}Tab ↵ Esc` + const line = visibleWidth(controls) <= innerWidth ? controls : fallback + return this.palette.dim(truncateToWidth(line, innerWidth, '…')) + } + + /** + * Choose option blocks that fit while keeping the selected option visible. + * Omitted blocks are counted at each end for explicit overflow markers. + */ + private windowBlocks( + blocks: readonly string[][], + budget: number, + innerWidth: number, + ): { visibleBlocks: string[][]; hiddenBefore: number; hiddenAfter: number } { + const totalLines = blocks.reduce((sum, block) => sum + block.length, 0) + if (totalLines <= budget && blocks.length <= this.maxVisible) { + return { visibleBlocks: [...blocks], hiddenBefore: 0, hiddenAfter: 0 } + } + // `blocks` is dense and selectedIndex is derived from the same options. + let start = this.selectedIndex + let end = this.selectedIndex + 1 + /* v8 ignore next -- selectedIndex stays inside [0, options.length). */ + let used = blocks[this.selectedIndex]?.length ?? 0 + const markerLines = (before: number, after: number): number => + (before > 0 ? 1 : 0) + (after > 0 ? 1 : 0) + const fits = (nextStart: number, nextEnd: number, nextUsed: number): boolean => + nextEnd - nextStart <= this.maxVisible + && nextUsed + markerLines(nextStart, blocks.length - nextEnd) <= budget + const selectedMarkers = markerLines(start, blocks.length - end) + if (used + selectedMarkers > budget) { + /* v8 ignore next -- selectedIndex stays inside [0, options.length). */ + const selectedBlock = blocks[this.selectedIndex] ?? [] + const hiddenBefore = start + const hiddenAfter = blocks.length - end + const pageSize = budget - selectedMarkers - 1 + const maxOffset = Math.max(0, selectedBlock.length - pageSize) + const offset = Math.min(this.selectedBlockPage.offset, maxOffset) + this.selectedBlockPage = { offset, size: pageSize, maxOffset } + const keptLines = selectedBlock.slice(offset, offset + pageSize) + const first = offset + 1 + const last = offset + keptLines.length + const overflow = this.pagerStatus(first, last, selectedBlock.length, innerWidth) + return { + visibleBlocks: [[...keptLines, overflow]], + hiddenBefore, + hiddenAfter, + } + } + this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } + let expanded = true + while (expanded && (start > 0 || end < blocks.length)) { + expanded = false + if (end < blocks.length) { + /* v8 ignore next -- guarded by `end < blocks.length` above. */ + const next = blocks[end]?.length ?? 0 + if (fits(start, end + 1, used + next)) { + used += next + end += 1 + expanded = true + continue + } + } + if (start > 0) { + /* v8 ignore next -- guarded by `start > 0` above. */ + const previous = blocks[start - 1]?.length ?? 0 + if (fits(start - 1, end, used + previous)) { + used += previous + start -= 1 + expanded = true + } + } + } + return { + visibleBlocks: blocks.slice(start, end), + hiddenBefore: start, + hiddenAfter: blocks.length - end, + } + } } diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts index c8ba061166..f0422e8417 100644 --- a/packages/ui/tui/src/components/transcript.ts +++ b/packages/ui/tui/src/components/transcript.ts @@ -15,6 +15,7 @@ import { type Component, type MarkdownTheme, } from '@earendil-works/pi-tui' +import { diffLines as compareLines } from 'diff' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { JsonValue, SessionEvent, TodoItem } from '@deepseek-ai/dsh-session' @@ -52,12 +53,17 @@ function pretty(value: unknown): string { return displayText(serialized ?? String(value)) } +interface RenderedDiff { + lines: string[] + added: number + removed: number + approximate: boolean +} + /** * A side's content lines under the terminator rule the Web DiffBlock also - * applies: empty text is zero lines (a full deletion's `newText`, a create's - * absent `oldText`), and a single trailing newline terminates the last line - * rather than adding an empty one. An interior blank line survives. Keeping the - * two front ends on the same rule holds their `+A -R` footers in step. + * applies: empty text is zero lines, a trailing newline terminates the last + * line, and an interior blank line survives. */ function diffContentLines(text: string): string[] { if (text === '') return [] @@ -65,16 +71,47 @@ function diffContentLines(text: string): string[] { return body.split('\n') } -/** A file diff as colored `+`/`-` lines, optionally prefixed with its path. */ -function diffLines(diff: FileDiff, palette: Palette): string[] { +/** + * A file diff whose unchanged context stays neutral and does not affect exact + * change totals. Comparisons beyond the edit-distance budget fall back to + * whole-side rendering so a model-authored pending edit cannot stall the TUI. + */ +function renderDiff(diff: FileDiff, maxDiffEditLength: number, palette: Palette): RenderedDiff { // The card header is a fixed `Tool / ` frame that never names a file, so // each hunk always carries its own path header (no redundancy to suppress). const lines = [palette.bold(displayText(diff.path))] - if (diff.oldText !== null) { - for (const line of diffContentLines(displayText(diff.oldText))) lines.push(palette.error(`- ${line}`)) + let added = 0 + let removed = 0 + if (diff.oldText === null) { + const newLines = diffContentLines(displayText(diff.newText)) + added = newLines.length + for (const line of newLines) lines.push(palette.success(`+ ${line}`)) + return { lines, added, removed, approximate: false } } - for (const line of diffContentLines(displayText(diff.newText))) lines.push(palette.success(`+ ${line}`)) - return lines + const changes = compareLines(diff.oldText, diff.newText, { maxEditLength: maxDiffEditLength }) + if (changes === undefined) { + const oldLines = diffContentLines(displayText(diff.oldText)) + const newLines = diffContentLines(displayText(diff.newText)) + lines.push(palette.dim(`[exact line diff omitted: >${maxDiffEditLength} changed lines]`)) + removed = oldLines.length + added = newLines.length + for (const line of oldLines) lines.push(palette.error(`- ${line}`)) + for (const line of newLines) lines.push(palette.success(`+ ${line}`)) + return { lines, added, removed, approximate: true } + } + for (const change of changes) { + const changedLines = diffContentLines(displayText(change.value)) + if (change.added) { + added += changedLines.length + for (const line of changedLines) lines.push(palette.success(`+ ${line}`)) + } else if (change.removed) { + removed += changedLines.length + for (const line of changedLines) lines.push(palette.error(`- ${line}`)) + } else { + for (const line of changedLines) lines.push(palette.dim(` ${line}`)) + } + } + return { lines, added, removed, approximate: false } } /** @@ -149,20 +186,28 @@ export class UserMessageComponent extends Container { } } -/** Children of a settled assistant message: optional reasoning block then the response text. */ +/** + * Children of a settled assistant message: optional reasoning block then the + * response text. A folded continuation (a later step of a turn while tool cards + * are hidden) drops the `Assistant` header and renders nothing when it has no + * visible body, so tool-only steps leave no blank segment behind. + */ function assistantMessageChildren( content: readonly ContentBlock[], showReasoning: boolean, + foldedContinuation: boolean, palette: Palette, mdTheme: MarkdownTheme, ): Component[] { const reasoning = displayText(textBlocks(content, 'reasoning').trim()) const text = displayText(textBlocks(content, 'text').trim()) - const children: Component[] = [ - new Spacer(1), - new Text(messageHeader('Assistant', palette.accent, palette), 0, 0), - ] - if (reasoning && showReasoning) { + const showsReasoning = reasoning !== '' && showReasoning + if (foldedContinuation && !showsReasoning && text === '') return [] + const children: Component[] = [new Spacer(1)] + if (!foldedContinuation) { + children.push(new Text(messageHeader('Assistant', palette.accent, palette), 0, 0)) + } + if (showsReasoning) { children.push( new Text(palette.italic(palette.dim('Reasoning')), 0, 0), new Markdown(reasoning, 0, 0, mdTheme, { color: value => palette.dim(value), italic: true }), @@ -220,6 +265,7 @@ interface StreamingBlock { export class StreamingAssistantComponent extends Container { private readonly blocks = new Map() private settledContent: readonly ContentBlock[] | undefined + private foldedContinuation = false /** * The step's timing footer. The renderer keeps it at the tail of the chat so * it trails any tool cards the step appends after this assistant message; it @@ -228,7 +274,8 @@ export class StreamingAssistantComponent extends Container { readonly timing: StepTimingComponent constructor( - position: StepPosition, + /** The step's turn/step coordinates, used to group steps into their turn. */ + readonly position: StepPosition, events: () => readonly SessionEvent[], now: () => number, private showReasoning: boolean, @@ -299,18 +346,49 @@ export class StreamingAssistantComponent extends Container { this.rebuild() } - private rebuild(): void { - this.clear() - const content: readonly ContentBlock[] = this.settledContent ?? [...this.blocks.entries()] + /** + * Mark this step as a folded continuation of its turn: no `Assistant` header, + * and no output at all while the step has no visible body. Used while tool + * cards are hidden so a turn reads as one assistant message. + * @param folded - Whether to render as a headerless continuation. + */ + setFoldedContinuation(folded: boolean): void { + if (this.foldedContinuation === folded) return + this.foldedContinuation = folded + this.rebuild() + } + + /** + * Whether the step currently renders visible reasoning or text. + * @returns `true` when a header-owning render would show a body. + */ + hasVisibleBody(): boolean { + const content = this.presentedContent() + return textBlocks(content, 'text').trim() !== '' + || (this.showReasoning && textBlocks(content, 'reasoning').trim() !== '') + } + + /** The settled content when available, otherwise the streamed blocks in model order. */ + private presentedContent(): readonly ContentBlock[] { + return this.settledContent ?? [...this.blocks.entries()] .sort(([left], [right]) => left - right) .flatMap(([, block]) => { if (block.type === 'text') return [{ type: 'text', text: block.text }] if (block.type === 'reasoning') return [{ type: 'reasoning', text: block.text }] return [] }) - for (const child of assistantMessageChildren(content, this.showReasoning, this.palette, this.mdTheme)) { - this.addChild(child) - } + } + + private rebuild(): void { + this.clear() + const children = assistantMessageChildren( + this.presentedContent(), + this.showReasoning, + this.foldedContinuation, + this.palette, + this.mdTheme, + ) + for (const child of children) this.addChild(child) } } @@ -337,12 +415,14 @@ export class ToolCardComponent implements Component { private visibility: ToolCardVisibility = 'collapsed' private callView: ToolCallView private resultView: ToolResultView | undefined + private diffBodyCache: { view: ToolCallView | ToolResultView; body: CardBody } | undefined constructor( private readonly name: string, private readonly parsed: ParsedArguments, private readonly definition: ToolDefinition | undefined, private readonly maxOutputLines: number, + private readonly maxDiffEditLength: number, private readonly palette: Palette, private readonly mdTheme: MarkdownTheme, ) { @@ -366,6 +446,7 @@ export class ToolCardComponent implements Component { * @param event - The `tool/result` event payload. */ updateResult(event: Extract['data']): void { + this.diffBodyCache = undefined const result = event.message.content[0] this.result = { content: [...result.content], @@ -519,24 +600,28 @@ export class ToolCardComponent implements Component { return { prelude: prelude.filter(Boolean), lines: lines.filter(Boolean) } } if (view.card === 'diff') { + if (this.diffBodyCache?.view === view) return this.diffBodyCache.body // The header no longer names the file, so each diff keeps its own path - // header. A trailing footer summarizes the change (`+A -R · N file(s)`), - // on the same terminator rule and distinct-path count the Web DiffBlock - // uses, so the two front ends' footers agree. - let added = 0 - let removed = 0 - const paths = new Set() - const hunks = view.diffs.flatMap((diff, index) => { - paths.add(diff.path) - if (diff.oldText !== null) removed += diffContentLines(displayText(diff.oldText)).length - added += diffContentLines(displayText(diff.newText)).length - return [...index > 0 ? [''] : [], ...diffLines(diff, this.palette)] + // header. A trailing footer summarizes the exact changed rows when the + // bounded comparison succeeds (`+A -R · N file(s)`). + const renderedDiffs = view.diffs.map(diff => + renderDiff(diff, this.maxDiffEditLength, this.palette), + ) + const added = renderedDiffs.reduce((total, rendered) => total + rendered.added, 0) + const removed = renderedDiffs.reduce((total, rendered) => total + rendered.removed, 0) + const approximate = renderedDiffs.some(rendered => rendered.approximate) + const hunks = renderedDiffs.flatMap((rendered, index) => { + return [...index > 0 ? [''] : [], ...rendered.lines] }) - const files = paths.size - const footer = this.palette.dim(`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}`) + const files = new Set(view.diffs.map(diff => diff.path)).size + const footer = this.palette.dim( + `└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}${approximate ? ' · approximate' : ''}`, + ) // A diff's own `+`/`-` colors carry its meaning, so it renders verbatim // rather than under the dim result-output color. - return { prelude: [...hunks, footer], lines: [] } + const body = { prelude: [...hunks, footer], lines: [] } + this.diffBodyCache = { view, body } + return body } // A generic or read card carries its own envelope-stripped `content`; a // search or web card carries no `content` copy and falls back to the raw diff --git a/packages/ui/tui/src/config.ts b/packages/ui/tui/src/config.ts index 010fe3285c..8b07629af8 100644 --- a/packages/ui/tui/src/config.ts +++ b/packages/ui/tui/src/config.ts @@ -34,6 +34,8 @@ export interface TuiConfig { showReasoning?: boolean /** Maximum tool-card body lines retained in its collapsed head/tail preview. */ maxToolOutputLines?: number + /** Maximum added and removed lines explored while deriving an exact line diff. */ + maxDiffEditLength?: number /** Maximum options visible at once in a user-question panel. */ maxQuestionOptions?: number /** Maximum models visible at once in the model selector. */ @@ -48,6 +50,8 @@ export interface TuiConfig { modelDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number + /** Transcript-details selector width in terminal columns. */ + detailsDialogWidth?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ fileSearchMaxResults?: number /** Maximum paths retained in one `@` workspace index. */ @@ -64,6 +68,7 @@ export interface TuiConfig { const showReasoningSchema = z.boolean().default(true) const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6) +const maxDiffEditLengthSchema = z.number().step(1).min(1).default(1000) const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8) const maxModelOptionsSchema = z.number().step(1).min(1).default(8) const maxResumeOptionsSchema = z.number().step(1).min(1).default(8) @@ -71,6 +76,7 @@ const questionDialogWidthSchema = z.number().step(1).min(20).default(200) const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20) const modelDialogWidthSchema = z.number().step(1).min(20).default(76) const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20) +const detailsDialogWidthSchema = z.number().step(1).min(20).default(72) const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS) const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES) const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES]) @@ -95,6 +101,7 @@ const titleSchema = z.string().default('DeepSeek Harness') const tuiConfigSchemaFields = { showReasoning: showReasoningSchema, maxToolOutputLines: maxToolOutputLinesSchema, + maxDiffEditLength: maxDiffEditLengthSchema, maxQuestionOptions: maxQuestionOptionsSchema, maxModelOptions: maxModelOptionsSchema, maxResumeOptions: maxResumeOptionsSchema, @@ -102,6 +109,7 @@ const tuiConfigSchemaFields = { questionDialogMaxHeight: questionDialogMaxHeightSchema, modelDialogWidth: modelDialogWidthSchema, modelDialogMaxHeight: modelDialogMaxHeightSchema, + detailsDialogWidth: detailsDialogWidthSchema, fileSearchMaxResults: fileSearchMaxResultsSchema, fileSearchMaxEntries: fileSearchMaxEntriesSchema, fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema, @@ -135,6 +143,7 @@ export const Config: z = z.object({ initialSkill: z.string(), showReasoning: tuiConfigSchemaFields.showReasoning, maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines, + maxDiffEditLength: tuiConfigSchemaFields.maxDiffEditLength, maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions, maxModelOptions: tuiConfigSchemaFields.maxModelOptions, maxResumeOptions: tuiConfigSchemaFields.maxResumeOptions, @@ -142,6 +151,7 @@ export const Config: z = z.object({ questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight, modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth, modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight, + detailsDialogWidth: tuiConfigSchemaFields.detailsDialogWidth, fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults, fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries, fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories, @@ -164,6 +174,7 @@ export interface ResolvedTuiThemeConfig { export interface ResolvedTuiConfig { showReasoning: boolean maxToolOutputLines: number + maxDiffEditLength: number maxQuestionOptions: number maxModelOptions: number maxResumeOptions: number @@ -171,6 +182,7 @@ export interface ResolvedTuiConfig { questionDialogMaxHeight: number modelDialogWidth: number modelDialogMaxHeight: number + detailsDialogWidth: number fileSearchMaxResults: number fileSearchMaxEntries: number fileSearchExcludedDirectories: string[] @@ -189,6 +201,7 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf return { showReasoning: config?.showReasoning ?? true, maxToolOutputLines: config?.maxToolOutputLines ?? 6, + maxDiffEditLength: config?.maxDiffEditLength ?? 1000, maxQuestionOptions: config?.maxQuestionOptions ?? 8, maxModelOptions: config?.maxModelOptions ?? 8, maxResumeOptions: config?.maxResumeOptions ?? 8, @@ -196,6 +209,7 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20, modelDialogWidth: config?.modelDialogWidth ?? 76, modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20, + detailsDialogWidth: config?.detailsDialogWidth ?? 72, fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS, fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES, fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)], diff --git a/packages/ui/tui/src/extension/overlay-manager.ts b/packages/ui/tui/src/extension/overlay-manager.ts index 59d84ff3d5..ab80f49cab 100644 --- a/packages/ui/tui/src/extension/overlay-manager.ts +++ b/packages/ui/tui/src/extension/overlay-manager.ts @@ -12,7 +12,6 @@ import type { TuiExtensionService } from '../index.ts' import type { Component, Focusable, - OverlayHandle, } from '@earendil-works/pi-tui' import type { TuiComponent, @@ -36,14 +35,20 @@ export interface TuiOverlayDriver { theme(): TuiTheme /** Escape text at the terminal display boundary. */ display(value: string): string - /** Mount one guarded component and return its private pi-tui handle. */ - show(component: Component, options: TuiOverlayOptions | undefined): OverlayHandle + /** Mount one guarded modal and return its private focus/lifecycle handle. */ + show(component: Component, options: TuiOverlayOptions | undefined, placement: TuiOverlayPlacement): TuiModalHandle /** Invalidate the mounted UI and request a render. */ invalidate(): void /** Report a contained extension failure. */ reportError(error: unknown): void } +type TuiOverlayPlacement = 'overlay' | 'inline' + +interface TuiModalHandle { + hide(): void +} + interface OverlayEntry { readonly request: TuiOverlayRequest readonly controller: AbortController @@ -51,9 +56,10 @@ interface OverlayEntry { readonly closed: Promise readonly resolveClosed: (outcome: TuiOverlayOutcome) => void readonly session: TuiOverlaySession + readonly placement: TuiOverlayPlacement state: TuiOverlayState component?: GuardedOverlayComponent - handle?: OverlayHandle + handle?: TuiModalHandle removeRequestAbort?: () => void outcome?: TuiOverlayOutcome failing?: boolean @@ -165,11 +171,12 @@ export class TuiOverlayManager { } /** - * Queue one overlay without assigning Cordis ownership. + * Queue one modal without assigning Cordis ownership. * @param request - component factory, constraints, and request signal. + * @param placement - terminal overlay for extensions, or inline for the built-in question panel. * @returns an internal session that can close with an ownership reason. */ - open(request: TuiOverlayRequest): TuiOverlaySession & { + open(request: TuiOverlayRequest, placement: TuiOverlayPlacement = 'overlay'): TuiOverlaySession & { closeWith(reason: Exclude): Promise } { if (!this.accepting) throw new Error('TUI is shutting down') @@ -202,6 +209,7 @@ export class TuiOverlayManager { closed: deferred.promise, resolveClosed: deferred.resolve, session, + placement, state: 'queued', } if (requestSignal?.aborted === true) { @@ -251,7 +259,7 @@ export class TuiOverlayManager { }) entry.component = guarded try { - const handle = this.driver.show(guarded, entry.request.options) + const handle = this.driver.show(guarded, entry.request.options, entry.placement) if (this.active !== entry) { this.hide(handle) return @@ -306,7 +314,7 @@ export class TuiOverlayManager { } } - private hide(handle: OverlayHandle): void { + private hide(handle: TuiModalHandle): void { try { handle.hide() } catch (error) { diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index a2cf047e0a..0ddb73ed9c 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -15,6 +15,7 @@ import { ProcessTerminal, matchesKey, visibleWidth, + type Component, type EditorTheme, type SlashCommand, type TerminalColorScheme, @@ -105,6 +106,7 @@ import { } from './components/transcript.ts' import { compactTargetLabel, + DetailsDialog, diagnosticMeter, formatDiagnosticCount, formatDiagnosticNumber, @@ -113,6 +115,7 @@ import { StatusCardComponent, PromptContextComponent, targetLabel, + type DetailsSelection, type StatusCardRow, } from './components/dialogs.ts' import { @@ -287,6 +290,23 @@ interface FadingStatus { timer: ReturnType } +/** Width/height adapter for a modal component rendered inside the base TUI flow. */ +class InlineModalComponent extends Container { + constructor( + component: Component, + private readonly width: number, + private readonly maxHeight: number, + ) { + super() + this.addChild(component) + } + + override render(width: number): string[] { + const lines = super.render(Math.max(1, Math.min(width, this.width))) + return lines.slice(0, Math.max(1, this.maxHeight)) + } +} + /** Lifecycle handle for a mounted interactive terminal channel. */ export interface TuiController { /** Stop rendering, restore the terminal, and reject pending questions. */ @@ -314,6 +334,7 @@ export function createTuiChat( const ui = new TUI(runtime.terminal, resolved.showHardwareCursor) const chat = new Container() const todoContainer = new Container() + const questionContainer = new Container() const inputTemplate = parseTuiPromptTemplate(displayInlineText(resolved.theme.inputPrompt)) const renderInputPrompt = (): string => renderTuiPromptTemplate(inputTemplate, valueName => ctx.tuiPrompt.get(valueName)) const initialInputPrompt = renderInputPrompt() @@ -337,6 +358,10 @@ export function createTuiChat( let toolsVisibility: ToolCardVisibility = 'collapsed' let streaming: StreamingAssistantComponent | undefined let completedStreaming: StreamingAssistantComponent | undefined + // Assistant step components in model order per turn, for hidden-mode folding: + // with tool cards hidden, a turn keeps one Assistant header and later steps + // render as headerless continuations (see applyTurnFolding). + const assistantSteps = new Map() let runningStatus: RunningStatus | undefined let fadingStatus: FadingStatus | undefined /** @@ -474,6 +499,7 @@ export function createTuiChat( ui.addChild(todoContainer) ui.addChild(compactionStatusLine) ui.addChild(promptContext) + ui.addChild(questionContainer) ui.addChild(editor) ui.setFocus(editor) const updateTerminalTitle = (): void => { @@ -523,14 +549,32 @@ export function createTuiChat( }), theme: () => extensionTheme, display: displayText, - show: (component, options) => ui.showOverlay(component, options === undefined - ? undefined - : { - ...options, - ...typeof options.margin === 'object' - ? { margin: { ...options.margin } } - : {}, - }), + show: (component, options, placement) => { + if (placement === 'overlay') { + return ui.showOverlay(component, options === undefined + ? undefined + : { + ...options, + ...typeof options.margin === 'object' + ? { margin: { ...options.margin } } + : {}, + }) + } + const modal = new InlineModalComponent( + component, + resolved.questionDialogWidth, + resolved.questionDialogMaxHeight, + ) + questionContainer.clear() + questionContainer.addChild(modal) + ui.setFocus(component) + return { + hide(): void { + questionContainer.clear() + ui.setFocus(editor) + }, + } + }, invalidate: requestRender, reportError: (error) => { const message = errorChain(error) @@ -637,6 +681,7 @@ export function createTuiChat( parsed, ctx.tools.get(event.data.name, agent), resolved.maxToolOutputLines, + resolved.maxDiffEditLength, palette, mdTheme, ) @@ -646,6 +691,35 @@ export function createTuiChat( return card } + /** + * Re-derive hidden-mode folding for one turn: the first step with a visible + * body owns the turn's single Assistant header, every other step renders as a + * headerless continuation (empty ones render nothing). Any other visibility + * restores the per-step headers. + */ + const applyTurnFolding = (turn: number): void => { + const steps = assistantSteps.get(turn) + if (steps === undefined) return + let headerSeen = false + for (const step of steps) { + if (toolsVisibility !== 'hidden') { + step.setFoldedContinuation(false) + } else if (!headerSeen && step.hasVisibleBody()) { + headerSeen = true + step.setFoldedContinuation(false) + } else { + step.setFoldedContinuation(true) + } + } + } + + const registerAssistantStep = (component: StreamingAssistantComponent): void => { + const steps = assistantSteps.get(component.position.turn) ?? [] + steps.push(component) + assistantSteps.set(component.position.turn, steps) + applyTurnFolding(component.position.turn) + } + const removeStreaming = (current: StreamingAssistantComponent | undefined): void => { if (current === undefined) return for (const child of [current, current.timing]) { @@ -653,6 +727,15 @@ export function createTuiChat( /* v8 ignore next -- streaming components and their timing footers are retained only while attached to the chat. */ if (index >= 0) chat.children.splice(index, 1) } + const steps = assistantSteps.get(current.position.turn) + /* v8 ignore next -- every attached streaming component is registered in the fold map. */ + if (steps === undefined) return + const index = steps.indexOf(current) + /* v8 ignore next -- registration precedes attachment, so the component is present until this removal. */ + if (index < 0) return + steps.splice(index, 1) + // A retracted step may have owned the turn's hidden-mode header. + applyTurnFolding(current.position.turn) } /** @@ -691,6 +774,7 @@ export function createTuiChat( palette, mdTheme, ) + registerAssistantStep(streaming) chat.addChild(streaming) chat.addChild(streaming.timing) } @@ -754,12 +838,22 @@ export function createTuiChat( startAssistantStep(event.data) break case 'assistant/chunk': - if (options.renderChunks) streaming?.update(event.data.chunk) + if (options.renderChunks && streaming !== undefined) { + streaming.update(event.data.chunk) + // The first streamed text/reasoning may make this step the turn's + // hidden-mode header owner (or a continuation with a visible body). + applyTurnFolding(streaming.position.turn) + } break case 'assistant/message': completedStreaming = undefined - if (streaming === undefined || !chat.children.includes(streaming)) startAssistantStep(event.data) - streaming?.settle(event.data.message.content) + // A settled component stays attached but never absorbs a later message + // of the same step; both the live and replay paths start a new one. + if (streaming === undefined || streaming.isSettled() || !chat.children.includes(streaming)) startAssistantStep(event.data) + if (streaming !== undefined) { + streaming.settle(event.data.message.content) + applyTurnFolding(streaming.position.turn) + } break case 'llm/retry': { retractFailedStreaming() @@ -780,7 +874,15 @@ export function createTuiChat( const callId = event.data.message.source.callId let card = toolCards.get(callId) if (card === undefined) { - card = new ToolCardComponent('tool', { value: {}, valid: true }, undefined, resolved.maxToolOutputLines, palette, mdTheme) + card = new ToolCardComponent( + 'tool', + { value: {}, valid: true }, + undefined, + resolved.maxToolOutputLines, + resolved.maxDiffEditLength, + palette, + mdTheme, + ) card.setVisibility(toolsVisibility) chat.addChild(card) allToolCards.add(card) @@ -870,6 +972,7 @@ export function createTuiChat( toolCards.clear() allToolCards.clear() contextCards.clear() + assistantSteps.clear() streaming = undefined todo.update([]) const transcriptCalls = transcriptToolCallIds(agent.session) @@ -891,6 +994,14 @@ export function createTuiChat( overlayManager, requestRender, isDisposed, + questionMaxHeight: () => { + const width = runtime.terminal.columns + const editorRows = editor.render(width).length + return Math.max(1, Math.min( + resolved.questionDialogMaxHeight, + runtime.terminal.rows - editorRows, + )) + }, }) const resume = createResumeController({ @@ -979,32 +1090,99 @@ export function createTuiChat( // same reason. ui.queryTerminalColorScheme({ timeoutMs: 2000 }).catch(() => {}) - const toggleTools = (): void => { - // The cycle order puts the two common reading modes adjacent: preview -> - // full detail -> conversation-only, then back to the preview default. - toolsVisibility = toolsVisibility === 'collapsed' ? 'expanded' - : toolsVisibility === 'expanded' ? 'hidden' : 'collapsed' + const setToolsVisibility = (next: ToolCardVisibility): void => { + toolsVisibility = next for (const card of allToolCards) card.setVisibility(toolsVisibility) // Context cards carry injected instructions rather than tool traffic, so // they never hide: the hidden phase reads as their collapsed preview. for (const card of contextCards) card.setExpanded(toolsVisibility === 'expanded') + // Hidden mode folds each turn's steps into one assistant message; other + // modes restore the per-step Assistant headers. + for (const turn of assistantSteps.keys()) applyTurnFolding(turn) appendNotice(toolsVisibility === 'hidden' ? 'Tool cards hidden.' : `Tool and context cards ${toolsVisibility}.`) } - const toggleReasoning = (): void => { - showReasoning = !showReasoning + const toggleTools = (): void => { + // The cycle order puts the two common reading modes adjacent: preview -> + // full detail -> conversation-only, then back to the preview default. + setToolsVisibility(toolsVisibility === 'collapsed' ? 'expanded' + : toolsVisibility === 'expanded' ? 'hidden' : 'collapsed') + } + + const setReasoning = (show: boolean): void => { + showReasoning = show const activeStreaming = streaming rebuildTranscript(false) /* v8 ignore next -- the non-streaming command path is covered; this branch preserves an active stream across rebuild. */ if (activeStreaming !== undefined) { streaming = activeStreaming streaming.setShowReasoning(showReasoning) + registerAssistantStep(activeStreaming) chat.addChild(activeStreaming) chat.addChild(activeStreaming.timing) } appendNotice(`Reasoning blocks ${showReasoning ? 'shown' : 'hidden'}.`) } + const toggleReasoning = (): void => { setReasoning(!showReasoning) } + + // The selector and the argument grammar mutate the same closure state the + // Ctrl+O cycle and Ctrl+R toggle drive, so every entry converges. + let detailsOverlay: TuiOverlaySession | undefined + const showDetailsSelector = (): void => { + void detailsOverlay?.close() + const session = overlayManager.open({ + create: () => new DetailsDialog( + toolsVisibility, + showReasoning, + palette, + // Each Tab applies immediately; one dimension changes per call. + (selection: DetailsSelection) => { + if (selection.showReasoning !== showReasoning) setReasoning(selection.showReasoning) + if (selection.visibility !== toolsVisibility) setToolsVisibility(selection.visibility) + }, + () => { void session.close() }, + ), + options: { width: resolved.detailsDialogWidth, anchor: 'center', margin: 1 }, + }) + detailsOverlay = session + void session.closed.then(() => { + if (detailsOverlay === session) detailsOverlay = undefined + }) + requestRender() + } + + // `/details` names the same transcript-detail state the Ctrl+O cycle and + // Ctrl+R toggle mutate, so a user can jump to a mode without cycling. + const runDetails = (rawInput: string): CommandResult => { + const tokens = rawInput.split(/\s+/u).filter(token => token !== '') + if (tokens.length === 0) { + showDetailsSelector() + return { kind: 'success' } + } + let visibility: ToolCardVisibility | undefined + let reasoning: boolean | undefined + for (let token = tokens.shift(); token !== undefined; token = tokens.shift()) { + if (token === 'collapsed' || token === 'expanded' || token === 'hidden') { + visibility = token + } else if (token === 'reasoning') { + const value = tokens[0] + if (value === 'on' || value === 'off') { + tokens.shift() + reasoning = value === 'on' + } else { + reasoning = !showReasoning + } + } else { + return { kind: 'error', text: `Unknown /details argument "${token}". Usage: /details [collapsed|expanded|hidden] [reasoning [on|off]]` } + } + } + // Reasoning first: its transcript rebuild would drop the visibility notice. + if (reasoning !== undefined) setReasoning(reasoning) + if (visibility !== undefined) setToolsVisibility(visibility) + return { kind: 'success' } + } + const showHelp = (): void => { const commandLines = ctx.commands.list(agent).map((command) => { const input = command.input === undefined ? '' : ` ${command.input.hint}` @@ -1190,6 +1368,12 @@ export function createTuiChat( description: 'Clear the transcript view (session history is unchanged)', handler: () => { chat.clear(); requestRender(); return { kind: 'success' } }, }) + commandCtx.commands.register({ + name: 'details', + description: 'Select tool-card visibility and reasoning display', + input: { hint: '[collapsed|expanded|hidden] [reasoning [on|off]]' }, + handler: ({ rawInput }) => runDetails(rawInput), + }) commandCtx.commands.register({ name: 'palette', description: 'Show every color and attribute role this terminal renders', @@ -1529,7 +1713,6 @@ export function createTuiChat( if (event.type === 'tool/result') fileSearch.invalidate() recordEventUsage(tokens, event) if (event.type === 'turn/start' && runningStatus !== undefined) runningStatus.turn = event.data.turn - if (event.type === 'assistant/message' && streaming?.isSettled()) streaming = undefined // Track live standalone compaction state. if (event.type === 'compact/start' && event.data.turn === null) { if (compacting === undefined) { diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt index 2a005383e1..20ec3ab324 100644 --- a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt +++ b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt @@ -1,7 +1,7 @@ -terminal 100x40 buffer=normal length=40 base=0 viewport=0 +terminal 100x40 buffer=normal length=41 base=1 viewport=1 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=7 viewportRow=34 bufferRow=34 +cursor hidden column=7 viewportRow=39 bufferRow=40 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -31,9 +31,9 @@ buffer style 0-10 bold 14| "- old line " style 0-9 fg=red -15| "… +3 lines (Ctrl+O to expand) " +15| "… +2 lines (Ctrl+O to expand) " style 0-28 dim -16| "└ +2 -2 · 1 file " +16| "└ +1 -1 · 1 file " style 0-15 dim 17| 18| "● Tool / subagent" @@ -58,17 +58,27 @@ buffer style 0-99 dim 30| "Loaded review instructions. " style 0-99 dim -31| "Model wait 0.0s " +31| +32| "● Tool / large_edit" + style 0-18 fg=green +33| "src/large.ts " + style 0-11 bold +34| "[exact line diff omitted: >2 changed lines] " + style 0-42 dim +35| "… +6 lines (Ctrl+O to expand) " + style 0-28 dim +36| "└ +3 -3 · 1 file · approximate " + style 0-29 dim +37| "Model wait 0.0s " style 0-14 dim -32| -33| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +38| +39| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-50 dim style 53-57 dim style 60-69 dim -34| " dsh > " +40| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse -35-39| diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt index 6f9aa094f3..7752484db7 100644 --- a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt +++ b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt @@ -1,7 +1,7 @@ -terminal 100x40 buffer=normal length=43 base=3 viewport=3 +terminal 100x40 buffer=normal length=53 base=13 viewport=13 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=7 viewportRow=39 bufferRow=42 +cursor hidden column=7 viewportRow=39 bufferRow=52 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -37,54 +37,73 @@ buffer style 0-10 bold 17| "- old line " style 0-9 fg=red -18| "- keep " - style 0-5 fg=red -19| "+ new line " +18| "+ new line " style 0-9 fg=green -20| "+ keep " - style 0-5 fg=green -21| "└ +2 -2 · 1 file " +19| " keep " + style 0-5 dim +20| "└ +1 -1 · 1 file " style 0-15 dim -22| -23| "● Tool / subagent" +21| +22| "● Tool / subagent" style 0-16 fg=green -24| "Delegate renderer audit " +23| "Delegate renderer audit " style 0-99 dim -25| "The renderer has explicit lifecycle ownership. " +24| "The renderer has explicit lifecycle ownership. " style 0-99 dim -26| -27| "● Tool / task_output" +25| +26| "● Tool / task_output" style 0-19 fg=green -28| "Read output from background task subagent-7 " +27| "Read output from background task subagent-7 " style 0-99 dim -29| " " -30| "console " +28| " " +29| "console " style 0-6 dim -31| " started background task bash-5 " +30| " started background task bash-5 " style 0-1 dim style 2-31 fg=cyan dim style 32-99 dim -32| " " -33| -34| "● Tool / skill" +31| " " +32| +33| "● Tool / skill" style 0-13 fg=green -35| "Load skill dsh-code-review " +34| "Load skill dsh-code-review " style 0-99 dim -36| "Loaded review instructions. " +35| "Loaded review instructions. " style 0-99 dim -37| "Model wait 0.0s " +36| +37| "● Tool / large_edit" + style 0-18 fg=green +38| "src/large.ts " + style 0-11 bold +39| "[exact line diff omitted: >2 changed lines] " + style 0-42 dim +40| "- old one " + style 0-8 fg=red +41| "- old two " + style 0-8 fg=red +42| "- old three " + style 0-10 fg=red +43| "+ new one " + style 0-8 fg=green +44| "+ new two " + style 0-8 fg=green +45| "+ new three " + style 0-10 fg=green +46| "└ +3 -3 · 1 file · approximate " + style 0-29 dim +47| "Model wait 0.0s " style 0-14 dim -38| -39| "Tool and context cards expanded. " +48| +49| "Tool and context cards expanded. " style 0-31 dim -40| -41| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +50| +51| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-50 dim style 53-57 dim style 60-69 dim -42| " dsh > " +52| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/details-command.expected.txt b/packages/ui/tui/tests/snapshots/details-command.expected.txt new file mode 100644 index 0000000000..4d971c88df --- /dev/null +++ b/packages/ui/tui/tests/snapshots/details-command.expected.txt @@ -0,0 +1,42 @@ +terminal 100x40 buffer=normal length=40 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=7 viewportRow=17 bufferRow=17 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 dim +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Running the check now. " +6| "Model wait 0.0s " + style 0-14 dim +7| +8| "You " + style 0-2 fg=bright-magenta bold underline +9| "Inspect the renderer. " +10| "Model wait 0.0s · Completed 2026-07-30 18:00:00 " + style 0-46 dim +11| +12| "Reasoning blocks hidden. " + style 0-23 dim +13| +14| "Tool cards hidden. " + style 0-17 dim +15| +16| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-57 dim + style 60-69 dim +17| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse +18-39| diff --git a/packages/ui/tui/tests/snapshots/details-selector.expected.txt b/packages/ui/tui/tests/snapshots/details-selector.expected.txt new file mode 100644 index 0000000000..69ab18bddd --- /dev/null +++ b/packages/ui/tui/tests/snapshots/details-selector.expected.txt @@ -0,0 +1,72 @@ +terminal 100x40 buffer=normal length=40 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=0 viewportRow=39 bufferRow=39 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 dim +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Running the check now. " +6| "Model wait 0.0s " + style 0-14 dim +7| +8| "You " + style 0-2 fg=bright-magenta bold underline +9| "Inspect the renderer. " +10| +11| "Assistant " + style 0-8 fg=bright-magenta bold underline +12| +13| "● Tool / bash / Run the coverage gate" + style 0-36 fg=green +14| "$ pnpm run test:coverage " + style 0-23 dim +15| "/workspace/project " + style 0-17 dim +16| "… +4 lines (Ctrl+O to expand) " + style 0-28 dim +17| "[exit 0] ╭ Transcript details ──────────────────────────────────────────────────╮ " + style 0-7 dim + style 14-85 fg=bright-magenta +18| "Model wait 0.0│ → Tool cards collapsed │ " + style 0-13 dim + style 14-14 fg=bright-magenta + style 16-58 fg=bright-magenta inverse + style 85-85 fg=bright-magenta +19| " │ Reasoning hidden │ " + style 14-14 fg=bright-magenta + style 27-55 dim + style 85-85 fg=bright-magenta +20| "Reasoning bloc│ │ " + style 0-13 dim + style 14-14 fg=bright-magenta + style 85-85 fg=bright-magenta +21| " │ ↑/↓ move • Tab toggle • Enter/Esc close │ " + style 14-14 fg=bright-magenta + style 16-54 dim + style 85-85 fg=bright-magenta +22| "Tool cards hid╰──────────────────────────────────────────────────────────────────────╯ " + style 0-13 dim + style 14-85 fg=bright-magenta +23| +24| "Tool and context cards collapsed. " + style 0-32 dim +25| +26| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-57 dim + style 60-69 dim +27| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse +28-39| diff --git a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt index a6a73566d6..1ade76a5aa 100644 --- a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt +++ b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt @@ -1,7 +1,7 @@ -terminal 92x32 buffer=normal length=37 base=5 viewport=5 +terminal 92x32 buffer=normal length=39 base=7 viewport=7 lifecycle started=1 stopped=1 progress=inactive title "DSH snapshot" -cursor visible column=0 viewportRow=31 bufferRow=36 +cursor visible column=0 viewportRow=31 bufferRow=38 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -29,48 +29,52 @@ buffer 12| " " 13| "/clear — Clear the transcript view (session history is unchanged) " style 0-64 dim -14| "/exit — Exit after the active turn reaches idle " +14| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Select tool-card visibility and " + style 0-91 dim +15| "reasoning display " + style 0-16 dim +16| "/exit — Exit after the active turn reaches idle " style 0-46 dim -15| "/help — Show keyboard shortcuts and commands " +17| "/help — Show keyboard shortcuts and commands " style 0-43 dim -16| "/model [[provider/]model] — Show or switch this session's model " +18| "/model [[provider/]model] — Show or switch this session's model " style 0-62 dim -17| "/palette — Show every color and attribute role this terminal renders " +19| "/palette — Show every color and attribute role this terminal renders " style 0-67 dim -18| "/quit — Exit after the active turn reaches idle " +20| "/quit — Exit after the active turn reaches idle " style 0-46 dim -19| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " +21| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " style 0-87 dim -20| "/resume — List this workspace's resumable sessions " +22| "/resume — List this workspace's resumable sessions " style 0-49 dim -21| "/status — Show session diagnostics, system prompt, and registered tools " +23| "/status — Show session diagnostics, system prompt, and registered tools " style 0-70 dim -22| "/skill: [instructions] — load a skill into the conversation " +24| "/skill: [instructions] — load a skill into the conversation " style 0-64 dim -23| -24| "provider stream failed after partial output " - style 0-42 fg=red 25| -26| "The previous process ended during this turn. " - style 0-43 fg=yellow +26| "provider stream failed after partial output " + style 0-42 fg=red 27| -28| "Turn stopped: the agent was disposed. " - style 0-36 fg=yellow +28| "The previous process ended during this turn. " + style 0-43 fg=yellow 29| -30| "Turn ended: plugin-policy. " - style 0-25 fg=yellow +30| "Turn stopped: the agent was disposed. " + style 0-36 fg=yellow 31| -32| "Unknown command: /unknown-advanced-command " - style 0-41 fg=yellow +32| "Turn ended: plugin-policy. " + style 0-25 fg=yellow 33| -34| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +34| "Unknown command: /unknown-advanced-command " + style 0-41 fg=yellow +35| +36| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-50 dim style 53-57 dim style 60-69 dim -35| " dsh > " +37| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse -36| +38| diff --git a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt index b8b62bca11..7da75336e3 100644 --- a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt +++ b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt @@ -1,7 +1,7 @@ -terminal 92x32 buffer=normal length=36 base=4 viewport=4 +terminal 92x32 buffer=normal length=38 base=6 viewport=6 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=7 viewportRow=31 bufferRow=35 +cursor hidden column=7 viewportRow=31 bufferRow=37 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -29,47 +29,51 @@ buffer 12| " " 13| "/clear — Clear the transcript view (session history is unchanged) " style 0-64 dim -14| "/exit — Exit after the active turn reaches idle " +14| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Select tool-card visibility and " + style 0-91 dim +15| "reasoning display " + style 0-16 dim +16| "/exit — Exit after the active turn reaches idle " style 0-46 dim -15| "/help — Show keyboard shortcuts and commands " +17| "/help — Show keyboard shortcuts and commands " style 0-43 dim -16| "/model [[provider/]model] — Show or switch this session's model " +18| "/model [[provider/]model] — Show or switch this session's model " style 0-62 dim -17| "/palette — Show every color and attribute role this terminal renders " +19| "/palette — Show every color and attribute role this terminal renders " style 0-67 dim -18| "/quit — Exit after the active turn reaches idle " +20| "/quit — Exit after the active turn reaches idle " style 0-46 dim -19| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " +21| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " style 0-87 dim -20| "/resume — List this workspace's resumable sessions " +22| "/resume — List this workspace's resumable sessions " style 0-49 dim -21| "/status — Show session diagnostics, system prompt, and registered tools " +23| "/status — Show session diagnostics, system prompt, and registered tools " style 0-70 dim -22| "/skill: [instructions] — load a skill into the conversation " +24| "/skill: [instructions] — load a skill into the conversation " style 0-64 dim -23| -24| "provider stream failed after partial output " - style 0-42 fg=red 25| -26| "The previous process ended during this turn. " - style 0-43 fg=yellow +26| "provider stream failed after partial output " + style 0-42 fg=red 27| -28| "Turn stopped: the agent was disposed. " - style 0-36 fg=yellow +28| "The previous process ended during this turn. " + style 0-43 fg=yellow 29| -30| "Turn ended: plugin-policy. " - style 0-25 fg=yellow +30| "Turn stopped: the agent was disposed. " + style 0-36 fg=yellow 31| -32| "Unknown command: /unknown-advanced-command " - style 0-41 fg=yellow +32| "Turn ended: plugin-policy. " + style 0-25 fg=yellow 33| -34| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +34| "Unknown command: /unknown-advanced-command " + style 0-41 fg=yellow +35| +36| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-50 dim style 53-57 dim style 60-69 dim -35| " dsh > " +37| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/question-dialog-detail-paged.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-detail-paged.expected.txt new file mode 100644 index 0000000000..415bedd976 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/question-dialog-detail-paged.expected.txt @@ -0,0 +1,39 @@ +terminal 56x20 buffer=normal length=25 base=5 viewport=5 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=56 viewportRow=13 bufferRow=18 +viewport +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 " + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-55 dim +8| " Review the complete plan including every required " +9| " checkpoint including every required checkpoint " +10| " including every required checkpoint including every " +11| " required checkpoint including every required " +12| " checkpoint including every required checkpoint " +13| " including every required checkpoint including every " +14| " required checkpoint including every required " +15| " checkpoint including every required checkpoint " +16| " including every required checkpoint including every " +17| " required checkpoint visible plan tail " +18| " … lines 4-13/13 • PgUp/PgDn " + style 2-28 dim +19| " › 1. [ ] Code Mode " + style 2-20 fg=bright-magenta bold +20| " run_code programs and captured output with " + style 12-53 dim +21| " … lines 1-2/12 • PgUp/PgDn " + style 2-27 dim +22| " ↓ 3 more " + style 2-9 dim +23| " ↑↓ Tab Sp ↵Esc " + style 2-15 dim +24| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/question-dialog-paged.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-paged.expected.txt new file mode 100644 index 0000000000..2969918f17 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/question-dialog-paged.expected.txt @@ -0,0 +1,39 @@ +terminal 56x20 buffer=normal length=25 base=5 viewport=5 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=56 viewportRow=16 bufferRow=21 +viewport +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 " + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-55 dim +8| " Review the complete plan including every required " +9| " checkpoint including every required checkpoint " +10| " including every required checkpoint including every " +11| " required checkpoint including every required " +12| " checkpoint including every required checkpoint " +13| " including every required checkpoint including every " +14| " required checkpoint including every required " +15| " checkpoint including every required checkpoint " +16| " including every required checkpoint including every " +17| " required checkpoint visible plan tail " +18| " … lines 4-13/13 • PgUp/PgDn " + style 2-28 dim +19| " detail with complete wrapped detail " + style 12-46 dim +20| " visible tail " + style 12-23 dim +21| " … lines 11-12/12 • PgUp/PgDn " + style 2-29 dim +22| " ↓ 3 more " + style 2-9 dim +23| " ↑↓ Tab Sp ↵Esc " + style 2-15 dim +24| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt index a81556d42a..6cfef15ead 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt @@ -1,7 +1,7 @@ terminal 56x20 buffer=normal length=20 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=0 viewportRow=19 bufferRow=19 +cursor hidden column=56 viewportRow=16 bufferRow=16 viewport 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -21,20 +21,20 @@ viewport style 18-31 dim style 34-50 dim style 53-55 dim -8| " dsh > " +8| " " +9| " Question 1/1 (1 unanswered) · Confirm " + style 2-38 dim +10| " Continue with this change? " +11| " " +12| " › 1. Proceed " + style 2-14 fg=bright-magenta bold +13| " Apply the proposed change " + style 8-32 dim +14| " Tab custom answer • Enter submit • Esc interrupt " + style 2-49 dim +15| " " +16| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse -9-11| -12| " " -13| " Question 1/1 (1 unanswered) · Confirm " - style 2-38 dim -14| " Continue with this change? " -15| " " -16| " › 1. Proceed Apply the proposed change " - style 2-13 fg=bright-magenta bold - style 16-40 dim -17| " Tab custom answer • Enter submit • Esc interrupt " - style 2-49 dim -18| " " -19| +17-19| diff --git a/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt index 14193efdda..ecd949cb8d 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt @@ -1,40 +1,40 @@ -terminal 56x20 buffer=normal length=20 base=0 viewport=0 +terminal 56x20 buffer=normal length=25 base=5 viewport=5 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=56 viewportRow=17 bufferRow=17 +cursor hidden column=56 viewportRow=17 bufferRow=22 viewport -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -1| " Snapshot agent ready." - style 1-21 dim -2| " main-session" - style 1-12 dim -3| -4| "Assistant " - style 0-8 fg=bright-magenta bold underline -5| " " -6| " Question 1/3 (3 unanswered) · Coverage " - style 2-39 dim -7| " Which advanced TUI states belong in the required " -8| " matrix? " -9| " " -10| " › 1. [ ] Code Mode run_code programs and capture " - style 2-19 fg=bright-magenta bold - style 25-53 dim -11| " 2. [ ] Workflows phases and parallel agents " - style 25-50 dim -12| " 3. [ ] Cordis tools inspect, mount, and unmount " - style 25-51 dim -13| " 1/4 " - style 2-4 dim -14| " Tab custom answer • ↑/↓ navigate • Space toggle • " - style 2-55 dim -15| " Enter submit • Esc interrupt " +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 " + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-55 dim +8| " Review the complete plan including every required " +9| " checkpoint including every required checkpoint " +10| " including every required checkpoint including every " +11| " required checkpoint including every required " +12| " checkpoint including every required checkpoint " +13| " including every required checkpoint including every " +14| " required checkpoint including every required " +15| " checkpoint including every required checkpoint " +16| " including every required checkpoint including every " +17| " … lines 4-12/13 • PgUp/PgDn " + style 2-28 dim +18| " detail with complete wrapped detail " + style 12-46 dim +19| " visible tail " + style 12-23 dim +20| " … lines 11-12/12 • PgUp/PgDn " style 2-29 dim -16| " Select at least one option, or press Tab for a " - style 2-55 fg=red -17| " custom answer. " - style 2-15 fg=red -18| " " -19| +21| " ↓ 3 more " + style 2-9 dim +22| " Error: Select at least one option, or press Tab for… " + style 2-52 fg=red +23| " ↑↓ Tab Sp ↵Esc " + style 2-15 dim +24| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/question-dialog.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog.expected.txt index 640bd6fed2..4b576099ae 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog.expected.txt @@ -1,39 +1,39 @@ -terminal 56x20 buffer=normal length=20 base=0 viewport=0 +terminal 56x20 buffer=normal length=25 base=5 viewport=5 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=0 viewportRow=19 bufferRow=19 +cursor hidden column=56 viewportRow=19 bufferRow=24 viewport -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -1| " Snapshot agent ready." - style 1-21 dim -2| " main-session" - style 1-12 dim -3| -4| "Assistant " - style 0-8 fg=bright-magenta bold underline 5| "Model wait 0.0s " style 0-14 dim 6| -7| " " -8| " Question 1/3 (3 unanswered) · Coverage " - style 2-39 dim -9| " Which advanced TUI states belong in the required " -10| " matrix? " -11| " " -12| " › 1. [ ] Code Mode run_code programs and capture " - style 2-19 fg=bright-magenta bold - style 25-53 dim -13| " 2. [ ] Workflows phases and parallel agents " - style 25-50 dim -14| " 3. [ ] Cordis tools inspect, mount, and unmount " - style 25-51 dim -15| " 1/4 " - style 2-4 dim -16| " Tab custom answer • ↑/↓ navigate • Space toggle • " - style 2-55 dim -17| " Enter submit • Esc interrupt " - style 2-29 dim -18| " " -19| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 " + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-55 dim +8| " Which advanced TUI states belong in the required " +9| " matrix? " +10| " " +11| " Review the complete plan including every required " +12| " checkpoint including every required checkpoint " +13| " including every required checkpoint including every " +14| " required checkpoint including every required " +15| " checkpoint including every required checkpoint " +16| " including every required checkpoint including every " +17| " required checkpoint including every required " +18| " … lines 1-10/13 • PgUp/PgDn " + style 2-28 dim +19| " › 1. [ ] Code Mode " + style 2-20 fg=bright-magenta bold +20| " run_code programs and captured output with " + style 12-53 dim +21| " … lines 1-2/12 • PgUp/PgDn " + style 2-27 dim +22| " ↓ 3 more " + style 2-9 dim +23| " ↑↓ Tab Sp ↵Esc " + style 2-15 dim +24| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/tool-cards-hidden-folded.expected.txt b/packages/ui/tui/tests/snapshots/tool-cards-hidden-folded.expected.txt new file mode 100644 index 0000000000..d4da5ab39c --- /dev/null +++ b/packages/ui/tui/tests/snapshots/tool-cards-hidden-folded.expected.txt @@ -0,0 +1,46 @@ +terminal 100x40 buffer=normal length=40 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=7 viewportRow=20 bufferRow=20 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 dim +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Inspecting the renderer first. " +6| "Model wait 0.0s " + style 0-14 dim +7| +8| "You " + style 0-2 fg=bright-magenta bold underline +9| "Refactor the renderer. " +10| "Model wait 0.0s · Completed 2026-07-29 22:30:00 " + style 0-46 dim +11| +12| "The renderer is sound; no refactor needed. " +13| "Model wait 0.0s · Completed 2026-07-29 22:30:00 " + style 0-46 dim +14| +15| "Tool and context cards expanded. " + style 0-31 dim +16| +17| "Tool cards hidden. " + style 0-17 dim +18| +19| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-57 dim + style 60-69 dim +20| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse +21-39| diff --git a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt index 74cdd416d9..5cb2529355 100644 --- a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt +++ b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt @@ -1,7 +1,7 @@ -terminal 100x34 buffer=normal length=34 base=0 viewport=0 +terminal 100x34 buffer=normal length=40 base=6 viewport=6 lifecycle started=1 stopped=0 progress=inactive title "Unsafe terminal title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" -cursor hidden column=0 viewportRow=33 bufferRow=33 +cursor hidden column=100 viewportRow=33 bufferRow=39 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -13,55 +13,65 @@ buffer 3| 4| "Assistant " style 0-8 fg=bright-magenta bold underline -5| -6| "You " +5| "Reasoning " + style 0-8 dim italic +6| "Unsafe reasoning \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 0-61 dim italic +7| "Unsafe assistant \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +8| "Model wait 0.0s " + style 0-14 dim +9| +10| "You " style 0-2 fg=bright-magenta bold underline -7| "Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " -8| -9| "● Tool / unsafe / Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" +11| "Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +12| +13| "Assistant " + style 0-8 fg=bright-magenta bold underline +14| +15| "● Tool / unsafe / Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" style 0-81 fg=green -10| "$ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +16| "$ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-59 dim -11| "/unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +17| "/unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-52 dim -12| "Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +18| "Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-58 dim -13| "[signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] " +19| "[signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] " style 0-56 fg=red -14| "Model wait 0.0s · Completed 2026-07-21 15:00:00 " +20| "Model wait 0.0s · Completed 2026-07-21 15:00:00 " style 0-46 dim -15| -16| "Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" +21| +22| "Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" style 0-61 dim -17| "Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +23| "Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-59 dim -18| -19| "Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +24| +25| "Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-62 fg=red -20-21| -22| "Plan" +26-27| +28| "Plan" style 0-3 fg=bright-magenta bold -23| " ● Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" +29| " ● Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" style 2-2 fg=yellow -24| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +30| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-50 dim style 53-57 dim style 60-69 dim -25| " dsh > " +31| " " +32| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 2-90 dim +33| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +34| " " +35| " › 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 2-66 fg=bright-magenta bold +36| " Unsafe detail \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 8-66 dim +37| " Tab custom answer • Enter submit • Esc interrupt " + style 2-49 dim +38| " " +39| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse -26| " " -27| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 2-90 dim -28| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " -29| " " -30| " › 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m Unsafe detail \\x1b]2;snapshot-c " - style 2-65 fg=bright-magenta bold - style 67-97 dim -31| " Tab custom answer • Enter submit • Esc interrupt " - style 2-49 dim -32| " " -33| diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 18f0a9a793..21696153a2 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -44,8 +44,13 @@ const CHECKPOINTS = [ 'cordis-tools-pending', 'advanced-cards-collapsed', 'advanced-cards-expanded', + 'tool-cards-hidden-folded', + 'details-command', + 'details-selector', 'untrusted-controls', 'question-dialog', + 'question-dialog-detail-paged', + 'question-dialog-paged', 'question-dialog-single-option', 'question-dialog-validation', 'surface-before-compaction', @@ -269,13 +274,32 @@ const ADVANCED_CARD_TOOLS: Record = { edit: visualTool( 'edit', () => ({ card: 'diff', title: 'Edit src/view.ts', diffs: [{ path: 'src/view.ts', oldText: 'old line', newText: 'new line' }] }), - // The real edit/write tools produce exactly one diff whose path the title - // already names, so the card omits the redundant per-file header. + // The fixed tool header never names a path, so the hunk retains its path. (): ToolResultView => ({ card: 'diff', diffs: [{ path: 'src/view.ts', oldText: 'old line\nkeep', newText: 'new line\nkeep' }], }), ), + large_edit: visualTool( + 'large_edit', + () => ({ + card: 'diff', + title: 'Edit src/large.ts', + diffs: [{ + path: 'src/large.ts', + oldText: 'old one\nold two\nold three', + newText: 'new one\nnew two\nnew three', + }], + }), + (): ToolResultView => ({ + card: 'diff', + diffs: [{ + path: 'src/large.ts', + oldText: 'old one\nold two\nold three', + newText: 'new one\nnew two\nnew three', + }], + }), + ), subagent: visualTool('subagent', args => ({ card: 'generic', title: 'Delegate renderer audit', @@ -585,7 +609,7 @@ describe('TUI terminal-state snapshots', () => { it('pins terminal, diff, subagent, task, skill, collapsed, and expanded cards', async () => { const harness = await setupSnapshot({ tools: ADVANCED_CARD_TOOLS, - config: { maxToolOutputLines: 3 }, + config: { maxToolOutputLines: 3, maxDiffEditLength: 2 }, }, { columns: 100, rows: 40 }) const calls = [ { id: 'advanced-1', name: 'bash', arguments: { command: 'pnpm run test:coverage' } }, @@ -593,6 +617,7 @@ describe('TUI terminal-state snapshots', () => { { id: 'advanced-3', name: 'subagent', arguments: { prompt: 'Review renderer ownership and report only gaps.' } }, { id: 'advanced-4', name: 'task_output', arguments: { task_id: 'subagent-7', wait: true } }, { id: 'advanced-5', name: 'skill', arguments: { name: 'dsh-code-review' } }, + { id: 'advanced-6', name: 'large_edit', arguments: { file_path: 'src/large.ts' } }, ] await renderAfter(harness, () => { appendToolCalls(harness.session, calls) @@ -601,6 +626,7 @@ describe('TUI terminal-state snapshots', () => { appendToolResult(harness.session, 'advanced-3', [{ type: 'text', text: 'The renderer has explicit lifecycle ownership.' }]) appendToolResult(harness.session, 'advanced-4', [{ type: 'text', text: 'audit complete\n[status: completed]' }]) appendToolResult(harness.session, 'advanced-5', [{ type: 'text', text: 'Loaded review instructions.' }]) + appendToolResult(harness.session, 'advanced-6', [{ type: 'text', text: 'large edit complete' }]) }) await checkpoint('advanced-cards-collapsed', harness.terminal, { includeScrollback: true }) @@ -609,6 +635,71 @@ describe('TUI terminal-state snapshots', () => { await disposeSnapshot(harness) }) + it('pins the hidden phase folding a multi-step turn into one assistant message', async () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 29, 22, 30, 0).getTime()) + const harness = await setupSnapshot({ + tools: ADVANCED_CARD_TOOLS, + config: { maxToolOutputLines: 3 }, + }, { columns: 100, rows: 40 }) + await renderAfter(harness, () => { + appendUser(harness.session, 'Refactor the renderer.') + appendAssistant(harness.session, [{ type: 'text', text: 'Inspecting the renderer first.' }]) + appendToolCalls(harness.session, [ + { id: 'fold-1', name: 'bash', arguments: { command: 'pnpm run test' } }, + ]) + appendToolResult(harness.session, 'fold-1', [{ type: 'text', text: 'all tests pass' }]) + harness.session.append('step/end', { turn: 1, step: 1 }) + harness.session.append('step/start', { turn: 1, step: 2 }) + appendAssistant(harness.session, [{ type: 'text', text: 'The renderer is sound; no refactor needed.' }], undefined, { turn: 1, step: 2 }) + harness.session.append('step/end', { turn: 1, step: 2 }) + harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }) + // collapsed -> expanded -> hidden: one Assistant header, no tool card. + await renderAfter(harness, () => { harness.terminal.send('\x0f') }) + await renderAfter(harness, () => { harness.terminal.send('\x0f') }) + await checkpoint('tool-cards-hidden-folded', harness.terminal, { includeScrollback: true }) + nowSpy.mockRestore() + await disposeSnapshot(harness) + }) + + it('pins /details jumping card visibility and reasoning display to named states', async () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 30, 18, 0, 0).getTime()) + const harness = await setupSnapshot({ + tools: ADVANCED_CARD_TOOLS, + config: { maxToolOutputLines: 3 }, + }, { columns: 100, rows: 40 }) + await renderAfter(harness, () => { + appendUser(harness.session, 'Inspect the renderer.') + appendAssistant(harness.session, [ + { type: 'reasoning', text: 'The tool card and this block vanish under /details hidden reasoning off.' }, + { type: 'text', text: 'Running the check now.' }, + ]) + appendToolCalls(harness.session, [ + { id: 'details-1', name: 'bash', arguments: { command: 'pnpm run test' } }, + ]) + appendToolResult(harness.session, 'details-1', [{ type: 'text', text: 'all tests pass' }]) + harness.session.append('step/end', { turn: 1, step: 1 }) + harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }) + await renderAfter(harness, () => { + harness.terminal.send('/details hidden reasoning off') + harness.terminal.send('\r') + }) + await checkpoint('details-command', harness.terminal, { includeScrollback: true }) + // Bare /details opens the two-entry toggle seeded with the current + // hidden/reasoning-off state; one Tab immediately cycles tool cards + // hidden -> collapsed, so the frame pins the applied notice, the restored + // tool card behind the dialog, and the updated entry value together. + await renderAfter(harness, () => { + harness.terminal.send('/details') + harness.terminal.send('\r') + harness.terminal.send('\t') + }) + await checkpoint('details-selector', harness.terminal, { includeScrollback: true }) + nowSpy.mockRestore() + await disposeSnapshot(harness) + }) + it('renders terminal controls as inert text across transcripts, tools, dialogs, diagnostics, and title', async () => { const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 15, 0, 0).getTime()) const tools = { @@ -697,9 +788,13 @@ describe('TUI terminal-state snapshots', () => { id: 'coverage', header: 'Coverage', question: 'Which advanced TUI states belong in the required matrix?', + detail: `Review the complete plan ${'including every required checkpoint '.repeat(12)}visible plan tail`, multiSelect: true, options: [ - { label: 'Code Mode', description: 'run_code programs and captured output' }, + { + label: 'Code Mode', + description: `run_code programs and captured output ${'with complete wrapped detail '.repeat(12)}visible tail`, + }, { label: 'Workflows', description: 'phases and parallel agents' }, { label: 'Cordis tools', description: 'inspect, mount, and unmount' }, { label: 'Compaction', description: 'surface replacement and reflow' }, @@ -714,6 +809,14 @@ describe('TUI terminal-state snapshots', () => { await harness.terminal.waitForFrame(beforeQuestion) await checkpoint('question-dialog', harness.terminal) + await renderAfter(harness, () => { harness.terminal.send('\x1b[6~') }) + await checkpoint('question-dialog-detail-paged', harness.terminal) + + await renderAfter(harness, () => { + for (let page = 0; page < 30; page += 1) harness.terminal.send('\x1b[6~') + }) + await checkpoint('question-dialog-paged', harness.terminal) + await renderAfter(harness, () => { harness.terminal.send('\r') }) await checkpoint('question-dialog-validation', harness.terminal) controller.abort() diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index fffcec4674..4ecf78d54a 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -50,6 +50,7 @@ import { disposeTuiTestHarness, type TuiHarnessOptions, } from './harness.ts' +import { HeadlessTerminal } from './headless-terminal.ts' import { TestSessionQueryService } from './session-query.ts' const UNUSED_TOOL_OUTPUT: ToolDefinition['output'] = { @@ -185,6 +186,7 @@ describe('TUI config', () => { expect(resolveTuiConfig(undefined)).toEqual({ showReasoning: true, maxToolOutputLines: 6, + maxDiffEditLength: 1000, maxQuestionOptions: 8, maxModelOptions: 8, maxResumeOptions: 8, @@ -192,6 +194,7 @@ describe('TUI config', () => { questionDialogMaxHeight: 20, modelDialogWidth: 76, modelDialogMaxHeight: 20, + detailsDialogWidth: 72, fileSearchMaxResults: 20, fileSearchMaxEntries: 10_000, fileSearchExcludedDirectories: ['.git', 'node_modules'], @@ -209,6 +212,7 @@ describe('TUI config', () => { expect(resolveTuiConfig({ showReasoning: false, maxToolOutputLines: 2, + maxDiffEditLength: 12, maxQuestionOptions: 3, maxModelOptions: 4, maxResumeOptions: 5, @@ -216,6 +220,7 @@ describe('TUI config', () => { questionDialogMaxHeight: 14, modelDialogWidth: 64, modelDialogMaxHeight: 16, + detailsDialogWidth: 44, fileSearchMaxResults: 7, fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], @@ -225,6 +230,7 @@ describe('TUI config', () => { })).toEqual({ showReasoning: false, maxToolOutputLines: 2, + maxDiffEditLength: 12, maxQuestionOptions: 3, maxModelOptions: 4, maxResumeOptions: 5, @@ -232,6 +238,7 @@ describe('TUI config', () => { questionDialogMaxHeight: 14, modelDialogWidth: 64, modelDialogMaxHeight: 16, + detailsDialogWidth: 44, fileSearchMaxResults: 7, fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], @@ -2687,6 +2694,94 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(result) }) + it('/details sets card visibility and reasoning display from arguments', async () => { + const result = await setup() + const run = async (line: string): Promise => { + result.terminal.send(line) + result.terminal.send('\r') + await tick() + } + + await run('/details hidden') + expect(result.terminal.output).toContain('Tool cards hidden.') + + await run('/details expanded reasoning off') + expect(result.terminal.output).toContain('Tool and context cards expanded.') + expect(result.terminal.output).toContain('Reasoning blocks hidden.') + + await run('/details reasoning on') + expect(result.terminal.output).toContain('Reasoning blocks shown.') + + // Bare `reasoning` toggles: shown -> hidden. + const toggleOutput = result.terminal.output.length + await run('/details reasoning') + expect(result.terminal.output.slice(toggleOutput)).toContain('Reasoning blocks hidden.') + await run('/details collapsed') + expect(result.terminal.output.slice(toggleOutput)).toContain('Tool and context cards collapsed.') + + await run('/details bogus') + expect(result.terminal.output).toContain('Unknown /details argument "bogus"') + + await dispose(result) + }) + + it('bare /details opens the transcript-details toggle and Tab applies immediately', async () => { + const result = await setup() + const open = async (): Promise => { + const from = result.terminal.output.length + result.terminal.send('/details') + result.terminal.send('\r') + await vi.waitFor(() => { expect(result.terminal.output.slice(from)).toContain('Transcript details') }) + return from + } + + const opened = await open() + expect(result.terminal.output.slice(opened)).toContain('Tool cards') + expect(result.terminal.output.slice(opened)).toContain('Reasoning') + + // A second /details while the selector is open replaces the overlay + // instead of stacking a second one behind it. + await result.ctx.commands.execute(result.agent, '/details', new AbortController().signal) + await tick() + + // Each Tab applies one step immediately while the dialog stays open: + // collapsed -> expanded -> hidden -> collapsed (wraparound). + result.terminal.send('\t') + await tick() + expect(result.terminal.output).toContain('Tool and context cards expanded.') + result.terminal.send('\t') + await tick() + expect(result.terminal.output).toContain('Tool cards hidden.') + result.terminal.send('\t') + await tick() + expect(result.terminal.output).toContain('Tool and context cards collapsed.') + + // The reasoning entry toggles the same way. + result.terminal.send('\x1b[B') + result.terminal.send('\t') + await tick() + expect(result.terminal.output).toContain('Reasoning blocks hidden.') + + // Enter closes without further changes. + const entered = result.terminal.output.length + result.terminal.send('\r') + await tick() + expect(result.terminal.output.slice(entered)).not.toContain('Reasoning blocks') + + // Esc and Ctrl+C also close; the reopened dialog shows the live values. + const reopened = await open() + expect(result.terminal.output.slice(reopened)).toContain('collapsed') + expect(result.terminal.output.slice(reopened)).toContain('hidden') + result.terminal.send('\x1b') + await tick() + const ctrlCOutput = await open() + result.terminal.send('\x03') + await tick() + expect(result.terminal.output.slice(ctrlCOutput)).not.toContain('Reasoning blocks') + + await dispose(result) + }) + it('sends, steers, handles commands, global keys, and disposed-agent input', async () => { const result = await setup() @@ -4584,7 +4679,11 @@ describe('tool cards and surface replay', () => { presentCall: () => ({ card: 'diff', title: 'Edit src/only.ts', - diffs: [{ path: 'src/only.ts', oldText: 'old', newText: 'new' }], + diffs: [{ + path: 'src/only.ts', + oldText: 'my: my-MM\nne: ne-NP\nnl: nl-NL\nnb: no-NO\npa: pa-Guru-IN\npl: pl-PL\npt_pt: pt-PT', + newText: 'my: my-MM\nne: ne-NP\nnl: nl-NL\nnb: nb-NO\npa: pa-Guru-IN\npl: pl-PL\npt_pt: pt-PT', + }], }), }, scatteredDiff: { @@ -4938,7 +5037,7 @@ describe('tool cards and surface replay', () => { }) it('names a single-file diff in the body once, under a fixed Tool header', async () => { - const result = await setup({ tools }) + const result = await setup({ tools, config: { maxToolOutputLines: 20 } }) appendUser(result.session, 'edit one file') appendAssistant(result.session, [ { type: 'text', text: 'Editing' }, @@ -4954,9 +5053,123 @@ describe('tool cards and surface replay', () => { expect(output).toContain('Tool / singleDiff') expect(output).not.toContain('Edit src/only.ts') expect(output.split('src/only.ts').length - 1).toBe(1) - expect(output).toContain('- old') - expect(output).toContain('+ new') - expect(output).toContain('· 1 file') + expect(output).toContain(' my: my-MM') + expect(output).not.toContain('- my: my-MM') + expect(output).not.toContain('+ my: my-MM') + expect(output).toContain('- nb: no-NO') + expect(output).toContain('+ nb: nb-NO') + expect(output).toContain('└ +1 -1 · 1 file') + await dispose(result) + }) + + it('renders an empty create without a synthetic added row', async () => { + const emptyCreate: Record = { + emptyCreate: { + name: 'emptyCreate', + description: '', + parameters: {}, + output: UNUSED_TOOL_OUTPUT, + execute: async () => [], + presentCall: () => ({ + card: 'diff', + title: 'Write empty.txt', + diffs: [{ path: 'empty.txt', oldText: null, newText: '' }], + }), + }, + } + const result = await setup({ + tools: emptyCreate, + config: { maxToolOutputLines: 20, theme: { color: false } }, + }) + appendAssistant(result.session, [ + { type: 'tool-call', id: 'empty-create' as never, name: 'emptyCreate', arguments: '{}' }, + ]) + result.session.append('tool/call', { + turn: 1, + step: 1, + callId: 'empty-create' as never, + name: 'emptyCreate', + arguments: '{}', + }) + await tick() + const rows = result.terminal.output.split('\n').map(row => row.trim()) + expect(result.terminal.output).toContain('empty.txt') + expect(result.terminal.output).toContain('└ +0 -0 · 1 file') + expect(rows).not.toContain('+') + await dispose(result) + }) + + it('bounds and caches exact diff comparison before whole-side fallback', async () => { + let oldTextReads = 0 + let newText = 'new one\nnew two' + const boundedDiff = { + path: 'bounded.txt', + get oldText() { + oldTextReads += 1 + return 'old one\nold two' + }, + get newText() { return newText }, + } + const boundedView = { + card: 'diff' as const, + title: 'Edit bounded.txt', + diffs: [boundedDiff], + } + const bounded: Record = { + bounded: { + name: 'bounded', + description: '', + parameters: {}, + output: UNUSED_TOOL_OUTPUT, + execute: async () => [], + presentCall: () => boundedView, + presentResult: () => { + newText = 'settled one\nsettled two' + return boundedView + }, + }, + } + const result = await setup({ + tools: bounded, + config: { + maxToolOutputLines: 20, + maxDiffEditLength: 1, + theme: { color: false }, + }, + }) + appendAssistant(result.session, [ + { type: 'tool-call', id: 'bounded-diff' as never, name: 'bounded', arguments: '{}' }, + ]) + result.session.append('tool/call', { + turn: 1, + step: 1, + callId: 'bounded-diff' as never, + name: 'bounded', + arguments: '{}', + }) + await tick() + expect(result.terminal.output).toContain('[exact line diff omitted: >1 changed lines]') + expect(result.terminal.output).toContain('- old one') + expect(result.terminal.output).toContain('+ new one') + expect(result.terminal.output).toContain('└ +2 -2 · 1 file · approximate') + const readsAfterFirstRender = oldTextReads + expect(readsAfterFirstRender).toBeGreaterThan(0) + result.session.append('tool/result', { + turn: 1, + step: 1, + message: createToolResultMessage({ + callId: 'bounded-diff' as never, + content: [{ type: 'text', text: 'done' }], + isError: false, + }), + }, { surfaceOp: 'append' }) + await tick() + expect(result.terminal.output).toContain('+ settled one') + expect(oldTextReads).toBeGreaterThan(readsAfterFirstRender) + const readsAfterResult = oldTextReads + result.terminal.resize(87) + await tick() + expect(oldTextReads).toBe(readsAfterResult) await dispose(result) }) @@ -5167,9 +5380,185 @@ describe('tool cards and surface replay', () => { expect(mounted).not.toContain('stored model-only payload') await dispose(result) }) + + /** The last repainted frame, with CSI/OSC escapes and carriage returns stripped. */ + const lastFrame = (terminal: FakeTerminal): string => terminal.output + .slice(terminal.output.lastIndexOf('\x1b[2J')) + .replaceAll(/\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07]*\x07|\r/g, '') + + const countAssistantHeaders = (frame: string): number => frame.split('\n') + .filter(row => row.trim() === 'Assistant').length + + /** One turn with text -> tool call/result -> text across two steps. */ + const appendTwoStepTurn = (session: Awaited>['session']): void => { + appendUser(session, 'fold me') + appendAssistant(session, [{ type: 'text', text: 'first step text' }]) + session.append('tool/call', { turn: 1, step: 1, callId: 'fold-1' as never, name: 'bash', arguments: '{}' }) + session.append('tool/result', { + turn: 1, step: 1, + message: createToolResultMessage({ + callId: 'fold-1' as never, content: [{ type: 'text', text: 'tool body' }], isError: false, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('step/start', { turn: 1, step: 2 }) + appendAssistant(session, [{ type: 'text', text: 'second step text' }], undefined, { turn: 1, step: 2 }) + session.append('step/end', { turn: 1, step: 2 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + } + + it('folds a turn to one Assistant header in hidden mode and restores headers on cycle', async () => { + const result = await setup({ tools }) + appendTwoStepTurn(result.session) + await tick() + + // Collapsed (default): each step keeps its own header. + result.terminal.send('\x0c') + await tick() + expect(countAssistantHeaders(lastFrame(result.terminal))).toBe(2) + + // collapsed -> expanded -> hidden. + result.terminal.send('\x0f') + result.terminal.send('\x0f') + await tick() + result.terminal.send('\x0c') + await tick() + const hidden = lastFrame(result.terminal) + expect(countAssistantHeaders(hidden)).toBe(1) + expect(hidden).toContain('first step text') + expect(hidden).toContain('second step text') + expect(hidden).not.toContain('Tool / bash') + // The fold keeps model order: header text precedes the continuation. + expect(hidden.indexOf('first step text')).toBeLessThan(hidden.indexOf('second step text')) + + // hidden -> collapsed restores per-step headers. + result.terminal.send('\x0f') + await tick() + result.terminal.send('\x0c') + await tick() + expect(countAssistantHeaders(lastFrame(result.terminal))).toBe(2) + await dispose(result) + }) + + it('gives the hidden-mode header to the first step with a visible body and keeps turns separate', async () => { + const result = await setup({ tools }) + // Turn 1, step 1 is tool-only; step 2 carries the turn's text. + appendUser(result.session, 'tool-only first step') + appendAssistant(result.session, [{ type: 'tool-call', id: 'only-1' as never, name: 'bash', arguments: '{}' }]) + result.session.append('tool/call', { turn: 1, step: 1, callId: 'only-1' as never, name: 'bash', arguments: '{}' }) + result.session.append('tool/result', { + turn: 1, step: 1, + message: createToolResultMessage({ + callId: 'only-1' as never, content: [{ type: 'text', text: 'tool body' }], isError: false, + }), + }, { surfaceOp: 'append' }) + result.session.append('step/end', { turn: 1, step: 1 }) + result.session.append('step/start', { turn: 1, step: 2 }) + appendAssistant(result.session, [{ type: 'text', text: 'late turn-one text' }], undefined, { turn: 1, step: 2 }) + result.session.append('step/end', { turn: 1, step: 2 }) + result.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + // Turn 2 keeps its own header. + result.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + appendUser(result.session, 'next turn') + result.session.append('step/start', { turn: 2, step: 1 }) + appendAssistant(result.session, [{ type: 'text', text: 'turn-two text' }], undefined, { turn: 2, step: 1 }) + result.session.append('step/end', { turn: 2, step: 1 }) + result.session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + await tick() + + result.terminal.send('\x0f') + result.terminal.send('\x0f') + await tick() + result.terminal.send('\x0c') + await tick() + const hidden = lastFrame(result.terminal) + // One header per turn: the tool-only step neither renders a blank segment + // nor consumes turn one's header, which the late text step owns. + expect(countAssistantHeaders(hidden)).toBe(2) + expect(hidden).toContain('late turn-one text') + expect(hidden).toContain('turn-two text') + const rows = hidden.split('\n').map(row => row.trim()) + const turnOneHeader = rows.indexOf('Assistant') + expect(rows[turnOneHeader + 1]).toBe('late turn-one text') + await dispose(result) + }) + + it('folds live hidden-mode streaming once a later step shows text', async () => { + const result = await setup({ tools, status: 'running' }) + result.terminal.send('\x0f') + result.terminal.send('\x0f') + await tick() + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'live first' } }) + result.session.append('step/end', { turn: 1, step: 1 }) + result.session.append('step/start', { turn: 1, step: 2 }) + result.session.append('assistant/chunk', { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'live second' } }) + await tick() + result.terminal.send('\x0c') + await tick() + const hidden = lastFrame(result.terminal) + expect(countAssistantHeaders(hidden)).toBe(1) + expect(hidden).toContain('live first') + expect(hidden).toContain('live second') + + // A transcript rebuild (resize) recomputes the same fold from the log. + result.terminal.resize(89) + await tick() + const rebuilt = lastFrame(result.terminal) + expect(countAssistantHeaders(rebuilt)).toBe(1) + expect(rebuilt).toContain('live second') + await dispose(result) + }) }) describe('TUI user-interaction dialogs', () => { + it('limits the visible option window to maxQuestionOptions', async () => { + const result = await setup({ + config: { maxQuestionOptions: 1, questionDialogWidth: 60, questionDialogMaxHeight: 20 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'cap', + question: 'Pick one', + options: [{ label: 'Visible first' }, { label: 'Hidden second' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('Visible first') + expect(result.terminal.output).not.toContain('Hidden second') + expect(result.terminal.output).toContain('↓ 1 more') + result.terminal.send('\x03') + await rejected + + await dispose(result) + }) + + it('renders a pending question between the transcript and editor', async () => { + const result = await setup({ + config: { questionDialogWidth: 40, questionDialogMaxHeight: 10 }, + }) + result.terminal.send('draft input') + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'placement', + question: 'Pick one', + options: [{ label: 'First' }, { label: 'Second' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(60, 20) + await tick() + const render = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + const questionIndex = render.indexOf('Pick one') + const editorIndex = render.indexOf('draft input') + expect(questionIndex).toBeGreaterThanOrEqual(0) + expect(editorIndex).toBeGreaterThan(questionIndex) + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + it('answers single-select, multi-select, custom, and optionless questions', async () => { const result = await setup({ config: { maxQuestionOptions: 1 } }) @@ -5195,8 +5584,29 @@ describe('TUI user-interaction dialogs', () => { result.terminal.send(' ') result.terminal.send('\x1b[B') result.terminal.send(' ') + result.terminal.send('\t') + await tick() + expect(result.terminal.output).toContain('2 selected • Enter submit • Esc options') + result.terminal.send('Tests') result.terminal.send('\r') - await expect(multi).resolves.toEqual({ answers: [{ id: 'targets', selected: ['Code', 'Docs'] }] }) + await expect(multi).resolves.toEqual({ + answers: [{ id: 'targets', selected: ['Code', 'Docs'], custom: 'Tests' }], + }) + + const labelsOnly = result.ctx.userInteraction.ask({ + questions: [{ + id: 'labels-only', + question: 'Pick one target', + multiSelect: true, + options: [{ label: 'Code' }, { label: 'Docs' }], + }], + }) + await tick() + result.terminal.send(' ') + result.terminal.send('\r') + await expect(labelsOnly).resolves.toEqual({ + answers: [{ id: 'labels-only', selected: ['Code'] }], + }) const custom = result.ctx.userInteraction.ask({ questions: [{ id: 'other', question: 'Choose or type', options: [{ label: 'Default' }] }], @@ -5239,7 +5649,6 @@ describe('TUI user-interaction dialogs', () => { options: [{ label: 'One', description: 'first' }, { label: 'Two' }], }], }) - const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) await tick() result.terminal.send('\x1b[A') result.terminal.send('\x1b[B') @@ -5255,14 +5664,482 @@ describe('TUI user-interaction dialogs', () => { }) result.terminal.send('c') await tick() + result.terminal.send('keep this') + await tick() + expect(result.terminal.output).toContain('0 selected • Enter submit • Esc options') result.terminal.send('\x1b') await tick() expect(result.terminal.output).toContain('Space toggle') + result.terminal.send(' ') + result.terminal.send('\r') + await expect(answer).resolves.toEqual({ + answers: [{ id: 'options', selected: ['One'], custom: 'keep this' }], + }) + await dispose(result) + }) + + it('scrolls tall option lists with ↑/↓ overflow markers when the dialog height is capped', async () => { + const result = await setup({ + config: { + questionDialogWidth: 60, + questionDialogMaxHeight: 12, + maxQuestionOptions: 8, + }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'scroll', + question: 'Pick one', + options: [ + { label: 'Alpha', description: 'first choice with a description that will wrap to multiple lines when the dialog is narrow' }, + { label: 'Bravo', description: 'second choice' }, + { label: 'Charlie', description: 'third choice' }, + { label: 'Delta', description: 'fourth choice' }, + { label: 'Echo', description: 'fifth choice' }, + { label: 'Foxtrot', description: 'sixth choice' }, + ], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('↓') + expect(result.terminal.output).toContain('more') + for (let step = 0; step < 5; step += 1) result.terminal.send('\x1b[B') + await tick() + expect(result.terminal.output).toContain('↑') result.terminal.send('\x03') await rejected await dispose(result) }) + it('keeps controls visible when the selected option block exceeds the row budget', async () => { + const result = await setup({ + config: { questionDialogWidth: 40, questionDialogMaxHeight: 10 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'oversize', + question: 'Pick one', + options: [ + { label: 'Huge', description: `start ${'middle '.repeat(40)}visible tail` }, + { label: 'Other' }, + ], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('Huge') + expect(result.terminal.output).toContain('PgUp/PgDn') + expect(result.terminal.output).toContain('↑↓ Tab ↵ Esc') + expect(result.terminal.output).not.toContain('visible tail') + for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[6~') + await tick() + expect(result.terminal.output).toContain('visible tail') + for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[5~') + result.terminal.send('\x1b[6~') + await tick() + expect(result.terminal.output).toContain('start middle') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('pages long question detail so every plan-review line remains reachable', async () => { + const result = await setup({ + config: { questionDialogWidth: 20, questionDialogMaxHeight: 10 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'long-detail', + question: 'Approve this plan?', + detail: `visible start ${'review step '.repeat(60)}visible tail`, + options: [{ label: 'Approve' }, { label: 'Reject' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(60, 20) + await tick() + const initialRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(initialRender).toContain('plan?') + expect(initialRender).toContain('visible start') + expect(initialRender).not.toContain('visible tail') + expect(initialRender).toMatch(/PgUp\/PgDn \d+\/\d+/u) + for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[6~') + await tick() + const finalRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(finalRender).toContain('visible tail') + expect(finalRender).toContain('Approve') + result.terminal.send('\x1b[B') + await tick() + const movedRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(movedRender).toContain('visible tail') + expect(movedRender).toContain('Reject') + result.terminal.send('\x1b[A') + result.terminal.send('\t') + await tick() + result.terminal.send('\x1b[6~') + await tick() + result.terminal.send('\x1b[5~') + result.terminal.resize(61, 20) + await tick() + const customPagedRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(customPagedRender).not.toContain('visible tail') + expect(customPagedRender).toContain('Esc options') + result.terminal.send('\x1b') + for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[5~') + await tick() + const restoredRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(restoredRender).toContain('visible start') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('reclaims enough rows to keep selected content, paging, and option markers visible', async () => { + const result = await setup({ + config: { questionDialogWidth: 60, questionDialogMaxHeight: 8 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'one-row', + question: 'Pick one', + options: [ + { label: 'Selected first', description: `start ${'middle '.repeat(30)}visible tail` }, + { label: 'Hidden second' }, + ], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('Selected first') + expect(result.terminal.output).not.toContain('Hidden second') + expect(result.terminal.output).toContain('↓ 1 more') + expect(result.terminal.output).toContain('PgUp/PgDn') + expect(result.terminal.output).toContain('Esc interrupt') + for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[6~') + await tick() + expect(result.terminal.output).toContain('visible tail') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('preserves both option markers and controls at the minimum configured height', async () => { + const result = await setup({ + config: { questionDialogWidth: 60, questionDialogMaxHeight: 6 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'minimum-options', + question: 'Pick one', + multiSelect: true, + options: ['One', 'Two', 'Three', 'Four', 'Five'].map(label => ({ + label, + description: `${label} ${'wrapped detail '.repeat(20)}`, + })), + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.send('\x1b[B') + result.terminal.send('\x1b[B') + await tick() + expect(result.terminal.output).toContain('↑ 2 more') + expect(result.terminal.output).toContain('Three') + expect(result.terminal.output).toContain('PgUp/PgDn') + expect(result.terminal.output).toContain('↓ 2 more') + expect(result.terminal.output).toContain('Tab custom') + expect(result.terminal.output).toContain('Space toggle') + expect(result.terminal.output).toContain('Esc interrupt') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Error: Select at least one') + result.terminal.resize(61) + await tick() + const validationRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(validationRender).toContain('Tab custom') + expect(validationRender).toContain('Space toggle') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('preserves detail text and every action when one compact header row remains', async () => { + const result = await setup({ + config: { questionDialogWidth: 20, questionDialogMaxHeight: 6 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'one-header-row', + question: 'Plan?', + detail: 'abcdvisible tail', + multiSelect: true, + options: [ + { label: 'Yes', description: 'accept' }, + { label: 'No', description: 'reject' }, + ], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(60, 20) + await tick() + const initialRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(initialRender).toContain('P↑↓ ↑↓ Tab S↵Esc') + result.terminal.send('\x1b[6~') + result.terminal.send('\x1b[6~') + await tick() + const detailRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(detailRender).toContain('visible tail') + result.terminal.send('\x03') + await rejected + + const single = result.ctx.userInteraction.ask({ + questions: [{ + id: 'one-header-row-single', + question: 'Plan?', + detail: 'abcdvisible tail', + options: [ + { label: 'Yes', description: 'accept' }, + { label: 'No', description: 'reject' }, + ], + }], + }) + const singleRejected = expect(single).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(61, 20) + await tick() + const singleRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(singleRender).toContain('P↑↓ ↑↓ Tab↵Esc') + result.terminal.send('\x03') + await singleRejected + + const compact = result.ctx.userInteraction.ask({ + questions: [{ + id: 'one-header-row-compact', + question: 'Pick?', + multiSelect: true, + options: [ + { label: 'Yes', description: 'accept' }, + { label: 'No', description: 'reject' }, + ], + }], + }) + const compactRejected = expect(compact).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(60, 20) + await tick() + const compactRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(compactRender).toContain('↑↓ Tab Sp ↵Esc') + result.terminal.send('\x03') + await compactRejected + + const oneOption = result.ctx.userInteraction.ask({ + questions: [{ + id: 'one-header-row-one-option', + question: 'Pick?', + detail: 'Review every line.', + options: [{ label: 'Yes', description: 'wrapped detail '.repeat(8) }], + }], + }) + const oneOptionRejected = expect(oneOption).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(61, 20) + await tick() + const oneOptionRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(oneOptionRender).toContain('P↑↓ Tab↵Esc') + expect(oneOptionRender).not.toContain('P↑↓ ↑↓') + result.terminal.send('\x03') + await oneOptionRejected + await dispose(result) + }) + + it('expands the visible option window forward and backward around the selection', async () => { + const result = await setup({ + config: { + questionDialogWidth: 60, + questionDialogMaxHeight: 14, + maxQuestionOptions: 8, + }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'middle-scroll', + question: 'Pick one', + options: [ + { label: 'One', description: 'a' }, + { label: 'Two', description: 'b' }, + { label: 'Three', description: 'c' }, + { label: 'Four', description: 'd' }, + { label: 'Five', description: 'e' }, + { label: 'Six', description: 'f' }, + { label: 'Seven', description: 'g' }, + { label: 'Eight', description: 'h' }, + { label: 'Nine', description: 'i' }, + { label: 'Ten', description: 'j' }, + ], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + for (let step = 0; step < 4; step += 1) result.terminal.send('\x1b[B') + await tick() + expect(result.terminal.output).toContain('↑') + expect(result.terminal.output).toContain('↓') + expect(result.terminal.output).toContain('Five') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('wraps a long option label across multiple lines instead of truncating it', async () => { + const result = await setup({ config: { questionDialogWidth: 40 } }) + const longLabel = 'this is a very long option label that will not fit on one line in a narrow dialog' + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'long-label', + question: 'Pick one', + options: [{ label: longLabel }, { label: 'Short' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('narrow dialog') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('wraps fixed question chrome within the minimum dialog width', async () => { + const result = await setup({ config: { questionDialogWidth: 20 } }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ id: 'narrow', question: 'Answer?' }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).not.toContain('Question 1/1 (1 unanswered)') + expect(result.terminal.output).toContain('unanswered)') + expect(result.terminal.output).not.toContain('Enter submit • Esc cancel') + expect(result.terminal.output).toContain('Esc cancel') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('keeps custom controls visible at the minimum dialog height', async () => { + const result = await setup({ + config: { questionDialogWidth: 20, questionDialogMaxHeight: 6 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ id: 'short-viewport', question: 'Answer this deliberately long question?' }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('long question?') + expect(result.terminal.output).toContain('Esc cancel') + result.terminal.resize(60, 4) + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Enter an answer') + expect(result.terminal.output).toContain('long question?') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('compacts custom controls for a question that also has options', async () => { + const result = await setup({ + config: { questionDialogWidth: 20, questionDialogMaxHeight: 6 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'compact-custom-options', + question: 'Choose or type a deliberately long answer', + options: [{ label: 'Default' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.send('\t') + await tick() + expect(result.terminal.output).toContain('Esc options') + result.terminal.send('\x1b') + await tick() + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('reports hidden question rows when the viewport leaves one row', async () => { + const result = await setup({ + config: { questionDialogWidth: 60, questionDialogMaxHeight: 6 }, + }) + result.terminal.resize(60, 2) + const answer = result.ctx.userInteraction.ask({ + questions: [{ id: 'one-row-dialog', question: 'Answer this deliberately long question?' }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('lines hidden') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('keeps question text when the viewport leaves two question rows', async () => { + const result = await setup({ + config: { questionDialogWidth: 60, questionDialogMaxHeight: 6 }, + }) + result.terminal.resize(60, 3) + const answer = result.ctx.userInteraction.ask({ + questions: [{ id: 'two-row-dialog', question: 'Answer this deliberately long question?' }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('long question?') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('bounds option mode when the viewport leaves three question rows', async () => { + const result = await setup({ + config: { questionDialogWidth: 60, questionDialogMaxHeight: 6 }, + }) + result.terminal.resize(60, 4) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'three-row-options', + question: 'Pick one', + options: [{ label: 'First' }, { label: 'Second' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('lines hidden') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('keeps question rows within a sub-five-column viewport', async () => { + const terminal = new HeadlessTerminal(4, 12) + const result = await createTuiTestHarness(terminal, vi.fn(), { + config: { questionDialogWidth: 20 }, + }) + const beforeQuestion = terminal.frames + const answer = result.ctx.userInteraction.ask({ + questions: [{ id: 'narrow-viewport', question: 'Pick?', options: [{ label: 'Yes' }] }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await terminal.waitForFrame(beforeQuestion) + await expect(terminal.snapshot()).resolves.toContain('terminal 4x12') + terminal.send('\x03') + await rejected + await disposeTuiTestHarness(result) + }) + it('asks batches in order and rejects cancelled or aborted work', async () => { const result = await setup() const preAborted = new AbortController() diff --git a/packages/ui/user-interaction/README.i18n.yaml b/packages/ui/user-interaction/README.i18n.yaml index a74e6ec71b..1909864c26 100644 --- a/packages/ui/user-interaction/README.i18n.yaml +++ b/packages/ui/user-interaction/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/user-interaction/README.md -README.md: d62e75d110b8be339c5f9449b0834320f695ac99 -README.zh.md: 55258e85e56df2375ed8f195fa0b3b731a9cb816 +README.md: c7fec590d6e44a13b94cc682f5e069b2d3c5e416 +README.zh.md: 340af3541a09a528aa0fcc580bda070ea703f39e diff --git a/packages/ui/user-interaction/README.md b/packages/ui/user-interaction/README.md index d62e75d110..c7fec590d6 100644 --- a/packages/ui/user-interaction/README.md +++ b/packages/ui/user-interaction/README.md @@ -20,7 +20,7 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod - `UserInteractionProvider` — UI implementation with `ask(request)`. - `UserInteractionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`. -When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch. +For a single-select question, `custom` overrides the selected choice and `selected` is empty. For a multi-select question, `custom` may supplement the labels in `selected`. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch. ### Presentation intent diff --git a/packages/ui/user-interaction/README.zh.md b/packages/ui/user-interaction/README.zh.md index 55258e85e5..340af3541a 100644 --- a/packages/ui/user-interaction/README.zh.md +++ b/packages/ui/user-interaction/README.zh.md @@ -20,7 +20,7 @@ - `UserInteractionProvider`:包含 `ask(request)` 的 UI 实现。 - `UserInteractionError`:`HarnessError` 的子类,包含 `EMPTY_QUESTIONS`、`BAD_INTENT`、`NO_PROVIDER`、`DUPLICATE_PROVIDER` 和 `ASK_ABORTED` 等代码。 -当回答包含 `custom` 时,`selected` 为空;自定义文本是所选选项的替代,而不是补充。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。 +对于单选题,`custom` 会覆盖选中的选项,且 `selected` 为空。对于多选题,`custom` 可以补充 `selected` 中的标签。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。 ### 呈现意图 diff --git a/packages/ui/user-interaction/src/types.ts b/packages/ui/user-interaction/src/types.ts index 63949e81a1..51edfc6bf7 100644 --- a/packages/ui/user-interaction/src/types.ts +++ b/packages/ui/user-interaction/src/types.ts @@ -53,7 +53,7 @@ export interface AskUserQuestionItem { export interface AskUserQuestionAnswerItem { /** The answered question id. */ id: string - /** Selected option labels. Empty for custom or unanswered choices. */ + /** Selected option labels. May accompany custom text for a multi-select question. */ selected: string[] /** Optional free-text "Other" answer. */ custom?: string diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b634898ea..5e27760b0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5838,6 +5838,9 @@ importers: '@earendil-works/pi-tui': specifier: 0.80.7 version: 0.80.7(patch_hash=6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946) + diff: + specifier: ^9.0.0 + version: 9.0.0 saxes: specifier: 6.0.0 version: 6.0.0 diff --git a/tsconfig.host.json b/tsconfig.host.json index 015998c271..70f5854c69 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -33,6 +33,7 @@ "apps/web/tests/cordis-tool-round.e2e.ts", "apps/web/tests/web-search-round.e2e.ts", "apps/web/tests/message-actions.e2e.ts", + "apps/web/tests/markdown-images.e2e.ts", "apps/web/tests/queue-actions.e2e.ts", "apps/web/tests/skill-invocation-policy.e2e.ts", "apps/web/tests/permission-policy-context.e2e.ts",