fix TUI diff context line accounting

This commit is contained in:
kingwl
2026-07-31 11:41:16 +08:00
parent 7239a2201e
commit e98cd522ee
12 changed files with 153 additions and 49 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/tui/README.md
README.md: 63c888b1d51c02fa85a8f0cc1617874debd87c4e
README.zh.md: ca5efc9ae26a9833d271991f73a21c607d8fb09d
README.md: b021789d660fd831c3fa0dad20d0bc174538eb57
README.zh.md: b9cd7210932558a3a2feb0d5c1bfaf7e115703f6

View File

@@ -83,7 +83,7 @@ Every SGR code the TUI emits lives in one table, `paletteSpec` in `components/th
There is one role per visual meaning: `dim` is the single recessed tone and `accent` the single emphasis color, while `success` and `error` double as a diff's added and removed lines. Colors and attributes are separately typed, so `bold(accent(x))` compiles and `accent(error(x))` does not — SGR has no color stack, so nesting one color inside another silently drops the outer color at the inner one's close. Attributes occupy independent SGR groups and compose with any color in either order. Run `/palette` to see every role as your terminal renders it, with its SGR pair.
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 colors and counts only added `+` and removed `-` lines; unchanged context stays dim and uncounted. 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

View File

@@ -83,7 +83,7 @@ TUI 发出的所有 SGR 代码都集中在一个表中,即 `components/theme.t
每种视觉语义只对应一个角色:`dim` 是唯一的弱化色调,`accent` 是唯一的强调色,`success``error` 还分别充当 diff 的新增行与删除行。颜色和属性分属不同类型,因此 `bold(accent(x))` 可以通过编译,`accent(error(x))` 则不行——SGR 没有颜色栈;在一种颜色内嵌套另一种颜色时,内层颜色闭合时会静默丢弃外层颜色。各属性占用彼此独立的 SGR 组,可以按任一顺序与任何颜色组合。运行 `/palette` 可查看每个角色在你的终端上的实际渲染效果及其 SGR 码对。
成组区域用户提示词、assistant 回复、工具卡片通过以角色色渲染的粗体带下划线角色标题和空行分隔而非填充背景块或逐行前缀因此用鼠标框选复制时不会带上任何左侧竖条或缩进工具卡片的状态进行中、错误、成功由其彩色带下划线的标题字形与标题体现。在工具卡片内部整个正文——presenter 标题、终端 `$` 命令与 cwd以及工具自身的输出——统一以同一种暗色渲染因此只有带状态色的表头携带颜色正文读作一个整体弱化的区块而不是一串互相竞争的色调注入上下文卡片的正文与其表头也是同一种色调。diff 卡片的 `+`/`-` 行与 `[signal …]` 标记保留颜色,因为那里的颜色本身就是语义,而非强调。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。
成组区域用户提示词、assistant 回复、工具卡片通过以角色色渲染的粗体带下划线角色标题和空行分隔而非填充背景块或逐行前缀因此用鼠标框选复制时不会带上任何左侧竖条或缩进工具卡片的状态进行中、错误、成功由其彩色带下划线的标题字形与标题体现。在工具卡片内部整个正文——presenter 标题、终端 `$` 命令与 cwd以及工具自身的输出——统一以同一种暗色渲染因此只有带状态色的表头携带颜色正文读作一个整体弱化的区块而不是一串互相竞争的色调注入上下文卡片的正文与其表头也是同一种色调。diff 卡片只为新增`+` 行和删除的 `-` 行着色并计数;未变更的上下文保持暗色且不纳入计数。`[signal …]` 标记保留颜色,因为那里的颜色本身就是语义,而非强调。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。
## 模型体验

View File

@@ -66,6 +66,7 @@
},
"dependencies": {
"@earendil-works/pi-tui": "0.80.7",
"diff": "^9.0.0",
"saxes": "6.0.0",
"schemastery": "^3.18.0"
},

View File

@@ -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,16 +53,45 @@ function pretty(value: unknown): string {
return displayText(serialized ?? String(value))
}
/** A file diff as colored `+`/`-` lines, optionally prefixed with its path. */
function diffLines(diff: FileDiff, palette: Palette): string[] {
interface RenderedDiff {
lines: string[]
added: number
removed: number
}
/** Split one diff change into display rows without counting its trailing line terminator. */
function diffValueLines(value: string): string[] {
if (value === '') return []
const safe = displayText(value)
return (safe.endsWith('\n') ? safe.slice(0, -1) : safe).split('\n')
}
/** A file diff whose unchanged context stays neutral and does not affect change totals. */
function renderDiff(diff: FileDiff, 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 displayText(diff.oldText).split('\n')) lines.push(palette.error(`- ${line}`))
let added = 0
let removed = 0
if (diff.oldText === null) {
const newLines = diffValueLines(diff.newText)
added = newLines.length
for (const line of newLines) lines.push(palette.success(`+ ${line}`))
return { lines, added, removed }
}
for (const line of displayText(diff.newText).split('\n')) lines.push(palette.success(`+ ${line}`))
return lines
for (const change of compareLines(diff.oldText, diff.newText)) {
const changedLines = diffValueLines(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 }
}
/**
@@ -505,9 +535,10 @@ export class ToolCardComponent implements Component {
let added = 0
let removed = 0
const hunks = view.diffs.flatMap((diff, index) => {
if (diff.oldText !== null) removed += displayText(diff.oldText).split('\n').length
added += displayText(diff.newText).split('\n').length
return [...index > 0 ? [''] : [], ...diffLines(diff, this.palette)]
const rendered = renderDiff(diff, this.palette)
added += rendered.added
removed += rendered.removed
return [...index > 0 ? [''] : [], ...rendered.lines]
})
const files = view.diffs.length
const footer = this.palette.dim(`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}`)

View File

@@ -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"

View File

@@ -1,7 +1,7 @@
terminal 100x40 buffer=normal length=43 base=3 viewport=3
terminal 100x40 buffer=normal length=42 base=2 viewport=2
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=41
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-magenta bold
@@ -37,54 +37,52 @@ 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| "Model wait 0.0s "
style 0-14 dim
38| <blank>
39| "Tool and context cards expanded. "
37| <blank>
38| "Tool and context cards expanded. "
style 0-31 dim
40| <blank>
41| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
39| <blank>
40| "/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 > "
41| " dsh > "
style 1-3 fg=bright-magenta bold
style 5-6 dim
style 7-7 inverse

View File

@@ -4315,7 +4315,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',
}],
}),
},
generic: {
@@ -4622,7 +4626,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' },
@@ -4638,9 +4642,12 @@ 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)
})