Merge remote-tracking branch 'origin/master' into agent/fix-remote-welcome-onboarding
This commit is contained in:
+2
-2
@@ -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
|
||||
+1
@@ -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)
|
||||
|
||||
+1
@@ -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) | 中文
|
||||
|
||||
@@ -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",
|
||||
|
||||
+6
@@ -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
|
||||
+25
@@ -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.
|
||||
+25
@@ -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 覆盖率固定终端中的组合回答流程,单选题的宿主覆盖率则固定其余的互斥规则。
|
||||
+6
@@ -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
|
||||
@@ -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.
|
||||
@@ -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` 终端快照在卡片折叠和展开状态下固定了中性上下文样式、变更行的语义色彩、精确结果页脚和近似回退。
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 路径均保持不变。
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,那是唯一一处一个实现对两者都正确的部分。
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: Bug
|
||||
about: 记录现有预期行为的失效
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
type: Bug
|
||||
---
|
||||
|
||||
<!-- 标题写中文行动或结果句;外露正文不超过 50 单位。 -->
|
||||
一句话说明错误结果。
|
||||
|
||||
<details>
|
||||
<summary>复现、预期与验收</summary>
|
||||
|
||||
- 复现步骤:
|
||||
- 实际结果:
|
||||
- 预期结果:
|
||||
- 环境:
|
||||
- 验收条件:
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,2 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links: []
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature
|
||||
about: 新增或有意改变可观察行为
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
type: Feature
|
||||
---
|
||||
|
||||
<!-- 标题写中文行动或结果句;外露正文不超过 50 单位。 -->
|
||||
一句话说明预期结果。
|
||||
|
||||
<details>
|
||||
<summary>验收与细节</summary>
|
||||
|
||||
- 验收条件:
|
||||
- 用户或模型可见变化:
|
||||
- 测试证据:
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Idea
|
||||
about: 记录尚未承诺实施、但具有行动可能的想法
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
type: Idea
|
||||
---
|
||||
|
||||
<!-- 标题写中文行动或结果句;外露正文不超过 50 单位。 -->
|
||||
一句话说明价值假设。
|
||||
|
||||
<details>
|
||||
<summary>价值与细节</summary>
|
||||
|
||||
- 价值假设:
|
||||
- 需要验证:
|
||||
- 可能的后续工作:
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: Research
|
||||
about: 形成结论、证据或决策
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
type: Research
|
||||
---
|
||||
|
||||
<!-- 标题写中文行动或结果句;外露正文不超过 50 单位。 -->
|
||||
一句话说明待回答的问题。
|
||||
|
||||
<details>
|
||||
<summary>问题与证据标准</summary>
|
||||
|
||||
- 核心问题:
|
||||
- 证据标准:
|
||||
- 交付结论:
|
||||
- 可能的后续工作:
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Task
|
||||
about: 明确的非 Feature、非 Bug 工作
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
type: Task
|
||||
---
|
||||
|
||||
<!-- 标题写中文行动或结果句;外露正文不超过 50 单位。 -->
|
||||
一句话说明要完成的工作。
|
||||
|
||||
<details>
|
||||
<summary>验收与细节</summary>
|
||||
|
||||
- 验收条件:
|
||||
- 交付物:
|
||||
- 测试证据:
|
||||
|
||||
</details>
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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 = '<!-- dsh-issue-policy -->'
|
||||
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(/<!--[\s\S]*?-->/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('正文必须包含默认收起的 <details> 区域')
|
||||
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(/<!--[\s\S]*?-->/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<typeof parseReferences>, issues: Map<number, {priority: string|null}>}} 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
|
||||
})
|
||||
}
|
||||
@@ -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<details><summary>验收与细节</summary>待补充。</details>`
|
||||
|
||||
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。<details>隐藏文字</details>'), {
|
||||
units: 4,
|
||||
balanced: true,
|
||||
detailsCount: 1,
|
||||
allCollapsed: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('requires a balanced default-collapsed details region', () => {
|
||||
assert.deepEqual(validateBody({ body: '完成工作。', assignees: [] }), [
|
||||
'正文必须包含默认收起的 <details> 区域',
|
||||
])
|
||||
assert.deepEqual(
|
||||
validateBody({
|
||||
body: '完成工作。\n\n<details open><summary>细节</summary>待补充。</details>',
|
||||
assignees: [],
|
||||
}),
|
||||
['details 必须默认收起,不得设置 open'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
validateBody({ body: '完成工作。\n\n<details><summary>细节</summary>', 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',
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
<!-- 写 Fixes #NN 表示解决并自动关闭;写 Related to #NN 仅关联。 -->
|
||||
<!-- 进入评审的非 Draft 人类 PR 至少引用一个同仓库 Issue。 -->
|
||||
<!-- 解决型 PR 与 Issue 同步 Priority;解决多个 Issue 时取最高值。 -->
|
||||
|
||||
关联 Issue:
|
||||
|
||||
<details>
|
||||
<summary>变更与验证</summary>
|
||||
|
||||
- 变更:
|
||||
- 验证:
|
||||
|
||||
</details>
|
||||
+14
-2
@@ -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.' },
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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<HTMLElement>('[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 })
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -7,8 +7,9 @@
|
||||
- text: Show the Markdown image policy. {{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
|
||||
- heading "Markdown images" [level=2]
|
||||
- paragraph:
|
||||
- img "Remote test image"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
- 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" [disabled]:
|
||||
|
||||
@@ -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"
|
||||
@@ -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"}}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
@@ -2114,7 +2116,7 @@ export interface TuiThemeConfig {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/ui/tui/src/config.ts:121`](../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`
|
||||
|
||||
|
||||
@@ -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:244`](../../packages/ui/tui/src/index.ts)
|
||||
Source: [`packages/ui/tui/src/index.ts:245`](../../packages/ui/tui/src/index.ts)
|
||||
|
||||
## `ctx.typert` — `TypertRegistry`
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: e1dbe7d4d5992b6b5b029fddfc9d9857ccae7443
|
||||
README.zh.md: fd82ad65f8e903a6f7106e8b8ff8eccbf1435957
|
||||
README.md: 78572ba0ab3ce9475dba31dee8844017564e2a18
|
||||
README.zh.md: 7708e980e24f4ea4365fbbacd641a5be6c61b138
|
||||
@@ -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: <active id>`), 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 <preset>` 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 <preset>` 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.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ 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: '<tool>', 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 <preset>`,而 `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 <preset>`,而 `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 · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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` 存在、提供了被指名的批准标签,且是二元单选(除批准外最多一个选项,且非多选)。其他任何情形 —— 没有意图、一批含多个问题、缺少计划、批准标签未命中任何选项、出现第三个选项、多选决定 —— 都留在能够表达它的通用流程上。意图改变的只是布局,从不改变可达的答案。
|
||||
|
||||
|
||||
@@ -98,12 +98,13 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
||||
|
||||
const choose = (label: string): void => {
|
||||
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<Questi
|
||||
const custom = value.custom.trim()
|
||||
return {
|
||||
id: item.id,
|
||||
selected: custom === '' ? value.selected : [],
|
||||
selected: custom === '' || item.multiSelect === true ? value.selected : [],
|
||||
...(custom === '' ? {} : { custom }),
|
||||
}
|
||||
}),
|
||||
@@ -155,14 +156,17 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
||||
submitDrafts(drafts)
|
||||
}
|
||||
|
||||
// Shared by the inline custom input and the optionless textarea: typing a
|
||||
// custom draft clears any selection, and Enter continues the flow
|
||||
// (Shift+Enter stays a newline in the textarea; on the single-line input it
|
||||
// is inert either way).
|
||||
// Shared by the inline custom input and the optionless textarea: a
|
||||
// multi-select draft retains checked labels, while a single-select custom
|
||||
// answer replaces its selection. Enter continues the flow (Shift+Enter
|
||||
// stays a newline in the textarea; on the single-line input it is inert).
|
||||
const draftCustom = (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): 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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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<HTMLButtonElement>('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<HTMLButtonElement>('button', { name: '提交' }).disabled).toBe(false)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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」未读标记是本地查看状态**:完成时间 > 上次查看时间这一事实永远不会到达宿主。
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<number, number> {
|
||||
const indexes = new Map<number, number>()
|
||||
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
|
||||
<IconChevronRightOutline14 className={css.thinkingChevron} size={12} />
|
||||
</button>
|
||||
{thinkingExpanded && (
|
||||
<MarkdownFragment
|
||||
@@ -1545,6 +1566,7 @@ export function TrajectoryTable({
|
||||
collapsedAssistants,
|
||||
)
|
||||
: filterRecords(allRecords, searchMatchIndexes)
|
||||
const requestBoundaryRuns = indexRequestBoundaryRuns(records)
|
||||
const selected = allRecords.find(record => 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({
|
||||
<span
|
||||
className={css.kindSlot}
|
||||
>
|
||||
<Tooltip
|
||||
label={KIND_LABEL[record.cell.kind]}
|
||||
side="right"
|
||||
<span
|
||||
className={`${css.kindTag} ${
|
||||
record.cell.kind === 'system'
|
||||
? css.systemNeutral
|
||||
: record.cell.kind === 'context'
|
||||
? css.contextGreen
|
||||
: record.cell.kind === 'compacted'
|
||||
? css.compacted
|
||||
: record.cell.kind === 'tool'
|
||||
? css.toolAmber
|
||||
: record.cell.kind === 'message'
|
||||
? css.assistantVioletBright
|
||||
: record.cell.kind === 'subtool'
|
||||
? css.subtoolAmber
|
||||
: css[record.cell.kind]
|
||||
}`}
|
||||
data-role-kind={record.cell.kind}
|
||||
>
|
||||
<span
|
||||
className={`${css.kindTag} ${
|
||||
record.cell.kind === 'system'
|
||||
? css.systemNeutral
|
||||
: record.cell.kind === 'context'
|
||||
? css.contextGreen
|
||||
: record.cell.kind === 'compacted'
|
||||
? css.compacted
|
||||
: record.cell.kind === 'tool'
|
||||
? css.toolAmber
|
||||
: record.cell.kind === 'message'
|
||||
? css.assistantVioletBright
|
||||
: record.cell.kind === 'subtool'
|
||||
? css.subtoolAmber
|
||||
: css[record.cell.kind]
|
||||
}`}
|
||||
data-role-kind={record.cell.kind}
|
||||
<Tooltip
|
||||
label={KIND_LABEL[record.cell.kind]}
|
||||
side="right"
|
||||
>
|
||||
<span className={css.kindTagIcon} aria-hidden="true">
|
||||
{KIND_ICON[record.cell.kind]}
|
||||
</span>
|
||||
<span className={css.kindTagLabel}>
|
||||
{KIND_LABEL[record.cell.kind]}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span className={css.kindTagLabel}>
|
||||
{KIND_LABEL[record.cell.kind]}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -2497,7 +2528,7 @@ export function TrajectoryTable({
|
||||
)}
|
||||
{selectedAssistantRequestTarget !== undefined && (
|
||||
<OverviewSection
|
||||
label="Timing"
|
||||
label="Request Timing"
|
||||
onOpen={() => {
|
||||
selectRequest(selectedAssistantRequestTarget, 'timing')
|
||||
}}
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('TrajectoryTable', () => {
|
||||
it('shows assistant timing facts after keyboard selection', () => {
|
||||
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
|
||||
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(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
|
||||
|
||||
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(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
|
||||
|
||||
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(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
|
||||
const toolTag = view.container.querySelector<HTMLElement>('[data-role-kind="tool"]')
|
||||
const toolIcon = toolTag?.querySelector<HTMLElement>('[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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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` 组合的应用内流程。
|
||||
@@ -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}',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
<div className={css.hoverContent}>
|
||||
<div className={css.hoverTitle}>{displayTitle(node, t)}</div>
|
||||
@@ -182,8 +183,8 @@ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number;
|
||||
before the first prompt. */}
|
||||
{!node.blank && <div className={css.hoverTime}>{hoverTimeLabel(node.updatedAt, now, t)}</div>}
|
||||
<div className={css.hoverStatus}>
|
||||
<StateDot state={node.running ? 'ongoing' : 'done'} />
|
||||
<span>{node.running ? t('status.running') : t('status.idle')}</span>
|
||||
<StateDot state={status.state} />
|
||||
<span>{status.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -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))
|
||||
}}
|
||||
>
|
||||
<span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
|
||||
<span className={css.slot}>
|
||||
{status.state !== 'done' && (
|
||||
<>
|
||||
<StateDot state={status.state} />
|
||||
<span className={css.visuallyHidden}>{status.label}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className={css.title}>{title}</span>
|
||||
{/* A blank New Session row is a provisional placeholder: nothing has
|
||||
happened in it yet, so a "now" timestamp and the row verbs
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
|
||||
@@ -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(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
|
||||
onRename={onRename} onFork={onFork} onArchive={onArchive} t={t} />)
|
||||
@@ -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(<SessionNodeItem node={node} currentId={undefined} now={60_000} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
|
||||
@@ -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(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
|
||||
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(<SessionNodeItem node={{ ...node, running: false }} currentId={undefined} now={0}
|
||||
onOpen={vi.fn()} onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
|
||||
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(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
|
||||
@@ -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(
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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: d4af267ef46f327bdabf31429b291ffd04308203
|
||||
README.zh.md: f148cfa0c023b54d62016c5211205d391b634544
|
||||
README.md: ed04a3431e4c9114b3115119c1e69d378cec77ae
|
||||
README.zh.md: e50a7f49cb8753f5b26dd27cb1d48a9cc18e9fd7
|
||||
@@ -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.
|
||||
|
||||
@@ -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 页永不携带该块,未装注册表的组合则两个面都不提供。
|
||||
|
||||
@@ -412,8 +412,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))
|
||||
})
|
||||
|
||||
@@ -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<MuxFrame>[]
|
||||
waitForQuestion(): Promise<RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>>
|
||||
} {
|
||||
const envelopes: RpcRequest<MuxFrame>[] = []
|
||||
let resolveQuestion!: (value: RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>) => void
|
||||
const question = new Promise<RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>>((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<Extract<MuxFrame, { type: 'question/requested' }>>)
|
||||
}
|
||||
}
|
||||
})()
|
||||
return { envelopes, waitForQuestion: () => question }
|
||||
}
|
||||
|
||||
function answer(
|
||||
envelope: RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>,
|
||||
selected: string[],
|
||||
custom?: string,
|
||||
): Parameters<ApiProxy['respond']>[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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": "..." }] }`。
|
||||
|
||||
## 职责
|
||||
|
||||
|
||||
@@ -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"}]}',
|
||||
}])
|
||||
})
|
||||
|
||||
|
||||
@@ -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: a577fb2f858f61eb765d4a1a9564f452d01d92c1
|
||||
README.zh.md: 61e1b9d526a00e3c8cbc2c9ed0cc483e2ab8dba2
|
||||
README.md: 5199e75d732f384497b28518920cc2c775c600c2
|
||||
README.zh.md: 02d4431fdab575b5596152c985b72594a948b081
|
||||
@@ -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 `<session title> — <configured title>`. 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 `<session title> — <configured title>`. 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`.
|
||||
|
||||
@@ -50,11 +50,12 @@ 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 |
|
||||
@@ -73,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']
|
||||
```
|
||||
|
||||
@@ -84,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
|
||||
|
||||
@@ -154,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
|
||||
|
||||
|
||||
@@ -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`,终端窗口标题则变为 `<session title> — <configured title>`。持久 `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`,终端窗口标题则变为 `<session title> — <configured title>`。持久 `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`。
|
||||
|
||||
@@ -50,11 +50,12 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
|
||||
| `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 细节选择器宽度(列数) |
|
||||
@@ -73,6 +74,7 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
|
||||
sessionId: main-session-123
|
||||
showReasoning: true
|
||||
maxToolOutputLines: 6
|
||||
maxDiffEditLength: 1000
|
||||
fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist']
|
||||
```
|
||||
|
||||
@@ -84,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` 可移除所有样式。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -154,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 影响
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-tui": "0.80.7",
|
||||
"diff": "^9.0.0",
|
||||
"saxes": "6.0.0",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -822,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<number>()
|
||||
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()
|
||||
@@ -838,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,
|
||||
@@ -861,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)
|
||||
@@ -868,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()
|
||||
@@ -895,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<AskUserQuestionItem['options']>[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<AskUserQuestionItem['options']>[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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 / <name>` 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 }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -378,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,
|
||||
) {
|
||||
@@ -407,6 +446,7 @@ export class ToolCardComponent implements Component {
|
||||
* @param event - The `tool/result` event payload.
|
||||
*/
|
||||
updateResult(event: Extract<SessionEvent, { type: 'tool/result' }>['data']): void {
|
||||
this.diffBodyCache = undefined
|
||||
const result = event.message.content[0]
|
||||
this.result = {
|
||||
content: [...result.content],
|
||||
@@ -560,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<string>()
|
||||
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
|
||||
|
||||
@@ -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. */
|
||||
@@ -66,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)
|
||||
@@ -98,6 +101,7 @@ const titleSchema = z.string().default('DeepSeek Harness')
|
||||
const tuiConfigSchemaFields = {
|
||||
showReasoning: showReasoningSchema,
|
||||
maxToolOutputLines: maxToolOutputLinesSchema,
|
||||
maxDiffEditLength: maxDiffEditLengthSchema,
|
||||
maxQuestionOptions: maxQuestionOptionsSchema,
|
||||
maxModelOptions: maxModelOptionsSchema,
|
||||
maxResumeOptions: maxResumeOptionsSchema,
|
||||
@@ -139,6 +143,7 @@ export const Config: z<Config> = z.object({
|
||||
initialSkill: z.string(),
|
||||
showReasoning: tuiConfigSchemaFields.showReasoning,
|
||||
maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines,
|
||||
maxDiffEditLength: tuiConfigSchemaFields.maxDiffEditLength,
|
||||
maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions,
|
||||
maxModelOptions: tuiConfigSchemaFields.maxModelOptions,
|
||||
maxResumeOptions: tuiConfigSchemaFields.maxResumeOptions,
|
||||
@@ -169,6 +174,7 @@ export interface ResolvedTuiThemeConfig {
|
||||
export interface ResolvedTuiConfig {
|
||||
showReasoning: boolean
|
||||
maxToolOutputLines: number
|
||||
maxDiffEditLength: number
|
||||
maxQuestionOptions: number
|
||||
maxModelOptions: number
|
||||
maxResumeOptions: number
|
||||
@@ -195,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,
|
||||
|
||||
@@ -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<TuiOverlayOutcome>
|
||||
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<TuiOverlayCloseReason, 'error'>): Promise<TuiOverlayOutcome>
|
||||
} {
|
||||
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) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ProcessTerminal,
|
||||
matchesKey,
|
||||
visibleWidth,
|
||||
type Component,
|
||||
type EditorTheme,
|
||||
type SlashCommand,
|
||||
type TerminalColorScheme,
|
||||
@@ -289,6 +290,23 @@ interface FadingStatus {
|
||||
timer: ReturnType<typeof setInterval>
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
@@ -316,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()
|
||||
@@ -480,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 => {
|
||||
@@ -529,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)
|
||||
@@ -643,6 +681,7 @@ export function createTuiChat(
|
||||
parsed,
|
||||
ctx.tools.get(event.data.name, agent),
|
||||
resolved.maxToolOutputLines,
|
||||
resolved.maxDiffEditLength,
|
||||
palette,
|
||||
mdTheme,
|
||||
)
|
||||
@@ -835,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)
|
||||
@@ -947,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({
|
||||
|
||||
@@ -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| <blank>
|
||||
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| <blank>
|
||||
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| <blank>
|
||||
33| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
38| <blank>
|
||||
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| <blank>
|
||||
@@ -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| <blank>
|
||||
23| "● Tool / subagent"
|
||||
21| <blank>
|
||||
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| <blank>
|
||||
27| "● Tool / task_output"
|
||||
25| <blank>
|
||||
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| <blank>
|
||||
34| "● Tool / skill"
|
||||
31| " "
|
||||
32| <blank>
|
||||
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| <blank>
|
||||
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| <blank>
|
||||
39| "Tool and context cards expanded. "
|
||||
48| <blank>
|
||||
49| "Tool and context cards expanded. "
|
||||
style 0-31 dim
|
||||
40| <blank>
|
||||
41| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
50| <blank>
|
||||
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
|
||||
@@ -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| <blank>
|
||||
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
|
||||
@@ -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| <blank>
|
||||
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
|
||||
@@ -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| <blank>
|
||||
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| <blank>
|
||||
17-19| <blank>
|
||||
@@ -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| <blank>
|
||||
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| <blank>
|
||||
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| <blank>
|
||||
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
|
||||
@@ -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| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
6| <blank>
|
||||
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| <blank>
|
||||
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
|
||||
@@ -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
|
||||
@@ -48,15 +48,30 @@ buffer
|
||||
24| <blank>
|
||||
25| "Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 0-62 fg=red
|
||||
26| " "
|
||||
27| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
26-27| <blank>
|
||||
28| "Plan"
|
||||
style 0-3 fg=bright-magenta bold
|
||||
29| " ● Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
|
||||
style 2-2 fg=yellow
|
||||
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
|
||||
31| " "
|
||||
32| " 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 "
|
||||
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
|
||||
32| " "
|
||||
33| <blank>
|
||||
38| " "
|
||||
39| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
@@ -49,6 +49,8 @@ const CHECKPOINTS = [
|
||||
'details-selector',
|
||||
'untrusted-controls',
|
||||
'question-dialog',
|
||||
'question-dialog-detail-paged',
|
||||
'question-dialog-paged',
|
||||
'question-dialog-single-option',
|
||||
'question-dialog-validation',
|
||||
'surface-before-compaction',
|
||||
@@ -272,13 +274,32 @@ const ADVANCED_CARD_TOOLS: Record<string, ToolDefinition> = {
|
||||
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',
|
||||
@@ -588,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' } },
|
||||
@@ -596,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)
|
||||
@@ -604,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 })
|
||||
|
||||
@@ -765,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' },
|
||||
@@ -782,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()
|
||||
|
||||
@@ -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,
|
||||
@@ -210,6 +212,7 @@ describe('TUI config', () => {
|
||||
expect(resolveTuiConfig({
|
||||
showReasoning: false,
|
||||
maxToolOutputLines: 2,
|
||||
maxDiffEditLength: 12,
|
||||
maxQuestionOptions: 3,
|
||||
maxModelOptions: 4,
|
||||
maxResumeOptions: 5,
|
||||
@@ -227,6 +230,7 @@ describe('TUI config', () => {
|
||||
})).toEqual({
|
||||
showReasoning: false,
|
||||
maxToolOutputLines: 2,
|
||||
maxDiffEditLength: 12,
|
||||
maxQuestionOptions: 3,
|
||||
maxModelOptions: 4,
|
||||
maxResumeOptions: 5,
|
||||
@@ -4675,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: {
|
||||
@@ -5029,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' },
|
||||
@@ -5045,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<string, ToolDefinition> = {
|
||||
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<string, ToolDefinition> = {
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -5389,6 +5511,54 @@ describe('tool cards and surface replay', () => {
|
||||
})
|
||||
|
||||
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 } })
|
||||
|
||||
@@ -5414,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' }] }],
|
||||
@@ -5458,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')
|
||||
@@ -5474,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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: [] }`,既维持现有回答形态,也保留该批次中的其他回答。
|
||||
|
||||
### 呈现意图
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user