203 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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
* │ <renderer body> ← 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
* `<Show>`, 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 <span style={{ fg: theme().color.muted }}>{text()}</span>
}
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<boolean | undefined>(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.
<box style={{ flexDirection: 'column', flexShrink: 0 }}>
{/* header — clickable to toggle when there's an expandable body */}
<box style={{ flexDirection: 'row', flexShrink: 0 }} onMouseDown={() => collapsible() && toggle()}>
<box style={{ flexShrink: 0, width: GUTTER }}>
<text selectable={false}>
<span style={{ fg: headColor() }}>{headGlyph()}</span>
</text>
</box>
<box style={{ flexDirection: 'row', flexGrow: 1, minWidth: 0 }}>
{/* 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. */}
<text selectable={false}>
{/* the NAME is the primary cue (text + bold; error when failed;
muted while running) — see toolNameStyle. Subtitle stays muted. */}
<span style={toolNameStyle({ failed: failed(), running: running() }, theme().color)}>
{props.part.name}
</span>
{/* 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. */}
<Show when={subtitle()}>
<span style={{ fg: props.part.error ? theme().color.error : theme().color.muted }}>
{` ${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. */}
<span style={{ fg: theme().color.muted }}>{` ${hint() ?? ''}`}</span>
</Show>
<Show when={!running() && props.part.duration !== undefined}>
<span style={{ fg: theme().color.muted }}>{` · ${fmtDuration(props.part.duration ?? 0)}`}</span>
</Show>
{/* live elapsed (running only) — the <Show> scopes the shared-tick
subscription to the running lifecycle (see RunningElapsed). */}
<Show when={running()}>
<RunningElapsed startedAt={props.part.startedAt} />
</Show>
<Show when={collapsible() && !expanded() && lines().length > 1}>
<span style={{ fg: theme().color.muted }}>{` (${lines().length} lines)`}</span>
</Show>
</text>
</box>
</box>
{/* 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). */}
<Show when={collapsible() && expanded()}>
<box
style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0, marginLeft: GUTTER, paddingLeft: 1 }}
border={['left']}
borderColor={props.part.error ? theme().color.error : theme().color.border}
>
{(() => {
const Body = renderer().Body
return <Body part={props.part} width={bodyWidth() - 2} cwd={info().cwd} />
})()}
</box>
</Show>
</box>
)
}