diff --git a/ui-opentui/src/logic/diff.ts b/ui-opentui/src/logic/diff.ts new file mode 100644 index 00000000000..ff73e432490 --- /dev/null +++ b/ui-opentui/src/logic/diff.ts @@ -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 `` 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) })) +} diff --git a/ui-opentui/src/logic/store.ts b/ui-opentui/src/logic/store.ts index 09d083c6477..edb6bda3d1f 100644 --- a/ui-opentui/src/logic/store.ts +++ b/ui-opentui/src/logic/store.ts @@ -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). diff --git a/ui-opentui/src/logic/theme.ts b/ui-opentui/src/logic/theme.ts index 7d20013b8e2..8941aa9f0f8 100644 --- a/ui-opentui/src/logic/theme.ts +++ b/ui-opentui/src/logic/theme.ts @@ -48,6 +48,13 @@ export interface ThemeColors { diffRemoved: string diffAddedWord: string diffRemovedWord: string + // Line backgrounds for the NATIVE `` 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 }, diff --git a/ui-opentui/src/test/diff.test.ts b/ui-opentui/src/test/diff.test.ts new file mode 100644 index 00000000000..33815dd4b21 --- /dev/null +++ b/ui-opentui/src/test/diff.test.ts @@ -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') + }) +}) diff --git a/ui-opentui/src/test/tools.test.tsx b/ui-opentui/src/test/tools.test.tsx index 55ab3e89b1e..1ebe3b3bc78 100644 --- a/ui-opentui/src/test/tools.test.tsx +++ b/ui-opentui/src/test/tools.test.tsx @@ -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 is deliberately untested here — like + // 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 diff --git a/ui-opentui/src/view/App.tsx b/ui-opentui/src/view/App.tsx index e6f4065ad7e..f31099da748 100644 --- a/ui-opentui/src/view/App.tsx +++ b/ui-opentui/src/view/App.tsx @@ -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 ( - - {/* 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). */} + props.store.state.info}> + + {/* a bottom rule under the header bookends the transcript with the status bar's top rule — frames the chrome as intentional (item 8). */} - -
- - {/* content zone: a full-screen overlay (pager / agents dashboard) OR the transcript + input zone */} - - - {/* transient busy face floats at the bottom of the transcript area */} - - {/* input region — a top-edge rule separates the status bar + textbox from the + +
+ + {/* content zone: a full-screen overlay (pager / agents dashboard) OR the transcript + input zone */} + + + {/* transient busy face floats at the bottom of the transcript area */} + + {/* 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). */} - - - props.store.state.completions ?? []} - completionFrom={() => props.store.state.completionFrom} - onDismiss={() => props.store.clearCompletions()} - history={props.history} - onImagePaste={props.onImagePaste} - pasteStore={props.pasteStore} - /> - } + - - - - - {sessions => } - - - {p => ( - { - p().onPick(value) - closePicker() - }} - onClose={closePicker} + + props.store.state.completions ?? []} + completionFrom={() => props.store.state.completionFrom} + onDismiss={() => props.store.clearCompletions()} + history={props.history} + onImagePaste={props.onImagePaste} + pasteStore={props.pasteStore} /> - )} - - - - - } - > - {p => } - - - - - + } + > + + + + + {sessions => } + + + {p => ( + { + p().onPick(value) + closePicker() + }} + onClose={closePicker} + /> + )} + + + + + } + > + {p => } + + + + + + ) } diff --git a/ui-opentui/src/view/markdown.tsx b/ui-opentui/src/view/markdown.tsx index 39e3315af36..c5c57ab2236 100644 --- a/ui-opentui/src/view/markdown.tsx +++ b/ui-opentui/src/view/markdown.tsx @@ -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 `` 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 } diff --git a/ui-opentui/src/view/sessionInfo.tsx b/ui-opentui/src/view/sessionInfo.tsx new file mode 100644 index 00000000000..155550219ec --- /dev/null +++ b/ui-opentui/src/view/sessionInfo.tsx @@ -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>() +const EMPTY: SessionInfo = {} +const EMPTY_INFO: Accessor = () => EMPTY + +export function SessionInfoProvider(props: { info: Accessor; children: JSX.Element }) { + return {props.children} +} + +/** The live session info accessor (empty info when no provider is mounted). */ +export function useSessionInfo(): Accessor { + return useContext(Ctx) ?? EMPTY_INFO +} diff --git a/ui-opentui/src/view/toolPart.tsx b/ui-opentui/src/view/toolPart.tsx index 1885c9f38e7..e29c7c326c6 100644 --- a/ui-opentui/src/view/toolPart.tsx +++ b/ui-opentui/src/view/toolPart.tsx @@ -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())}`} + + {/* `+N −M` change summary (file tools) — added in the ok/added color, + removed in the error/removed color (themed, never hardcoded). */} + {s => ( + <> + {` +${s().added}`} + {` −${s().removed}`} + + )} + {/* 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 + return })()} diff --git a/ui-opentui/src/view/tools/fileTool.tsx b/ui-opentui/src/view/tools/fileTool.tsx new file mode 100644 index 00000000000..7f3712571e8 --- /dev/null +++ b/ui-opentui/src/view/tools/fileTool.tsx @@ -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 `` 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 `` 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 . + */ +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)['diff'] === 'string') + } catch { + return false + } +} + +/** One file's diff: an optional path label (chrome) + the native ``. */ +function FileDiff(props: { file: DiffFileSection; label: boolean; cwd?: string | undefined }) { + const theme = useTheme() + return ( + + + {/* per-file section label (multi-file diffs) — chrome, not content */} + + {relativizePath(props.file.path, props.cwd)} + + + + + ) +} + +/** 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 ( + 0} fallback={}> + + {file => 1} cwd={props.cwd} />} + + + + + + ) +} + +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) +} diff --git a/ui-opentui/src/view/tools/registry.tsx b/ui-opentui/src/view/tools/registry.tsx index 45da7f218b5..60a8bafc721 100644 --- a/ui-opentui/src/view/tools/registry.tsx +++ b/ui-opentui/src/view/tools/registry.tsx @@ -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 = { // 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). */