mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-10 13:31:38 +00:00
opentui(v6): file tool renderer — relative path + full native diff
This commit is contained in:
parent
f76df0688c
commit
c4348480f3
11 changed files with 519 additions and 74 deletions
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 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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
||||
|
|
|
|||
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)', () => {
|
||||
// The gateway redacts verbose `args_text` (server.py _tool_args_text) but
|
||||
// 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 { 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
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 { 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>
|
||||
|
|
|
|||
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)
|
||||
*
|
||||
* 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). */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue