/** * ToolPart — one tool call, rendered COLLAPSED by default with a clear expand * affordance. This is the SHARED SHELL: the header (glyph + name + subtitle + * duration + line count + optional hint) and the expand/collapse mechanics — * what's INSIDE varies per tool and is dispatched through the tool renderer * registry (`view/tools/registry.tsx`, Epic 2.2): * * ⚡ terminal sleep 8 · 12s ← running (elapsed ticks live) * ▶ terminal ls -la src · 0.3s (12 lines) ← collapsed (default) * ▼ terminal ls -la src · 0.3s ← expanded header * │ ← labeled fields / output / … * ✗ terminal ✗ exit 1 · 0.1s (3 lines) ← failed (error-colored glyph) * * Lifecycle is legible from the HEAD GLYPH alone (Epic 2.5): `⚡` running (with * a live `· Ns` elapsed off the shared 1s tick in `elapsed.ts` — never a timer * per part), `▶`/`▼` settled-expandable, `✗` failed (theme error color). * Clicking an expandable header toggles it (wrapped in useScrollAnchor so * expanding never yanks the viewport); running parts have no expand * affordance. The header row is chrome (selectable=false) — a free-form drag * copies only the expanded body content. Fully themed (no hardcoded styles). */ import { type ToolPartState } from '../logic/store.ts' import type { ThemeColors } from '../logic/theme.ts' import { useDimensions } from './dimensions.tsx' import { useDisplay } from './display.tsx' import { createSignal, Show } from 'solid-js' import { truncate } from '../logic/toolOutput.ts' import { elapsedSeconds, useElapsedTick } from './elapsed.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' const GUTTER = 2 function fmtDuration(s: number): string { if (s < 10) return `${s.toFixed(1)}s` if (s < 60) return `${Math.round(s)}s` const m = Math.floor(s / 60) const r = Math.round(s % 60) return r ? `${m}m ${r}s` : `${m}m` } /** Live elapsed format — whole seconds (the tick advances 1s at a time). */ function fmtElapsed(s: number): string { if (s < 60) return `${s}s` const m = Math.floor(s / 60) const r = s % 60 return r ? `${m}m ${r}s` : `${m}m` } /** * Header tool-NAME style — the name is the PRIMARY cue for what a settled tool * IS, so it renders in the primary text color + BOLD (the transcript otherwise * reads as undifferentiated muted rows). The failed state's error coloring * wins (still bold — failures should be unmistakable alongside the ✗ glyph); * a running part keeps its current muted treatment (the ⚡ glyph + live * elapsed already carry the running signal). Exported so tests can pin the * selection logic (char frames carry no color/attribute info). */ export function toolNameStyle( state: { failed: boolean; running: boolean }, color: ThemeColors ): { fg: string; bold: boolean } { if (state.failed) return { bold: true, fg: color.error } if (state.running) return { bold: false, fg: color.muted } return { bold: true, fg: color.text } } /** * Live ` · 12s` elapsed for a RUNNING part. Mounted only under the running * ``, so its useElapsedTick subscription starts/stops the SHARED 1s * interval with the part's lifecycle (the last cleanup clears it). Falls back * to the plain ` …` marker when startedAt is unknown (e.g. a tool.complete * that arrived without a local tool.start). */ function RunningElapsed(props: { startedAt: number | undefined }) { const theme = useTheme() const tick = useElapsedTick() const text = () => { tick() // re-read every shared tick — Date.now() alone is not reactive return props.startedAt === undefined ? ' …' : ` · ${fmtElapsed(elapsedSeconds(props.startedAt))}` } return {text()} } 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 display = useDisplay() // /details expanded → settled bodies default-OPEN; a manual click overrides // either way (and a later global flip applies again to un-overridden parts). const [override, setOverride] = createSignal(undefined) const expanded = () => override() ?? display().details === 'expanded' const toggle = () => anchor(() => setOverride(!expanded())) // Per-tool renderer (re-dispatches if the name settles on tool.complete). const renderer = () => rendererFor(props.part.name) const bodyWidth = () => Math.max(20, dims().width - GUTTER - 4) const lines = () => resultLines(props.part) const running = () => props.part.state === 'running' // 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, 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)) // Failed parts are legible from the glyph alone: `✗` in the head position // (error-colored), regardless of expandability — `(N lines)` still marks an // expandable body. `error` only lands on tool.complete, so running stays ⚡. const failed = () => !running() && Boolean(props.part.error) const headGlyph = () => (failed() ? '✗' : collapsible() ? (expanded() ? '▼' : '▶') : '⚡') // accent glyph MARKS the tool (draws the eye); the NAME is primary (bold text // via toolNameStyle) so WHAT the tool is reads at a glance; subtitle/metadata // stay muted — the secondary tier below the bright assistant answer. const headColor = () => (failed() ? theme().color.error : theme().color.accent) const subWidth = () => Math.max(1, bodyWidth() - props.part.name.length - 2) return ( // Spacing between parts is owned by the parts column (gap), not per-part // margins — so a tool appearing mid-stream doesn't shift the layout. {/* header — clickable to toggle when there's an expandable body */} collapsible() && toggle()}> {headGlyph()} {/* the whole header row is a collapsed SUMMARY (tool name + subtitle + duration + "(N lines)") — chrome, not the copyable body — so a free-form drag over a tool yields only the expanded body content, never the header label. */} {/* the NAME is the primary cue (text + bold; error when failed; muted while running) — see toolNameStyle. Subtitle stays muted. */} {props.part.name} {/* subtitle shows while running too (the gateway argsPreview — e.g. the command being executed) so a running tool reads `⚡ terminal sleep 8 · 12s`, Ink parity. */} {` ${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. */} {` ${hint() ?? ''}`} {` · ${fmtDuration(props.part.duration ?? 0)}`} {/* live elapsed (running only) — the scopes the shared-tick subscription to the running lifecycle (see RunningElapsed). */} 1}> {` (${lines().length} lines)`} {/* expanded body — the per-tool renderer's Body, inside a single left-bordered column (a `│` rule, not a bg fill — opencode's BlockTool style; also renders faithfully and reads cleaner). */} {(() => { const Body = renderer().Body return })()} ) }