diff --git a/src/tui/components/event-log-assembly.ts b/src/tui/components/event-log-assembly.ts new file mode 100644 index 000000000..7e0b5ab6b --- /dev/null +++ b/src/tui/components/event-log-assembly.ts @@ -0,0 +1,1351 @@ +import type { ContentBlock } from "../use-stream.js"; +import type { StyledSegment } from "../markdown-parser.js"; +import { createMemoizedParseMarkdown } from "../markdown-parser.js"; +import { createIncrementalMarkdown } from "../streaming-markdown.js"; +import { + describeToolCall, + isShellExitEnvelope, + mergedToolCollapsedPreview, + shellOutcomeGlyph, + summarizeToolResult, + toolGlyph, +} from "../tool-formatter.js"; +import { extractMcpRecords, extractMcpRecord } from "../mcp-result-format.js"; +import { isMcpToolName } from "../../mcp/tool-name.js"; +import { mcpRecordsToView, mcpRecordToView } from "../mcp-view.js"; +import { viewToLines, type StyledLine } from "../view/index.js"; +import { wrapLines, wrapRanges, stringWidth } from "../view/height.js"; +import { color } from "../theme.js"; +import { diffStat, editDiffFromArgs, renderDiff } from "../diff.js"; +import { PRODUCT_NAME, PRODUCT_SHORT_NAME } from "../../branding.js"; + +export type RenderableBlock = Exclude; + +const SHELL_PREFIX = "$ "; +const USER_CODE_BLOCK_LINE_LIMIT = 12; +const EXPANDED_TOOL_RESULT_LINE_LIMIT = 200; +// Tool calls and results sit one level below assistant prose so the model's +// text draws the eye and tools read as subordinate actions. +const TOOL_INDENT = 2; + +// A pending row bakes no elapsed text, but RunningToolRow appends a live +// ` · ` clock outside the wrap budget. Shrink the pending wrap width by +// this reserve so the appended clock never spills past the content column and +// forces Ink to wrap the row onto an extra line. Wide enough for the longest +// realistic hour-form clock (` · 23h 59m 59s`), and RunningToolRow trims the +// rendered clock to this width so it can never exceed the reserved room. +export const RUNNING_ELAPSED_RESERVE = 14; + +function formatToolDurationMs(ms: number): string { + if (ms < 50) return ""; + return ` · ${(ms / 1000).toFixed(1)}s`; +} + +type ToolResultBlock = Extract; + +// A cold assemble calls toolResultForCall once per block, and a linear scan per +// call is O(blocks^2). Index each blocks array once (keyed on its identity, so a +// freshly-built array reindexes and a stale one is collected) and look up in +// O(1). The first matching result per callId wins, matching the old scan. +const toolResultIndexCache = new WeakMap>(); + +function toolResultIndex(blocks: RenderableBlock[]): Map { + const cached = toolResultIndexCache.get(blocks); + if (cached !== undefined) return cached; + const index = new Map(); + for (const block of blocks) { + if (block.type === "tool_result" && !index.has(block.callId)) index.set(block.callId, block); + } + toolResultIndexCache.set(blocks, index); + return index; +} + +function toolResultForCall(blocks: RenderableBlock[], callId: string): ToolResultBlock | undefined { + return toolResultIndex(blocks).get(callId); +} +// One-column gutter shared by the transcript, the chrome (header/tasks/status), +// and the prompt-box border, so every left edge lines up at the same column. +export const TEXT_GUTTER = 1; + +function indentLines(lines: StyledLine[], spaces: number): StyledLine[] { + if (spaces <= 0) return lines; + const pad: StyledSegment = { text: " ".repeat(spaces) }; + return lines.map((line) => [pad, ...line]); +} + +// When every segment in a line shares one backgroundColor, the row's trailing +// pad segment should carry it too so the wash reaches the full row width +// instead of stopping at the last painted character. +export function uniformBackground(line: StyledLine): string | undefined { + const first = line[0]?.backgroundColor; + if (first === undefined) return undefined; + return line.every((seg) => seg.backgroundColor === first) ? first : undefined; +} + +// Tag a pending tool call so the event log paints a static indicator on it. +// Only the two anchor segments carry the marker — the first row's leading +// segment for the pending glyph, the last row's trailing segment for the +// elapsed clock — so a wrapped, multi-row command stays intact: the clock +// lands at the logical end rather than in the middle of the wrapped text. +export function markRunningRow(lines: StyledLine[], startedAt: number): StyledLine[] { + if (lines.length === 0) return lines; + const lastRow = lines.length - 1; + return lines.map((line, li) => + line.map((seg, si) => { + const isSpinnerAnchor = li === 0 && si === 0; + const isElapsedAnchor = li === lastRow && si === line.length - 1; + return isSpinnerAnchor || isElapsedAnchor ? { ...seg, toolRunningSince: startedAt } : seg; + }), + ); +} + +export function runningStartOfLine(line: StyledLine): number | undefined { + return line[0]?.toolRunningSince ?? line[line.length - 1]?.toolRunningSince; +} + +const CACHE_KEY_SEPARATOR = "\x1f"; + +// Tool-call paint depends on whether a matching result exists (pending tint, +// duration, error). Fold that sibling state into the key so a completed call +// never reuses a pending cache entry. +function blockCacheKey( + block: RenderableBlock, + columns: number, + expanded: boolean, + allBlocks?: RenderableBlock[], +): string { + const base = [block.id, String(columns), expanded ? "1" : "0"]; + if (block.type === "tool_call" && allBlocks !== undefined) { + const result = toolResultForCall(allBlocks, block.callId ?? block.id); + base.push( + result === undefined + ? "p" + : `d${result.finishedAt ?? 0}${result.isError ? "e" : "o"}`, + ); + } + return base.join(CACHE_KEY_SEPARATOR); +} + +// When a tool_result is appended after its call was already painted into the +// incremental prefix, walk the prefix back so the call is reassembled and can +// merge / drop · running. Only newly appended results (index >= prevLength) +// trigger a walk-back — completed pairs already in prev stay frozen. +function earliestCallIndexForNewResults( + blocks: RenderableBlock[], + fromIndex: number, + prevLength: number, +): number { + let earliest = fromIndex; + for (let i = Math.max(fromIndex, prevLength); i < blocks.length; i++) { + const block = blocks[i]; + if (block?.type !== "tool_result") continue; + const callId = block.callId ?? block.id; + for (let j = 0; j < i; j++) { + const call = blocks[j]; + if (call?.type === "tool_call" && (call.callId ?? call.id) === callId) { + if (j < earliest) earliest = j; + break; + } + } + } + return earliest; +} + +function appendTextToLastLine(lines: StyledLine[], text: string, seg: Partial = {}): StyledLine[] { + if (text.length === 0 || lines.length === 0) return lines; + const last = lines[lines.length - 1]!; + return [...lines.slice(0, -1), [...last, { text, ...seg }]]; +} + +function blockIdFromCacheKey(key: string): string { + return key.split(CACHE_KEY_SEPARATOR, 1)[0] ?? key; +} + +export function isRenderable(block: ContentBlock): block is RenderableBlock { + return block.type !== "reply" && block.type !== "tasks"; +} + +export function renderableBlocks(blocks: ContentBlock[]): RenderableBlock[] { + return blocks.filter(isRenderable); +} + +export function styleKey(seg: StyledSegment): string { + return [ + seg.bold ? "b" : "", + seg.italic ? "i" : "", + seg.strikethrough ? "s" : "", + seg.heading ?? "", + seg.link ? "l" : "", + // Keep distinct link targets unmerged so OSC 8 sequences do not glue. + seg.linkUrl ?? "", + seg.blockquote ? "q" : "", + seg.rule ? "r" : "", + seg.bullet ? "u" : "", + seg.code ? "c" : "", + seg.color ?? "", + seg.dim ? "d" : "", + seg.backgroundColor ?? "", + ].join("\x1f"); +} + +// Ink lays out and diffs one node per every frame, so collapsing runs of +// identically styled segments into a single node cuts the per-frame cost of the +// visible window — most visibly on tables, whose padding fragments each row. +export function mergeAdjacentSegments(line: StyledLine): StyledSegment[] { + const out: StyledSegment[] = []; + let prevKey: string | undefined; + for (const seg of line) { + const key = styleKey(seg); + const last = out[out.length - 1]; + if (last !== undefined && key === prevKey) { + last.text += seg.text; + } else { + out.push({ ...seg }); + prevKey = key; + } + } + return out; +} +function wrapStyledLine(segments: StyledSegment[], width: number): StyledLine[] { + if (segments.length === 0) return [[]]; + + const first = segments[0]; + const markerWidth = first?.bullet === true && /^\s*(?:•|\d+\.)\s+/.test(first.text) + ? stringWidth(first.text) + : 0; + if (first !== undefined && markerWidth > 0) { + const marker = first; + const body = segments.slice(1); + if (body.length === 0) return [segments]; + const bodyText = body.map((s) => s.text).join(""); + const bodyWidth = Math.max(1, width - markerWidth); + return wrapRanges(bodyText, bodyWidth).map((range, index) => [ + index === 0 ? marker : { text: " ".repeat(markerWidth) }, + ...sliceSegments(body, range.start, range.end), + ]); + } + + const text = segments.map((s) => s.text).join(""); + return wrapRanges(text, width).map((range) => sliceSegments(segments, range.start, range.end)); +} + +function sliceSegments(segments: StyledSegment[], start: number, end: number): StyledSegment[] { + const out: StyledSegment[] = []; + let pos = 0; + for (const seg of segments) { + const segStart = pos; + const segEnd = pos + seg.text.length; + pos = segEnd; + const from = Math.max(start, segStart); + const to = Math.min(end, segEnd); + if (to > from) out.push({ ...seg, text: seg.text.slice(from - segStart, to - segStart) }); + } + return out; +} + +function sanitizeTerminalControls(content: string): string { + return content + .replace(/\x1B\[<\d+;\d+;\d+[Mm]/g, "") + .replace(/\[<\d+;\d+;\d+[Mm]/g, "") + .replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "") + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); +} + +function plainLines(content: string, base: Partial, width: number): StyledLine[] { + return sanitizeTerminalControls(content) + .split("\n") + .flatMap((line) => wrapLines(line, width).map((row) => [{ ...base, text: row }])); +} + +// Expanded tool output is tail-anchored to match the ingress cap policy: exit +// codes, error summaries, and test totals live at the end. A small head stub +// keeps enough context to orient the reader before the elision. +function limitLines(content: string, maxLines: number): string { + const lines = content.split("\n"); + if (lines.length <= maxLines) return content; + const hidden = lines.length - maxLines; + const headStub = Math.min(3, maxLines - 1); + const tailKeep = maxLines - headStub - 1; + return [ + ...lines.slice(0, headStub), + `[${hidden} more lines hidden]`, + ...lines.slice(lines.length - tailKeep), + ].join("\n"); +} + +const SESSION_BRAND = PRODUCT_NAME; +const SESSION_ATTRIBUTION = `Powered by ${PRODUCT_SHORT_NAME}`; + +// Static header at the top of the parent-session scrollback: product identity, +// workspace path, then skills/plugins loaded for this session. +export function buildResourceBanner( + skills: readonly { name: string }[], + plugins: readonly string[], + width: number, + cwd: string, + telemetryNotice?: string, +): StyledLine[] { + const lines: StyledLine[] = [ + [{ text: SESSION_BRAND, bold: true, color: color("brand") }], + [{ text: SESSION_ATTRIBUTION, color: color("muted"), dim: true }], + ...plainLines(cwd, { color: color("muted") }, width), + ]; + const section = (label: string, items: readonly string[]): void => { + if (items.length === 0) return; + lines.push([]); + lines.push([{ text: `[${label}]`, color: color("brand") }]); + lines.push(...plainLines(items.join(", "), { color: color("muted") }, width)); + }; + section("Skills", skills.map((s) => s.name)); + section("Plugins", plugins); + if (telemetryNotice !== undefined && telemetryNotice.length > 0) { + lines.push([]); + lines.push(...plainLines(telemetryNotice, { color: color("muted"), dim: true }, width)); + } + lines.push([]); + return lines; +} + +function compactUserCodeBlocks(content: string): string { + return content.replace(/```([^\n`]*)\n([\s\S]*?)\n```/g, (match, language: string, body: string) => { + const lineCount = body.length === 0 ? 0 : body.split("\n").length; + if (lineCount <= USER_CODE_BLOCK_LINE_LIMIT) return match; + const label = language.trim().length > 0 ? `${language.trim()} code block` : "code block"; + return `[${label} hidden: ${lineCount} lines]`; + }); +} + +// Shared across all blocks: bounded by createMemoizedParseMarkdown's own LRU +// capacity and cleared by clearMarkdownLineCache alongside the coarser +// per-block line cache (see app.tsx), so it never grows across a session. +const memoizedParseMarkdown = createMemoizedParseMarkdown(); + +export function clearMarkdownLineCache(): void { + memoizedParseMarkdown.clear(); +} + +function markdownLines(content: string, width: number): StyledLine[] { + return memoizedParseMarkdown(content, width).flatMap((segments) => + segments.length === 0 ? [[]] : wrapStyledLine(segments, width), + ); +} + +function shellLines(command: string, role: string, width: number, prefix = SHELL_PREFIX): StyledLine[] { + const lines: StyledLine[] = []; + command.split("\n").forEach((logical, li) => { + const rowWidth = li === 0 ? Math.max(1, width - prefix.length) : width; + wrapLines(logical, rowWidth).forEach((row, ri) => { + if (li === 0 && ri === 0) { + lines.push([{ text: prefix, color: color("muted"), dim: true }, { text: row, color: role }]); + } else { + lines.push([{ text: row, color: role }]); + } + }); + }); + return lines; +} + +// A collapsed shell row is a headline, not a document. Chained multi-line +// commands can span dozens of rows; unclamped, their continuation lines have no +// "$ " anchor and read as detached fragments in the transcript. Show the head, +// mark the rest; Ctrl+O reveals the full command. +const COLLAPSED_SHELL_ROW_LIMIT = 4; + +function clampedShellLines(command: string, role: string, width: number, prefix = SHELL_PREFIX): StyledLine[] { + const all = shellLines(command, role, width, prefix); + if (all.length <= COLLAPSED_SHELL_ROW_LIMIT) return all; + const shown = all.slice(0, COLLAPSED_SHELL_ROW_LIMIT); + const last = shown[shown.length - 1]!; + return [ + ...shown.slice(0, -1), + [...last, { text: ` … (+${all.length - COLLAPSED_SHELL_ROW_LIMIT} more lines)`, color: color("dim"), dim: true }], + ]; +} + +function toolCallLines( + block: Extract, + width: number, + expanded: boolean, + meta?: { pending?: boolean; durationSuffix?: string }, +): StyledLine[] { + const { display, role, summary, full, isShell, glyph } = describeToolCall(block.name, block.arguments); + const roleColor = color(role); + // A pending row bakes no duration text; its live spinner and elapsed clock are + // painted by the running-row component from the startedAt marker instead. + const durationSuffix = meta?.pending ? "" : (meta?.durationSuffix ?? ""); + // The running-row component appends a live ` · ` clock after the baked + // headline. Shrink the wrap budget by that reserve while pending so the appended + // clock never spills past the content column and forces Ink onto an extra row. + // A completed row bakes its own duration and gets the full width back. + const contentWidth = meta?.pending ? Math.max(8, width - RUNNING_ELAPSED_RESERVE) : width; + + if (isShell) { + // Shell rows keep the full width: reserving here would push a near-full command + // past the collapse-row limit and drop command text. Instead the running row + // renders with truncation, so an appended clock on a wide last row clips rather + // than soft-wrapping onto a second terminal line and skewing viewport accounting. + const rows = expanded ? shellLines(full, roleColor, width) : clampedShellLines(summary, roleColor, width); + return appendTextToLastLine(rows, durationSuffix, { color: color("dim"), dim: true }); + } + + if (expanded) { + const headline = wrapStyledLine([ + { text: `${glyph} `, color: roleColor }, + { text: `${display}${durationSuffix}`, color: roleColor }, + ], contentWidth); + const edit = editDiffFromArgs(block.name, block.arguments); + if (edit !== null) { + // write_file replaces a whole file, so collapse its unchanged context + // and number lines from 1. edit_file hunks diff old_string against + // new_string with no known file offset, so the number gutter stays off + // rather than showing snippet-relative numbers as if they were real. + const diff = renderDiff( + edit.oldText, + edit.newText, + width, + block.name === "write_file" ? { contextLines: 3 } : { lineNumbers: false }, + ); + return [...headline, ...diff]; + } + return full.length > 0 ? [...headline, ...plainLines(full, { color: color("muted") }, width)] : headline; + } + + // Collapsed non-shell tool calls are subordinate — danger stays loud, everything + // else recedes so the model's actual text output draws the eye instead. The + // leading bullet stays in the action colour so a call still reads as a call. + const collapsedColor = role === "danger" ? roleColor : color("muted"); + return wrapStyledLine( + [ + { text: `${glyph} `, color: roleColor, dim: role !== "danger" }, + { text: `${display}${durationSuffix}`, color: collapsedColor, dim: role !== "danger" }, + ...(summary.length > 0 ? [{ text: ` ${summary}`, color: color("dim"), dim: true }] : []), + ], + contentWidth, + ); +} + +// A collapsed edit_file/write_file row hides the diff itself, so it needs its +// own signal of how big the change was. Appended as two colored runs rather +// than one plain string so +/- read at a glance against the diff palette. +function appendDiffStat(lines: StyledLine[], toolName: string, rawArgs: string): StyledLine[] { + const edit = editDiffFromArgs(toolName, rawArgs); + if (edit === null) return lines; + const { added, removed } = diffStat(edit.oldText, edit.newText); + if (added === 0 && removed === 0) return lines; + const withAdded = appendTextToLastLine(lines, ` +${added}`, { color: color("diffAdded") }); + return appendTextToLastLine(withAdded, `/-${removed}`, { color: color("diffRemoved") }); +} + +function mergedFileEditGroupLines(count: number, width: number): StyledLine[] { + return wrapStyledLine( + [ + { text: `${toolGlyph("edit_file")} `, color: color("success"), dim: true }, + { text: `Edited ${count} files`, color: color("muted"), dim: true }, + ], + width, + ); +} + +function mergedToolLines( + call: Extract, + result: Extract, + width: number, +): StyledLine[] { + const { role, isShell, summary, glyph } = describeToolCall(call.name, call.arguments); + const roleColor = color(role); + + const durationSuffix = formatToolDurationMs((result.finishedAt ?? call.startedAt ?? 0) - (call.startedAt ?? 0)); + + if (isShell) { + // Command and outcome are recomputed from source rather than re-split out of + // a merged string — a " → " inside the command or output would corrupt it. + const outcome = summarizeToolResult(call.name, result.content).preview; + + if (!result.isError) { + const suffix = outcome === "(no output)" ? undefined : outcome; + let rows = clampedShellLines(summary, roleColor, width); + if (suffix !== undefined && suffix.length > 0) { + rows = appendTextToLastLine(rows, ` → ${suffix}`, { color: color("dim"), dim: true }); + } + return appendTextToLastLine(rows, durationSuffix, { color: color("dim"), dim: true }); + } + + if (isShellExitEnvelope(call.name, result.content)) { + const rows = clampedShellLines(summary, roleColor, width, `${shellOutcomeGlyph(true)} `); + const withOutcome = appendTextToLastLine(rows, ` → ${outcome}`, { color: color("danger"), dim: false }); + return appendTextToLastLine(withOutcome, durationSuffix, { color: color("dim"), dim: true }); + } + } + + const merged = mergedToolCollapsedPreview(call.name, call.arguments, result.content, result.isError); + const collapsedColor = role === "danger" ? roleColor : color("muted"); + const rows = wrapStyledLine( + [ + { text: `${glyph} `, color: roleColor, dim: role !== "danger" }, + { text: merged, color: collapsedColor, dim: role !== "danger" }, + ], + width, + ); + const withStat = + !result.isError && (call.name === "edit_file" || call.name === "write_file") + ? appendDiffStat(rows, call.name, call.arguments) + : rows; + return appendTextToLastLine(withStat, durationSuffix, { color: collapsedColor, dim: role !== "danger" }); +} + +function toolResultLines(block: Extract, columns: number, width: number, expanded: boolean): StyledLine[] { + if (block.isError) { + if (!expanded) { + if (isShellExitEnvelope(block.name, block.content)) { + // summarizeToolResult's run_shell case already parses the "exit code N\n" + // envelope into a one-line preview; reuse it instead of re-deriving it here. + const { preview } = summarizeToolResult(block.name, block.content); + return plainLines(`${shellOutcomeGlyph(true)} ${preview}`, { color: color("danger") }, width); + } + const firstLine = block.content.split("\n")[0] ?? ""; + return plainLines(`error: ${firstLine}`, { color: color("danger") }, width); + } + + const labeled = block.content + .split("\n") + .map((line, i) => (i === 0 ? "error: " : "") + line) + .join("\n"); + return plainLines(limitLines(labeled, EXPANDED_TOOL_RESULT_LINE_LIMIT), { color: color("danger") }, width); + } + + if (isMcpToolName(block.name)) { + const records = extractMcpRecords(block.content); + if (records !== null) return viewToLines(mcpRecordsToView(records), columns); + const record = extractMcpRecord(block.content); + if (record !== null) return viewToLines(mcpRecordToView(record), columns); + } + + const { preview, full, isJSONDocument } = summarizeToolResult(block.name, block.content); + if (expanded) { + const limited = limitLines(full, EXPANDED_TOOL_RESULT_LINE_LIMIT); + return isJSONDocument ? markdownLines(limited, width) : plainLines(limited, { color: color("muted") }, width); + } + return plainLines(preview, { color: color("muted"), dim: true }, width); +} + +export type PlanContext = { + currentStep: number | null; + deviated: boolean; +}; + +function planStepLines( + block: Extract, + width: number, + ctx: PlanContext | undefined, +): StyledLine[] { + const currentStep = ctx?.currentStep ?? null; + const deviated = ctx?.deviated ?? false; + + const lines: StyledLine[] = []; + block.steps.forEach((step, i) => { + const isDone = currentStep !== null && i < currentStep; + const isActive = currentStep !== null && i === currentStep; + const isCancelled = deviated && currentStep !== null && i >= currentStep && !isActive; + + const text = `${step.action} ${step.file}`.trim(); + + if (isDone) { + lines.push([ + { text: "✓ ", color: color("success") }, + { text, color: color("muted"), strikethrough: true, dim: true }, + ]); + } else if (isActive) { + lines.push([ + { text: "◯ ", color: color("text") }, + { text, color: color("text"), bold: true }, + ]); + } else if (isCancelled) { + lines.push([ + { text: "✗ ", color: color("danger") }, + { text: "cancelled", color: color("danger") }, + { text: ` ${text}`, color: color("dim"), dim: true }, + ]); + } else { + lines.push([ + { text: "○ ", color: color("muted"), dim: true }, + { text, color: color("muted"), dim: true }, + ]); + } + }); + return lines; +} + +// One cache slot is enough: only the transcript's last text block streams, and +// any content replacement or width change resets it. +const streamingTextLines = createIncrementalMarkdown(markdownLines); + +function blockToLines( + block: RenderableBlock, + columns: number, + expanded: boolean, + thinkingExpanded: boolean, + planCtx?: PlanContext, + streaming = false, + allBlocks?: RenderableBlock[], +): StyledLine[] { + const width = Math.max(8, columns); + + switch (block.type) { + case "thinking": { + if (!thinkingExpanded) return [[{ text: "▸ thinking…", color: color("muted"), dim: true }]]; + const thinkingContentLines = plainLines(block.content, { color: color("muted"), dim: true }, width); + // Gutter prefix distinguishes thinking from model output at a glance. + const prefixedLines: StyledLine[] = thinkingContentLines.map((line) => [ + { text: "│ ", color: color("dim"), dim: true }, + ...line, + ]); + return [ + [{ text: "▾ thinking", color: color("muted"), dim: true }], + ...prefixedLines, + ]; + } + case "user": { + // A subtle box with a blank padded row above and below so the text has + // breathing room. Text starts at column 1 to line up with the assistant's + // "●" marker; a 1-col right margin keeps the fill off the edge. + const bg = color("userMessageBg"); + const LEFT = 1; + const RIGHT = 1; + const innerWidth = Math.max(1, width - LEFT - RIGHT); + const blankRow = [{ text: " ".repeat(width), backgroundColor: bg }]; + const userLines = plainLines( + compactUserCodeBlocks(block.content), + { color: color("text"), backgroundColor: bg }, + innerWidth, + ); + const body = userLines.map((line) => { + const textLen = line.reduce((n, s) => n + s.text.length, 0); + const pad = Math.max(0, innerWidth - textLen + RIGHT); + return [ + { text: " ".repeat(LEFT), backgroundColor: bg }, + ...line, + { text: " ".repeat(pad), backgroundColor: bg }, + ]; + }); + return [blankRow, ...body, blankRow]; + } + case "text": { + const textLines = streaming + ? streamingTextLines(block.content, width) + : markdownLines(block.content, width); + // Leading marker keeps assistant prose visually separate from the colored + // user banner while staying quiet in the layout. + if (textLines.length === 0) return textLines; + // Fold the marker into the wrap budget so a full first line doesn't + // overflow the column — an uncounted spill row would push the viewport's + // bottom line (the newest text) out of view. + const firstWrapped = wrapStyledLine( + [{ text: " ● ", color: color("text") }, ...(textLines[0] ?? [])], + width, + ); + return [...firstWrapped, ...textLines.slice(1)]; + } + case "tool_call": { + const callId = block.callId ?? block.id; + const result = allBlocks !== undefined ? toolResultForCall(allBlocks, callId) : undefined; + const pending = result === undefined; + const started = block.startedAt ?? 0; + const finished = result?.finishedAt ?? started; + const durationSuffix = result !== undefined ? formatToolDurationMs(finished - started) : ""; + const indented = indentLines( + toolCallLines(block, width - TOOL_INDENT, expanded, { + pending, + durationSuffix, + }), + TOOL_INDENT, + ); + const callLines = indented; + return pending ? markRunningRow(callLines, started) : callLines; + } + case "tool_result": { + const indented = indentLines(toolResultLines(block, columns, width - TOOL_INDENT, expanded), TOOL_INDENT); + return indented; + } + case "view": + return viewToLines(block.node, columns); + case "error": + return plainLines(block.message, { color: color("danger") }, width); + case "plan": + return planStepLines(block, width, planCtx); + default: + return []; + } +} + +type AssembleBlocksArgs = { + blocks: RenderableBlock[]; + columns: number; + thinkingExpanded: boolean; + isExpanded: (block: RenderableBlock) => boolean; + cache?: Map; + planCtx?: PlanContext; + startBlockIndex: number; + prefixLines: StyledLine[]; +}; + +function pruneBlockLineCache(cache: Map, blocks: RenderableBlock[]): void { + const activeIds = new Set(blocks.map((b) => b.id)); + for (const key of cache.keys()) { + if (!activeIds.has(blockIdFromCacheKey(key))) cache.delete(key); + } +} + +function assembleRenderableBlocks(args: AssembleBlocksArgs): { lines: StyledLine[]; blockLineStarts: number[] } { + const { + blocks, + columns, + thinkingExpanded, + isExpanded, + cache, + planCtx, + startBlockIndex, + prefixLines, + } = args; + const lines = [...prefixLines]; + const blockLineStarts = new Array(blocks.length); + const lastIdx = blocks.length - 1; + let lastWasAction = false; + + for (let i = startBlockIndex; i < blocks.length; i++) { + blockLineStarts[i] = lines.length; + const block = blocks[i]!; + const next = blocks[i + 1]; + const startsTurn = block.type === "user" || block.type === "text"; + const isAction = block.type === "tool_call" || block.type === "tool_result"; + // Insert a blank line before a new turn or before an action group that + // follows prose. This keeps model text from butting directly into tool + // headlines (the main source of the "smashed together" complaint). + // Consecutive actions stay compact (no extra blank between them). + if ((startsTurn || (isAction && !lastWasAction)) && lines.length > 0) { + const last = lines[lines.length - 1]; + if (last && last.length > 0) lines.push([]); + } + + if ( + block.type === "tool_call" + && (block.name === "edit_file" || block.name === "write_file") + && !isExpanded(block) + ) { + let j = i; + let pairCount = 0; + while (j + 1 < blocks.length) { + const call = blocks[j]; + const result = blocks[j + 1]; + if ( + call?.type !== "tool_call" + || result?.type !== "tool_result" + || (call.name !== "edit_file" && call.name !== "write_file") + || result.name !== call.name + || isExpanded(call) + || isExpanded(result) + ) break; + pairCount++; + j += 2; + } + if (pairCount >= 3) { + const groupLines = indentLines( + mergedFileEditGroupLines(pairCount, Math.max(8, columns) - TOOL_INDENT), + TOOL_INDENT, + ); + lines.push(...groupLines); + for (let k = i + 1; k < j; k++) blockLineStarts[k] = lines.length; + i = j - 1; + lastWasAction = true; + continue; + } + } + + if ( + block.type === "tool_call" + && next?.type === "tool_result" + && (next.callId ?? next.id) === (block.callId ?? block.id) + && next.name === block.name + && !isExpanded(block) + && !isExpanded(next) + ) { + const mergedLines = indentLines( + mergedToolLines(block, next, Math.max(8, columns) - TOOL_INDENT), + TOOL_INDENT, + ); + lines.push(...mergedLines); + if (i + 1 < blocks.length) blockLineStarts[i + 1] = lines.length; + i++; + lastWasAction = true; + continue; + } + + const expanded = isExpanded(block); + const isStreaming = i === lastIdx || block.type === "plan"; + let blockLines: StyledLine[]; + + if (cache !== undefined && !isStreaming) { + const key = blockCacheKey(block, columns, expanded, blocks); + const cached = cache.get(key); + if (cached !== undefined) { + blockLines = cached; + } else { + blockLines = blockToLines(block, columns, expanded, thinkingExpanded, planCtx, false, blocks); + cache.set(key, blockLines); + } + } else { + blockLines = blockToLines(block, columns, expanded, thinkingExpanded, planCtx, isStreaming, blocks); + } + lines.push(...blockLines); + lastWasAction = isAction; + } + + return { lines, blockLineStarts }; +} + +export const DEFAULT_MAX_RENDERED_LOG_LINES = 2000; + +export type IncrementalLinesState = { + blocks: RenderableBlock[]; + lines: StyledLine[]; + blockLineStarts: number[]; + /** Visual line contribution per block index from the last raw assemble (pre-trim). */ + blockRenderLineCounts: number[]; + layoutKey: string; + firstRenderedBlockIndex: number; + hiddenRenderedLineCount: number; + // The exact ContentBlock[] reference `blocks` was filtered from. The stream + // state getter reuses its snapshot array reference across content-only + // mutations (streamed tokens mutate a block in place rather than replacing + // the array), so an identical reference here means renderableBlocks/filter + // would recompute an identical result — skip it and reuse `blocks` directly + // rather than re-walking every block on every streamed token. + sourceBlocks: ContentBlock[]; +}; + +function blockLineCountsFromStarts(blockLineStarts: number[], lineCount: number): number[] { + // assembleRenderableBlocks leaves indices below startBlockIndex sparse. Treat + // any hole as zero-width so downstream sums never see NaN — `??` does not + // catch NaN once it propagates, so the guard belongs here at the source. + const counts = new Array(blockLineStarts.length); + for (let i = 0; i < blockLineStarts.length; i++) { + const start = blockLineStarts[i] ?? 0; + const rawNext = i + 1 < blockLineStarts.length ? blockLineStarts[i + 1] : lineCount; + const next = rawNext ?? start; + counts[i] = Math.max(0, next - start); + } + return counts; +} + +function findTailStartFromLineCounts(counts: number[], maxLines: number, markerReserve: number): number { + const budget = maxLines - markerReserve; + let accumulated = 0; + for (let i = counts.length - 1; i >= 0; i--) { + const count = counts[i] ?? 0; + if (accumulated + count > budget && i < counts.length - 1) return i + 1; + accumulated += count; + } + return 0; +} + +function hiddenLinesMarker(hidden: number): StyledLine { + return [ + { text: `↑ ${hidden} earlier rendered lines hidden to keep the UI responsive`, dim: true }, + ]; +} + +function trimBuiltLinesToMax( + lines: StyledLine[], + blockLineStarts: number[], + blocks: RenderableBlock[], + maxLines: number, +): Pick { + if (lines.length <= maxLines) { + return { + lines, + blockLineStarts, + firstRenderedBlockIndex: 0, + hiddenRenderedLineCount: 0, + }; + } + + const keptLineCount = maxLines - 2; + const cutAt = lines.length - keptLineCount; + let firstRenderedBlockIndex = 0; + for (let i = 0; i < blocks.length; i++) { + if ((blockLineStarts[i] ?? 0) >= cutAt) { + firstRenderedBlockIndex = i; + break; + } + } + + const trimmed = [ + hiddenLinesMarker(lines.length - keptLineCount), + [] satisfies StyledLine, + ...lines.slice(cutAt), + ]; + const offset = cutAt - 2; + const nextStarts = blockLineStarts.map((start, i) => + i < firstRenderedBlockIndex ? 0 : Math.max(0, start - offset), + ); + + return { + lines: trimmed, + blockLineStarts: nextStarts, + firstRenderedBlockIndex, + hiddenRenderedLineCount: lines.length - keptLineCount, + }; +} + +function estimateBlockLineCount( + block: RenderableBlock, + columns: number, + expanded: boolean, + cache?: Map, + allBlocks?: RenderableBlock[], +): number { + const cached = cache?.get(blockCacheKey(block, columns, expanded, allBlocks)); + if (cached !== undefined) return cached.length; + if (block.type === "tool_call" || block.type === "tool_result") return 1; + if (block.type === "view") { + // Use real layout so tall grids / stacks from present get accurate virtual scroll estimates. + return viewToLines(block.node, columns).length; + } + let chars = 40; + if (block.type === "user" || block.type === "text" || block.type === "thinking") { + chars = block.content.length; + } else if (block.type === "error") { + chars = block.message.length; + } + return Math.max(1, Math.ceil(chars / Math.max(8, columns))); +} + +function findColdPathStartIndex( + blocks: RenderableBlock[], + columns: number, + isExpanded: (block: RenderableBlock) => boolean, + cache: Map | undefined, + maxLines: number, + knownCounts?: number[], +): number { + if (knownCounts !== undefined && knownCounts.length === blocks.length) { + return findTailStartFromLineCounts(knownCounts, maxLines, 4); + } + const budget = maxLines - 4; + let accumulated = 0; + for (let i = blocks.length - 1; i >= 0; i--) { + const block = blocks[i]!; + const expanded = isExpanded(block); + const turnGap = block.type === "user" || block.type === "text" ? 1 : 0; + const count = estimateBlockLineCount(block, columns, expanded, cache, blocks) + turnGap; + if (accumulated + count > budget && i < blocks.length - 1) return i + 1; + accumulated += count; + } + return 0; +} + +export function buildLinesIncremental( + prev: IncrementalLinesState | undefined, + contentBlocks: ContentBlock[], + columns: number, + thinkingExpanded: boolean, + isExpanded: (block: RenderableBlock) => boolean, + cache?: Map, + planCtx?: PlanContext, + layoutKey?: string, + maxRenderedLines: number = DEFAULT_MAX_RENDERED_LOG_LINES, +): IncrementalLinesState { + // Streamed tokens mutate the trailing block's content in place rather than + // replacing contentBlocks, so the array reference is stable across a whole + // burst of token deltas. Reusing prev's filtered result on a reference match + // skips re-walking every block on every token, which is what kept this + // O(transcript length) per streamed token instead of O(1). + const key = layoutKey ?? ""; + const blocks = + prev !== undefined && prev.sourceBlocks === contentBlocks && prev.layoutKey === key + ? prev.blocks + : renderableBlocks(contentBlocks).filter((b) => thinkingExpanded || b.type !== "thinking"); + // Prune stale cache entries only when blocks were removed (cache has more + // entries than active blocks). During streaming only the tail changes, so + // this skips the O(cache+n) scan on every frame. + if (cache !== undefined && cache.size > blocks.length) { + pruneBlockLineCache(cache, blocks); + } + + let startBlockIndex = 0; + let prefixLines: StyledLine[] = []; + + const prevTailStart = prev?.firstRenderedBlockIndex ?? 0; + + if (prev !== undefined && prev.layoutKey === key && blocks.length > 0) { + const prevLength = prev.blocks.length; + const maxPrefix = Math.min(prevLength, blocks.length); + let commonPrefix = 0; + while ( + commonPrefix < maxPrefix + && blocks[commonPrefix] === prev.blocks[commonPrefix] + ) { + commonPrefix++; + } + + if (blocks.length >= prevLength && commonPrefix >= prevLength - 1) { + // All changes are confined to prev's last block (the one that was + // streaming) and any number of appended blocks — covers a drain that + // both grew the streaming block and delivered new tool blocks. When the + // arrays are identical, the last block is still reassembled so the + // streaming path stays uncached. + startBlockIndex = + commonPrefix === blocks.length ? blocks.length - 1 : Math.min(commonPrefix, blocks.length - 1); + // A newly arrived tool_result still shares object identity with its + // earlier tool_call. Pull the assemble window back so the call is not + // left frozen as · running in the prefix. + startBlockIndex = earliestCallIndexForNewResults(blocks, startBlockIndex, prevLength); + prefixLines = + startBlockIndex === prevLength + ? prev.lines + : prev.lines.slice(0, prev.blockLineStarts[startBlockIndex] ?? 0); + } else if (blocks.length === prevLength && commonPrefix < prevTailStart) { + startBlockIndex = prevTailStart; + prefixLines = []; + } else if (blocks.length < prevLength) { + const dropped = prevLength - blocks.length; + const suffixMatches = blocks.every((b, i) => b === prev.blocks[i + dropped]); + if (suffixMatches) { + startBlockIndex = Math.max(0, prevTailStart - dropped); + prefixLines = []; + } + } + } + + if (startBlockIndex === 0 && blocks.length > 60) { + const knownCounts = + prev !== undefined + && prev.layoutKey === key + && prev.blockRenderLineCounts.length === blocks.length + ? prev.blockRenderLineCounts + : undefined; + const coldStart = findColdPathStartIndex( + blocks, + columns, + isExpanded, + cache, + maxRenderedLines, + knownCounts, + ); + if (coldStart > 0) { + startBlockIndex = coldStart; + prefixLines = [ + [{ text: `↑ ${coldStart} earlier blocks skipped during initial layout to keep the UI responsive`, dim: true }], + [], + ]; + } + } + + const { lines: rawLines, blockLineStarts: rawStarts } = assembleRenderableBlocks({ + blocks, + columns, + thinkingExpanded, + isExpanded, + ...(cache !== undefined ? { cache } : {}), + ...(planCtx !== undefined ? { planCtx } : {}), + startBlockIndex, + prefixLines, + }); + + let blockLineStarts = rawStarts; + let lines = rawLines; + let blockRenderLineCounts = blockLineCountsFromStarts(rawStarts, rawLines.length); + + if (prev !== undefined && startBlockIndex > 0) { + for (let i = 0; i < startBlockIndex; i++) { + blockLineStarts[i] = prev.blockLineStarts[i] ?? 0; + if (i < prev.blockRenderLineCounts.length) { + const prevCount = prev.blockRenderLineCounts[i]; + if (prevCount !== undefined) blockRenderLineCounts[i] = prevCount; + } + } + } + + const trimmed = trimBuiltLinesToMax(lines, blockLineStarts, blocks, maxRenderedLines); + lines = trimmed.lines; + blockLineStarts = trimmed.blockLineStarts; + + return { + blocks, + lines, + blockLineStarts, + blockRenderLineCounts, + layoutKey: key, + firstRenderedBlockIndex: trimmed.firstRenderedBlockIndex, + hiddenRenderedLineCount: trimmed.hiddenRenderedLineCount, + sourceBlocks: contentBlocks, + }; +} + +export function buildLines( + contentBlocks: ContentBlock[], + columns: number, + thinkingExpanded: boolean, + isExpanded: (block: RenderableBlock) => boolean, + cache?: Map, + planCtx?: PlanContext, +): StyledLine[] { + return buildLinesIncremental( + undefined, + contentBlocks, + columns, + thinkingExpanded, + isExpanded, + cache, + planCtx, + ).lines; +} + +export function maxLineOffset(lines: StyledLine[], visibleRows: number): number { + return Math.max(0, lines.length - visibleRows); +} + +export function lineWindow(lines: StyledLine[], scrollOffset: number, visibleRows: number): { start: number; end: number } { + const start = Math.max(0, Math.min(scrollOffset, maxLineOffset(lines, visibleRows))); + const end = Math.min(lines.length, start + Math.max(1, visibleRows)); + return { start, end }; +} + +function isFileEditTool(name: string): boolean { + return name === "edit_file" || name === "write_file"; +} + +/** Adjacent call+result with matching name — same rule as the merge assembler. */ +function isAdjacentToolPair( + call: RenderableBlock, + result: RenderableBlock, +): boolean { + return call.type === "tool_call" + && result.type === "tool_result" + && result.name === call.name; +} + +/** + * Recover zero-width partners: paired results, and whole file-edit groups (≥3 pairs). + * Mutates `ids` in place. + */ +function recoverCollapsedPartners( + blocks: readonly RenderableBlock[], + ids: Set, +): void { + for (let i = 0; i < blocks.length - 1; i++) { + const call = blocks[i]!; + const result = blocks[i + 1]!; + if (!isAdjacentToolPair(call, result)) continue; + if (ids.has(call.id)) ids.add(result.id); + else if (ids.has(result.id)) ids.add(call.id); + } + + // Collapsed file-edit groups (≥3 pairs) draw one summary on the first call and + // leave every other block zero-width. Expand the whole group when any member hits. + for (let i = 0; i < blocks.length; ) { + const head = blocks[i]!; + if (head.type !== "tool_call" || !isFileEditTool(head.name)) { + i++; + continue; + } + let j = i; + let pairCount = 0; + const groupIds: string[] = []; + while (j + 1 < blocks.length) { + const call = blocks[j]!; + const result = blocks[j + 1]!; + if (call.type !== "tool_call" || !isAdjacentToolPair(call, result) || !isFileEditTool(call.name)) break; + groupIds.push(call.id, result.id); + pairCount++; + j += 2; + } + if (pairCount >= 3 && groupIds.some((id) => ids.has(id))) { + for (const id of groupIds) ids.add(id); + } + i = j > i ? j : i + 1; + } +} + +/** + * Map a line window onto tool block ids in the same line space as `blockLineStarts`. + * Callers must pass metrics from the layout that produced the scroll offset + * (display layout while verbose) so membership tracks what is on screen. + * + * Zero-width slots left by collapsed merges are recovered: + * - adjacent tool call/result pairs (same name) expand together + * - collapsed file-edit groups (≥3 pairs) expand as a whole when any member hits + */ +export function blockIdsInLineRange( + blocks: readonly RenderableBlock[], + blockLineStarts: readonly number[], + lineCount: number, + lineStart: number, + lineEnd: number, +): Set { + const ids = new Set(); + if (blocks.length === 0 || lineEnd <= lineStart) return ids; + + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]!; + if (block.type !== "tool_call" && block.type !== "tool_result") continue; + const start = blockLineStarts[i] ?? 0; + const rawNext = i + 1 < blockLineStarts.length ? blockLineStarts[i + 1] : lineCount; + const end = rawNext ?? start; + if (end > lineStart && start < lineEnd) { + ids.add(block.id); + } + } + + recoverCollapsedPartners(blocks, ids); + return ids; +} + +/** Hard cap on tool_call blocks expanded by Ctrl+O for one viewport. */ +export const DEFAULT_MAX_VIEWPORT_EXPAND_TOOL_CALLS = 12; + +export type ViewportToolIdsArgs = { + blocks: readonly RenderableBlock[]; + blockLineStarts: readonly number[]; + lineCount: number; + prefixLineCount: number; + visibleRows: number; + scrollOffset: number; + atBottom: boolean; + /** Extra lines above/below the visible window (defaults to one screen). */ + bufferRows?: number; +}; + +function contentWindowRange(args: { + prefixLineCount: number; + lineCount: number; + visibleRows: number; + scrollOffset: number; + atBottom: boolean; + bufferRows: number; +}): { lineStart: number; lineEnd: number; contentCenter: number } { + const visibleRows = Math.max(1, args.visibleRows); + const buffer = Math.max(0, args.bufferRows); + const total = args.prefixLineCount + args.lineCount; + const maxOff = Math.max(0, total - visibleRows); + const windowStart = args.atBottom + ? maxOff + : Math.min(Math.max(0, args.scrollOffset), maxOff); + const windowEnd = windowStart + visibleRows; + const lineStart = Math.max(0, windowStart - args.prefixLineCount - buffer); + const lineEnd = Math.max(0, windowEnd - args.prefixLineCount + buffer); + const contentCenter = Math.max( + 0, + (windowStart + windowEnd) / 2 - args.prefixLineCount, + ); + return { lineStart, lineEnd, contentCenter }; +} + +/** + * Tools intersecting the visible window (± buffer) in the given layout's line space. + * `scrollOffset` / `atBottom` must come from the same layout as `lineCount`. + */ +export function viewportToolIds(args: ViewportToolIdsArgs): Set { + const visibleRows = Math.max(1, args.visibleRows); + const buffer = Math.max(0, args.bufferRows ?? visibleRows); + const { lineStart, lineEnd } = contentWindowRange({ + prefixLineCount: args.prefixLineCount, + lineCount: args.lineCount, + visibleRows, + scrollOffset: args.scrollOffset, + atBottom: args.atBottom, + bufferRows: buffer, + }); + return blockIdsInLineRange( + args.blocks, + args.blockLineStarts, + args.lineCount, + lineStart, + lineEnd, + ); +} + +/** + * Prefer tools closest to the viewport center when the raw set is denser than the + * hang-safe budget. Keeps paired results and file-edit groups intact after the cut. + */ +export function capViewportToolIds( + ids: ReadonlySet, + blocks: readonly RenderableBlock[], + blockLineStarts: readonly number[], + lineCount: number, + contentCenter: number, + maxToolCalls: number = DEFAULT_MAX_VIEWPORT_EXPAND_TOOL_CALLS, +): Set { + if (ids.size === 0 || maxToolCalls <= 0) return new Set(); + + const scored: { id: string; dist: number }[] = []; + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]!; + if (block.type !== "tool_call" || !ids.has(block.id)) continue; + const start = blockLineStarts[i] ?? 0; + const rawNext = i + 1 < blockLineStarts.length ? blockLineStarts[i + 1] : lineCount; + const end = rawNext ?? start; + const mid = end > start ? (start + end) / 2 : start; + scored.push({ id: block.id, dist: Math.abs(mid - contentCenter) }); + } + + if (scored.length <= maxToolCalls) { + return new Set(ids); + } + + scored.sort((a, b) => a.dist - b.dist || a.id.localeCompare(b.id)); + const kept = new Set(); + for (let i = 0; i < maxToolCalls; i++) kept.add(scored[i]!.id); + recoverCollapsedPartners(blocks, kept); + return kept; +} + +export type ResolveViewportExpandIdsArgs = ViewportToolIdsArgs & { + previousIds?: ReadonlySet; + /** Buffer for entering the expand set (defaults to one screen). */ + enterBufferRows?: number; + /** Buffer for staying expanded once entered (defaults to two screens). */ + holdBufferRows?: number; + maxToolCalls?: number; +}; + +/** + * Sticky viewport membership: tools enter at the enter buffer, stay expanded + * until they leave the hold buffer, then drop. Caps density near the viewport + * center so a tool-packed screen cannot expand unbounded. + */ +export function resolveViewportExpandIds(args: ResolveViewportExpandIdsArgs): Set { + const visibleRows = Math.max(1, args.visibleRows); + const enterBuffer = Math.max(0, args.enterBufferRows ?? visibleRows); + const holdBuffer = Math.max(enterBuffer, args.holdBufferRows ?? visibleRows * 2); + const maxToolCalls = args.maxToolCalls ?? DEFAULT_MAX_VIEWPORT_EXPAND_TOOL_CALLS; + const previous = args.previousIds ?? new Set(); + + const enter = viewportToolIds({ ...args, bufferRows: enterBuffer }); + const hold = holdBuffer === enterBuffer + ? enter + : viewportToolIds({ ...args, bufferRows: holdBuffer }); + + const sticky = new Set(enter); + for (const id of previous) { + if (hold.has(id)) sticky.add(id); + } + + const { contentCenter } = contentWindowRange({ + prefixLineCount: args.prefixLineCount, + lineCount: args.lineCount, + visibleRows, + scrollOffset: args.scrollOffset, + atBottom: args.atBottom, + bufferRows: 0, + }); + + return capViewportToolIds( + sticky, + args.blocks, + args.blockLineStarts, + args.lineCount, + contentCenter, + maxToolCalls, + ); +} diff --git a/src/tui/components/event-log.tsx b/src/tui/components/event-log.tsx index 3c0d3238c..6a2daea6b 100644 --- a/src/tui/components/event-log.tsx +++ b/src/tui/components/event-log.tsx @@ -1,31 +1,44 @@ import { Box, Text } from "ink"; -import type { ContentBlock } from "../use-stream.js"; import { memo, useMemo, type ReactNode } from "react"; import { formatElapsed } from "./in-flight-indicator.js"; import { elapsedMsFromAnchor } from "../hooks/use-spinner.js"; -import { createMemoizedParseMarkdown } from "../markdown-parser.js"; -import { createIncrementalMarkdown } from "../streaming-markdown.js"; import type { StyledSegment } from "../markdown-parser.js"; -import { - describeToolCall, - isShellExitEnvelope, - mergedToolCollapsedPreview, - shellOutcomeGlyph, - summarizeToolResult, - toolGlyph, -} from "../tool-formatter.js"; -import { extractMcpRecords, extractMcpRecord } from "../mcp-result-format.js"; -import { isMcpToolName } from "../../mcp/tool-name.js"; -import { mcpRecordsToView, mcpRecordToView } from "../mcp-view.js"; -import { viewToLines, type StyledLine } from "../view/index.js"; -import { wrapLines, wrapRanges, stringWidth } from "../view/height.js"; +import type { StyledLine } from "../view/index.js"; +import { stringWidth } from "../view/height.js"; import { color } from "../theme.js"; import { inkPropsForSegment } from "../styled-segment-props.js"; import { osc8Hyperlink } from "../osc8.js"; -import { diffStat, editDiffFromArgs, renderDiff } from "../diff.js"; -import { PRODUCT_NAME, PRODUCT_SHORT_NAME } from "../../branding.js"; - -export type RenderableBlock = Exclude; +import { + RUNNING_ELAPSED_RESERVE, + lineWindow, + mergeAdjacentSegments, + runningStartOfLine, + uniformBackground, +} from "./event-log-assembly.js"; + +export { + buildLines, + buildLinesIncremental, + buildResourceBanner, + capViewportToolIds, + blockIdsInLineRange, + clearMarkdownLineCache, + DEFAULT_MAX_RENDERED_LOG_LINES, + DEFAULT_MAX_VIEWPORT_EXPAND_TOOL_CALLS, + isRenderable, + lineWindow, + maxLineOffset, + renderableBlocks, + resolveViewportExpandIds, + RUNNING_ELAPSED_RESERVE, + TEXT_GUTTER, + viewportToolIds, + type IncrementalLinesState, + type PlanContext, + type RenderableBlock, + type ResolveViewportExpandIdsArgs, + type ViewportToolIdsArgs, +} from "./event-log-assembly.js"; export type EventLogProps = { lines: StyledLine[]; @@ -34,196 +47,10 @@ export type EventLogProps = { width: number; }; -const SHELL_PREFIX = "$ "; -const USER_CODE_BLOCK_LINE_LIMIT = 12; -const EXPANDED_TOOL_RESULT_LINE_LIMIT = 200; -// Tool calls and results sit one level below assistant prose so the model's -// text draws the eye and tools read as subordinate actions. -const TOOL_INDENT = 2; - -// A pending row bakes no elapsed text, but RunningToolRow appends a live -// ` · ` clock outside the wrap budget. Shrink the pending wrap width by -// this reserve so the appended clock never spills past the content column and -// forces Ink to wrap the row onto an extra line. Wide enough for the longest -// realistic hour-form clock (` · 23h 59m 59s`), and RunningToolRow trims the -// rendered clock to this width so it can never exceed the reserved room. -export const RUNNING_ELAPSED_RESERVE = 14; - -function formatToolDurationMs(ms: number): string { - if (ms < 50) return ""; - return ` · ${(ms / 1000).toFixed(1)}s`; -} - -type ToolResultBlock = Extract; - -// A cold assemble calls toolResultForCall once per block, and a linear scan per -// call is O(blocks^2). Index each blocks array once (keyed on its identity, so a -// freshly-built array reindexes and a stale one is collected) and look up in -// O(1). The first matching result per callId wins, matching the old scan. -const toolResultIndexCache = new WeakMap>(); - -function toolResultIndex(blocks: RenderableBlock[]): Map { - const cached = toolResultIndexCache.get(blocks); - if (cached !== undefined) return cached; - const index = new Map(); - for (const block of blocks) { - if (block.type === "tool_result" && !index.has(block.callId)) index.set(block.callId, block); - } - toolResultIndexCache.set(blocks, index); - return index; -} - -function toolResultForCall(blocks: RenderableBlock[], callId: string): ToolResultBlock | undefined { - return toolResultIndex(blocks).get(callId); -} -// One-column gutter shared by the transcript, the chrome (header/tasks/status), -// and the prompt-box border, so every left edge lines up at the same column. -export const TEXT_GUTTER = 1; - -function indentLines(lines: StyledLine[], spaces: number): StyledLine[] { - if (spaces <= 0) return lines; - const pad: StyledSegment = { text: " ".repeat(spaces) }; - return lines.map((line) => [pad, ...line]); -} - -// When every segment in a line shares one backgroundColor, the row's trailing -// pad segment should carry it too so the wash reaches the full row width -// instead of stopping at the last painted character. -function uniformBackground(line: StyledLine): string | undefined { - const first = line[0]?.backgroundColor; - if (first === undefined) return undefined; - return line.every((seg) => seg.backgroundColor === first) ? first : undefined; -} - -// Tag a pending tool call so the event log paints a static indicator on it. -// Only the two anchor segments carry the marker — the first row's leading -// segment for the pending glyph, the last row's trailing segment for the -// elapsed clock — so a wrapped, multi-row command stays intact: the clock -// lands at the logical end rather than in the middle of the wrapped text. -function markRunningRow(lines: StyledLine[], startedAt: number): StyledLine[] { - if (lines.length === 0) return lines; - const lastRow = lines.length - 1; - return lines.map((line, li) => - line.map((seg, si) => { - const isSpinnerAnchor = li === 0 && si === 0; - const isElapsedAnchor = li === lastRow && si === line.length - 1; - return isSpinnerAnchor || isElapsedAnchor ? { ...seg, toolRunningSince: startedAt } : seg; - }), - ); -} - -function runningStartOfLine(line: StyledLine): number | undefined { - return line[0]?.toolRunningSince ?? line[line.length - 1]?.toolRunningSince; -} - -const CACHE_KEY_SEPARATOR = "\x1f"; - -// Tool-call paint depends on whether a matching result exists (pending tint, -// duration, error). Fold that sibling state into the key so a completed call -// never reuses a pending cache entry. -function blockCacheKey( - block: RenderableBlock, - columns: number, - expanded: boolean, - allBlocks?: RenderableBlock[], -): string { - const base = [block.id, String(columns), expanded ? "1" : "0"]; - if (block.type === "tool_call" && allBlocks !== undefined) { - const result = toolResultForCall(allBlocks, block.callId ?? block.id); - base.push( - result === undefined - ? "p" - : `d${result.finishedAt ?? 0}${result.isError ? "e" : "o"}`, - ); - } - return base.join(CACHE_KEY_SEPARATOR); -} - -// When a tool_result is appended after its call was already painted into the -// incremental prefix, walk the prefix back so the call is reassembled and can -// merge / drop · running. Only newly appended results (index >= prevLength) -// trigger a walk-back — completed pairs already in prev stay frozen. -function earliestCallIndexForNewResults( - blocks: RenderableBlock[], - fromIndex: number, - prevLength: number, -): number { - let earliest = fromIndex; - for (let i = Math.max(fromIndex, prevLength); i < blocks.length; i++) { - const block = blocks[i]; - if (block?.type !== "tool_result") continue; - const callId = block.callId ?? block.id; - for (let j = 0; j < i; j++) { - const call = blocks[j]; - if (call?.type === "tool_call" && (call.callId ?? call.id) === callId) { - if (j < earliest) earliest = j; - break; - } - } - } - return earliest; -} - -function appendTextToLastLine(lines: StyledLine[], text: string, seg: Partial = {}): StyledLine[] { - if (text.length === 0 || lines.length === 0) return lines; - const last = lines[lines.length - 1]!; - return [...lines.slice(0, -1), [...last, { text, ...seg }]]; -} - -function blockIdFromCacheKey(key: string): string { - return key.split(CACHE_KEY_SEPARATOR, 1)[0] ?? key; -} - -export function isRenderable(block: ContentBlock): block is RenderableBlock { - return block.type !== "reply" && block.type !== "tasks"; -} - -export function renderableBlocks(blocks: ContentBlock[]): RenderableBlock[] { - return blocks.filter(isRenderable); -} - function segmentProps(seg: StyledSegment) { return inkPropsForSegment(seg); } -function styleKey(seg: StyledSegment): string { - return [ - seg.bold ? "b" : "", - seg.italic ? "i" : "", - seg.strikethrough ? "s" : "", - seg.heading ?? "", - seg.link ? "l" : "", - // Keep distinct link targets unmerged so OSC 8 sequences do not glue. - seg.linkUrl ?? "", - seg.blockquote ? "q" : "", - seg.rule ? "r" : "", - seg.bullet ? "u" : "", - seg.code ? "c" : "", - seg.color ?? "", - seg.dim ? "d" : "", - seg.backgroundColor ?? "", - ].join("\x1f"); -} - -// Ink lays out and diffs one node per every frame, so collapsing runs of -// identically styled segments into a single node cuts the per-frame cost of the -// visible window — most visibly on tables, whose padding fragments each row. -function mergeAdjacentSegments(line: StyledLine): StyledSegment[] { - const out: StyledSegment[] = []; - let prevKey: string | undefined; - for (const seg of line) { - const key = styleKey(seg); - const last = out[out.length - 1]; - if (last !== undefined && key === prevKey) { - last.text += seg.text; - } else { - out.push({ ...seg }); - prevKey = key; - } - } - return out; -} - type RenderedLineProps = { line: StyledLine; width: number; @@ -312,1150 +139,6 @@ const RunningToolRow = memo(function RunningToolRow({ line, width, startedAt }: ); }); -function wrapStyledLine(segments: StyledSegment[], width: number): StyledLine[] { - if (segments.length === 0) return [[]]; - - const first = segments[0]; - const markerWidth = first?.bullet === true && /^\s*(?:•|\d+\.)\s+/.test(first.text) - ? stringWidth(first.text) - : 0; - if (first !== undefined && markerWidth > 0) { - const marker = first; - const body = segments.slice(1); - if (body.length === 0) return [segments]; - const bodyText = body.map((s) => s.text).join(""); - const bodyWidth = Math.max(1, width - markerWidth); - return wrapRanges(bodyText, bodyWidth).map((range, index) => [ - index === 0 ? marker : { text: " ".repeat(markerWidth) }, - ...sliceSegments(body, range.start, range.end), - ]); - } - - const text = segments.map((s) => s.text).join(""); - return wrapRanges(text, width).map((range) => sliceSegments(segments, range.start, range.end)); -} - -function sliceSegments(segments: StyledSegment[], start: number, end: number): StyledSegment[] { - const out: StyledSegment[] = []; - let pos = 0; - for (const seg of segments) { - const segStart = pos; - const segEnd = pos + seg.text.length; - pos = segEnd; - const from = Math.max(start, segStart); - const to = Math.min(end, segEnd); - if (to > from) out.push({ ...seg, text: seg.text.slice(from - segStart, to - segStart) }); - } - return out; -} - -function sanitizeTerminalControls(content: string): string { - return content - .replace(/\x1B\[<\d+;\d+;\d+[Mm]/g, "") - .replace(/\[<\d+;\d+;\d+[Mm]/g, "") - .replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "") - .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); -} - -function plainLines(content: string, base: Partial, width: number): StyledLine[] { - return sanitizeTerminalControls(content) - .split("\n") - .flatMap((line) => wrapLines(line, width).map((row) => [{ ...base, text: row }])); -} - -// Expanded tool output is tail-anchored to match the ingress cap policy: exit -// codes, error summaries, and test totals live at the end. A small head stub -// keeps enough context to orient the reader before the elision. -function limitLines(content: string, maxLines: number): string { - const lines = content.split("\n"); - if (lines.length <= maxLines) return content; - const hidden = lines.length - maxLines; - const headStub = Math.min(3, maxLines - 1); - const tailKeep = maxLines - headStub - 1; - return [ - ...lines.slice(0, headStub), - `[${hidden} more lines hidden]`, - ...lines.slice(lines.length - tailKeep), - ].join("\n"); -} - -const SESSION_BRAND = PRODUCT_NAME; -const SESSION_ATTRIBUTION = `Powered by ${PRODUCT_SHORT_NAME}`; - -// Static header at the top of the parent-session scrollback: product identity, -// workspace path, then skills/plugins loaded for this session. -export function buildResourceBanner( - skills: readonly { name: string }[], - plugins: readonly string[], - width: number, - cwd: string, - telemetryNotice?: string, -): StyledLine[] { - const lines: StyledLine[] = [ - [{ text: SESSION_BRAND, bold: true, color: color("brand") }], - [{ text: SESSION_ATTRIBUTION, color: color("muted"), dim: true }], - ...plainLines(cwd, { color: color("muted") }, width), - ]; - const section = (label: string, items: readonly string[]): void => { - if (items.length === 0) return; - lines.push([]); - lines.push([{ text: `[${label}]`, color: color("brand") }]); - lines.push(...plainLines(items.join(", "), { color: color("muted") }, width)); - }; - section("Skills", skills.map((s) => s.name)); - section("Plugins", plugins); - if (telemetryNotice !== undefined && telemetryNotice.length > 0) { - lines.push([]); - lines.push(...plainLines(telemetryNotice, { color: color("muted"), dim: true }, width)); - } - lines.push([]); - return lines; -} - -function compactUserCodeBlocks(content: string): string { - return content.replace(/```([^\n`]*)\n([\s\S]*?)\n```/g, (match, language: string, body: string) => { - const lineCount = body.length === 0 ? 0 : body.split("\n").length; - if (lineCount <= USER_CODE_BLOCK_LINE_LIMIT) return match; - const label = language.trim().length > 0 ? `${language.trim()} code block` : "code block"; - return `[${label} hidden: ${lineCount} lines]`; - }); -} - -// Shared across all blocks: bounded by createMemoizedParseMarkdown's own LRU -// capacity and cleared by clearMarkdownLineCache alongside the coarser -// per-block line cache (see app.tsx), so it never grows across a session. -const memoizedParseMarkdown = createMemoizedParseMarkdown(); - -export function clearMarkdownLineCache(): void { - memoizedParseMarkdown.clear(); -} - -function markdownLines(content: string, width: number): StyledLine[] { - return memoizedParseMarkdown(content, width).flatMap((segments) => - segments.length === 0 ? [[]] : wrapStyledLine(segments, width), - ); -} - -function shellLines(command: string, role: string, width: number, prefix = SHELL_PREFIX): StyledLine[] { - const lines: StyledLine[] = []; - command.split("\n").forEach((logical, li) => { - const rowWidth = li === 0 ? Math.max(1, width - prefix.length) : width; - wrapLines(logical, rowWidth).forEach((row, ri) => { - if (li === 0 && ri === 0) { - lines.push([{ text: prefix, color: color("muted"), dim: true }, { text: row, color: role }]); - } else { - lines.push([{ text: row, color: role }]); - } - }); - }); - return lines; -} - -// A collapsed shell row is a headline, not a document. Chained multi-line -// commands can span dozens of rows; unclamped, their continuation lines have no -// "$ " anchor and read as detached fragments in the transcript. Show the head, -// mark the rest; Ctrl+O reveals the full command. -const COLLAPSED_SHELL_ROW_LIMIT = 4; - -function clampedShellLines(command: string, role: string, width: number, prefix = SHELL_PREFIX): StyledLine[] { - const all = shellLines(command, role, width, prefix); - if (all.length <= COLLAPSED_SHELL_ROW_LIMIT) return all; - const shown = all.slice(0, COLLAPSED_SHELL_ROW_LIMIT); - const last = shown[shown.length - 1]!; - return [ - ...shown.slice(0, -1), - [...last, { text: ` … (+${all.length - COLLAPSED_SHELL_ROW_LIMIT} more lines)`, color: color("dim"), dim: true }], - ]; -} - -function toolCallLines( - block: Extract, - width: number, - expanded: boolean, - meta?: { pending?: boolean; durationSuffix?: string }, -): StyledLine[] { - const { display, role, summary, full, isShell, glyph } = describeToolCall(block.name, block.arguments); - const roleColor = color(role); - // A pending row bakes no duration text; its live spinner and elapsed clock are - // painted by the running-row component from the startedAt marker instead. - const durationSuffix = meta?.pending ? "" : (meta?.durationSuffix ?? ""); - // The running-row component appends a live ` · ` clock after the baked - // headline. Shrink the wrap budget by that reserve while pending so the appended - // clock never spills past the content column and forces Ink onto an extra row. - // A completed row bakes its own duration and gets the full width back. - const contentWidth = meta?.pending ? Math.max(8, width - RUNNING_ELAPSED_RESERVE) : width; - - if (isShell) { - // Shell rows keep the full width: reserving here would push a near-full command - // past the collapse-row limit and drop command text. Instead the running row - // renders with truncation, so an appended clock on a wide last row clips rather - // than soft-wrapping onto a second terminal line and skewing viewport accounting. - const rows = expanded ? shellLines(full, roleColor, width) : clampedShellLines(summary, roleColor, width); - return appendTextToLastLine(rows, durationSuffix, { color: color("dim"), dim: true }); - } - - if (expanded) { - const headline = wrapStyledLine([ - { text: `${glyph} `, color: roleColor }, - { text: `${display}${durationSuffix}`, color: roleColor }, - ], contentWidth); - const edit = editDiffFromArgs(block.name, block.arguments); - if (edit !== null) { - // write_file replaces a whole file, so collapse its unchanged context - // and number lines from 1. edit_file hunks diff old_string against - // new_string with no known file offset, so the number gutter stays off - // rather than showing snippet-relative numbers as if they were real. - const diff = renderDiff( - edit.oldText, - edit.newText, - width, - block.name === "write_file" ? { contextLines: 3 } : { lineNumbers: false }, - ); - return [...headline, ...diff]; - } - return full.length > 0 ? [...headline, ...plainLines(full, { color: color("muted") }, width)] : headline; - } - - // Collapsed non-shell tool calls are subordinate — danger stays loud, everything - // else recedes so the model's actual text output draws the eye instead. The - // leading bullet stays in the action colour so a call still reads as a call. - const collapsedColor = role === "danger" ? roleColor : color("muted"); - return wrapStyledLine( - [ - { text: `${glyph} `, color: roleColor, dim: role !== "danger" }, - { text: `${display}${durationSuffix}`, color: collapsedColor, dim: role !== "danger" }, - ...(summary.length > 0 ? [{ text: ` ${summary}`, color: color("dim"), dim: true }] : []), - ], - contentWidth, - ); -} - -// A collapsed edit_file/write_file row hides the diff itself, so it needs its -// own signal of how big the change was. Appended as two colored runs rather -// than one plain string so +/- read at a glance against the diff palette. -function appendDiffStat(lines: StyledLine[], toolName: string, rawArgs: string): StyledLine[] { - const edit = editDiffFromArgs(toolName, rawArgs); - if (edit === null) return lines; - const { added, removed } = diffStat(edit.oldText, edit.newText); - if (added === 0 && removed === 0) return lines; - const withAdded = appendTextToLastLine(lines, ` +${added}`, { color: color("diffAdded") }); - return appendTextToLastLine(withAdded, `/-${removed}`, { color: color("diffRemoved") }); -} - -function mergedFileEditGroupLines(count: number, width: number): StyledLine[] { - return wrapStyledLine( - [ - { text: `${toolGlyph("edit_file")} `, color: color("success"), dim: true }, - { text: `Edited ${count} files`, color: color("muted"), dim: true }, - ], - width, - ); -} - -function mergedToolLines( - call: Extract, - result: Extract, - width: number, -): StyledLine[] { - const { role, isShell, summary, glyph } = describeToolCall(call.name, call.arguments); - const roleColor = color(role); - - const durationSuffix = formatToolDurationMs((result.finishedAt ?? call.startedAt ?? 0) - (call.startedAt ?? 0)); - - if (isShell) { - // Command and outcome are recomputed from source rather than re-split out of - // a merged string — a " → " inside the command or output would corrupt it. - const outcome = summarizeToolResult(call.name, result.content).preview; - - if (!result.isError) { - const suffix = outcome === "(no output)" ? undefined : outcome; - let rows = clampedShellLines(summary, roleColor, width); - if (suffix !== undefined && suffix.length > 0) { - rows = appendTextToLastLine(rows, ` → ${suffix}`, { color: color("dim"), dim: true }); - } - return appendTextToLastLine(rows, durationSuffix, { color: color("dim"), dim: true }); - } - - if (isShellExitEnvelope(call.name, result.content)) { - const rows = clampedShellLines(summary, roleColor, width, `${shellOutcomeGlyph(true)} `); - const withOutcome = appendTextToLastLine(rows, ` → ${outcome}`, { color: color("danger"), dim: false }); - return appendTextToLastLine(withOutcome, durationSuffix, { color: color("dim"), dim: true }); - } - } - - const merged = mergedToolCollapsedPreview(call.name, call.arguments, result.content, result.isError); - const collapsedColor = role === "danger" ? roleColor : color("muted"); - const rows = wrapStyledLine( - [ - { text: `${glyph} `, color: roleColor, dim: role !== "danger" }, - { text: merged, color: collapsedColor, dim: role !== "danger" }, - ], - width, - ); - const withStat = - !result.isError && (call.name === "edit_file" || call.name === "write_file") - ? appendDiffStat(rows, call.name, call.arguments) - : rows; - return appendTextToLastLine(withStat, durationSuffix, { color: collapsedColor, dim: role !== "danger" }); -} - -function toolResultLines(block: Extract, columns: number, width: number, expanded: boolean): StyledLine[] { - if (block.isError) { - if (!expanded) { - if (isShellExitEnvelope(block.name, block.content)) { - // summarizeToolResult's run_shell case already parses the "exit code N\n" - // envelope into a one-line preview; reuse it instead of re-deriving it here. - const { preview } = summarizeToolResult(block.name, block.content); - return plainLines(`${shellOutcomeGlyph(true)} ${preview}`, { color: color("danger") }, width); - } - const firstLine = block.content.split("\n")[0] ?? ""; - return plainLines(`error: ${firstLine}`, { color: color("danger") }, width); - } - - const labeled = block.content - .split("\n") - .map((line, i) => (i === 0 ? "error: " : "") + line) - .join("\n"); - return plainLines(limitLines(labeled, EXPANDED_TOOL_RESULT_LINE_LIMIT), { color: color("danger") }, width); - } - - if (isMcpToolName(block.name)) { - const records = extractMcpRecords(block.content); - if (records !== null) return viewToLines(mcpRecordsToView(records), columns); - const record = extractMcpRecord(block.content); - if (record !== null) return viewToLines(mcpRecordToView(record), columns); - } - - const { preview, full, isJSONDocument } = summarizeToolResult(block.name, block.content); - if (expanded) { - const limited = limitLines(full, EXPANDED_TOOL_RESULT_LINE_LIMIT); - return isJSONDocument ? markdownLines(limited, width) : plainLines(limited, { color: color("muted") }, width); - } - return plainLines(preview, { color: color("muted"), dim: true }, width); -} - -export type PlanContext = { - currentStep: number | null; - deviated: boolean; -}; - -function planStepLines( - block: Extract, - width: number, - ctx: PlanContext | undefined, -): StyledLine[] { - const currentStep = ctx?.currentStep ?? null; - const deviated = ctx?.deviated ?? false; - - const lines: StyledLine[] = []; - block.steps.forEach((step, i) => { - const isDone = currentStep !== null && i < currentStep; - const isActive = currentStep !== null && i === currentStep; - const isCancelled = deviated && currentStep !== null && i >= currentStep && !isActive; - - const text = `${step.action} ${step.file}`.trim(); - - if (isDone) { - lines.push([ - { text: "✓ ", color: color("success") }, - { text, color: color("muted"), strikethrough: true, dim: true }, - ]); - } else if (isActive) { - lines.push([ - { text: "◯ ", color: color("text") }, - { text, color: color("text"), bold: true }, - ]); - } else if (isCancelled) { - lines.push([ - { text: "✗ ", color: color("danger") }, - { text: "cancelled", color: color("danger") }, - { text: ` ${text}`, color: color("dim"), dim: true }, - ]); - } else { - lines.push([ - { text: "○ ", color: color("muted"), dim: true }, - { text, color: color("muted"), dim: true }, - ]); - } - }); - return lines; -} - -// One cache slot is enough: only the transcript's last text block streams, and -// any content replacement or width change resets it. -const streamingTextLines = createIncrementalMarkdown(markdownLines); - -function blockToLines( - block: RenderableBlock, - columns: number, - expanded: boolean, - thinkingExpanded: boolean, - planCtx?: PlanContext, - streaming = false, - allBlocks?: RenderableBlock[], -): StyledLine[] { - const width = Math.max(8, columns); - - switch (block.type) { - case "thinking": { - if (!thinkingExpanded) return [[{ text: "▸ thinking…", color: color("muted"), dim: true }]]; - const thinkingContentLines = plainLines(block.content, { color: color("muted"), dim: true }, width); - // Gutter prefix distinguishes thinking from model output at a glance. - const prefixedLines: StyledLine[] = thinkingContentLines.map((line) => [ - { text: "│ ", color: color("dim"), dim: true }, - ...line, - ]); - return [ - [{ text: "▾ thinking", color: color("muted"), dim: true }], - ...prefixedLines, - ]; - } - case "user": { - // A subtle box with a blank padded row above and below so the text has - // breathing room. Text starts at column 1 to line up with the assistant's - // "●" marker; a 1-col right margin keeps the fill off the edge. - const bg = color("userMessageBg"); - const LEFT = 1; - const RIGHT = 1; - const innerWidth = Math.max(1, width - LEFT - RIGHT); - const blankRow = [{ text: " ".repeat(width), backgroundColor: bg }]; - const userLines = plainLines( - compactUserCodeBlocks(block.content), - { color: color("text"), backgroundColor: bg }, - innerWidth, - ); - const body = userLines.map((line) => { - const textLen = line.reduce((n, s) => n + s.text.length, 0); - const pad = Math.max(0, innerWidth - textLen + RIGHT); - return [ - { text: " ".repeat(LEFT), backgroundColor: bg }, - ...line, - { text: " ".repeat(pad), backgroundColor: bg }, - ]; - }); - return [blankRow, ...body, blankRow]; - } - case "text": { - const textLines = streaming - ? streamingTextLines(block.content, width) - : markdownLines(block.content, width); - // Leading marker keeps assistant prose visually separate from the colored - // user banner while staying quiet in the layout. - if (textLines.length === 0) return textLines; - // Fold the marker into the wrap budget so a full first line doesn't - // overflow the column — an uncounted spill row would push the viewport's - // bottom line (the newest text) out of view. - const firstWrapped = wrapStyledLine( - [{ text: " ● ", color: color("text") }, ...(textLines[0] ?? [])], - width, - ); - return [...firstWrapped, ...textLines.slice(1)]; - } - case "tool_call": { - const callId = block.callId ?? block.id; - const result = allBlocks !== undefined ? toolResultForCall(allBlocks, callId) : undefined; - const pending = result === undefined; - const started = block.startedAt ?? 0; - const finished = result?.finishedAt ?? started; - const durationSuffix = result !== undefined ? formatToolDurationMs(finished - started) : ""; - const indented = indentLines( - toolCallLines(block, width - TOOL_INDENT, expanded, { - pending, - durationSuffix, - }), - TOOL_INDENT, - ); - const callLines = indented; - return pending ? markRunningRow(callLines, started) : callLines; - } - case "tool_result": { - const indented = indentLines(toolResultLines(block, columns, width - TOOL_INDENT, expanded), TOOL_INDENT); - return indented; - } - case "view": - return viewToLines(block.node, columns); - case "error": - return plainLines(block.message, { color: color("danger") }, width); - case "plan": - return planStepLines(block, width, planCtx); - default: - return []; - } -} - -type AssembleBlocksArgs = { - blocks: RenderableBlock[]; - columns: number; - thinkingExpanded: boolean; - isExpanded: (block: RenderableBlock) => boolean; - cache?: Map; - planCtx?: PlanContext; - startBlockIndex: number; - prefixLines: StyledLine[]; -}; - -function pruneBlockLineCache(cache: Map, blocks: RenderableBlock[]): void { - const activeIds = new Set(blocks.map((b) => b.id)); - for (const key of cache.keys()) { - if (!activeIds.has(blockIdFromCacheKey(key))) cache.delete(key); - } -} - -function assembleRenderableBlocks(args: AssembleBlocksArgs): { lines: StyledLine[]; blockLineStarts: number[] } { - const { - blocks, - columns, - thinkingExpanded, - isExpanded, - cache, - planCtx, - startBlockIndex, - prefixLines, - } = args; - const lines = [...prefixLines]; - const blockLineStarts = new Array(blocks.length); - const lastIdx = blocks.length - 1; - let lastWasAction = false; - - for (let i = startBlockIndex; i < blocks.length; i++) { - blockLineStarts[i] = lines.length; - const block = blocks[i]!; - const next = blocks[i + 1]; - const startsTurn = block.type === "user" || block.type === "text"; - const isAction = block.type === "tool_call" || block.type === "tool_result"; - // Insert a blank line before a new turn or before an action group that - // follows prose. This keeps model text from butting directly into tool - // headlines (the main source of the "smashed together" complaint). - // Consecutive actions stay compact (no extra blank between them). - if ((startsTurn || (isAction && !lastWasAction)) && lines.length > 0) { - const last = lines[lines.length - 1]; - if (last && last.length > 0) lines.push([]); - } - - if ( - block.type === "tool_call" - && (block.name === "edit_file" || block.name === "write_file") - && !isExpanded(block) - ) { - let j = i; - let pairCount = 0; - while (j + 1 < blocks.length) { - const call = blocks[j]; - const result = blocks[j + 1]; - if ( - call?.type !== "tool_call" - || result?.type !== "tool_result" - || (call.name !== "edit_file" && call.name !== "write_file") - || result.name !== call.name - || isExpanded(call) - || isExpanded(result) - ) break; - pairCount++; - j += 2; - } - if (pairCount >= 3) { - const groupLines = indentLines( - mergedFileEditGroupLines(pairCount, Math.max(8, columns) - TOOL_INDENT), - TOOL_INDENT, - ); - lines.push(...groupLines); - for (let k = i + 1; k < j; k++) blockLineStarts[k] = lines.length; - i = j - 1; - lastWasAction = true; - continue; - } - } - - if ( - block.type === "tool_call" - && next?.type === "tool_result" - && (next.callId ?? next.id) === (block.callId ?? block.id) - && next.name === block.name - && !isExpanded(block) - && !isExpanded(next) - ) { - const mergedLines = indentLines( - mergedToolLines(block, next, Math.max(8, columns) - TOOL_INDENT), - TOOL_INDENT, - ); - lines.push(...mergedLines); - if (i + 1 < blocks.length) blockLineStarts[i + 1] = lines.length; - i++; - lastWasAction = true; - continue; - } - - const expanded = isExpanded(block); - const isStreaming = i === lastIdx || block.type === "plan"; - let blockLines: StyledLine[]; - - if (cache !== undefined && !isStreaming) { - const key = blockCacheKey(block, columns, expanded, blocks); - const cached = cache.get(key); - if (cached !== undefined) { - blockLines = cached; - } else { - blockLines = blockToLines(block, columns, expanded, thinkingExpanded, planCtx, false, blocks); - cache.set(key, blockLines); - } - } else { - blockLines = blockToLines(block, columns, expanded, thinkingExpanded, planCtx, isStreaming, blocks); - } - lines.push(...blockLines); - lastWasAction = isAction; - } - - return { lines, blockLineStarts }; -} - -export const DEFAULT_MAX_RENDERED_LOG_LINES = 2000; - -export type IncrementalLinesState = { - blocks: RenderableBlock[]; - lines: StyledLine[]; - blockLineStarts: number[]; - /** Visual line contribution per block index from the last raw assemble (pre-trim). */ - blockRenderLineCounts: number[]; - layoutKey: string; - firstRenderedBlockIndex: number; - hiddenRenderedLineCount: number; - // The exact ContentBlock[] reference `blocks` was filtered from. The stream - // state getter reuses its snapshot array reference across content-only - // mutations (streamed tokens mutate a block in place rather than replacing - // the array), so an identical reference here means renderableBlocks/filter - // would recompute an identical result — skip it and reuse `blocks` directly - // rather than re-walking every block on every streamed token. - sourceBlocks: ContentBlock[]; -}; - -function blockLineCountsFromStarts(blockLineStarts: number[], lineCount: number): number[] { - // assembleRenderableBlocks leaves indices below startBlockIndex sparse. Treat - // any hole as zero-width so downstream sums never see NaN — `??` does not - // catch NaN once it propagates, so the guard belongs here at the source. - const counts = new Array(blockLineStarts.length); - for (let i = 0; i < blockLineStarts.length; i++) { - const start = blockLineStarts[i] ?? 0; - const rawNext = i + 1 < blockLineStarts.length ? blockLineStarts[i + 1] : lineCount; - const next = rawNext ?? start; - counts[i] = Math.max(0, next - start); - } - return counts; -} - -function findTailStartFromLineCounts(counts: number[], maxLines: number, markerReserve: number): number { - const budget = maxLines - markerReserve; - let accumulated = 0; - for (let i = counts.length - 1; i >= 0; i--) { - const count = counts[i] ?? 0; - if (accumulated + count > budget && i < counts.length - 1) return i + 1; - accumulated += count; - } - return 0; -} - -function hiddenLinesMarker(hidden: number): StyledLine { - return [ - { text: `↑ ${hidden} earlier rendered lines hidden to keep the UI responsive`, dim: true }, - ]; -} - -function trimBuiltLinesToMax( - lines: StyledLine[], - blockLineStarts: number[], - blocks: RenderableBlock[], - maxLines: number, -): Pick { - if (lines.length <= maxLines) { - return { - lines, - blockLineStarts, - firstRenderedBlockIndex: 0, - hiddenRenderedLineCount: 0, - }; - } - - const keptLineCount = maxLines - 2; - const cutAt = lines.length - keptLineCount; - let firstRenderedBlockIndex = 0; - for (let i = 0; i < blocks.length; i++) { - if ((blockLineStarts[i] ?? 0) >= cutAt) { - firstRenderedBlockIndex = i; - break; - } - } - - const trimmed = [ - hiddenLinesMarker(lines.length - keptLineCount), - [] satisfies StyledLine, - ...lines.slice(cutAt), - ]; - const offset = cutAt - 2; - const nextStarts = blockLineStarts.map((start, i) => - i < firstRenderedBlockIndex ? 0 : Math.max(0, start - offset), - ); - - return { - lines: trimmed, - blockLineStarts: nextStarts, - firstRenderedBlockIndex, - hiddenRenderedLineCount: lines.length - keptLineCount, - }; -} - -function estimateBlockLineCount( - block: RenderableBlock, - columns: number, - expanded: boolean, - cache?: Map, - allBlocks?: RenderableBlock[], -): number { - const cached = cache?.get(blockCacheKey(block, columns, expanded, allBlocks)); - if (cached !== undefined) return cached.length; - if (block.type === "tool_call" || block.type === "tool_result") return 1; - if (block.type === "view") { - // Use real layout so tall grids / stacks from present get accurate virtual scroll estimates. - return viewToLines(block.node, columns).length; - } - let chars = 40; - if (block.type === "user" || block.type === "text" || block.type === "thinking") { - chars = block.content.length; - } else if (block.type === "error") { - chars = block.message.length; - } - return Math.max(1, Math.ceil(chars / Math.max(8, columns))); -} - -function findColdPathStartIndex( - blocks: RenderableBlock[], - columns: number, - isExpanded: (block: RenderableBlock) => boolean, - cache: Map | undefined, - maxLines: number, - knownCounts?: number[], -): number { - if (knownCounts !== undefined && knownCounts.length === blocks.length) { - return findTailStartFromLineCounts(knownCounts, maxLines, 4); - } - const budget = maxLines - 4; - let accumulated = 0; - for (let i = blocks.length - 1; i >= 0; i--) { - const block = blocks[i]!; - const expanded = isExpanded(block); - const turnGap = block.type === "user" || block.type === "text" ? 1 : 0; - const count = estimateBlockLineCount(block, columns, expanded, cache, blocks) + turnGap; - if (accumulated + count > budget && i < blocks.length - 1) return i + 1; - accumulated += count; - } - return 0; -} - -export function buildLinesIncremental( - prev: IncrementalLinesState | undefined, - contentBlocks: ContentBlock[], - columns: number, - thinkingExpanded: boolean, - isExpanded: (block: RenderableBlock) => boolean, - cache?: Map, - planCtx?: PlanContext, - layoutKey?: string, - maxRenderedLines: number = DEFAULT_MAX_RENDERED_LOG_LINES, -): IncrementalLinesState { - // Streamed tokens mutate the trailing block's content in place rather than - // replacing contentBlocks, so the array reference is stable across a whole - // burst of token deltas. Reusing prev's filtered result on a reference match - // skips re-walking every block on every token, which is what kept this - // O(transcript length) per streamed token instead of O(1). - const key = layoutKey ?? ""; - const blocks = - prev !== undefined && prev.sourceBlocks === contentBlocks && prev.layoutKey === key - ? prev.blocks - : renderableBlocks(contentBlocks).filter((b) => thinkingExpanded || b.type !== "thinking"); - // Prune stale cache entries only when blocks were removed (cache has more - // entries than active blocks). During streaming only the tail changes, so - // this skips the O(cache+n) scan on every frame. - if (cache !== undefined && cache.size > blocks.length) { - pruneBlockLineCache(cache, blocks); - } - - let startBlockIndex = 0; - let prefixLines: StyledLine[] = []; - - const prevTailStart = prev?.firstRenderedBlockIndex ?? 0; - - if (prev !== undefined && prev.layoutKey === key && blocks.length > 0) { - const prevLength = prev.blocks.length; - const maxPrefix = Math.min(prevLength, blocks.length); - let commonPrefix = 0; - while ( - commonPrefix < maxPrefix - && blocks[commonPrefix] === prev.blocks[commonPrefix] - ) { - commonPrefix++; - } - - if (blocks.length >= prevLength && commonPrefix >= prevLength - 1) { - // All changes are confined to prev's last block (the one that was - // streaming) and any number of appended blocks — covers a drain that - // both grew the streaming block and delivered new tool blocks. When the - // arrays are identical, the last block is still reassembled so the - // streaming path stays uncached. - startBlockIndex = - commonPrefix === blocks.length ? blocks.length - 1 : Math.min(commonPrefix, blocks.length - 1); - // A newly arrived tool_result still shares object identity with its - // earlier tool_call. Pull the assemble window back so the call is not - // left frozen as · running in the prefix. - startBlockIndex = earliestCallIndexForNewResults(blocks, startBlockIndex, prevLength); - prefixLines = - startBlockIndex === prevLength - ? prev.lines - : prev.lines.slice(0, prev.blockLineStarts[startBlockIndex] ?? 0); - } else if (blocks.length === prevLength && commonPrefix < prevTailStart) { - startBlockIndex = prevTailStart; - prefixLines = []; - } else if (blocks.length < prevLength) { - const dropped = prevLength - blocks.length; - const suffixMatches = blocks.every((b, i) => b === prev.blocks[i + dropped]); - if (suffixMatches) { - startBlockIndex = Math.max(0, prevTailStart - dropped); - prefixLines = []; - } - } - } - - if (startBlockIndex === 0 && blocks.length > 60) { - const knownCounts = - prev !== undefined - && prev.layoutKey === key - && prev.blockRenderLineCounts.length === blocks.length - ? prev.blockRenderLineCounts - : undefined; - const coldStart = findColdPathStartIndex( - blocks, - columns, - isExpanded, - cache, - maxRenderedLines, - knownCounts, - ); - if (coldStart > 0) { - startBlockIndex = coldStart; - prefixLines = [ - [{ text: `↑ ${coldStart} earlier blocks skipped during initial layout to keep the UI responsive`, dim: true }], - [], - ]; - } - } - - const { lines: rawLines, blockLineStarts: rawStarts } = assembleRenderableBlocks({ - blocks, - columns, - thinkingExpanded, - isExpanded, - ...(cache !== undefined ? { cache } : {}), - ...(planCtx !== undefined ? { planCtx } : {}), - startBlockIndex, - prefixLines, - }); - - let blockLineStarts = rawStarts; - let lines = rawLines; - let blockRenderLineCounts = blockLineCountsFromStarts(rawStarts, rawLines.length); - - if (prev !== undefined && startBlockIndex > 0) { - for (let i = 0; i < startBlockIndex; i++) { - blockLineStarts[i] = prev.blockLineStarts[i] ?? 0; - if (i < prev.blockRenderLineCounts.length) { - const prevCount = prev.blockRenderLineCounts[i]; - if (prevCount !== undefined) blockRenderLineCounts[i] = prevCount; - } - } - } - - const trimmed = trimBuiltLinesToMax(lines, blockLineStarts, blocks, maxRenderedLines); - lines = trimmed.lines; - blockLineStarts = trimmed.blockLineStarts; - - return { - blocks, - lines, - blockLineStarts, - blockRenderLineCounts, - layoutKey: key, - firstRenderedBlockIndex: trimmed.firstRenderedBlockIndex, - hiddenRenderedLineCount: trimmed.hiddenRenderedLineCount, - sourceBlocks: contentBlocks, - }; -} - -export function buildLines( - contentBlocks: ContentBlock[], - columns: number, - thinkingExpanded: boolean, - isExpanded: (block: RenderableBlock) => boolean, - cache?: Map, - planCtx?: PlanContext, -): StyledLine[] { - return buildLinesIncremental( - undefined, - contentBlocks, - columns, - thinkingExpanded, - isExpanded, - cache, - planCtx, - ).lines; -} - -export function maxLineOffset(lines: StyledLine[], visibleRows: number): number { - return Math.max(0, lines.length - visibleRows); -} - -export function lineWindow(lines: StyledLine[], scrollOffset: number, visibleRows: number): { start: number; end: number } { - const start = Math.max(0, Math.min(scrollOffset, maxLineOffset(lines, visibleRows))); - const end = Math.min(lines.length, start + Math.max(1, visibleRows)); - return { start, end }; -} - -function isFileEditTool(name: string): boolean { - return name === "edit_file" || name === "write_file"; -} - -/** Adjacent call+result with matching name — same rule as the merge assembler. */ -function isAdjacentToolPair( - call: RenderableBlock, - result: RenderableBlock, -): boolean { - return call.type === "tool_call" - && result.type === "tool_result" - && result.name === call.name; -} - -/** - * Recover zero-width partners: paired results, and whole file-edit groups (≥3 pairs). - * Mutates `ids` in place. - */ -function recoverCollapsedPartners( - blocks: readonly RenderableBlock[], - ids: Set, -): void { - for (let i = 0; i < blocks.length - 1; i++) { - const call = blocks[i]!; - const result = blocks[i + 1]!; - if (!isAdjacentToolPair(call, result)) continue; - if (ids.has(call.id)) ids.add(result.id); - else if (ids.has(result.id)) ids.add(call.id); - } - - // Collapsed file-edit groups (≥3 pairs) draw one summary on the first call and - // leave every other block zero-width. Expand the whole group when any member hits. - for (let i = 0; i < blocks.length; ) { - const head = blocks[i]!; - if (head.type !== "tool_call" || !isFileEditTool(head.name)) { - i++; - continue; - } - let j = i; - let pairCount = 0; - const groupIds: string[] = []; - while (j + 1 < blocks.length) { - const call = blocks[j]!; - const result = blocks[j + 1]!; - if (call.type !== "tool_call" || !isAdjacentToolPair(call, result) || !isFileEditTool(call.name)) break; - groupIds.push(call.id, result.id); - pairCount++; - j += 2; - } - if (pairCount >= 3 && groupIds.some((id) => ids.has(id))) { - for (const id of groupIds) ids.add(id); - } - i = j > i ? j : i + 1; - } -} - -/** - * Map a line window onto tool block ids in the same line space as `blockLineStarts`. - * Callers must pass metrics from the layout that produced the scroll offset - * (display layout while verbose) so membership tracks what is on screen. - * - * Zero-width slots left by collapsed merges are recovered: - * - adjacent tool call/result pairs (same name) expand together - * - collapsed file-edit groups (≥3 pairs) expand as a whole when any member hits - */ -export function blockIdsInLineRange( - blocks: readonly RenderableBlock[], - blockLineStarts: readonly number[], - lineCount: number, - lineStart: number, - lineEnd: number, -): Set { - const ids = new Set(); - if (blocks.length === 0 || lineEnd <= lineStart) return ids; - - for (let i = 0; i < blocks.length; i++) { - const block = blocks[i]!; - if (block.type !== "tool_call" && block.type !== "tool_result") continue; - const start = blockLineStarts[i] ?? 0; - const rawNext = i + 1 < blockLineStarts.length ? blockLineStarts[i + 1] : lineCount; - const end = rawNext ?? start; - if (end > lineStart && start < lineEnd) { - ids.add(block.id); - } - } - - recoverCollapsedPartners(blocks, ids); - return ids; -} - -/** Hard cap on tool_call blocks expanded by Ctrl+O for one viewport. */ -export const DEFAULT_MAX_VIEWPORT_EXPAND_TOOL_CALLS = 12; - -export type ViewportToolIdsArgs = { - blocks: readonly RenderableBlock[]; - blockLineStarts: readonly number[]; - lineCount: number; - prefixLineCount: number; - visibleRows: number; - scrollOffset: number; - atBottom: boolean; - /** Extra lines above/below the visible window (defaults to one screen). */ - bufferRows?: number; -}; - -function contentWindowRange(args: { - prefixLineCount: number; - lineCount: number; - visibleRows: number; - scrollOffset: number; - atBottom: boolean; - bufferRows: number; -}): { lineStart: number; lineEnd: number; contentCenter: number } { - const visibleRows = Math.max(1, args.visibleRows); - const buffer = Math.max(0, args.bufferRows); - const total = args.prefixLineCount + args.lineCount; - const maxOff = Math.max(0, total - visibleRows); - const windowStart = args.atBottom - ? maxOff - : Math.min(Math.max(0, args.scrollOffset), maxOff); - const windowEnd = windowStart + visibleRows; - const lineStart = Math.max(0, windowStart - args.prefixLineCount - buffer); - const lineEnd = Math.max(0, windowEnd - args.prefixLineCount + buffer); - const contentCenter = Math.max( - 0, - (windowStart + windowEnd) / 2 - args.prefixLineCount, - ); - return { lineStart, lineEnd, contentCenter }; -} - -/** - * Tools intersecting the visible window (± buffer) in the given layout's line space. - * `scrollOffset` / `atBottom` must come from the same layout as `lineCount`. - */ -export function viewportToolIds(args: ViewportToolIdsArgs): Set { - const visibleRows = Math.max(1, args.visibleRows); - const buffer = Math.max(0, args.bufferRows ?? visibleRows); - const { lineStart, lineEnd } = contentWindowRange({ - prefixLineCount: args.prefixLineCount, - lineCount: args.lineCount, - visibleRows, - scrollOffset: args.scrollOffset, - atBottom: args.atBottom, - bufferRows: buffer, - }); - return blockIdsInLineRange( - args.blocks, - args.blockLineStarts, - args.lineCount, - lineStart, - lineEnd, - ); -} - -/** - * Prefer tools closest to the viewport center when the raw set is denser than the - * hang-safe budget. Keeps paired results and file-edit groups intact after the cut. - */ -export function capViewportToolIds( - ids: ReadonlySet, - blocks: readonly RenderableBlock[], - blockLineStarts: readonly number[], - lineCount: number, - contentCenter: number, - maxToolCalls: number = DEFAULT_MAX_VIEWPORT_EXPAND_TOOL_CALLS, -): Set { - if (ids.size === 0 || maxToolCalls <= 0) return new Set(); - - const scored: { id: string; dist: number }[] = []; - for (let i = 0; i < blocks.length; i++) { - const block = blocks[i]!; - if (block.type !== "tool_call" || !ids.has(block.id)) continue; - const start = blockLineStarts[i] ?? 0; - const rawNext = i + 1 < blockLineStarts.length ? blockLineStarts[i + 1] : lineCount; - const end = rawNext ?? start; - const mid = end > start ? (start + end) / 2 : start; - scored.push({ id: block.id, dist: Math.abs(mid - contentCenter) }); - } - - if (scored.length <= maxToolCalls) { - return new Set(ids); - } - - scored.sort((a, b) => a.dist - b.dist || a.id.localeCompare(b.id)); - const kept = new Set(); - for (let i = 0; i < maxToolCalls; i++) kept.add(scored[i]!.id); - recoverCollapsedPartners(blocks, kept); - return kept; -} - -export type ResolveViewportExpandIdsArgs = ViewportToolIdsArgs & { - previousIds?: ReadonlySet; - /** Buffer for entering the expand set (defaults to one screen). */ - enterBufferRows?: number; - /** Buffer for staying expanded once entered (defaults to two screens). */ - holdBufferRows?: number; - maxToolCalls?: number; -}; - -/** - * Sticky viewport membership: tools enter at the enter buffer, stay expanded - * until they leave the hold buffer, then drop. Caps density near the viewport - * center so a tool-packed screen cannot expand unbounded. - */ -export function resolveViewportExpandIds(args: ResolveViewportExpandIdsArgs): Set { - const visibleRows = Math.max(1, args.visibleRows); - const enterBuffer = Math.max(0, args.enterBufferRows ?? visibleRows); - const holdBuffer = Math.max(enterBuffer, args.holdBufferRows ?? visibleRows * 2); - const maxToolCalls = args.maxToolCalls ?? DEFAULT_MAX_VIEWPORT_EXPAND_TOOL_CALLS; - const previous = args.previousIds ?? new Set(); - - const enter = viewportToolIds({ ...args, bufferRows: enterBuffer }); - const hold = holdBuffer === enterBuffer - ? enter - : viewportToolIds({ ...args, bufferRows: holdBuffer }); - - const sticky = new Set(enter); - for (const id of previous) { - if (hold.has(id)) sticky.add(id); - } - - const { contentCenter } = contentWindowRange({ - prefixLineCount: args.prefixLineCount, - lineCount: args.lineCount, - visibleRows, - scrollOffset: args.scrollOffset, - atBottom: args.atBottom, - bufferRows: 0, - }); - - return capViewportToolIds( - sticky, - args.blocks, - args.blockLineStarts, - args.lineCount, - contentCenter, - maxToolCalls, - ); -} - // Memoized so typing in the prompt — which re-renders the App shell on every // keystroke — does not re-walk the visible window unless the lines, scroll // position, or viewport actually change.