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 f76df0688c
commit c4348480f3
11 changed files with 519 additions and 74 deletions

View 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) }))
}

View file

@ -21,6 +21,7 @@ import {
type CatalogDecoded,
type SessionInfoPatchDecoded
} from '../boundary/schema/SessionInfo.ts'
import { diffStats, type DiffStats } from './diff.ts'
import { stripAnsi, stripOmittedNote, stripToolEnvelope } from './toolOutput.ts'
import { DEFAULT_THEME, type Theme, themeFromSkin } from './theme.ts'
@ -46,6 +47,10 @@ export interface ToolPartState {
duration?: number
/** Tidy note when the gateway truncated output (e.g. "5 lines / 234 chars"). */
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). */
@ -655,6 +660,9 @@ export function createSessionStore() {
// when verbose `args_text` wasn't captured on start. `duration_s` → header.
const argsObj = event.payload['args']
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(
produce(draft => {
let part = findToolPart(draft, id)
@ -671,6 +679,10 @@ export function createSessionStore() {
if (error) part.error = error
if (duration !== undefined) part.duration = duration
if (omittedNote) part.omittedNote = omittedNote
if (diffUnified) {
part.diffUnified = diffUnified
part.diffStats = diffStats(diffUnified)
}
// argsPreview (from tool.start `context`) is intentionally NOT overwritten.
if (argsObj && typeof argsObj === 'object') {
// structured args feed the per-tool renderers (labeled fields, bash command).

View file

@ -48,6 +48,13 @@ export interface ThemeColors {
diffRemoved: string
diffAddedWord: 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
}
@ -272,6 +279,8 @@ export const DARK_THEME: Theme = {
diffRemoved: 'rgb(255,220,220)',
diffAddedWord: 'rgb(36,138,61)',
diffRemovedWord: 'rgb(207,34,46)',
diffAddedBg: '#1a4d1a',
diffRemovedBg: '#4d1a1a',
shellDollar: '#4dabf7'
},
brand: BRAND,
@ -312,6 +321,8 @@ export const LIGHT_THEME: Theme = {
diffRemoved: 'rgb(240,200,200)',
diffAddedWord: 'rgb(27,94,32)',
diffRemovedWord: 'rgb(183,28,28)',
diffAddedBg: '#c8f0c8',
diffRemovedBg: '#f0c8c8',
shellDollar: '#1565C0'
},
brand: BRAND,
@ -467,6 +478,8 @@ export function fromSkin(
diffRemoved: d.color.diffRemoved,
diffAddedWord: d.color.diffAddedWord,
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
},

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')
})
})

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

View file

@ -27,6 +27,7 @@ import { Pager } from './overlays/pager.tsx'
import { Picker } from './overlays/picker.tsx'
import { SessionSwitcher } from './overlays/sessionSwitcher.tsx'
import { PromptOverlay } from './prompts/promptOverlay.tsx'
import { SessionInfoProvider } from './sessionInfo.tsx'
import { StatusBar } from './statusBar.tsx'
import { StatusLine } from './statusLine.tsx'
import { useTheme } from './theme.tsx'
@ -69,75 +70,79 @@ export function App(props: AppProps) {
return (
<DimensionsProvider>
<box style={{ flexDirection: 'column', flexGrow: 1, paddingTop: 1, paddingLeft: 1, paddingRight: 1 }}>
{/* a bottom rule under the header bookends the transcript with the status
{/* 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 }}>
{/* a bottom rule under the header bookends the transcript with the status
bar's top rule frames the chrome as intentional (item 8). */}
<box border={['bottom']} borderColor={theme().color.border} style={{ flexShrink: 0 }}>
<Header store={props.store} />
</box>
{/* content zone: a full-screen overlay (pager / agents dashboard) OR the transcript + input zone */}
<Switch
fallback={
<>
<Transcript store={props.store} />
{/* transient busy face floats at the bottom of the transcript area */}
<StatusLine store={props.store} />
{/* input region a top-edge rule separates the status bar + textbox from the
<box border={['bottom']} borderColor={theme().color.border} style={{ flexShrink: 0 }}>
<Header store={props.store} />
</box>
{/* content zone: a full-screen overlay (pager / agents dashboard) OR the transcript + input zone */}
<Switch
fallback={
<>
<Transcript store={props.store} />
{/* transient busy face floats at the bottom of the transcript area */}
<StatusLine store={props.store} />
{/* input region a top-edge rule separates the status bar + textbox from the
transcript above; the status bar sits directly ABOVE the composer (item 14). */}
<box
border={['top']}
borderColor={theme().color.border}
style={{ flexShrink: 0, flexDirection: 'column' }}
>
<StatusBar store={props.store} />
<Switch
fallback={
<Composer
onSubmit={props.onSubmit ?? NOOP}
onType={props.onType}
completions={() => props.store.state.completions ?? []}
completionFrom={() => props.store.state.completionFrom}
onDismiss={() => props.store.clearCompletions()}
history={props.history}
onImagePaste={props.onImagePaste}
pasteStore={props.pasteStore}
/>
}
<box
border={['top']}
borderColor={theme().color.border}
style={{ flexShrink: 0, flexDirection: 'column' }}
>
<Match when={blocked()}>
<PromptOverlay
store={props.store}
onRespond={props.onRespond ?? NOOP_RESPOND}
sessionId={props.sessionId ?? NO_SESSION}
/>
</Match>
<Match when={switcher()}>
{sessions => <SessionSwitcher sessions={sessions()} onPick={resume} onClose={closeSwitcher} />}
</Match>
<Match when={picker()}>
{p => (
<Picker
title={p().title}
items={p().items}
onPick={value => {
p().onPick(value)
closePicker()
}}
onClose={closePicker}
<StatusBar store={props.store} />
<Switch
fallback={
<Composer
onSubmit={props.onSubmit ?? NOOP}
onType={props.onType}
completions={() => props.store.state.completions ?? []}
completionFrom={() => props.store.state.completionFrom}
onDismiss={() => props.store.clearCompletions()}
history={props.history}
onImagePaste={props.onImagePaste}
pasteStore={props.pasteStore}
/>
)}
</Match>
</Switch>
</box>
</>
}
>
<Match when={pager()}>{p => <Pager title={p().title} text={p().text} onClose={closePager} />}</Match>
<Match when={dashboard()}>
<AgentsDashboard subagents={props.store.state.subagents} onClose={closeDashboard} />
</Match>
</Switch>
</box>
}
>
<Match when={blocked()}>
<PromptOverlay
store={props.store}
onRespond={props.onRespond ?? NOOP_RESPOND}
sessionId={props.sessionId ?? NO_SESSION}
/>
</Match>
<Match when={switcher()}>
{sessions => <SessionSwitcher sessions={sessions()} onPick={resume} onClose={closeSwitcher} />}
</Match>
<Match when={picker()}>
{p => (
<Picker
title={p().title}
items={p().items}
onPick={value => {
p().onPick(value)
closePicker()
}}
onClose={closePicker}
/>
)}
</Match>
</Switch>
</box>
</>
}
>
<Match when={pager()}>{p => <Pager title={p().title} text={p().text} onClose={closePager} />}</Match>
<Match when={dashboard()}>
<AgentsDashboard subagents={props.store.state.subagents} onClose={closeDashboard} />
</Match>
</Switch>
</box>
</SessionInfoProvider>
</DimensionsProvider>
)
}

View file

@ -51,7 +51,9 @@ function buildSyntaxStyle(theme: Theme): SyntaxStyle {
}
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
const style = buildSyntaxStyle(theme)
cache = { style, theme }

View 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
}

View file

@ -20,6 +20,7 @@ import { createSignal, Show } from 'solid-js'
import { truncate } from '../logic/toolOutput.ts'
import { useScrollAnchor } from './scrollAnchor.tsx'
import { useSessionInfo } from './sessionInfo.tsx'
import { useTheme } from './theme.tsx'
import { resultLines } from './tools/defaultTool.tsx'
import { rendererFor } from './tools/registry.tsx'
@ -37,6 +38,7 @@ function fmtDuration(s: number): string {
export function ToolPart(props: { part: ToolPartState }) {
const theme = useTheme()
const dims = useDimensions()
const info = useSessionInfo() // session cwd for path-relativizing renderers
const anchor = useScrollAnchor()
const [expanded, setExpanded] = createSignal(false)
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.
const collapsible = () => !running() && renderer().expandable(props.part)
// 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)
// 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() ? '▼' : '▶') : '⚡')
// 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())}`}
</span>
</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()}>
{/* per-tool muted hint (e.g. delegate_task's "(/agents to monitor)")
shown while running too, Ink parity. */}
@ -110,7 +124,7 @@ export function ToolPart(props: { part: ToolPartState }) {
>
{(() => {
const Body = renderer().Body
return <Body part={props.part} width={bodyWidth() - 2} />
return <Body part={props.part} width={bodyWidth() - 2} cwd={info().cwd} />
})()}
</box>
</Show>

View 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)
}

View file

@ -10,28 +10,34 @@
* - `Body` the expanded body (labeled arg fields / output / diff)
*
* 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.
* `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`.
* renderer NEVER a raw JSON dump. To add a per-tool renderer: export a
* `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 { DiffStats } from '../../logic/diff.ts'
import type { ToolPartState } from '../../logic/store.ts'
import { bashRenderer } from './bashTool.tsx'
import { defaultRenderer } from './defaultTool.tsx'
import { fileRenderer } from './fileTool.tsx'
/** Props every tool Body receives: the part + usable content columns. */
export interface ToolBodyProps {
part: ToolPartState
/** Width (columns) available for body lines inside the bordered frame. */
width: number
/** Session cwd (from SessionInfoProvider) — file renderers relativize paths. */
cwd?: string | undefined
}
export interface ToolRenderer {
/** Collapsed one-line subtitle (verbatim command, primary arg, …). */
subtitle: (part: ToolPartState) => string
/** Collapsed one-line subtitle (verbatim command, primary arg, relative path, …). */
subtitle: (part: ToolPartState, cwd?: string) => string
/** Optional muted header note (chrome) — e.g. delegate_task's "(/agents to monitor)". */
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). */
expandable: (part: ToolPartState) => boolean
/** 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.
execute_code: 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). */