Merge remote-tracking branch 'origin/master' into feat/search-presenter
# Conflicts: # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/tools.i18n.yaml # docs/core-data-structures/tools.md # docs/core-data-structures/tools.zh.md # docs/event-producer-consumer.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/tools/README.i18n.yaml # packages/core/tools/README.md # packages/core/tools/README.zh.md # packages/core/tools/src/index.ts # packages/core/tools/src/presentation.ts # packages/ui/tui/src/components/transcript.ts
This commit is contained in:
@@ -52,15 +52,28 @@ function pretty(value: unknown): string {
|
||||
return displayText(serialized ?? String(value))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function diffContentLines(text: string): string[] {
|
||||
if (text === '') return []
|
||||
const body = text.endsWith('\n') ? text.slice(0, -1) : text
|
||||
return body.split('\n')
|
||||
}
|
||||
|
||||
/** A file diff as colored `+`/`-` lines, optionally prefixed with its path. */
|
||||
function diffLines(diff: FileDiff, palette: Palette): string[] {
|
||||
// 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}`))
|
||||
for (const line of diffContentLines(displayText(diff.oldText))) lines.push(palette.error(`- ${line}`))
|
||||
}
|
||||
for (const line of displayText(diff.newText).split('\n')) lines.push(palette.success(`+ ${line}`))
|
||||
for (const line of diffContentLines(displayText(diff.newText))) lines.push(palette.success(`+ ${line}`))
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -389,14 +402,16 @@ export class ToolCardComponent implements Component {
|
||||
const glyph = this.result === undefined ? '○' : '●'
|
||||
const rawBody = this.renderBody()
|
||||
const view = this.resultView ?? this.callView
|
||||
// A generic card's own content, or a search/web card's fallback to the raw
|
||||
// result content (neither the `search` nor the `web` view carries a `content`
|
||||
// copy), all render as one dim Markdown block below, so links/lists/headings
|
||||
// keep the unified dim styling rather than reading as bare text. A search card
|
||||
// thus stays byte-identical to the pre-search-card generic fallback. Terminal
|
||||
// and diff cards own their body styling, so they are excluded (mirrors
|
||||
// renderBody's post-terminal/diff fallback).
|
||||
const markdownContent = view.card === 'generic'
|
||||
// A generic card's own content, a read card's `content` fallback (the
|
||||
// envelope-stripped file text — the TUI has no dedicated read rendering, so a
|
||||
// read renders exactly as before the read card existed), or a search/web
|
||||
// card's fallback to the raw result content (neither the `search` nor the
|
||||
// `web` view carries a `content` copy), all render as one dim Markdown block
|
||||
// below, so links/lists/headings keep the unified dim styling rather than
|
||||
// reading as bare text. A search card thus stays byte-identical to the
|
||||
// pre-search-card generic fallback. Terminal and diff cards own their body
|
||||
// styling, so they are excluded (mirrors renderBody's post-terminal/diff fallback).
|
||||
const markdownContent = view.card === 'generic' || view.card === 'read'
|
||||
? view.content ?? this.result?.content
|
||||
: view.card === 'search'
|
||||
? this.result?.content
|
||||
@@ -505,25 +520,31 @@ export class ToolCardComponent implements Component {
|
||||
}
|
||||
if (view.card === 'diff') {
|
||||
// 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)`).
|
||||
// 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) => {
|
||||
if (diff.oldText !== null) removed += displayText(diff.oldText).split('\n').length
|
||||
added += displayText(diff.newText).split('\n').length
|
||||
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)]
|
||||
})
|
||||
const files = view.diffs.length
|
||||
const files = paths.size
|
||||
const footer = this.palette.dim(`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}`)
|
||||
// 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: [] }
|
||||
}
|
||||
// Neither a search card nor a web card carries a `content` copy, so those
|
||||
// result views fall back to the raw result content here (`view.card ===
|
||||
// 'generic'` narrows the generic union arm; a search or web card takes the
|
||||
// same fallback, mirroring the `markdownContent` selection in render()).
|
||||
const content = (view.card === 'generic' ? view.content : undefined) ?? this.result?.content
|
||||
// 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
|
||||
// result content here. (Mirrors the `markdownContent` selection in render();
|
||||
// a read card has no dedicated TUI rendering, so its `content` takes the same
|
||||
// body path, keeping read output as it was before the read card existed, and
|
||||
// a search card stays byte-identical to the pre-search-card fallback.)
|
||||
const content = (view.card === 'generic' || view.card === 'read' ? view.content : undefined) ?? this.result?.content
|
||||
const prelude: string[] = []
|
||||
const lines: string[] = []
|
||||
// The presenter title headlines the body now that the header is a fixed
|
||||
|
||||
@@ -4318,6 +4318,25 @@ describe('tool cards and surface replay', () => {
|
||||
diffs: [{ path: 'src/only.ts', oldText: 'old', newText: 'new' }],
|
||||
}),
|
||||
},
|
||||
scatteredDiff: {
|
||||
name: 'scatteredDiff', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
|
||||
// Three hunks in ONE file. The first two sides end in the terminator
|
||||
// newline real write/edit content carries; the third removes a line and
|
||||
// leaves an EMPTY added side (a full deletion), so `diffContentLines('')`
|
||||
// returns zero lines. The footer must read `+2 -1 · 1 file`: each trailing
|
||||
// newline terminates its line rather than adding a phantom empty one, the
|
||||
// empty side contributes no `+ ` row, and the three hunks count as the
|
||||
// single distinct path they touch.
|
||||
presentCall: () => ({
|
||||
card: 'diff',
|
||||
title: 'Edit src/scatter.ts',
|
||||
diffs: [
|
||||
{ path: 'src/scatter.ts', oldText: null, newText: 'first\n' },
|
||||
{ path: 'src/scatter.ts', oldText: null, newText: 'second\n' },
|
||||
{ path: 'src/scatter.ts', oldText: 'gone\n', newText: '' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
generic: {
|
||||
name: 'generic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
|
||||
presentCall: () => ({ card: 'generic', title: 'Inspect value', rawInput: { alpha: 1 } }),
|
||||
@@ -4672,6 +4691,35 @@ describe('tool cards and surface replay', () => {
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('counts a same-file diff once and terminates its trailing newline', async () => {
|
||||
// A budget past the card's row count so every hunk row stays visible (the
|
||||
// collapse arithmetic is covered elsewhere); this test is about the
|
||||
// terminator rule and the distinct-path footer count.
|
||||
const result = await setup({ tools, config: { maxToolOutputLines: 20 } })
|
||||
appendUser(result.session, 'scatter edits in one file')
|
||||
appendAssistant(result.session, [
|
||||
{ type: 'text', text: 'Editing' },
|
||||
{ type: 'tool-call', id: 'scatter' as never, name: 'scatteredDiff', arguments: '{}' },
|
||||
])
|
||||
result.session.append('tool/call', {
|
||||
turn: 1, step: 1, callId: 'scatter' as never, name: 'scatteredDiff', arguments: '{}',
|
||||
})
|
||||
await tick()
|
||||
const output = result.terminal.output
|
||||
// Three hunks, one path: distinct-path count, same as the Web DiffBlock.
|
||||
expect(output).toContain('· 1 file')
|
||||
expect(output).not.toContain('· 3 files')
|
||||
// The `first\n`/`second\n` sides each contribute exactly one added line —
|
||||
// the trailing newline terminates rather than adding a phantom empty `+ `.
|
||||
expect(output).toContain('+ first')
|
||||
expect(output).toContain('+ second')
|
||||
// The third hunk removes `gone` and leaves an empty added side, which
|
||||
// contributes no `+ ` row (diffContentLines('') is zero lines).
|
||||
expect(output).toContain('- gone')
|
||||
expect(output).toContain('+2 -1')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('drops blank rows from a terminal card result that the dim styling wraps', async () => {
|
||||
const blankRowTools: Record<string, ToolDefinition> = {
|
||||
trailing: {
|
||||
|
||||
Reference in New Issue
Block a user