diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml similarity index 66% rename from .agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml rename to .agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml index a8472075e4..635e3fca62 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml +++ b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md -2026-07-27-tui-diff-card-redundant-path-header.md: 708e543ff079828b4929d2a50ac697a9c846608a -2026-07-27-tui-diff-card-redundant-path-header.zh.md: 863868ae707f37689bbc202267c5470d8c3163e9 +2026-07-27-tui-diff-card-redundant-path-header.md: 608a11892a20d020087180175eff847021dc0554 +2026-07-27-tui-diff-card-redundant-path-header.zh.md: bf7f1c1eeb994f9940b5f7dfb7db72d422293bd4 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md rename to .agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md index 708e543ff0..608a11892a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md +++ b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md @@ -1,6 +1,7 @@ # Agent Note: TUI diff card dropped the duplicated file path Status: implemented +Archived: 2026-07-31 English | [中文](2026-07-27-tui-diff-card-redundant-path-header.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md rename to .agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md index 863868ae70..bf7f1c1eeb 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md +++ b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md @@ -1,6 +1,7 @@ # Agent Note: TUI diff 卡片重复打印文件路径 Status: implemented +Archived: 2026-07-31 [English](2026-07-27-tui-diff-card-redundant-path-header.md) | 中文 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index e46d7c34cd..1ed77225ae 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -46,6 +46,9 @@ "bug-fix/2026-07-26-intent-draft-same-tick-echo.i18n.yaml": "sha256:c623947c4fa00e6d4b51792c7972ba09582bbcb7605beb373725c0dd666f2c81", "bug-fix/2026-07-26-intent-draft-same-tick-echo.md": "sha256:fa8b1417b2cdd3deecbf8e55bdddd73dd3a8c6e3486fd399b0b8bdf317e56373", "bug-fix/2026-07-26-intent-draft-same-tick-echo.zh.md": "sha256:00ce72552dbaa11562fbc541343a5d33f9449edabbe6dd354eb879a7d4d530f8", + "bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml": "sha256:8613a1cfcf4b9c7fafa78a8d8565e2a65ef0335b7b826af9b2bb32097836af55", + "bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md": "sha256:1bd344aec5454d2a2d6e1e6a32eff035c4a99c3df409f2624b39fd32e23ee402", + "bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md": "sha256:0a1747006efb1a4b67feceb9b627a437a0f023158e90ae86e1fe8aef76485384", "feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918", "feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b", "feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69", diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml new file mode 100644 index 0000000000..b2bfc991c9 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md +2026-07-31-tui-diff-context-line-accounting.md: d465568d5f6cad15ef4647be7ef936c2f5824bba +2026-07-31-tui-diff-context-line-accounting.zh.md: dd1a3eb1acf03477059d48461219700b224b2f96 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md new file mode 100644 index 0000000000..d465568d5f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md @@ -0,0 +1,33 @@ +# Agent Note: TUI diff context lines stay neutral + +Status: implemented + +English | [中文](2026-07-31-tui-diff-context-line-accounting.zh.md) + +## Problem + +Result-time filesystem diffs carry the applied change with three surrounding context lines in each `FileDiff.oldText` and `FileDiff.newText`. The TUI rendered every old-side row as removed and every new-side row as added, including the identical context present on both sides. A one-line edit therefore appeared as seven removals plus seven additions, and the footer repeated those inflated totals. + +## Decision + +The TUI compares each `FileDiff` whose old and new text are both available. Added and removed rows retain their green `+` and red `-` markers; equal context rows use the recessed body tone with a neutral two-space prefix. The footer sums only the rows classified as added or removed. `maxDiffEditLength` bounds the exact comparison by its combined added and removed line count; the default is 1000. Exceeding the bound renders the complete old side as removed and the complete new side as added, marks the footer approximate, and caches that result so redraws do not repeat the comparison. A tool result clears the pending-view cache before deriving the settled view, including when a presenter mutates and reuses the same view object. + +When `oldText` is `null`, the renderer cannot distinguish a create from a pending overwrite or an argument fallback whose prior text is unavailable. It therefore shows every non-empty new-side row as added, without claiming those rows were absent from an existing file. Empty new content renders no synthetic added row. + +This remains a consumer-side interpretation of the existing `FileDiff` contract. Filesystem tools continue to persist contextual before/after snippets, so other consumers keep their placement context and existing session logs replay with corrected TUI presentation. The TUI uses the same maintained `diff` package as `dsh-tool-fs` instead of introducing a second line-diff implementation. + +## Alternatives considered + +**Remove context from filesystem result metadata.** Rejected: contextual applied hunks are intentional producer output used by capable editors, and changing them would weaken every consumer while leaving old session logs misleading in the TUI. + +**Extend `FileDiff` with persisted per-line tags.** Rejected: the tags can be derived deterministically from the existing before/after pair; persisting them would widen the cross-package and session-log contract solely for one renderer. + +**Match equal lines by position without a diff algorithm.** Rejected: insertions and deletions shift subsequent context, so positional pairing would misclassify valid hunks. + +**Run every comparison to completion.** Rejected: pending tool views can contain unrestricted model-authored old and new strings, and an unbounded Myers comparison can block the synchronous terminal renderer. + +## Consequences + +TUI diff cards distinguish evidence-bearing context from the mutation itself, and an exact `+A -R` footer reports the actual line delta. Replaying an existing contextual diff gains the corrected rendering without a migration. Result-time filesystem hunks are context-bounded; unrestricted pending views either complete within the configured edit-length budget or degrade to an explicitly approximate linear rendering. + +The focused TUI tests cover neutral context, exact totals, an empty create, bounded fallback, result-time cache invalidation, and redraw cache reuse. The assembled `advanced-cards` terminal snapshots pin the neutral context style, semantic change colors, exact footer, and approximate fallback through collapsed and expanded card states. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md new file mode 100644 index 0000000000..dd1a3eb1ac --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md @@ -0,0 +1,33 @@ +# Agent Note: TUI diff 上下文行保持中性 + +Status: implemented + +[English](2026-07-31-tui-diff-context-line-accounting.md) | 中文 + +## 问题 + +文件系统 diff 返回结果时,每个 `FileDiff.oldText` 和 `FileDiff.newText` 都会包含已应用的变更及其前后各 3 行上下文。TUI 将旧侧的每一行都渲染为删除行,将新侧的每一行都渲染为新增行,其中包括两侧相同的上下文。因此,一行编辑会显示为删除 7 行并新增 7 行,页脚还会重复这些虚高的合计值。 + +## 决策 + +TUI 会比较每个变更前后文本均可用的 `FileDiff`。新增行和删除行仍分别使用绿色 `+` 和红色 `-` 标记;相同的上下文行则使用弱化的正文色调,并带有由两个空格构成的中性前缀。页脚只汇总归类为新增或删除的行。`maxDiffEditLength` 以新增行与删除行的合计数为精确比较设置上限,默认值为 1000。超过上限时,TUI 会把完整旧侧渲染为删除内容、把完整新侧渲染为新增内容,将页脚标记为近似结果,并缓存该结果,避免后续重绘重复比较。工具结果会在派生已结算视图前清除待处理视图缓存,即使 presenter 修改并复用同一个视图对象也一样。 + +当 `oldText` 为 `null` 时,渲染器无法区分文件创建、待处理覆写,以及旧文本不可用的参数回退。因此,它会把新侧的每个非空行显示并计作新增行,但不会声称这些行原先不存在于已有文件中。新内容为空时,不会渲染虚构的新增行。 + +该行为仍然只是消费方对现有 `FileDiff` 契约的解释。文件系统工具仍会持久化带上下文的变更前后片段,因此其他消费方仍能获得定位上下文,已有会话日志在回放时也会采用修正后的 TUI 呈现。TUI 与 `dsh-tool-fs` 共用同一个受维护的 `diff` 包(package),无需引入第二套逐行 diff 实现。 + +## 考虑过的替代方案 + +**从文件系统结果元数据中移除上下文。** 不予采纳:带上下文的已应用 hunk 是有意保留的生产方输出,供具备相应能力的编辑器使用;更改这些内容会让所有消费方丢失信息,同时旧会话日志在 TUI 中仍会产生误导。 + +**为 `FileDiff` 扩展持久化的逐行标签。** 不予采纳:这些标签可以根据现有的变更前后文本对确定性派生;仅为一个渲染器持久化标签,会扩大跨包契约和会话日志契约。 + +**不使用 diff 算法,按位置匹配相同行。** 不予采纳:插入和删除会使后续上下文发生位移,因此按位置配对会把有效 hunk 错误分类。 + +**让所有比较都运行至完成。** 不予采纳:待处理工具视图可能包含由模型生成且长度不受限制的新旧字符串,无界的 Myers 比较可能阻塞同步终端渲染器。 + +## 后果 + +TUI diff 卡片会区分用于佐证的上下文与变更本身,精确的 `+A -R` 页脚会报告实际的行变更量。回放已有的上下文 diff 无需迁移即可获得修正后的渲染。结果时刻的文件系统 hunk 受上下文范围限制;不受限制的待处理视图要么在配置的编辑长度预算内完成比较,要么降级为明确标注为近似结果的线性渲染。 + +聚焦的 TUI 测试覆盖中性上下文、精确合计值、空文件创建、有界回退、结果到达时的缓存失效和重绘缓存复用。组装后的 `advanced-cards` 终端快照在卡片折叠和展开状态下固定了中性上下文样式、变更行的语义色彩、精确结果页脚和近似回退。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml index f9b2cfa908..388a8de9cd 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-diff-card.md -2026-07-30-web-diff-card.md: 396bdbc2843c1bbed5c6a913be436d8b9e96a81c -2026-07-30-web-diff-card.zh.md: afdeafa6e94b46b4f0fbd4a065afdac8a93ac57d +2026-07-30-web-diff-card.md: eb43e09d6173ca2270df97cecaab6da36c70a679 +2026-07-30-web-diff-card.zh.md: 669cd49abc8eba8637705cd7c9f331cc51607755 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md index 396bdbc284..eb43e09d61 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md @@ -16,13 +16,13 @@ This is the [terminal card](2026-07-28-web-terminal-card.md) done for the `diff` `DiffBlock` is a `ui-primitives` component that renders a file mutation as an inline diff surface, and both Web render sites for a write/edit call consume the diff render intent through it: the chat tool row's body and the details panel's Output section. `ui-conversation/src/client/contract/diff-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a change. It returns null — the generic path — whenever neither side declares `card: 'diff'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how write/edit keep their execution errors on the generic path. The result side is authoritative once the call settles: the applied hunks replace the call-time diff derived from the arguments alone. A paging window that drops the call head still renders, because the result view carries the whole change. -The component's contract follows the TUI's `diffLines` (`packages/ui/tui/src/components/transcript.ts`) so a diff reads the same shape across front ends: +The component shares the TUI's single-column framing, line-terminator rule, and distinct-path file count. Line classification differs: Web renders the complete old and new sides, while the TUI derives neutral context and exact changed rows when its bounded comparison completes and labels its whole-side fallback approximate. -- **One path header per file.** A new file opens a bold path header; a same-file second hunk (a scattered edit, or a `replace_all`) opens with a `⋯` gap instead of repeating the path. The `N file(s)` footer counts DISTINCT paths on both front ends — this PR moved the TUI footer off `diffs.length` onto the distinct-path count, so two hunks in one file read as `1 file` in both. -- **The change in the diff's own colors.** A removed line is `- ` on the error token, an added line is `+ ` on the success token, drawn verbatim with `white-space: pre` inside a horizontally scrolling box — a source line is read by its indentation, so it scrolls rather than folds. A create (`oldText: null`) has no removed side. +- **Path grouping.** A new file opens a bold path header; a same-file second hunk (a scattered edit, or a `replace_all`) opens with a `⋯` gap instead of repeating the path. The TUI keeps a path header on every hunk, but both front ends count distinct paths in the `N file(s)` footer, so two hunks in one file read as `1 file`. +- **Whole-side change colors.** Every old-side line is `- ` on the error token and every new-side line is `+ ` on the success token, drawn verbatim with `white-space: pre` inside a horizontally scrolling box — a source line is read by its indentation, so it scrolls rather than folds. A create (`oldText: null`) has no removed side. - **Height cap with an expand control.** A diff longer than `DEFAULT_DIFF_MAX_LINES` (16) shows `ceil(max/2)` head rows plus the remaining tail rows, with a button between reporting the hidden count. The split arithmetic matches `TerminalBlock` and the TUI's collapsed card, so a long diff's head and tail slices agree across front ends. -- **Line terminator.** A side's content splits on `\n` under the terminator rule `TerminalBlock` uses: empty text is zero lines (a full deletion's `newText`, a create's absent `oldText` side), a single trailing newline terminates its last line rather than adding a phantom empty one, and an interior blank line survives. This PR applied the same rule to the TUI diff branch, so the `+A -R` footer counts agree on both front ends for the newline-terminated content real write/edit calls carry. -- **Footer and copy.** A dim `└ +A -R · N file(s)` footer summarizes the change; `+A -R` are the added/removed line counts, the same per-side counts the TUI footer draws. The copy control copies the prefixed diff text (path headers, `- `/`+ ` lines, the `⋯` gap), so a multi-file copy stays attributable. +- **Line terminator.** A side's content splits on `\n` under the terminator rule `TerminalBlock` and the TUI use: empty text is zero lines (a full deletion's `newText`, a create's absent `oldText` side), a single trailing newline terminates its last line rather than adding a phantom empty one, and an interior blank line survives. +- **Footer and copy.** A dim `└ +A -R · N file(s)` footer reports the Web card's complete new- and old-side line counts. The TUI footer instead reports exact changed rows when available and marks a bounded whole-side fallback approximate; both use the same distinct-path file count. The copy control copies the prefixed Web diff text (path headers, `- `/`+ ` lines, the `⋯` gap), so a multi-file copy stays attributable. Geometry, radius, and fonts mirror `CodeBlock`/`TerminalBlock` so a diff card, a terminal card, and a fenced block read as one family; `white-space: pre` plus horizontal scroll is the deliberate divergence. The copy control floats in the card's top-right corner rather than on a banner row of its own, because a banner carrying only a copy button drew an empty band above the first diff line — the TUI diff card has no banner either, only the footer. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md index afdeafa6e9..669cd49abc 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md @@ -16,13 +16,13 @@ Web 客户端忽略了它。write/edit 调用落到 `GenericToolCard`,其行 `DiffBlock` 是一个 `ui-primitives` 组件,把文件改动渲染为内联 diff 表面,write/edit 调用的两个 Web 渲染点都通过它消费 diff 渲染意图:chat 工具行的行体和详情面板的 Output 区。`ui-conversation/src/client/contract/diff-card-model.ts` 是唯一把快照的 `callView`/`resultView` 对转成组件 props 的地方,因此两个渲染点不会对一次改动产生分歧。当两侧都未声明 `card: 'diff'` 时它返回 null —— 走通用路径 —— 包括本客户端版本不认识的 `card` 值,以及已结算调用的 result view 是 generic 的情况(write/edit 的执行错误正是这样留在通用路径上的)。调用结算后 result 侧是权威:已应用的 hunk 替换仅从参数推导的 call 时 diff。分页窗口丢弃了 call 头也仍能渲染,因为 result view 携带完整改动。 -组件的契约遵循 TUI 的 `diffLines`(`packages/ui/tui/src/components/transcript.ts`),使 diff 在两个前端读起来是同一形态: +该组件与 TUI 共用单栏框架、行终止符规则和去重路径计数。两者的行分类不同:Web 渲染完整的变更前后两侧,而 TUI 会在有界比较完成时派生中性上下文和精确变更行,并把整侧回退标记为近似结果。 -- **每个文件一个路径头。** 新文件开启一个粗体路径头;同文件的第二个 hunk(分散编辑,或 `replace_all`)以一个 `⋯` gap 开启,而非重复路径。`N file(s)` 页脚在两个前端都统计**去重后的路径数** —— 本 PR 把 TUI 页脚从 `diffs.length` 改为去重路径计数,因此同文件两个 hunk 在两端都读作 `1 file`。 -- **改动用 diff 自身的颜色。** 删除行是 error token 上的 `- `,新增行是 success token 上的 `+ `,在横向滚动的盒子里以 `white-space: pre` 逐字绘制 —— 源码行靠缩进阅读,所以滚动而不折行。新建(`oldText: null`)没有删除侧。 +- **路径分组。** 新文件开启一个粗体路径头;同文件的第二个 hunk(分散编辑,或 `replace_all`)以一个 `⋯` gap 开启,而非重复路径。TUI 在每个 hunk 上都保留路径头,但两个前端的 `N file(s)` 页脚都按去重路径计数,因此同文件两个 hunk 在两端都读作 `1 file`。 +- **整侧改动配色。** 旧侧每一行都以 error token 上的 `- ` 显示,新侧每一行都以 success token 上的 `+ ` 显示,并在横向滚动的盒子里以 `white-space: pre` 逐字绘制:源码行靠缩进阅读,因此滚动而不折行。新建(`oldText: null`)没有删除侧。 - **高度上限带展开控件。** 长于 `DEFAULT_DIFF_MAX_LINES`(16)的 diff 显示 `ceil(max/2)` 个头部行加剩余尾部行,中间一个按钮报告隐藏行数。分割算术与 `TerminalBlock` 和 TUI 的折叠卡片一致,因此长 diff 的头尾切片在两个前端一致。 -- **行终止符。** 每一侧的内容按 `TerminalBlock` 的终止符规则在 `\n` 上切分:空文本是零行(整文件删除的 `newText`、新建缺失的 `oldText` 侧),单个结尾换行终止其最后一行而非新增一条幻影空行,内部空行保留。本 PR 把同一规则应用到了 TUI diff 分支,因此对于真实 write/edit 调用携带的以换行结尾的内容,两个前端的 `+A -R` 页脚计数一致。 -- **页脚与复制。** 暗色 `└ +A -R · N file(s)` 页脚概括改动;`+A -R` 是新增/删除行数,与 TUI 页脚绘制的每侧计数相同。复制控件复制带前缀的 diff 文本(路径头、`- `/`+ ` 行、`⋯` gap),使多文件复制保持可归属。 +- **行终止符。** 每一侧的内容按 `TerminalBlock` 与 TUI 共用的终止符规则在 `\n` 上切分:空文本是零行(整文件删除的 `newText`、新建缺失的 `oldText` 侧),单个结尾换行终止其最后一行而非新增一条幻影空行,内部空行保留。 +- **页脚与复制。** 暗色 `└ +A -R · N file(s)` 页脚报告 Web 卡片完整新侧与旧侧的行数。TUI 页脚则在可用时报告精确变更行数,并把有界整侧回退标记为近似结果;两者使用相同的去重路径计数。复制控件复制带前缀的 Web diff 文本(路径头、`- `/`+ ` 行、`⋯` gap),使多文件复制保持可辨别归属。 几何、圆角、字体镜像 `CodeBlock`/`TerminalBlock`,使 diff 卡片、terminal 卡片、代码块读起来是一家;`white-space: pre` 加横向滚动是刻意的分歧。复制控件浮在卡片右上角,而非占据自己的 banner 行,因为只放一个复制按钮的 banner 会在第一行 diff 上方画出一条空带 —— TUI 的 diff 卡片也没有 banner,只有页脚。 @@ -32,7 +32,7 @@ chat 行把 diff 常驻渲染在路径链接摘要之下,上限 `CHAT_DIFF_MAX **并排(双栏)diff。** owner 目前拒绝:它更密但不适合狭窄的 chat 行,目标是与 TUI 单栏统一形式对齐。详情面板里的双栏模式是后续的 props 改动,不是重设计。 -**git 式行号槽。** `FileDiff` 契约只携带 `{ path, oldText, newText }` —— `structuredPatch` 的 hunk 起始行在 `diff.ts` 里被丢弃,所以没有行号抵达客户端。渲染行号槽需要后端契约改动(携带 `oldStart`/`newStart`)并同步升级 TUI 以保持一致;推迟,使本 PR 保持为对既有契约的纯 Web 消费。 +**git 式行号槽。** `FileDiff` 契约只携带 `{ path, oldText, newText }` —— `structuredPatch` 的 hunk 起始行在 `diff.ts` 里被丢弃,所以没有行号抵达客户端。渲染行号槽需要后端契约改动(携带 `oldStart`/`newStart`)并同步升级 TUI 以保持一致;推迟,使本 PR(Pull Request)保持为对既有契约的纯 Web 消费。 **复用 `CodeBlock`。** 因与 terminal 卡片相同的理由拒绝:`CodeBlock` 会折行,且没有每行 `+`/`-` 角色、没有路径头、没有页脚。两者共享几何与字体 token,那是唯一一处一个实现对两者都正确的部分。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f1b2a02397..617cfb82be 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2067,6 +2067,8 @@ export interface TuiConfig { showReasoning?: boolean /** Maximum tool-card body lines retained in its collapsed head/tail preview. */ maxToolOutputLines?: number + /** Maximum added and removed lines explored while deriving an exact line diff. */ + maxDiffEditLength?: number /** Maximum options visible at once in a user-question panel. */ maxQuestionOptions?: number /** Maximum models visible at once in the model selector. */ @@ -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` diff --git a/packages/client/ui-primitives/src/DiffBlock.tsx b/packages/client/ui-primitives/src/DiffBlock.tsx index 23c498b1d1..41af796ab3 100644 --- a/packages/client/ui-primitives/src/DiffBlock.tsx +++ b/packages/client/ui-primitives/src/DiffBlock.tsx @@ -1,13 +1,12 @@ // DiffBlock: the inline-diff surface for a file mutation (write/edit) — a copy // control over one or more per-file hunks, each a bold path header followed by // the removed block (`-`, error color) and the added block (`+`, success -// color), with a dim `└ +A -R · N file(s)` footer. The +/- block form mirrors -// the TUI transcript's diff card (packages/ui/tui: diffLines) so a diff reads -// the same across front ends: the removed side is the old text in full, the -// added side the new text in full, both split on the same terminator rule, and -// the footer counts distinct paths on both ends. Output never soft-wraps — an -// aligned source line keeps its indentation and scrolls horizontally instead of -// folding. Colors resolve through --dsw-* tokens; geometry mirrors CodeBlock. +// color), with a dim `└ +A -R · N file(s)` footer. Unlike the TUI's exact +// changed-row comparison, this block renders the old and new sides in full. +// Both front ends share the line-terminator rule and distinct-path file count. +// Output never soft-wraps — an aligned source line keeps its indentation and +// scrolls horizontally instead of folding. Colors resolve through --dsw-* +// tokens; geometry mirrors CodeBlock. import { useCallback, useMemo, useState } from 'react' import clsx from 'clsx' diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index f0c2feb95f..246e459e57 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/tui/README.md -README.md: a577fb2f858f61eb765d4a1a9564f452d01d92c1 -README.zh.md: 61e1b9d526a00e3c8cbc2c9ed0cc483e2ab8dba2 +README.md: 60e42a64305931a6bf93a470f851f662be33e2bb +README.zh.md: fc12b75a1e320103a1d4cc70cc00adfeb639a3d9 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index a577fb2f85..60e42a6430 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -50,6 +50,7 @@ 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 | +| `maxDiffEditLength` | `1000` | Maximum added and removed lines explored for an exact diff before whole-side fallback | | `maxQuestionOptions` | `8` | Visible options in a question panel | | `maxModelOptions` | `8` | Visible models in the model selector | | `maxResumeOptions` | `8` | Visible sessions in the resume selector | @@ -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 diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 61e1b9d526..fc12b75a1e 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -50,6 +50,7 @@ Footer 将会话报告的用量汇总为 `↑`;任 | `sessionId` | `main` | 由终端驱动的精确共享 agent/会话身份 | | `showReasoning` | `true` | 渲染 reasoning 块 | | `maxToolOutputLines` | `6` | 折叠工具卡片的头尾预览所保留的输出行数 | +| `maxDiffEditLength` | `1000` | 回退到整侧展示前,精确 diff 最多探索的新增与删除行总数 | | `maxQuestionOptions` | `8` | 问题面板中可见的选项数 | | `maxModelOptions` | `8` | 模型选择器中可见的模型数 | | `maxResumeOptions` | `8` | 恢复选择器中可见的会话数 | @@ -73,6 +74,7 @@ Footer 将会话报告的用量汇总为 `↑`;任 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` 可移除所有样式。 ## 模型体验 diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index c3506ea338..a68fe7968a 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -66,6 +66,7 @@ }, "dependencies": { "@earendil-works/pi-tui": "0.80.7", + "diff": "^9.0.0", "saxes": "6.0.0", "schemastery": "^3.18.0" }, diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts index 300b39fe08..f0422e8417 100644 --- a/packages/ui/tui/src/components/transcript.ts +++ b/packages/ui/tui/src/components/transcript.ts @@ -15,6 +15,7 @@ import { type Component, type MarkdownTheme, } from '@earendil-works/pi-tui' +import { diffLines as compareLines } from 'diff' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { JsonValue, SessionEvent, TodoItem } from '@deepseek-ai/dsh-session' @@ -52,12 +53,17 @@ function pretty(value: unknown): string { return displayText(serialized ?? String(value)) } +interface RenderedDiff { + lines: string[] + added: number + removed: number + approximate: boolean +} + /** * A side's content lines under the terminator rule the Web DiffBlock also - * applies: empty text is zero lines (a full deletion's `newText`, a create's - * absent `oldText`), and a single trailing newline terminates the last line - * rather than adding an empty one. An interior blank line survives. Keeping the - * two front ends on the same rule holds their `+A -R` footers in step. + * applies: empty text is zero lines, a trailing newline terminates the last + * line, and an interior blank line survives. */ function diffContentLines(text: string): string[] { if (text === '') return [] @@ -65,16 +71,47 @@ function diffContentLines(text: string): string[] { return body.split('\n') } -/** A file diff as colored `+`/`-` lines, optionally prefixed with its path. */ -function diffLines(diff: FileDiff, palette: Palette): string[] { +/** + * A file diff whose unchanged context stays neutral and does not affect exact + * change totals. Comparisons beyond the edit-distance budget fall back to + * whole-side rendering so a model-authored pending edit cannot stall the TUI. + */ +function renderDiff(diff: FileDiff, maxDiffEditLength: number, palette: Palette): RenderedDiff { // The card header is a fixed `Tool / ` frame that never names a file, so // each hunk always carries its own path header (no redundancy to suppress). const lines = [palette.bold(displayText(diff.path))] - if (diff.oldText !== null) { - for (const line of diffContentLines(displayText(diff.oldText))) lines.push(palette.error(`- ${line}`)) + let added = 0 + let removed = 0 + if (diff.oldText === null) { + const newLines = diffContentLines(displayText(diff.newText)) + added = newLines.length + for (const line of newLines) lines.push(palette.success(`+ ${line}`)) + return { lines, added, removed, approximate: false } } - for (const line of diffContentLines(displayText(diff.newText))) lines.push(palette.success(`+ ${line}`)) - return lines + const changes = compareLines(diff.oldText, diff.newText, { maxEditLength: maxDiffEditLength }) + if (changes === undefined) { + const oldLines = diffContentLines(displayText(diff.oldText)) + const newLines = diffContentLines(displayText(diff.newText)) + lines.push(palette.dim(`[exact line diff omitted: >${maxDiffEditLength} changed lines]`)) + removed = oldLines.length + added = newLines.length + for (const line of oldLines) lines.push(palette.error(`- ${line}`)) + for (const line of newLines) lines.push(palette.success(`+ ${line}`)) + return { lines, added, removed, approximate: true } + } + for (const change of changes) { + const changedLines = diffContentLines(displayText(change.value)) + if (change.added) { + added += changedLines.length + for (const line of changedLines) lines.push(palette.success(`+ ${line}`)) + } else if (change.removed) { + removed += changedLines.length + for (const line of changedLines) lines.push(palette.error(`- ${line}`)) + } else { + for (const line of changedLines) lines.push(palette.dim(` ${line}`)) + } + } + return { lines, added, removed, approximate: false } } /** @@ -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['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() - const hunks = view.diffs.flatMap((diff, index) => { - paths.add(diff.path) - if (diff.oldText !== null) removed += diffContentLines(displayText(diff.oldText)).length - added += diffContentLines(displayText(diff.newText)).length - return [...index > 0 ? [''] : [], ...diffLines(diff, this.palette)] + // header. A trailing footer summarizes the exact changed rows when the + // bounded comparison succeeds (`+A -R · N file(s)`). + const renderedDiffs = view.diffs.map(diff => + renderDiff(diff, this.maxDiffEditLength, this.palette), + ) + const added = renderedDiffs.reduce((total, rendered) => total + rendered.added, 0) + const removed = renderedDiffs.reduce((total, rendered) => total + rendered.removed, 0) + const approximate = renderedDiffs.some(rendered => rendered.approximate) + const hunks = renderedDiffs.flatMap((rendered, index) => { + return [...index > 0 ? [''] : [], ...rendered.lines] }) - const files = paths.size - const footer = this.palette.dim(`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}`) + const files = new Set(view.diffs.map(diff => diff.path)).size + const footer = this.palette.dim( + `└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}${approximate ? ' · approximate' : ''}`, + ) // A diff's own `+`/`-` colors carry its meaning, so it renders verbatim // rather than under the dim result-output color. - return { prelude: [...hunks, footer], lines: [] } + const body = { prelude: [...hunks, footer], lines: [] } + this.diffBodyCache = { view, body } + return body } // A generic or read card carries its own envelope-stripped `content`; a // search or web card carries no `content` copy and falls back to the raw diff --git a/packages/ui/tui/src/config.ts b/packages/ui/tui/src/config.ts index 568bc46dee..8b07629af8 100644 --- a/packages/ui/tui/src/config.ts +++ b/packages/ui/tui/src/config.ts @@ -34,6 +34,8 @@ export interface TuiConfig { showReasoning?: boolean /** Maximum tool-card body lines retained in its collapsed head/tail preview. */ maxToolOutputLines?: number + /** Maximum added and removed lines explored while deriving an exact line diff. */ + maxDiffEditLength?: number /** Maximum options visible at once in a user-question panel. */ maxQuestionOptions?: number /** Maximum models visible at once in the model selector. */ @@ -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 = 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, diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index eb5e346ea7..f50894b59d 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -643,6 +643,7 @@ export function createTuiChat( parsed, ctx.tools.get(event.data.name, agent), resolved.maxToolOutputLines, + resolved.maxDiffEditLength, palette, mdTheme, ) @@ -835,7 +836,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) diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt index 2a005383e1..20ec3ab324 100644 --- a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt +++ b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt @@ -1,7 +1,7 @@ -terminal 100x40 buffer=normal length=40 base=0 viewport=0 +terminal 100x40 buffer=normal length=41 base=1 viewport=1 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=7 viewportRow=34 bufferRow=34 +cursor hidden column=7 viewportRow=39 bufferRow=40 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -31,9 +31,9 @@ buffer style 0-10 bold 14| "- old line " style 0-9 fg=red -15| "… +3 lines (Ctrl+O to expand) " +15| "… +2 lines (Ctrl+O to expand) " style 0-28 dim -16| "└ +2 -2 · 1 file " +16| "└ +1 -1 · 1 file " style 0-15 dim 17| 18| "● Tool / subagent" @@ -58,17 +58,27 @@ buffer style 0-99 dim 30| "Loaded review instructions. " style 0-99 dim -31| "Model wait 0.0s " +31| +32| "● Tool / large_edit" + style 0-18 fg=green +33| "src/large.ts " + style 0-11 bold +34| "[exact line diff omitted: >2 changed lines] " + style 0-42 dim +35| "… +6 lines (Ctrl+O to expand) " + style 0-28 dim +36| "└ +3 -3 · 1 file · approximate " + style 0-29 dim +37| "Model wait 0.0s " style 0-14 dim -32| -33| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +38| +39| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-50 dim style 53-57 dim style 60-69 dim -34| " dsh > " +40| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse -35-39| diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt index 6f9aa094f3..7752484db7 100644 --- a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt +++ b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt @@ -1,7 +1,7 @@ -terminal 100x40 buffer=normal length=43 base=3 viewport=3 +terminal 100x40 buffer=normal length=53 base=13 viewport=13 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=7 viewportRow=39 bufferRow=42 +cursor hidden column=7 viewportRow=39 bufferRow=52 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -37,54 +37,73 @@ buffer style 0-10 bold 17| "- old line " style 0-9 fg=red -18| "- keep " - style 0-5 fg=red -19| "+ new line " +18| "+ new line " style 0-9 fg=green -20| "+ keep " - style 0-5 fg=green -21| "└ +2 -2 · 1 file " +19| " keep " + style 0-5 dim +20| "└ +1 -1 · 1 file " style 0-15 dim -22| -23| "● Tool / subagent" +21| +22| "● Tool / subagent" style 0-16 fg=green -24| "Delegate renderer audit " +23| "Delegate renderer audit " style 0-99 dim -25| "The renderer has explicit lifecycle ownership. " +24| "The renderer has explicit lifecycle ownership. " style 0-99 dim -26| -27| "● Tool / task_output" +25| +26| "● Tool / task_output" style 0-19 fg=green -28| "Read output from background task subagent-7 " +27| "Read output from background task subagent-7 " style 0-99 dim -29| " " -30| "console " +28| " " +29| "console " style 0-6 dim -31| " started background task bash-5 " +30| " started background task bash-5 " style 0-1 dim style 2-31 fg=cyan dim style 32-99 dim -32| " " -33| -34| "● Tool / skill" +31| " " +32| +33| "● Tool / skill" style 0-13 fg=green -35| "Load skill dsh-code-review " +34| "Load skill dsh-code-review " style 0-99 dim -36| "Loaded review instructions. " +35| "Loaded review instructions. " style 0-99 dim -37| "Model wait 0.0s " +36| +37| "● Tool / large_edit" + style 0-18 fg=green +38| "src/large.ts " + style 0-11 bold +39| "[exact line diff omitted: >2 changed lines] " + style 0-42 dim +40| "- old one " + style 0-8 fg=red +41| "- old two " + style 0-8 fg=red +42| "- old three " + style 0-10 fg=red +43| "+ new one " + style 0-8 fg=green +44| "+ new two " + style 0-8 fg=green +45| "+ new three " + style 0-10 fg=green +46| "└ +3 -3 · 1 file · approximate " + style 0-29 dim +47| "Model wait 0.0s " style 0-14 dim -38| -39| "Tool and context cards expanded. " +48| +49| "Tool and context cards expanded. " style 0-31 dim -40| -41| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +50| +51| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-50 dim style 53-57 dim style 60-69 dim -42| " dsh > " +52| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 706ed7dce5..72718cfae1 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -272,13 +272,32 @@ const ADVANCED_CARD_TOOLS: Record = { edit: visualTool( 'edit', () => ({ card: 'diff', title: 'Edit src/view.ts', diffs: [{ path: 'src/view.ts', oldText: 'old line', newText: 'new line' }] }), - // The real edit/write tools produce exactly one diff whose path the title - // already names, so the card omits the redundant per-file header. + // The fixed tool header never names a path, so the hunk retains its path. (): ToolResultView => ({ card: 'diff', diffs: [{ path: 'src/view.ts', oldText: 'old line\nkeep', newText: 'new line\nkeep' }], }), ), + large_edit: visualTool( + 'large_edit', + () => ({ + card: 'diff', + title: 'Edit src/large.ts', + diffs: [{ + path: 'src/large.ts', + oldText: 'old one\nold two\nold three', + newText: 'new one\nnew two\nnew three', + }], + }), + (): ToolResultView => ({ + card: 'diff', + diffs: [{ + path: 'src/large.ts', + oldText: 'old one\nold two\nold three', + newText: 'new one\nnew two\nnew three', + }], + }), + ), subagent: visualTool('subagent', args => ({ card: 'generic', title: 'Delegate renderer audit', @@ -588,7 +607,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 +615,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 +624,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 }) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index f70440d37a..b442df0710 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -185,6 +185,7 @@ describe('TUI config', () => { expect(resolveTuiConfig(undefined)).toEqual({ showReasoning: true, maxToolOutputLines: 6, + maxDiffEditLength: 1000, maxQuestionOptions: 8, maxModelOptions: 8, maxResumeOptions: 8, @@ -210,6 +211,7 @@ describe('TUI config', () => { expect(resolveTuiConfig({ showReasoning: false, maxToolOutputLines: 2, + maxDiffEditLength: 12, maxQuestionOptions: 3, maxModelOptions: 4, maxResumeOptions: 5, @@ -227,6 +229,7 @@ describe('TUI config', () => { })).toEqual({ showReasoning: false, maxToolOutputLines: 2, + maxDiffEditLength: 12, maxQuestionOptions: 3, maxModelOptions: 4, maxResumeOptions: 5, @@ -4675,7 +4678,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 +5036,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 +5052,123 @@ describe('tool cards and surface replay', () => { expect(output).toContain('Tool / singleDiff') expect(output).not.toContain('Edit src/only.ts') expect(output.split('src/only.ts').length - 1).toBe(1) - expect(output).toContain('- old') - expect(output).toContain('+ new') - expect(output).toContain('· 1 file') + expect(output).toContain(' my: my-MM') + expect(output).not.toContain('- my: my-MM') + expect(output).not.toContain('+ my: my-MM') + expect(output).toContain('- nb: no-NO') + expect(output).toContain('+ nb: nb-NO') + expect(output).toContain('└ +1 -1 · 1 file') + await dispose(result) + }) + + it('renders an empty create without a synthetic added row', async () => { + const emptyCreate: Record = { + emptyCreate: { + name: 'emptyCreate', + description: '', + parameters: {}, + output: UNUSED_TOOL_OUTPUT, + execute: async () => [], + presentCall: () => ({ + card: 'diff', + title: 'Write empty.txt', + diffs: [{ path: 'empty.txt', oldText: null, newText: '' }], + }), + }, + } + const result = await setup({ + tools: emptyCreate, + config: { maxToolOutputLines: 20, theme: { color: false } }, + }) + appendAssistant(result.session, [ + { type: 'tool-call', id: 'empty-create' as never, name: 'emptyCreate', arguments: '{}' }, + ]) + result.session.append('tool/call', { + turn: 1, + step: 1, + callId: 'empty-create' as never, + name: 'emptyCreate', + arguments: '{}', + }) + await tick() + const rows = result.terminal.output.split('\n').map(row => row.trim()) + expect(result.terminal.output).toContain('empty.txt') + expect(result.terminal.output).toContain('└ +0 -0 · 1 file') + expect(rows).not.toContain('+') + await dispose(result) + }) + + it('bounds and caches exact diff comparison before whole-side fallback', async () => { + let oldTextReads = 0 + let newText = 'new one\nnew two' + const boundedDiff = { + path: 'bounded.txt', + get oldText() { + oldTextReads += 1 + return 'old one\nold two' + }, + get newText() { return newText }, + } + const boundedView = { + card: 'diff' as const, + title: 'Edit bounded.txt', + diffs: [boundedDiff], + } + const bounded: Record = { + bounded: { + name: 'bounded', + description: '', + parameters: {}, + output: UNUSED_TOOL_OUTPUT, + execute: async () => [], + presentCall: () => boundedView, + presentResult: () => { + newText = 'settled one\nsettled two' + return boundedView + }, + }, + } + const result = await setup({ + tools: bounded, + config: { + maxToolOutputLines: 20, + maxDiffEditLength: 1, + theme: { color: false }, + }, + }) + appendAssistant(result.session, [ + { type: 'tool-call', id: 'bounded-diff' as never, name: 'bounded', arguments: '{}' }, + ]) + result.session.append('tool/call', { + turn: 1, + step: 1, + callId: 'bounded-diff' as never, + name: 'bounded', + arguments: '{}', + }) + await tick() + expect(result.terminal.output).toContain('[exact line diff omitted: >1 changed lines]') + expect(result.terminal.output).toContain('- old one') + expect(result.terminal.output).toContain('+ new one') + expect(result.terminal.output).toContain('└ +2 -2 · 1 file · approximate') + const readsAfterFirstRender = oldTextReads + expect(readsAfterFirstRender).toBeGreaterThan(0) + result.session.append('tool/result', { + turn: 1, + step: 1, + message: createToolResultMessage({ + callId: 'bounded-diff' as never, + content: [{ type: 'text', text: 'done' }], + isError: false, + }), + }, { surfaceOp: 'append' }) + await tick() + expect(result.terminal.output).toContain('+ settled one') + expect(oldTextReads).toBeGreaterThan(readsAfterFirstRender) + const readsAfterResult = oldTextReads + result.terminal.resize(87) + await tick() + expect(oldTextReads).toBe(readsAfterResult) await dispose(result) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b634898ea..5e27760b0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5838,6 +5838,9 @@ importers: '@earendil-works/pi-tui': specifier: 0.80.7 version: 0.80.7(patch_hash=6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946) + diff: + specifier: ^9.0.0 + version: 9.0.0 saxes: specifier: 6.0.0 version: 6.0.0