|
|
@@ -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 }
|
|
|
+ }
|
|
|
+ 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}`))
|
|
|
+ }
|
|
|
}
|
|
|
- for (const line of diffContentLines(displayText(diff.newText))) lines.push(palette.success(`+ ${line}`))
|
|
|
- return lines
|
|
|
+ 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
|