| import type { ToolOutputStream, ToolResultContent } from '@maka/core'; |
| import { |
| formatQuietJsonValue, |
| formatToolInvocationLine, |
| isActiveShellRunStatus, |
| countDiffLineStats, |
| ptyTuiTerminalRows, |
| ptyTuiTerminalView, |
| readWriteStdinInputPreview, |
| type PtyShellOutput, |
| type ShellRunOperation, |
| } from '@maka/core'; |
| import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; |
| import { ansi, disc } from './tui-ansi.js'; |
| import { colorDiff } from './tui-diff.js'; |
| import { |
| collapseToSingleLine, |
| fitLine, |
| formatUnknownInline, |
| limitText, |
| renderIndented, |
| } from './pi-transcript-format.js'; |
| import type { MakaPiToolEntry, MakaPiToolOutputDelta } from './pi-transcript.js'; |
| |
| export function renderToolBlock( |
| entry: MakaPiToolEntry, |
| width: number, |
| expanded: boolean, |
| ): string[] { |
| return expanded ? renderExpandedToolBlock(entry, width) : renderCompactToolBlock(entry, width); |
| } |
| |
| /** Status disc for a tool row: green = done, accent = running, danger = error/aborted/failed, muted = detached/unavailable. */ |
| function toolDisc(entry: MakaPiToolEntry): string { |
| if (entry.status === 'running') return disc('accent'); |
| if (entry.status === 'error' || entry.status === 'aborted' || entry.status === 'failed') |
| return disc('danger'); |
| if (entry.status === 'detached' || entry.status === 'unavailable') return disc('muted'); |
| return disc('ok'); |
| } |
| |
| /** |
| * Duration/status part of the annotation, in integer seconds (#1053): `5s`, |
| * `running 12s`. Sub-second durations are noise and never shown — the gate is |
| * on the raw milliseconds, not the rounded value, so 999ms does not surface |
| * as `1s`. Status words for rows without a live process (`detached`, |
| * `source unavailable`) are dimmed like the other placeholders. |
| */ |
| function toolDurationText(entry: MakaPiToolEntry): string { |
| const subSecond = entry.durationMs !== undefined && entry.durationMs < 1000; |
| const secs = |
| entry.durationMs === undefined ? undefined : Math.max(0, Math.round(entry.durationMs / 1000)); |
| if (entry.status === 'running') { |
| return secs === undefined || subSecond ? 'running' : `running ${secs}s`; |
| } |
| if (entry.status === 'detached') return ansi.dim('detached'); |
| if (entry.status === 'unavailable') return ansi.dim('source unavailable'); |
| return secs === undefined || subSecond ? '' : `${secs}s`; |
| } |
| |
| /** |
| * Compact tool card: a single line — `● Name target (annotation)`. The disc |
| * carries the status color (green done / blue running / red failed / grey |
| * detached); the parenthesized annotation carries the outcome in short fixed |
| * shapes: counts (`5 matches`, `3 lines`), sizes (`42 bytes`), a diff tally |
| * (`+1 -3`), durations (`5s`, sharing the parens as `5s · 3 lines`), red exit |
| * codes, the dim `no output` placeholder, or a free-text `N lines · M bytes` |
| * summary. Output content never appears on the row — it lives in the expanded |
| * card, and the annotation's shapes already say whether there is anything to |
| * expand, so the row needs neither |
| * a separator glyph nor an expand marker. Short annotations are reserved |
| * whole during truncation: a long command can never hide an `exit 1`. |
| */ |
| function renderCompactToolBlock(entry: MakaPiToolEntry, width: number): string[] { |
| const inputSummary = collapseToSingleLine(toolInputSummary(entry)); |
| const head = `${toolDisc(entry)} ${entry.title ?? entry.toolName}`; |
| const annotation = compactAnnotation(entry); |
| return [ |
| fitLine( |
| assembleCompactToolRow(head, inputSummary, annotation.text, width, annotation.protect), |
| width, |
| ), |
| ]; |
| } |
| |
| /** |
| * Build the `(part · part)` annotation for a compact row: duration/status |
| * first, then the outcome. While running only the duration part shows |
| * (`(running 12s)`). A placeholder outcome (`no output`) appears only when it |
| * would be the annotation's sole content — `(5s)` needs no silence disclaimer. |
| * `protect` reports whether every part is a fixed shape (durations always |
| * are): only protected annotations are reserved whole during truncation. |
| */ |
| function compactAnnotation(entry: MakaPiToolEntry): { text: string; protect: boolean } { |
| const parts: string[] = []; |
| const duration = toolDurationText(entry); |
| if (duration) parts.push(duration); |
| let protect = true; |
| if (entry.status !== 'running') { |
| const summary = compactToolSummary(entry); |
| if (summary && !(summary.placeholder && parts.length > 0)) { |
| parts.push(collapseToSingleLine(summary.text)); |
| protect = summary.protect === true; |
| } |
| } |
| return { text: parts.length > 0 ? `(${parts.join(' · ')})` : '', protect }; |
| } |
| |
| /** |
| * Lay out `head target (annotation)` on one line. A protected annotation is |
| * preserved whole whenever it fits alongside the head; the input target |
| * truncates first, so a long command can never hide a fixed-shape outcome. |
| * An annotation that cannot fit with the head takes whatever room remains. |
| */ |
| function assembleCompactToolRow( |
| head: string, |
| input: string, |
| annotation: string, |
| width: number, |
| protect: boolean, |
| ): string { |
| const inputSeg = input ? ` ${input}` : ''; |
| const annSeg = annotation ? ` ${annotation}` : ''; |
| const full = `${head}${inputSeg}${annSeg}`; |
| if (visibleWidth(full) <= width) return full; |
| const reserved = protect && visibleWidth(annSeg) <= Math.max(0, width - visibleWidth(head)); |
| const budget = Math.max(0, width - visibleWidth(head) - (reserved ? visibleWidth(annSeg) : 0)); |
| let builtInput = ''; |
| if (input && budget > 3) { |
| const room = budget - 2; |
| builtInput = ` ${visibleWidth(input) > room ? truncateToWidth(input, room, '…') : input}`; |
| } |
| if (reserved) return `${head}${builtInput}${annSeg}`; |
| let builtAnnotation = ''; |
| const leftover = budget - visibleWidth(builtInput); |
| if (annotation && leftover > 1) { |
| builtAnnotation = ` ${visibleWidth(annotation) > leftover ? truncateToWidth(annotation, leftover, '…') : annotation}`; |
| } |
| return `${head}${builtInput}${builtAnnotation}`; |
| } |
| |
| function renderExpandedToolBlock(entry: MakaPiToolEntry, width: number): string[] { |
| const duration = toolDurationText(entry); |
| let header = `${toolDisc(entry)} ${entry.title ?? entry.toolName}`; |
| if (duration) header += ` (${duration})`; |
| const lines = [fitLine(header, width)]; |
| |
| const inputSummary = toolInputSummary(entry); |
| if (inputSummary) lines.push(...renderIndented(inputSummary, width, 2).map(ansi.dim)); |
| if (entry.progress.droppedChars > 0) { |
| lines.push( |
| ...renderIndented( |
| ansi.dim(`⋯ ${entry.progress.droppedChars} earlier progress chars truncated ⋯`), |
| width, |
| 2, |
| ), |
| ); |
| } |
| if (entry.progress.length > 0) { |
| lines.push(...renderCappedResultText(entry.progress.values().join(''), width, ansi.dim)); |
| } |
| // A terminal snapshot is the authoritative accumulated stream. Rendering |
| // the deltas that preceded it as well would repeat every line once the Bash |
| // card settles. Compact results intentionally keep the live deltas because |
| // they may be the only output available. |
| const renderLiveOutput = !shellResultSupersedesLiveOutput(entry.result); |
| if (renderLiveOutput) { |
| if (entry.outputDeltas.droppedChars > 0) { |
| lines.push( |
| ...renderIndented( |
| ansi.dim(`⋯ ${entry.outputDeltas.droppedChars} earlier live-output chars truncated ⋯`), |
| width, |
| 2, |
| ), |
| ); |
| } |
| lines.push(...renderToolStreams(entry.outputDeltas.values(), width)); |
| } |
| if (entry.result || entry.output) { |
| lines.push(...renderToolResult(entry, width)); |
| } |
| if ( |
| entry.toolName === 'Bash' && |
| entry.status === 'running' && |
| entry.result?.kind === 'shell_run' |
| ) { |
| lines.push(...renderIndented(ansi.dim('Ask Maka to stop this task'), width, 2)); |
| } |
| return lines.map((line) => fitLine(line, width)); |
| } |
| |
| function shellResultSupersedesLiveOutput(result: ToolResultContent | undefined): boolean { |
| return ( |
| (result?.kind === 'terminal' || result?.kind === 'shell_run') && result.output !== undefined |
| ); |
| } |
| |
| interface CompactToolSummary { |
| text: string; |
| /** Placeholder shown only when the annotation would otherwise be empty (`no output`). */ |
| placeholder?: boolean; |
| /** |
| * Fixed-shape outcome (a count, size, diff tally, exit status, or free-text |
| * line/byte count) eligible for whole-annotation reservation when the row |
| * overflows. A WriteStdin operation echo remains unprotected because it is |
| * an action preview rather than an outcome. |
| */ |
| protect?: boolean; |
| } |
| |
| function noOutput(): CompactToolSummary { |
| return { text: ansi.dim('no output'), placeholder: true, protect: true }; |
| } |
| |
| function textResultSummary(text: string): CompactToolSummary { |
| if (!text.trim()) return noOutput(); |
| return { |
| text: `${linesText(readBodyLineCount(text))} · ${byteLength(text)} bytes`, |
| protect: true, |
| }; |
| } |
| |
| function linesText(count: number): string { |
| return `${count} line${count === 1 ? '' : 's'}`; |
| } |
| |
| /** |
| * Combined line count for a pipes result. Each stream is counted after |
| * trimming its own trailing newlines and the counts are summed — joining the |
| * streams first would invent a line at the boundary (`out\n` + `err\n` is |
| * two lines, not three). |
| */ |
| function pipeOutputLineCount(output: { stdout?: string; stderr?: string }): number { |
| let count = 0; |
| for (const stream of [output.stdout, output.stderr]) { |
| const trimmed = stream?.replace(/\n+$/, ''); |
| if (trimmed) count += trimmed.split('\n').length; |
| } |
| return count; |
| } |
| |
| function compactToolSummary(entry: MakaPiToolEntry): CompactToolSummary | undefined { |
| const result = entry.result; |
| if (result?.kind === 'shell_run') { |
| if (entry.toolName === 'WriteStdin') { |
| return { text: formatPtyControlOperation(result.operation, entry.input) }; |
| } |
| // A settled background run reports its outcome, not its output: the status |
| // and exit code are the signal, the stream body lives in the expanded card. |
| if (!isActiveShellRunStatus(result.status) && result.status !== 'completed') { |
| const parts: string[] = [result.status]; |
| if (result.exitCode !== undefined) parts.push(`exit ${result.exitCode}`); |
| return { text: ansi.red(parts.join(' · ')), protect: true }; |
| } |
| if (result.output?.mode === 'pty') { |
| const rows = ptyTuiTerminalRows(result.output).length; |
| return rows > 0 ? { text: linesText(rows), protect: true } : noOutput(); |
| } |
| if (result.output?.mode === 'pipes') { |
| const lines = pipeOutputLineCount(result.output); |
| if (lines > 0) return { text: linesText(lines), protect: true }; |
| } |
| return noOutput(); |
| } |
| |
| if (result?.kind === 'terminal') return compactTerminalSummary(result); |
| if (result?.kind === 'file_diff') return compactDiffSummary(result); |
| if (result?.kind === 'file_write') { |
| // The path is already the card's input summary; the result adds only size. |
| return { text: `${result.bytes} bytes`, protect: true }; |
| } |
| |
| if (entry.toolName === 'Grep') { |
| const count = jsonArrayCount(entry, 'matches'); |
| if (count !== undefined) |
| return { text: `${count} match${count === 1 ? '' : 'es'}`, protect: true }; |
| } |
| |
| if (entry.toolName === 'Glob') { |
| const count = jsonArrayCount(entry, 'files'); |
| if (count !== undefined) |
| return { text: `${count} file${count === 1 ? '' : 's'}`, protect: true }; |
| } |
| |
| if (result?.kind === 'archived_tool_result') { |
| return { text: `archived: ${result.status}`, protect: true }; |
| } |
| |
| const text = plainResultText(entry); |
| if (!text) return result ? noOutput() : undefined; |
| // Only a successful filesystem Read that carries real file content gets the |
| // line summary — the same guard the expanded card uses. A runtime |
| // resource or errored Read uses the generic fixed-shape summary instead of a |
| // fabricated file count. |
| if ( |
| entry.toolName === 'Read' && |
| entry.status !== 'error' && |
| isFilesystemReadPath(entry) && |
| isReadBodyResult(result) |
| ) { |
| return { text: linesText(readBodyLineCount(text)), protect: true }; |
| } |
| return textResultSummary(text); |
| } |
| |
| function compactTerminalSummary( |
| content: Extract<ToolResultContent, { kind: 'terminal' }>, |
| ): CompactToolSummary { |
| if (content.status !== 'completed') { |
| // The exit code is the whole signal on a failed row; the error text lives |
| // in the expanded card. |
| return { |
| text: ansi.red(content.exitCode === undefined ? content.status : `exit ${content.exitCode}`), |
| protect: true, |
| }; |
| } |
| if (content.output.mode === 'pty') { |
| const rows = ptyTuiTerminalRows(content.output).length; |
| return rows > 0 ? { text: linesText(rows), protect: true } : noOutput(); |
| } |
| const lines = pipeOutputLineCount(content.output); |
| return lines > 0 ? { text: linesText(lines), protect: true } : noOutput(); |
| } |
| |
| function compactDiffSummary( |
| content: Extract<ToolResultContent, { kind: 'file_diff' }>, |
| ): CompactToolSummary { |
| const { additions, deletions } = countDiffLineStats(content.diff); |
| // The path is already the card's input summary; the tally is the outcome. |
| return { text: `${ansi.green(`+${additions}`)} ${ansi.red(`-${deletions}`)}`, protect: true }; |
| } |
| |
| /** |
| * Row count for list-shaped json results (Grep `matches`, Glob `files`). |
| * Returns a count only when the keyed field is genuinely an array; an |
| * error-shaped result (e.g. `{ error: "..." }`) returns undefined so the |
| * caller falls back to a generic first-line summary rather than reporting a |
| * fabricated "N matches" / "N files" from an unrelated line count. |
| */ |
| function jsonArrayCount(entry: MakaPiToolEntry, key: string): number | undefined { |
| const result = entry.result; |
| if (result?.kind === 'json' && result.value !== null && typeof result.value === 'object') { |
| const rows = (result.value as Record<string, unknown>)[key]; |
| if (Array.isArray(rows)) return rows.length; |
| } |
| return undefined; |
| } |
| |
| function renderToolText(text: string, width: number): string[] { |
| return renderIndented(limitText(text, 12_000), width, 2); |
| } |
| |
| // Expanding a tool card should reveal enough to orient, not replay a whole |
| // file or command dump into the transcript. Long output collapses to its first |
| // and last few lines with a hidden-count marker; diffs are the deliberate |
| // exception (rendered in full) because the whole change is the point. |
| const EXPANDED_TOOL_HEAD_LINES = 3; |
| const EXPANDED_TOOL_TAIL_LINES = 3; |
| |
| /** |
| * Render tool output for the expanded card, keeping at most the first |
| * `EXPANDED_TOOL_HEAD_LINES` and last `EXPANDED_TOOL_TAIL_LINES` source lines |
| * with a dim marker in between. `style` colors the content lines (e.g. dim for |
| * stderr); the marker is always dim. |
| */ |
| function renderCappedResultText( |
| text: string, |
| width: number, |
| style: (line: string) => string = (line) => line, |
| ): string[] { |
| // Command output almost always ends in a newline; splitting raw would count |
| // that trailing empty string as a line, capping 7 real lines as if they were |
| // 8 and spending a tail slot on a blank. Drop trailing newlines before both |
| // the cap decision and the slice so the head/tail counts are real lines. |
| const trimmed = text.replace(/\n+$/, ''); |
| const sourceLines = trimmed.split('\n'); |
| if (sourceLines.length <= EXPANDED_TOOL_HEAD_LINES + EXPANDED_TOOL_TAIL_LINES + 1) { |
| return renderToolText(trimmed, width).map(style); |
| } |
| const hidden = sourceLines.length - EXPANDED_TOOL_HEAD_LINES - EXPANDED_TOOL_TAIL_LINES; |
| const head = sourceLines.slice(0, EXPANDED_TOOL_HEAD_LINES).join('\n'); |
| const tail = sourceLines.slice(sourceLines.length - EXPANDED_TOOL_TAIL_LINES).join('\n'); |
| return [ |
| ...renderToolText(head, width).map(style), |
| ...renderIndented(ansi.dim(`⋯ ${hidden} lines hidden ⋯`), width, 2), |
| ...renderToolText(tail, width).map(style), |
| ]; |
| } |
| |
| /** |
| * Line count for a Read body, dropping only the single conventional EOF newline |
| * so `foo\n` counts as one line while a real trailing blank line is preserved |
| * (`a\n\n` is two lines, `\n` is one). Shared by the compact and expanded |
| * summaries so the same card can never flip its line count when toggled. |
| */ |
| function readBodyLineCount(text: string): number { |
| if (text === '') return 0; |
| const body = text.endsWith('\n') ? text.slice(0, -1) : text; |
| return body.split('\n').length; |
| } |
| |
| function renderReadSummary(entry: MakaPiToolEntry, width: number): string[] { |
| const text = plainResultText(entry); |
| // Byte count keeps the full content, since that is the file's real size on |
| // disk; the line count drops the trailing newline (see readBodyLineCount). |
| const lineCount = readBodyLineCount(text); |
| const summary = `Read ${lineCount} line${lineCount === 1 ? '' : 's'}, ${byteLength(text)} bytes`; |
| return renderIndented(ansi.dim(summary), width, 2); |
| } |
| |
| function readInputPath(entry: MakaPiToolEntry): string | undefined { |
| const input = entry.input; |
| const path = |
| input !== null && typeof input === 'object' ? (input as { path?: unknown }).path : undefined; |
| return typeof path === 'string' && path.length > 0 ? path : undefined; |
| } |
| |
| function readInputRef(entry: MakaPiToolEntry): string | undefined { |
| const input = entry.input; |
| const ref = |
| input !== null && typeof input === 'object' ? (input as { ref?: unknown }).ref : undefined; |
| return typeof ref === 'string' && ref.length > 0 ? ref : undefined; |
| } |
| |
| /** A Read using the filesystem branch. */ |
| function isFilesystemReadPath(entry: MakaPiToolEntry): boolean { |
| return readInputPath(entry) !== undefined; |
| } |
| |
| /** A Read using the runtime-resource branch (background-task output, etc.). */ |
| function isRuntimeResourceRead(entry: MakaPiToolEntry): boolean { |
| return readInputRef(entry)?.startsWith('maka://runtime/') ?? false; |
| } |
| |
| /** |
| * True only for the result shapes a filesystem Read uses to carry actual file |
| * content. An `archived_tool_result` placeholder (or any other kind) is not a |
| * read body, so it renders its own status instead of a fabricated line count. |
| */ |
| function isReadBodyResult(result: ToolResultContent | undefined): boolean { |
| if (result?.kind === 'text') return true; |
| // A json Read body is the `{ content: string }` shape the file loader |
| // returns; any other json (e.g. an `{ error }` payload) is a status object, |
| // not file content, and should render its real shape rather than a |
| // fabricated line/byte count. |
| if (result?.kind === 'json') { |
| const value = result.value; |
| return ( |
| value !== null && |
| typeof value === 'object' && |
| typeof (value as { content?: unknown }).content === 'string' |
| ); |
| } |
| return false; |
| } |
| |
| /** |
| * Render live `tool_output_delta` chunks. Chunks are de-duped and ordered by |
| * `seq` (so a late or repeated seq cannot corrupt the display), consecutive |
| * same-stream chunks are grouped under a single dim `[stream]` label, and any |
| * redacted chunk shows a `[redacted]` marker instead of its raw content. |
| */ |
| function renderToolStreams(deltas: readonly MakaPiToolOutputDelta[], width: number): string[] { |
| const lines: string[] = []; |
| for (const group of groupOutputDeltas(deltas)) { |
| lines.push(fitLine(ansi.dim(`[${group.stream}]`), width)); |
| lines.push(...renderCappedResultText(group.text, width, ansi.dim)); |
| } |
| return lines; |
| } |
| |
| function groupOutputDeltas( |
| deltas: readonly MakaPiToolOutputDelta[], |
| ): Array<{ stream: ToolOutputStream; text: string }> { |
| const bySeq = new Map<number, MakaPiToolOutputDelta>(); |
| for (const delta of deltas) { |
| if (!bySeq.has(delta.seq)) bySeq.set(delta.seq, delta); |
| } |
| const ordered = [...bySeq.values()].sort((a, b) => a.seq - b.seq); |
| const groups: Array<{ stream: ToolOutputStream; text: string }> = []; |
| for (const delta of ordered) { |
| const chunk = delta.redacted ? '[redacted]' : delta.chunk; |
| const last = groups[groups.length - 1]; |
| if (last && last.stream === delta.stream) { |
| last.text += chunk; |
| } else { |
| groups.push({ stream: delta.stream, text: chunk }); |
| } |
| } |
| return groups; |
| } |
| |
| function renderToolResult(entry: MakaPiToolEntry, width: number): string[] { |
| const result = entry.result; |
| // A `maka://runtime/...` resource Read surfaces live state (background-task |
| // metadata + stdout/stderr) that only lives in the transcript. Its body opens |
| // with several metadata/separator lines, so a head/tail cap would hide the very |
| // output the user expanded to see — render it in full. |
| if (entry.toolName === 'Read' && isRuntimeResourceRead(entry)) { |
| return renderToolText(plainResultText(entry), width); |
| } |
| // A successful filesystem Read that returned real file content pulled it into |
| // the model's context; the transcript only needs to note that it happened, so |
| // skip the content and keep a summary. Everything else falls through to render |
| // its content: a failed Read (its error), and — critically — an |
| // `archived_tool_result` placeholder, so its not_loaded/missing status stays |
| // visible instead of being mistaken for a one-line file. |
| if ( |
| entry.toolName === 'Read' && |
| entry.status !== 'error' && |
| isFilesystemReadPath(entry) && |
| isReadBodyResult(result) |
| ) { |
| return renderReadSummary(entry, width); |
| } |
| if (result?.kind === 'terminal') return renderTerminalResult(result, width); |
| // A background `shell_run` carries process metadata (ref, status, exit) the |
| // head/tail cap must never hide — otherwise a failed or timed-out background |
| // command looks the same as a successful one. Render the status in full and |
| // cap only the stdout/stderr stream bodies. |
| if (result?.kind === 'shell_run') { |
| if (entry.toolName === 'WriteStdin') { |
| return renderIndented(formatPtyControlOperation(result.operation, entry.input), width, 2); |
| } |
| return renderShellRunResult(entry, result, width); |
| } |
| // Diffs are the deliberate exception to the head/tail cap: the whole change |
| // is what the user is expanding to see. |
| if (result?.kind === 'file_diff') return renderDiffResult(result.diff, width); |
| if (result?.kind === 'file_write') { |
| return renderIndented(`Wrote ${result.bytes} bytes to ${result.path}`, width, 2); |
| } |
| // A generic `text` dump — a Bash body or raw tool text — is what the head/tail |
| // cap targets: the model already holds the full body, so the transcript only |
| // needs enough to orient. An undefined result with a formatted `output` string |
| // is treated the same way. `json` is deliberately excluded: a Read json is |
| // summarized above, a Grep/Glob json is a structured list the user expands to |
| // scan in full, and any other json collapses to a single inline line where the |
| // cap would be a no-op anyway. |
| if (result === undefined || result.kind === 'text') { |
| return renderCappedResultText(plainResultText(entry), width); |
| } |
| // Everything else — json lists (Grep/Glob), agent reports, web-search results, |
| // subagent / workflow summaries, structured tool output — is content the user |
| // expands to read in full, so render it without the cap, like a diff. |
| return renderToolText(plainResultText(entry), width); |
| } |
| |
| /** Best-effort extraction of the human-readable body from a tool result. */ |
| function plainResultText(entry: MakaPiToolEntry): string { |
| const result = entry.result; |
| if (result?.kind === 'text') return typeof result.text === 'string' ? result.text : ''; |
| if (result?.kind === 'json') { |
| const value = result.value; |
| if (value !== null && typeof value === 'object') { |
| const content = (value as { content?: unknown }).content; |
| if (typeof content === 'string') return content; |
| const record = value as { matches?: unknown; files?: unknown }; |
| const rows = record.matches ?? record.files; |
| if (Array.isArray(rows)) return rows.map((row) => String(row)).join('\n'); |
| } |
| // Generic json fallback: use the shared quiet-value formatter instead of |
| // dumping a single-line JSON blob. It extracts headline + body from |
| // known shapes (lists, text payloads, Write/Edit results, key-value) and |
| // never produces escaped JSON braces (#1065). AskUserQuestion, GoalSet, |
| // Automation, and any future tool without a custom case render |
| // human-readable text here. |
| const preview = formatQuietJsonValue(value, 'en'); |
| return preview.headline ? `${preview.headline}\n${preview.body}` : preview.body; |
| } |
| return entry.output ?? ''; |
| } |
| |
| function renderTerminalResult( |
| content: Extract<ToolResultContent, { kind: 'terminal' }>, |
| width: number, |
| ): string[] { |
| const lines: string[] = []; |
| if (content.status !== 'completed') { |
| const status = content.exitCode === undefined ? content.status : `exit ${content.exitCode}`; |
| lines.push(...renderIndented(ansi.red(status), width, 2)); |
| if (content.failureMessage) |
| lines.push(...renderIndented(ansi.red(content.failureMessage), width, 2)); |
| } |
| if (content.output.mode === 'pty') { |
| lines.push(...renderPtyTerminalRows(content.output, width)); |
| } else { |
| if (content.output.stdout) lines.push(...renderCappedResultText(content.output.stdout, width)); |
| if (content.output.stderr) { |
| lines.push(...renderIndented(ansi.dim('[stderr]'), width, 2)); |
| lines.push(...renderCappedResultText(content.output.stderr, width, ansi.dim)); |
| } |
| } |
| return lines; |
| } |
| |
| function renderPtyTerminalRows(output: PtyShellOutput, width: number): string[] { |
| const view = ptyTuiTerminalView(output); |
| const lines = view.rows.map((row) => { |
| const available = Math.max(0, width - 2); |
| const body = visibleWidth(row) > available ? truncateToWidth(row, available, '…') : row; |
| return ` ${body}`; |
| }); |
| if (output.truncated || view.rowsOmitted) { |
| lines.push(...renderIndented(ansi.dim('terminal output truncated'), width, 2)); |
| } |
| if (output.redacted) |
| lines.push(...renderIndented(ansi.dim('terminal output redacted'), width, 2)); |
| return lines; |
| } |
| |
| function renderPipeShellOutput( |
| output: Extract< |
| NonNullable<Extract<ToolResultContent, { kind: 'shell_run' }>['output']>, |
| { mode: 'pipes' } |
| >, |
| width: number, |
| ): string[] { |
| const lines: string[] = []; |
| if (output.stdout) lines.push(...renderCappedResultText(output.stdout, width)); |
| if (output.stderr) { |
| lines.push(...renderIndented(ansi.dim('[stderr]'), width, 2)); |
| lines.push(...renderCappedResultText(output.stderr, width, ansi.dim)); |
| } |
| return lines; |
| } |
| |
| /** |
| * Render a `shell_run` (background-process) result. The status line — status, |
| * exit code, failure message, and the run `ref` — is always shown in full so a |
| * head/tail cap can never hide whether the command failed or timed out; only |
| * the stdout/stderr stream bodies are capped. |
| */ |
| function renderShellRunResult( |
| entry: MakaPiToolEntry, |
| content: Extract<ToolResultContent, { kind: 'shell_run' }>, |
| width: number, |
| ): string[] { |
| const lines: string[] = []; |
| // The command/cwd live on the result. The Bash input summary shows only the |
| // command's first line (`command.split('\n')[0]`), so skip the result-side |
| // `$ cmd` only when the input already shows the whole command — a single-line |
| // command. A multiline command, or a ref-only StopBackgroundTask input, |
| // renders the full command here so none of it is lost. The cwd is in neither |
| // input summary, so show it once here. |
| const input = entry.input; |
| const command = |
| input !== null && typeof input === 'object' |
| ? (input as { command?: unknown }).command |
| : undefined; |
| const inputShowsFullCommand = |
| typeof command === 'string' && command.trim() !== '' && !command.includes('\n'); |
| if (!inputShowsFullCommand) { |
| lines.push(...renderIndented(ansi.dim(`$ ${content.cmd}`), width, 2)); |
| } |
| lines.push(...renderIndented(ansi.dim(`cwd: ${content.cwd}`), width, 2)); |
| const settled = !isActiveShellRunStatus(content.status) && content.status !== 'completed'; |
| const parts: string[] = [content.status]; |
| if (content.exitCode !== undefined) parts.push(`exit ${content.exitCode}`); |
| if (content.failureMessage) parts.push(content.failureMessage); |
| const head = parts.join(' · '); |
| // Keep the colored status and the dim ref as separate ansi spans; nesting one |
| // inside the other would let the inner reset terminate the outer color early. |
| const statusLine = `${settled ? ansi.red(head) : ansi.dim(head)} ${ansi.dim(`(${content.ref})`)}`; |
| lines.push(...renderIndented(statusLine, width, 2)); |
| if (content.output?.mode === 'pty') { |
| const hasTerminalView = ptyTuiTerminalRows(content.output).length > 0; |
| lines.push(...renderPtyTerminalRows(content.output, width)); |
| if (!hasTerminalView && (content.status === 'failed' || content.status === 'orphaned')) { |
| lines.push(...renderIndented(ansi.dim('No terminal view available'), width, 2)); |
| } |
| } else if (content.output?.mode === 'pipes') { |
| lines.push(...renderPipeShellOutput(content.output, width)); |
| } |
| return lines; |
| } |
| |
| function renderDiffResult(diff: string, width: number): string[] { |
| // The structural parse drops the `---`/`+++` file headers the card already |
| // names; the copyable ledger keeps them. |
| return renderIndented(colorDiff(limitText(diff, 12_000)), width, 2); |
| } |
| |
| function byteLength(text: string): number { |
| return Buffer.byteLength(text, 'utf8'); |
| } |
| |
| function formatPtyControlOperation( |
| operation: ShellRunOperation | undefined, |
| args: unknown, |
| ): string { |
| if (operation?.kind !== 'pty_control') return 'Background terminal interaction failed'; |
| const parts: string[] = []; |
| if (operation.input) { |
| const preview = readWriteStdinInputPreview(args); |
| const action = operation.input.queued ? 'Entered' : 'Not entered'; |
| if (preview) { |
| parts.push( |
| preview.truncated |
| ? `${action}: ${preview.text}… · ${operation.input.bytes} bytes total` |
| : `${action}: ${preview.text}`, |
| ); |
| } else { |
| parts.push(`${action} ${operation.input.bytes} bytes`); |
| } |
| } |
| if (operation.resize) { |
| const size = `${operation.resize.cols}x${operation.resize.rows}`; |
| if (!operation.resize.applied) parts.push(`Did not resize to ${size}`); |
| else if (operation.resize.changed) parts.push(`Resized to ${size}`); |
| else if (!operation.input) parts.push(`Size already ${size}`); |
| } |
| if (operation.failed) parts.push('Background terminal interaction failed'); |
| return parts.join(' · ') || 'Background terminal interaction completed'; |
| } |
| |
| function toolInputSummary(entry: MakaPiToolEntry): string { |
| const input = entry.input; |
| const obj = |
| input !== null && typeof input === 'object' ? (input as Record<string, unknown>) : undefined; |
| switch (entry.toolName) { |
| case 'Bash': { |
| const command = obj?.command; |
| if (typeof command === 'string' && command.trim()) { |
| // Agents often lead with `#` comment lines; the row names what the |
| // command does, so show the first real command line when one exists. |
| const firstRealLine = command |
| .split('\n') |
| .map((line) => line.trim()) |
| .find((line) => line !== '' && !line.startsWith('#')); |
| return `$ ${firstRealLine ?? command.split('\n')[0]!.trim()}`; |
| } |
| break; |
| } |
| case 'Read': { |
| const path = obj?.path; |
| if (typeof path === 'string' && path.trim()) { |
| const parts = [path]; |
| if (typeof obj?.offset === 'number') parts.push(`offset ${obj.offset}`); |
| if (typeof obj?.limit === 'number') parts.push(`limit ${obj.limit}`); |
| return parts.join(' '); |
| } |
| break; |
| } |
| case 'WriteStdin': { |
| const parts: string[] = []; |
| const input = readWriteStdinInputPreview(obj); |
| if (input) parts.push(input.truncated ? `${input.text}… · ${input.bytes} bytes` : input.text); |
| if (obj?.size && typeof obj.size === 'object') { |
| const size = obj.size as { cols?: unknown; rows?: unknown }; |
| if (typeof size.cols === 'number' && typeof size.rows === 'number') { |
| parts.push(`${size.cols}x${size.rows}`); |
| } |
| } |
| if (parts.length > 0) return parts.join(' · '); |
| if (typeof obj?.ref === 'string') return obj.ref; |
| break; |
| } |
| case 'Write': |
| case 'Edit': { |
| const path = obj?.path; |
| if (typeof path === 'string' && path.trim()) return path; |
| break; |
| } |
| case 'Grep': { |
| const pattern = obj?.pattern; |
| if (typeof pattern === 'string' && pattern.trim()) { |
| const parts = [pattern]; |
| if (typeof obj?.path === 'string' && obj.path.trim()) parts.push(`in ${obj.path}`); |
| if (typeof obj?.glob === 'string' && obj.glob.trim()) parts.push(`glob ${obj.glob}`); |
| return parts.join(' '); |
| } |
| break; |
| } |
| case 'Glob': { |
| const pattern = obj?.pattern; |
| if (typeof pattern === 'string' && pattern.trim()) { |
| const cwd = obj?.cwd; |
| return typeof cwd === 'string' && cwd.trim() ? `${pattern} in ${cwd}` : pattern; |
| } |
| break; |
| } |
| case 'maka_computer': { |
| const line = computerCallSummary(obj); |
| if (line) return line; |
| break; |
| } |
| } |
| if (input === undefined) return ''; |
| // Generic fallback: use the shared invocation-line formatter instead of |
| // dumping raw JSON. It extracts headline fields (command/path/pattern/query/ |
| // name/…) and never produces escaped JSON braces (#1065). The explicit per- |
| // tool cases above are an optimization for the common tools; this fallback |
| // covers Skill, AskUserQuestion, GoalSet, Automation, and any future tool. |
| const line = formatToolInvocationLine({ toolName: entry.toolName, args: input }, 'en'); |
| if (line) return limitText(line, 600); |
| // Absolute last resort — still single-line for the compact header contract. |
| return `input: ${limitText(formatUnknownInline(input), 600)}`; |
| } |
| |
| /** |
| * What one Computer Use call did, for the row that names it. |
| * |
| * Every other tool's row is legible from its name plus one headline argument. |
| * This tool's name is `Maka Computer` for observing a window, clicking a |
| * button and observing again alike, so ten calls in a turn printed ten |
| * identical headers; the generic fallback then spelled the arguments out as |
| * `action: … / approvalClass: … / rememberForTurnAllowed: …`, which names the |
| * host's own approval bookkeeping rather than anything the model asked for. |
| * |
| * The arguments here are a `ComputerUseModelCallArgs`, not the raw wire call: |
| * `ToolRuntime` substitutes that projection for any tool declaring |
| * `categoryHint: 'computer_use'` before anything is persisted. It keeps every |
| * key the model sent in the names the tool accepts — `window_id`, `element_id` |
| * — and reduces a screen-derived or typed value to its shape, so the action, |
| * the app, the window and the element are available and a written value is |
| * not. Reading `windowId`/`elementId` here instead is not a compile error and |
| * not a crash; it is every row silently losing its element, which is the defect |
| * this function exists to remove. |
| */ |
| function computerCallSummary(args: Record<string, unknown> | undefined): string | undefined { |
| if (!args) return undefined; |
| const text = (key: string): string | undefined => { |
| const value = args[key]; |
| return typeof value === 'string' && value.trim() ? value.trim() : undefined; |
| }; |
| const action = text('action'); |
| if (!action) return undefined; |
| const parts = [action]; |
| const elementId = text('element_id'); |
| if (elementId) parts.push(`element ${elementId}`); |
| const app = text('app'); |
| const windowId = args.window_id; |
| const target = |
| app && typeof windowId === 'number' |
| ? `${app} window ${windowId}` |
| : (app ?? (typeof windowId === 'number' ? `window ${windowId}` : undefined)); |
| if (target) parts.push(target); |
| return limitText(parts.join(' · '), 600); |
| } |