opentui(v6): file tool renderer — relative path + full native diff

This commit is contained in:
alt-glitch
2026-06-10 16:48:15 +05:30
parent 99d163a8ae
commit e17e94c8de
11 changed files with 519 additions and 74 deletions
+79
View File
@@ -0,0 +1,79 @@
/**
* Unit tests for the pure diff helpers (Epic 2.3 — logic/diff.ts): `+N M`
* counting (file headers excluded, trailing newline optional), cwd-relative
* paths (exact prefix strip only — no `~`), and per-file splitting of
* multi-file unified diffs (the native DiffRenderable parses only the first
* file, so the renderer feeds it one section at a time).
*/
import { describe, expect, test } from 'vitest'
import { diffStats, relativizePath, splitUnifiedDiff } from '../logic/diff.ts'
const ONE_FILE = ['--- a/src/main.ts', '+++ b/src/main.ts', '@@ -1,3 +1,4 @@', ' ctx', '-old', '+new', '+more'].join(
'\n'
)
describe('diffStats', () => {
test('counts added/removed lines, excluding the +++/--- file headers', () => {
expect(diffStats(ONE_FILE + '\n')).toEqual({ added: 2, removed: 1 })
})
test('handles a diff without a trailing newline', () => {
expect(diffStats(ONE_FILE)).toEqual({ added: 2, removed: 1 })
})
test('a multi-file diff counts headers of every file out', () => {
const diff = `${ONE_FILE}\n--- a/b.py\n+++ b/b.py\n@@ -1 +1 @@\n-x\n+y\n`
expect(diffStats(diff)).toEqual({ added: 3, removed: 2 })
})
test('empty diff → zero stats', () => {
expect(diffStats('')).toEqual({ added: 0, removed: 0 })
})
})
describe('relativizePath', () => {
test.each([
// inside cwd → relative
['/home/u/proj/src/main.ts', '/home/u/proj', 'src/main.ts'],
// outside cwd → unchanged
['/etc/hosts', '/home/u/proj', '/etc/hosts'],
// exactly the cwd → '.'
['/home/u/proj', '/home/u/proj', '.'],
// trailing slash on cwd tolerated
['/home/u/proj/a.txt', '/home/u/proj/', 'a.txt'],
// sibling dir sharing the prefix string is NOT inside cwd
['/home/u/proj2/a.txt', '/home/u/proj', '/home/u/proj2/a.txt'],
// no cwd → unchanged (and already-relative paths pass through)
['src/main.ts', undefined, 'src/main.ts']
])('%s relative to %s → %s', (path, cwd, expected) => {
expect(relativizePath(path, cwd)).toBe(expected)
})
})
describe('splitUnifiedDiff', () => {
test('single-file diff → one section with the b/ path stripped', () => {
const sections = splitUnifiedDiff(ONE_FILE + '\n')
expect(sections).toHaveLength(1)
expect(sections[0]?.path).toBe('src/main.ts')
expect(sections[0]?.diff).toBe(ONE_FILE)
})
test('multi-file diff splits at the next ---/+++ header pair', () => {
const second = ['--- a/b.py', '+++ b/b.py', '@@ -1 +1 @@', '-x', '+y'].join('\n')
const sections = splitUnifiedDiff(`${ONE_FILE}\n${second}\n`)
expect(sections.map(s => s.path)).toEqual(['src/main.ts', 'b.py'])
expect(sections[1]?.diff).toBe(second)
})
test('a removed line starting with --- does not split the file', () => {
const tricky = ['--- a/x.md', '+++ b/x.md', '@@ -1,2 +1,1 @@', '--- a heading rule', ' kept'].join('\n')
const sections = splitUnifiedDiff(tricky)
expect(sections).toHaveLength(1)
})
test('new-file diff (--- /dev/null) takes the +++ path', () => {
const created = ['--- /dev/null', '+++ b/new.txt', '@@ -0,0 +1 @@', '+hello'].join('\n')
expect(splitUnifiedDiff(created)[0]?.path).toBe('new.txt')
})
})
+85
View File
@@ -235,6 +235,91 @@ describe('bash tool renderer — command + full output (Epic 2.4)', () => {
})
})
describe('file tool renderer — relative path + diff stats (Epic 2.3)', () => {
// NOTE: the EXPANDED native <diff> is deliberately untested here — like
// <markdown> it tokenizes via Tree-sitter ASYNCHRONOUSLY and may not settle
// in the headless renderer. The diff visuals belong to the live smoke; these
// tests pin the LOGIC surface (collapsed header, fallback body).
const DIFF = ['--- a/src/main.ts', '+++ b/src/main.ts', '@@ -1,3 +1,4 @@', ' ctx', '-old', '+new', '+more'].join('\n')
test('collapsed write_file shows the cwd-RELATIVE path and the themed +N M stats', async () => {
const store = createSessionStore()
store.apply({ type: 'session.info', payload: { cwd: '/home/u/proj' } })
seedTool(
store,
{ tool_id: 'f1', name: 'write_file', context: '/home/u/proj/src/main.ts' },
{
tool_id: 'f1',
name: 'write_file',
args: { path: '/home/u/proj/src/main.ts', content: 'new\nmore\n' },
diff_unified: DIFF + '\n',
duration_s: 0.1,
result: '{"success": true}'
}
)
const probe = await mountApp(store)
try {
const frame = await probe.waitForFrame(f => f.includes('write_file'))
expect(frame).toContain('src/main.ts') // relative to the session cwd…
expect(frame).not.toContain('/home/u/proj/src/main.ts') // …never absolute
expect(frame).toContain('+2') // added (excludes the +++ header)
expect(frame).toContain('1') // removed (excludes the --- header)
} finally {
probe.destroy()
}
})
test('read_file gets NO diff body — expanded falls back to labeled fields + output', async () => {
const store = createSessionStore()
store.apply({ type: 'session.info', payload: { cwd: '/home/u/proj' } })
seedTool(
store,
{ tool_id: 'f2', name: 'read_file' },
{
tool_id: 'f2',
name: 'read_file',
args: { path: '/home/u/proj/notes.md', limit: 50 },
result_text: '1|# Notes\n2|hello'
}
)
const probe = await mountApp(store)
try {
const collapsed = await probe.waitForFrame(f => f.includes('read_file'))
expect(collapsed).toContain('notes.md') // relpath subtitle
expect(collapsed).not.toContain('+0') // no diff → no stats summary
await clickHeader(probe, 'read_file')
const expanded = await probe.waitForFrame(f => f.includes('limit'))
expect(expanded).toContain('path') // default labeled fields…
expect(expanded).toContain('50')
expect(expanded).toContain('# Notes') // …and the output body
expect(expanded).not.toContain('@@') // never a diff
} finally {
probe.destroy()
}
})
test('store: tool.complete diff_unified lands on the part with computed stats', () => {
const store = createSessionStore()
seedTool(
store,
{ tool_id: 'f3', name: 'patch' },
{
tool_id: 'f3',
name: 'patch',
args: { mode: 'replace', path: 'x.py' },
diff_unified: DIFF
}
)
const last = store.state.messages[store.state.messages.length - 1]
const part = last?.parts?.find((p): p is ToolPartState => p.type === 'tool' && p.id === 'f3')
expect(part?.diffUnified).toBe(DIFF)
expect(part?.diffStats).toEqual({ added: 2, removed: 1 })
})
})
describe('redaction precedence — gateway args_text wins over raw args (security)', () => {
// The gateway redacts verbose `args_text` (server.py _tool_args_text) but
// sends the raw `args` dict on tool.complete UNREDACTED. structuredArgs must