opentui(v6): file tool renderer — relative path + full native diff
This commit is contained in:
parent
f76df0688c
commit
c4348480f3
87
ui-opentui/src/logic/diff.ts
Normal file
87
ui-opentui/src/logic/diff.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Pure unified-diff helpers for the file-tool renderer (Epic 2.3). No
|
||||||
|
* OpenTUI/Solid imports — just string work, trivially unit-testable (like
|
||||||
|
* `toolOutput.ts`). The gateway ships the FULL raw unified diff on file-edit
|
||||||
|
* `tool.complete` (`diff_unified`); these helpers turn it into the collapsed
|
||||||
|
* `+N −M` summary and per-file sections for the native `<diff>` renderable
|
||||||
|
* (which parses only the FIRST file of a multi-file diff — so we split).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Added/removed line counts for the collapsed header summary (`+N −M`). */
|
||||||
|
export interface DiffStats {
|
||||||
|
added: number
|
||||||
|
removed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count changed lines in a unified diff, excluding the `+++`/`---` file headers. */
|
||||||
|
export function diffStats(diff: string): DiffStats {
|
||||||
|
let added = 0
|
||||||
|
let removed = 0
|
||||||
|
for (const line of diff.split('\n')) {
|
||||||
|
if (line.startsWith('+++') || line.startsWith('---')) continue
|
||||||
|
if (line.startsWith('+')) added++
|
||||||
|
else if (line.startsWith('-')) removed++
|
||||||
|
}
|
||||||
|
return { added, removed }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path relative to the session cwd: exact prefix strip only (no `~` for home —
|
||||||
|
* deliberately simple). Paths outside cwd come back unchanged; the cwd itself
|
||||||
|
* becomes `.`. A trailing slash on cwd is tolerated.
|
||||||
|
*/
|
||||||
|
export function relativizePath(path: string, cwd?: string): string {
|
||||||
|
if (!path || !cwd) return path
|
||||||
|
const base = cwd.endsWith('/') && cwd !== '/' ? cwd.slice(0, -1) : cwd
|
||||||
|
if (path === base) return '.'
|
||||||
|
const prefix = base === '/' ? '/' : base + '/'
|
||||||
|
if (path.startsWith(prefix)) return path.slice(prefix.length) || '.'
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One file's section of a (possibly multi-file) unified diff. */
|
||||||
|
export interface DiffFileSection {
|
||||||
|
/** Target path from the `+++ b/…` header (or `--- a/…` for deletions); '' if unknown. */
|
||||||
|
path: string
|
||||||
|
/** The section's unified diff text, parseable on its own. */
|
||||||
|
diff: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract the path from a `--- a/x` / `+++ b/x` header line ('' for /dev/null). */
|
||||||
|
function headerPath(line: string): string {
|
||||||
|
let p = line.slice(4).trim()
|
||||||
|
const tab = p.indexOf('\t') // difflib may append a date after a tab
|
||||||
|
if (tab !== -1) p = p.slice(0, tab)
|
||||||
|
if (!p || p === '/dev/null') return ''
|
||||||
|
if (p.startsWith('a/') || p.startsWith('b/')) p = p.slice(2)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionPath(lines: string[]): string {
|
||||||
|
const to = lines.find(l => l.startsWith('+++ '))
|
||||||
|
const from = lines.find(l => l.startsWith('--- '))
|
||||||
|
return (to ? headerPath(to) : '') || (from ? headerPath(from) : '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a unified diff into per-file sections (the gateway concatenates one
|
||||||
|
* difflib diff per edited file; `patch`-mode diffs can also be multi-file). A
|
||||||
|
* new section starts at a `--- ` header — required to be FOLLOWED by `+++ `
|
||||||
|
* and to come after the current section's hunks, so removed lines that merely
|
||||||
|
* start with `--` can't split a file in half.
|
||||||
|
*/
|
||||||
|
export function splitUnifiedDiff(diff: string): DiffFileSection[] {
|
||||||
|
const lines = diff.replace(/\n$/, '').split('\n')
|
||||||
|
const sections: string[][] = []
|
||||||
|
let current: string[] = []
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i] ?? ''
|
||||||
|
if (current.some(l => l.startsWith('@@')) && line.startsWith('--- ') && (lines[i + 1] ?? '').startsWith('+++ ')) {
|
||||||
|
sections.push(current)
|
||||||
|
current = []
|
||||||
|
}
|
||||||
|
current.push(line)
|
||||||
|
}
|
||||||
|
if (current.length > 0) sections.push(current)
|
||||||
|
return sections.filter(s => s.some(l => l.startsWith('@@'))).map(s => ({ diff: s.join('\n'), path: sectionPath(s) }))
|
||||||
|
}
|
||||||
@ -21,6 +21,7 @@ import {
|
|||||||
type CatalogDecoded,
|
type CatalogDecoded,
|
||||||
type SessionInfoPatchDecoded
|
type SessionInfoPatchDecoded
|
||||||
} from '../boundary/schema/SessionInfo.ts'
|
} from '../boundary/schema/SessionInfo.ts'
|
||||||
|
import { diffStats, type DiffStats } from './diff.ts'
|
||||||
import { stripAnsi, stripOmittedNote, stripToolEnvelope } from './toolOutput.ts'
|
import { stripAnsi, stripOmittedNote, stripToolEnvelope } from './toolOutput.ts'
|
||||||
import { DEFAULT_THEME, type Theme, themeFromSkin } from './theme.ts'
|
import { DEFAULT_THEME, type Theme, themeFromSkin } from './theme.ts'
|
||||||
|
|
||||||
@ -46,6 +47,10 @@ export interface ToolPartState {
|
|||||||
duration?: number
|
duration?: number
|
||||||
/** Tidy note when the gateway truncated output (e.g. "5 lines / 234 chars"). */
|
/** Tidy note when the gateway truncated output (e.g. "5 lines / 234 chars"). */
|
||||||
omittedNote?: string
|
omittedNote?: string
|
||||||
|
/** FULL raw unified diff from file-edit tools (gateway `diff_unified`, 512KB-capped). */
|
||||||
|
diffUnified?: string
|
||||||
|
/** `+N −M` line counts derived from diffUnified (collapsed header summary). */
|
||||||
|
diffStats?: DiffStats
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One ordered piece of an assistant turn (§7). */
|
/** One ordered piece of an assistant turn (§7). */
|
||||||
@ -655,6 +660,9 @@ export function createSessionStore() {
|
|||||||
// when verbose `args_text` wasn't captured on start. `duration_s` → header.
|
// when verbose `args_text` wasn't captured on start. `duration_s` → header.
|
||||||
const argsObj = event.payload['args']
|
const argsObj = event.payload['args']
|
||||||
const duration = readOptNum(event.payload, 'duration_s')
|
const duration = readOptNum(event.payload, 'duration_s')
|
||||||
|
// FULL raw unified diff (file-edit tools; gateway caps at 512KB). Stats
|
||||||
|
// are computed once here, not per render.
|
||||||
|
const diffUnified = readStr(event.payload, 'diff_unified')
|
||||||
setState(
|
setState(
|
||||||
produce(draft => {
|
produce(draft => {
|
||||||
let part = findToolPart(draft, id)
|
let part = findToolPart(draft, id)
|
||||||
@ -671,6 +679,10 @@ export function createSessionStore() {
|
|||||||
if (error) part.error = error
|
if (error) part.error = error
|
||||||
if (duration !== undefined) part.duration = duration
|
if (duration !== undefined) part.duration = duration
|
||||||
if (omittedNote) part.omittedNote = omittedNote
|
if (omittedNote) part.omittedNote = omittedNote
|
||||||
|
if (diffUnified) {
|
||||||
|
part.diffUnified = diffUnified
|
||||||
|
part.diffStats = diffStats(diffUnified)
|
||||||
|
}
|
||||||
// argsPreview (from tool.start `context`) is intentionally NOT overwritten.
|
// argsPreview (from tool.start `context`) is intentionally NOT overwritten.
|
||||||
if (argsObj && typeof argsObj === 'object') {
|
if (argsObj && typeof argsObj === 'object') {
|
||||||
// structured args feed the per-tool renderers (labeled fields, bash command).
|
// structured args feed the per-tool renderers (labeled fields, bash command).
|
||||||
|
|||||||
@ -48,6 +48,13 @@ export interface ThemeColors {
|
|||||||
diffRemoved: string
|
diffRemoved: string
|
||||||
diffAddedWord: string
|
diffAddedWord: string
|
||||||
diffRemovedWord: string
|
diffRemovedWord: string
|
||||||
|
// Line backgrounds for the NATIVE `<diff>` renderable (file-tool renderer).
|
||||||
|
// Separate from the Ink-parity diffAdded/diffRemoved pair above: those are
|
||||||
|
// `rgb(…)` strings (Ink parses them; OpenTUI's parseColor only takes hex /
|
||||||
|
// CSS names / "transparent"), and they're pastel full-line fills tuned for
|
||||||
|
// Ink's fg-on-bg rendering — the native diff wants darker hex backgrounds.
|
||||||
|
diffAddedBg: string
|
||||||
|
diffRemovedBg: string
|
||||||
|
|
||||||
shellDollar: string
|
shellDollar: string
|
||||||
}
|
}
|
||||||
@ -272,6 +279,8 @@ export const DARK_THEME: Theme = {
|
|||||||
diffRemoved: 'rgb(255,220,220)',
|
diffRemoved: 'rgb(255,220,220)',
|
||||||
diffAddedWord: 'rgb(36,138,61)',
|
diffAddedWord: 'rgb(36,138,61)',
|
||||||
diffRemovedWord: 'rgb(207,34,46)',
|
diffRemovedWord: 'rgb(207,34,46)',
|
||||||
|
diffAddedBg: '#1a4d1a',
|
||||||
|
diffRemovedBg: '#4d1a1a',
|
||||||
shellDollar: '#4dabf7'
|
shellDollar: '#4dabf7'
|
||||||
},
|
},
|
||||||
brand: BRAND,
|
brand: BRAND,
|
||||||
@ -312,6 +321,8 @@ export const LIGHT_THEME: Theme = {
|
|||||||
diffRemoved: 'rgb(240,200,200)',
|
diffRemoved: 'rgb(240,200,200)',
|
||||||
diffAddedWord: 'rgb(27,94,32)',
|
diffAddedWord: 'rgb(27,94,32)',
|
||||||
diffRemovedWord: 'rgb(183,28,28)',
|
diffRemovedWord: 'rgb(183,28,28)',
|
||||||
|
diffAddedBg: '#c8f0c8',
|
||||||
|
diffRemovedBg: '#f0c8c8',
|
||||||
shellDollar: '#1565C0'
|
shellDollar: '#1565C0'
|
||||||
},
|
},
|
||||||
brand: BRAND,
|
brand: BRAND,
|
||||||
@ -467,6 +478,8 @@ export function fromSkin(
|
|||||||
diffRemoved: d.color.diffRemoved,
|
diffRemoved: d.color.diffRemoved,
|
||||||
diffAddedWord: d.color.diffAddedWord,
|
diffAddedWord: d.color.diffAddedWord,
|
||||||
diffRemovedWord: d.color.diffRemovedWord,
|
diffRemovedWord: d.color.diffRemovedWord,
|
||||||
|
diffAddedBg: c('diff_added_bg') ?? d.color.diffAddedBg,
|
||||||
|
diffRemovedBg: c('diff_removed_bg') ?? d.color.diffRemovedBg,
|
||||||
shellDollar: c('shell_dollar') ?? d.color.shellDollar
|
shellDollar: c('shell_dollar') ?? d.color.shellDollar
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
79
ui-opentui/src/test/diff.test.ts
Normal file
79
ui-opentui/src/test/diff.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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)', () => {
|
describe('redaction precedence — gateway args_text wins over raw args (security)', () => {
|
||||||
// The gateway redacts verbose `args_text` (server.py _tool_args_text) but
|
// The gateway redacts verbose `args_text` (server.py _tool_args_text) but
|
||||||
// sends the raw `args` dict on tool.complete UNREDACTED. structuredArgs must
|
// sends the raw `args` dict on tool.complete UNREDACTED. structuredArgs must
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import { Pager } from './overlays/pager.tsx'
|
|||||||
import { Picker } from './overlays/picker.tsx'
|
import { Picker } from './overlays/picker.tsx'
|
||||||
import { SessionSwitcher } from './overlays/sessionSwitcher.tsx'
|
import { SessionSwitcher } from './overlays/sessionSwitcher.tsx'
|
||||||
import { PromptOverlay } from './prompts/promptOverlay.tsx'
|
import { PromptOverlay } from './prompts/promptOverlay.tsx'
|
||||||
|
import { SessionInfoProvider } from './sessionInfo.tsx'
|
||||||
import { StatusBar } from './statusBar.tsx'
|
import { StatusBar } from './statusBar.tsx'
|
||||||
import { StatusLine } from './statusLine.tsx'
|
import { StatusLine } from './statusLine.tsx'
|
||||||
import { useTheme } from './theme.tsx'
|
import { useTheme } from './theme.tsx'
|
||||||
@ -69,6 +70,9 @@ export function App(props: AppProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DimensionsProvider>
|
<DimensionsProvider>
|
||||||
|
{/* live session chrome (cwd, model, …) for deep nodes — e.g. the file-tool
|
||||||
|
renderer relativizes paths against `info.cwd` (Epic 2.3). */}
|
||||||
|
<SessionInfoProvider info={() => props.store.state.info}>
|
||||||
<box style={{ flexDirection: 'column', flexGrow: 1, paddingTop: 1, paddingLeft: 1, paddingRight: 1 }}>
|
<box style={{ flexDirection: 'column', flexGrow: 1, paddingTop: 1, paddingLeft: 1, paddingRight: 1 }}>
|
||||||
{/* a bottom rule under the header bookends the transcript with the status
|
{/* a bottom rule under the header bookends the transcript with the status
|
||||||
bar's top rule — frames the chrome as intentional (item 8). */}
|
bar's top rule — frames the chrome as intentional (item 8). */}
|
||||||
@ -138,6 +142,7 @@ export function App(props: AppProps) {
|
|||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</box>
|
</box>
|
||||||
|
</SessionInfoProvider>
|
||||||
</DimensionsProvider>
|
</DimensionsProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,7 +51,9 @@ function buildSyntaxStyle(theme: Theme): SyntaxStyle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cache: { theme: Theme; style: SyntaxStyle } | undefined
|
let cache: { theme: Theme; style: SyntaxStyle } | undefined
|
||||||
function syntaxStyleFor(theme: Theme): SyntaxStyle {
|
/** Theme-derived SyntaxStyle, cached by theme identity — shared with the
|
||||||
|
* file-tool `<diff>` renderable (one instance per skin, same as markdown). */
|
||||||
|
export function syntaxStyleFor(theme: Theme): SyntaxStyle {
|
||||||
if (cache && cache.theme === theme) return cache.style
|
if (cache && cache.theme === theme) return cache.style
|
||||||
const style = buildSyntaxStyle(theme)
|
const style = buildSyntaxStyle(theme)
|
||||||
cache = { style, theme }
|
cache = { style, theme }
|
||||||
|
|||||||
24
ui-opentui/src/view/sessionInfo.tsx
Normal file
24
ui-opentui/src/view/sessionInfo.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* SessionInfoProvider — exposes the live `SessionInfo` (store `state.info`) to
|
||||||
|
* deep view nodes WITHOUT threading the store through every layer (same pattern
|
||||||
|
* as dimensions.tsx). First consumer: the file-tool renderer, which relativizes
|
||||||
|
* paths against the session `cwd` (Epic 2.3). The fallback accessor (no
|
||||||
|
* provider, e.g. a bare component test) is an empty info — consumers must treat
|
||||||
|
* every field as optional anyway.
|
||||||
|
*/
|
||||||
|
import { type Accessor, createContext, type JSX, useContext } from 'solid-js'
|
||||||
|
|
||||||
|
import type { SessionInfo } from '../logic/store.ts'
|
||||||
|
|
||||||
|
const Ctx = createContext<Accessor<SessionInfo>>()
|
||||||
|
const EMPTY: SessionInfo = {}
|
||||||
|
const EMPTY_INFO: Accessor<SessionInfo> = () => EMPTY
|
||||||
|
|
||||||
|
export function SessionInfoProvider(props: { info: Accessor<SessionInfo>; children: JSX.Element }) {
|
||||||
|
return <Ctx.Provider value={props.info}>{props.children}</Ctx.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The live session info accessor (empty info when no provider is mounted). */
|
||||||
|
export function useSessionInfo(): Accessor<SessionInfo> {
|
||||||
|
return useContext(Ctx) ?? EMPTY_INFO
|
||||||
|
}
|
||||||
@ -20,6 +20,7 @@ import { createSignal, Show } from 'solid-js'
|
|||||||
|
|
||||||
import { truncate } from '../logic/toolOutput.ts'
|
import { truncate } from '../logic/toolOutput.ts'
|
||||||
import { useScrollAnchor } from './scrollAnchor.tsx'
|
import { useScrollAnchor } from './scrollAnchor.tsx'
|
||||||
|
import { useSessionInfo } from './sessionInfo.tsx'
|
||||||
import { useTheme } from './theme.tsx'
|
import { useTheme } from './theme.tsx'
|
||||||
import { resultLines } from './tools/defaultTool.tsx'
|
import { resultLines } from './tools/defaultTool.tsx'
|
||||||
import { rendererFor } from './tools/registry.tsx'
|
import { rendererFor } from './tools/registry.tsx'
|
||||||
@ -37,6 +38,7 @@ function fmtDuration(s: number): string {
|
|||||||
export function ToolPart(props: { part: ToolPartState }) {
|
export function ToolPart(props: { part: ToolPartState }) {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const dims = useDimensions()
|
const dims = useDimensions()
|
||||||
|
const info = useSessionInfo() // session cwd for path-relativizing renderers
|
||||||
const anchor = useScrollAnchor()
|
const anchor = useScrollAnchor()
|
||||||
const [expanded, setExpanded] = createSignal(false)
|
const [expanded, setExpanded] = createSignal(false)
|
||||||
const toggle = () => anchor(() => setExpanded(e => !e))
|
const toggle = () => anchor(() => setExpanded(e => !e))
|
||||||
@ -49,8 +51,10 @@ export function ToolPart(props: { part: ToolPartState }) {
|
|||||||
// Expandable when the renderer says there's a body to reveal beyond the header.
|
// Expandable when the renderer says there's a body to reveal beyond the header.
|
||||||
const collapsible = () => !running() && renderer().expandable(props.part)
|
const collapsible = () => !running() && renderer().expandable(props.part)
|
||||||
// Header subtitle: errors win; otherwise the renderer's collapsed summary.
|
// Header subtitle: errors win; otherwise the renderer's collapsed summary.
|
||||||
const subtitle = () => (props.part.error ? `✗ ${props.part.error}` : renderer().subtitle(props.part))
|
const subtitle = () => (props.part.error ? `✗ ${props.part.error}` : renderer().subtitle(props.part, info().cwd))
|
||||||
const hint = () => renderer().hint?.(props.part)
|
const hint = () => renderer().hint?.(props.part)
|
||||||
|
// Optional `+N −M` change summary (file tools) — themed, settled parts only.
|
||||||
|
const stats = () => (running() || props.part.error ? undefined : renderer().stats?.(props.part))
|
||||||
|
|
||||||
const headGlyph = () => (collapsible() ? (expanded() ? '▼' : '▶') : '⚡')
|
const headGlyph = () => (collapsible() ? (expanded() ? '▼' : '▶') : '⚡')
|
||||||
// accent glyph MARKS the tool (draws the eye); the rest is muted so tools read
|
// accent glyph MARKS the tool (draws the eye); the rest is muted so tools read
|
||||||
@ -84,6 +88,16 @@ export function ToolPart(props: { part: ToolPartState }) {
|
|||||||
{` ${truncate(subtitle(), subWidth())}`}
|
{` ${truncate(subtitle(), subWidth())}`}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
|
<Show when={stats()}>
|
||||||
|
{/* `+N −M` change summary (file tools) — added in the ok/added color,
|
||||||
|
removed in the error/removed color (themed, never hardcoded). */}
|
||||||
|
{s => (
|
||||||
|
<>
|
||||||
|
<span style={{ fg: theme().color.ok }}>{` +${s().added}`}</span>
|
||||||
|
<span style={{ fg: theme().color.error }}>{` −${s().removed}`}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
<Show when={hint()}>
|
<Show when={hint()}>
|
||||||
{/* per-tool muted hint (e.g. delegate_task's "(/agents to monitor)") —
|
{/* per-tool muted hint (e.g. delegate_task's "(/agents to monitor)") —
|
||||||
shown while running too, Ink parity. */}
|
shown while running too, Ink parity. */}
|
||||||
@ -110,7 +124,7 @@ export function ToolPart(props: { part: ToolPartState }) {
|
|||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
const Body = renderer().Body
|
const Body = renderer().Body
|
||||||
return <Body part={props.part} width={bodyWidth() - 2} />
|
return <Body part={props.part} width={bodyWidth() - 2} cwd={info().cwd} />
|
||||||
})()}
|
})()}
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
112
ui-opentui/src/view/tools/fileTool.tsx
Normal file
112
ui-opentui/src/view/tools/fileTool.tsx
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* FileTool — renderer for the file tools `write_file`, `patch`, `read_file` and
|
||||||
|
* `skill_manage` (Epic 2.3). Collapsed: the file path RELATIVE to the session
|
||||||
|
* cwd, plus a themed `+N −M` change summary (rendered by the shell from
|
||||||
|
* `stats`). Expanded: the FULL unified diff (gateway `diff_unified`, 512KB-
|
||||||
|
* capped) through the NATIVE `<diff>` renderable — unified view (the transcript
|
||||||
|
* is column-constrained), word-wrapped, line-numbered, themed. Multi-file diffs
|
||||||
|
* are split per file (DiffRenderable parses only the FIRST file of a multi-file
|
||||||
|
* diff) with a path label above each section. `read_file` (or any run without a
|
||||||
|
* diff) falls back to the default labeled fields + output body.
|
||||||
|
*
|
||||||
|
* Arg keys verified against the Python tool schemas (tools/file_tools.py
|
||||||
|
* READ_FILE_SCHEMA / WRITE_FILE_SCHEMA / PATCH_SCHEMA: `path`;
|
||||||
|
* tools/skill_manager_tool.py skill_manage: `file_path`). patch-mode `patch`
|
||||||
|
* calls have no path arg → gateway argsPreview fallback.
|
||||||
|
*
|
||||||
|
* Sizing: the `<diff>` gets NO height — like opencode's Edit() it sizes to its
|
||||||
|
* content (the unified view is one auto-height code pane), so it never scrolls
|
||||||
|
* internally against the transcript's outer <scrollbox>.
|
||||||
|
*/
|
||||||
|
import { pathToFiletype } from '@opentui/core'
|
||||||
|
import { createMemo, For, Show } from 'solid-js'
|
||||||
|
|
||||||
|
import { type DiffFileSection, relativizePath, splitUnifiedDiff } from '../../logic/diff.ts'
|
||||||
|
import type { ToolPartState } from '../../logic/store.ts'
|
||||||
|
import { syntaxStyleFor } from '../markdown.tsx'
|
||||||
|
import { useTheme } from '../theme.tsx'
|
||||||
|
import { DefaultToolBody, defaultRenderer, defaultSubtitle, structuredArgs, ToolOutputBlock } from './defaultTool.tsx'
|
||||||
|
import type { ToolBodyProps, ToolRenderer } from './registry.tsx'
|
||||||
|
|
||||||
|
/** The tool's target path: `path` (file tools) / `file_path` (skill_manage),
|
||||||
|
* via structuredArgs (prefers gateway-redacted argsText); else argsPreview. */
|
||||||
|
export function filePathOf(part: ToolPartState): string {
|
||||||
|
const args = structuredArgs(part)
|
||||||
|
const p = args?.['path'] ?? args?.['file_path']
|
||||||
|
if (typeof p === 'string' && p.trim()) return p
|
||||||
|
return part.argsPreview ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the settled output adds nothing over the rendered diff: file-edit
|
||||||
|
* results are JSON records whose payload IS the diff (`patch` returns
|
||||||
|
* `{success, diff, …}`) — re-printing that below the native diff is noise.
|
||||||
|
* Plain-text results (lint warnings, errors, capped tails) still show.
|
||||||
|
*/
|
||||||
|
function outputRedundantWithDiff(part: ToolPartState): boolean {
|
||||||
|
const r = (part.resultText ?? '').trim()
|
||||||
|
if (!r) return true
|
||||||
|
if (!r.startsWith('{')) return false
|
||||||
|
try {
|
||||||
|
const o: unknown = JSON.parse(r)
|
||||||
|
return Boolean(o && typeof o === 'object' && typeof (o as Record<string, unknown>)['diff'] === 'string')
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One file's diff: an optional path label (chrome) + the native `<diff>`. */
|
||||||
|
function FileDiff(props: { file: DiffFileSection; label: boolean; cwd?: string | undefined }) {
|
||||||
|
const theme = useTheme()
|
||||||
|
return (
|
||||||
|
<box style={{ flexDirection: 'column', flexShrink: 0, minWidth: 0 }}>
|
||||||
|
<Show when={props.label && props.file.path}>
|
||||||
|
{/* per-file section label (multi-file diffs) — chrome, not content */}
|
||||||
|
<text selectable={false}>
|
||||||
|
<span style={{ fg: theme().color.label }}>{relativizePath(props.file.path, props.cwd)}</span>
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
<diff
|
||||||
|
diff={props.file.diff}
|
||||||
|
view="unified"
|
||||||
|
wrapMode="word"
|
||||||
|
showLineNumbers
|
||||||
|
width="100%"
|
||||||
|
filetype={pathToFiletype(props.file.path)}
|
||||||
|
syntaxStyle={syntaxStyleFor(theme())}
|
||||||
|
fg={theme().color.text}
|
||||||
|
addedBg={theme().color.diffAddedBg}
|
||||||
|
removedBg={theme().color.diffRemovedBg}
|
||||||
|
addedSignColor={theme().color.ok}
|
||||||
|
removedSignColor={theme().color.error}
|
||||||
|
lineNumberFg={theme().color.muted}
|
||||||
|
selectionBg={theme().color.selectionBg}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expanded body: per-file native diffs (+ non-redundant output), else default. */
|
||||||
|
export function FileToolBody(props: ToolBodyProps) {
|
||||||
|
const files = createMemo(() => (props.part.diffUnified ? splitUnifiedDiff(props.part.diffUnified) : []))
|
||||||
|
return (
|
||||||
|
<Show when={files().length > 0} fallback={<DefaultToolBody part={props.part} width={props.width} />}>
|
||||||
|
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0 }}>
|
||||||
|
<For each={files()}>{file => <FileDiff file={file} label={files().length > 1} cwd={props.cwd} />}</For>
|
||||||
|
<Show when={!outputRedundantWithDiff(props.part)}>
|
||||||
|
<ToolOutputBlock part={props.part} width={props.width} label />
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fileRenderer: ToolRenderer = {
|
||||||
|
Body: FileToolBody,
|
||||||
|
// A diff is always hidden content worth expanding; otherwise same as default.
|
||||||
|
expandable: part => Boolean(part.diffUnified) || defaultRenderer.expandable(part),
|
||||||
|
// `+N −M` (store-computed from diffUnified) — themed by the shell.
|
||||||
|
stats: part => part.diffStats,
|
||||||
|
// The target path, relative to the session cwd.
|
||||||
|
subtitle: (part, cwd) => relativizePath(filePathOf(part), cwd) || defaultSubtitle(part)
|
||||||
|
}
|
||||||
@ -10,28 +10,34 @@
|
|||||||
* - `Body` — the expanded body (labeled arg fields / output / diff)
|
* - `Body` — the expanded body (labeled arg fields / output / diff)
|
||||||
*
|
*
|
||||||
* Unmapped tools (incl. MCP tools) fall back to the labeled-fields default
|
* Unmapped tools (incl. MCP tools) fall back to the labeled-fields default
|
||||||
* renderer — NEVER a raw JSON dump. To add a per-tool renderer (e.g.
|
* renderer — NEVER a raw JSON dump. To add a per-tool renderer: export a
|
||||||
* `fileTool.tsx` for read/write/edit path+diff — Epic 2.3): export a
|
* `ToolRenderer` from a sibling module and add its tool names to `TOOLS`
|
||||||
* `ToolRenderer` from a sibling module and add its tool names to `TOOLS`.
|
* (see `fileTool.tsx` — read/write/edit path + full diff, Epic 2.3).
|
||||||
*/
|
*/
|
||||||
import type { Component } from 'solid-js'
|
import type { Component } from 'solid-js'
|
||||||
|
|
||||||
|
import type { DiffStats } from '../../logic/diff.ts'
|
||||||
import type { ToolPartState } from '../../logic/store.ts'
|
import type { ToolPartState } from '../../logic/store.ts'
|
||||||
import { bashRenderer } from './bashTool.tsx'
|
import { bashRenderer } from './bashTool.tsx'
|
||||||
import { defaultRenderer } from './defaultTool.tsx'
|
import { defaultRenderer } from './defaultTool.tsx'
|
||||||
|
import { fileRenderer } from './fileTool.tsx'
|
||||||
|
|
||||||
/** Props every tool Body receives: the part + usable content columns. */
|
/** Props every tool Body receives: the part + usable content columns. */
|
||||||
export interface ToolBodyProps {
|
export interface ToolBodyProps {
|
||||||
part: ToolPartState
|
part: ToolPartState
|
||||||
/** Width (columns) available for body lines inside the bordered frame. */
|
/** Width (columns) available for body lines inside the bordered frame. */
|
||||||
width: number
|
width: number
|
||||||
|
/** Session cwd (from SessionInfoProvider) — file renderers relativize paths. */
|
||||||
|
cwd?: string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolRenderer {
|
export interface ToolRenderer {
|
||||||
/** Collapsed one-line subtitle (verbatim command, primary arg, …). */
|
/** Collapsed one-line subtitle (verbatim command, primary arg, relative path, …). */
|
||||||
subtitle: (part: ToolPartState) => string
|
subtitle: (part: ToolPartState, cwd?: string) => string
|
||||||
/** Optional muted header note (chrome) — e.g. delegate_task's "(/agents to monitor)". */
|
/** Optional muted header note (chrome) — e.g. delegate_task's "(/agents to monitor)". */
|
||||||
hint?: (part: ToolPartState) => string
|
hint?: (part: ToolPartState) => string
|
||||||
|
/** Optional `+N −M` change summary, themed by the shell next to the subtitle. */
|
||||||
|
stats?: (part: ToolPartState) => DiffStats | undefined
|
||||||
/** Whether the part has expandable content beyond the header (when settled). */
|
/** Whether the part has expandable content beyond the header (when settled). */
|
||||||
expandable: (part: ToolPartState) => boolean
|
expandable: (part: ToolPartState) => boolean
|
||||||
/** The expanded body, rendered inside the shared left-bordered frame. */
|
/** The expanded body, rendered inside the shared left-bordered frame. */
|
||||||
@ -45,7 +51,13 @@ const TOOLS: Record<string, ToolRenderer> = {
|
|||||||
// shell-ish tools (Epic 2.4): collapsed = the command verbatim; expanded = full output.
|
// shell-ish tools (Epic 2.4): collapsed = the command verbatim; expanded = full output.
|
||||||
execute_code: bashRenderer,
|
execute_code: bashRenderer,
|
||||||
process: bashRenderer,
|
process: bashRenderer,
|
||||||
terminal: bashRenderer
|
terminal: bashRenderer,
|
||||||
|
// file tools (Epic 2.3): collapsed = cwd-relative path + `+N −M`; expanded =
|
||||||
|
// the FULL native diff (write_file/patch/skill_manage) or labeled fields (read_file).
|
||||||
|
patch: fileRenderer,
|
||||||
|
read_file: fileRenderer,
|
||||||
|
skill_manage: fileRenderer,
|
||||||
|
write_file: fileRenderer
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the renderer for a tool name (default = labeled-fields fallback). */
|
/** Resolve the renderer for a tool name (default = labeled-fields fallback). */
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user