import { type Component, truncateToWidth, VersionedRenderCache, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui"; import { formatAgentMessageParticipant } from "../../../core/agent-messages.js"; import type { KernelContentDisplay } from "../../../core/kernel/index.js"; import { previewIpythonCode, pythonStatementLines } from "../../../core/tools/code-preview.js"; import { generateDiffString } from "../../../core/tools/edit-diff.js"; import { parseIpythonBashCell } from "../../../core/tools/ipython-cell-code.js"; import { formatToolDuration } from "../../../core/tools/render-utils.js"; import { getLanguageFromPath, highlightCode, theme } from "../theme/theme.js"; import { getWorkingPulseFrame, WORKING_ICON_FRAMES, workingIconFrame } from "../theme/working-icon.js"; import { agentMessageBodyLines, agentMessageSummaryLine } from "./agent-message.js"; import { normalizeErrorDetails, summarizeErrorDetails } from "./collapsible-error.js"; import { renderDiffSeparator, renderRichDiff } from "./diff.js"; import { countChangedLines, FILE_CHANGE_DIFF_INDENT, formatFileChangeSummaryLine } from "./edit-summary.js"; import { expandCollapseHint } from "./keybinding-hints.js"; import type { BackgroundShellHandle, ShellCompletion } from "./shell-completion.js"; export interface IPythonCellContentBlock { type: string; text?: string; data?: string; mimeType?: string; } export interface IPythonCellState { code: string; backgroundShell?: BackgroundShellHandle; shellCompletion?: ShellCompletion; shellCompletionAmbiguous?: boolean; content?: readonly IPythonCellContentBlock[]; details?: unknown; isPartial?: boolean; isError?: boolean; expanded?: boolean; agentMessagesExpanded?: boolean; editDiffsExpanded?: boolean; showExpandHint?: boolean; executionStarted?: boolean; argsComplete?: boolean; showImages?: boolean; /** Session cwd — edit paths nested under it render relative, else absolute. */ cwd?: string; } interface DiffDisplay { path: string; oldStr: string; newStr: string; startLine?: number; } interface SentAgentMessageDisplay { id: string; message: string; deliveryStatus: "delivered" | "queued"; receiverRole?: "parent" | "sibling" | "child"; target: { activeSessionId: string; sessionId: string; sessionName?: string; }; } interface IpythonDetails { durationMs?: number; status?: string; errorEname?: string; stdout?: string; stderr?: string; result?: string; backgroundOutput?: string; diffs?: DiffDisplay[]; displays?: KernelContentDisplay[]; sentAgentMessages?: SentAgentMessageDisplay[]; error?: IpythonErrorDetails; } interface IpythonErrorDetails { ename: string; evalue: string; traceback: readonly string[]; } interface TracebackParts { output: string; traceback: string; preview: string; } const MAGIC_LINE_PATTERN = /^\s*!/; // Match the agent-message tree gutter; input and output text share the same column. const OUTPUT_INDENT = " "; const SGR_PATTERN = /\x1b\[([0-9;]*)m/g; /** * Append `ESC[0m` when `line` ends with a foreground or background color still * open, so a span that wrapTextWithAnsi split across lines cannot bleed into the * trailing padding or the next line. */ function closeOpenSgr(line: string): string { let fgOpen = false; let bgOpen = false; for (const match of line.matchAll(SGR_PATTERN)) { const params = match[1] === "" ? ["0"] : match[1].split(";"); for (let i = 0; i < params.length; i++) { const code = Number(params[i]); if (code === 0) { fgOpen = false; bgOpen = false; } else if (code === 38 || code === 48) { // Skip the color data of `38;5;n` / `38;2;r;g;b` so a component (e.g. 38) isn't read as a code. if (code === 38) fgOpen = true; else bgOpen = true; const mode = Number(params[i + 1]); i += mode === 2 ? 4 : mode === 5 ? 2 : 1; } else if (code === 39) { fgOpen = false; } else if (code === 49) { bgOpen = false; } else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) { fgOpen = true; } else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) { bgOpen = true; } } } return fgOpen || bgOpen ? `${line}\x1b[0m` : line; } export function getIpythonCodeFromArgs(args: unknown): string { if (!args || typeof args !== "object" || !("code" in args)) { return ""; } const code = (args as { code?: unknown }).code; return typeof code === "string" ? code : ""; } function readDetails(details: unknown): IpythonDetails { if (!details || typeof details !== "object") { return {}; } const record = details as Record; const error = readErrorDetails(record.error); return { durationMs: typeof record.durationMs === "number" ? record.durationMs : undefined, status: typeof record.status === "string" ? record.status : undefined, errorEname: error?.ename ?? (typeof record.errorEname === "string" ? record.errorEname : undefined), stdout: typeof record.stdout === "string" ? record.stdout : undefined, stderr: typeof record.stderr === "string" ? record.stderr : undefined, result: typeof record.result === "string" ? record.result : undefined, backgroundOutput: typeof record.backgroundOutput === "string" ? record.backgroundOutput : undefined, diffs: readDiffDisplays(record.diffs), displays: readContentDisplays(record.displays), sentAgentMessages: readSentAgentMessages(record.sentAgentMessages), error, }; } function readSentAgentMessages(value: unknown): SentAgentMessageDisplay[] | undefined { if (!Array.isArray(value)) { return undefined; } const messages = value.flatMap((entry): SentAgentMessageDisplay[] => { if (!entry || typeof entry !== "object") { return []; } const record = entry as Record; const target = record.target; if (!target || typeof target !== "object") { return []; } const targetRecord = target as Record; if ( typeof record.id !== "string" || typeof record.message !== "string" || (record.deliveryStatus !== "delivered" && record.deliveryStatus !== "queued") || typeof targetRecord.activeSessionId !== "string" || typeof targetRecord.sessionId !== "string" ) { return []; } return [ { id: record.id, message: record.message, deliveryStatus: record.deliveryStatus, ...(record.receiverRole === "parent" || record.receiverRole === "sibling" || record.receiverRole === "child" ? { receiverRole: record.receiverRole } : {}), target: { activeSessionId: targetRecord.activeSessionId, sessionId: targetRecord.sessionId, ...(typeof targetRecord.sessionName === "string" ? { sessionName: targetRecord.sessionName } : {}), }, }, ]; }); return messages.length > 0 ? messages : undefined; } function readDiffDisplays(value: unknown): DiffDisplay[] | undefined { if (!Array.isArray(value)) { return undefined; } const diffs = value.flatMap((entry): DiffDisplay[] => { if (!entry || typeof entry !== "object") { return []; } const record = entry as Record; if (typeof record.path !== "string" || typeof record.oldStr !== "string" || typeof record.newStr !== "string") { return []; } return [ { path: record.path, oldStr: record.oldStr, newStr: record.newStr, startLine: typeof record.startLine === "number" ? record.startLine : undefined, }, ]; }); return diffs.length > 0 ? diffs : undefined; } function readContentDisplays(value: unknown): KernelContentDisplay[] | undefined { if (!Array.isArray(value)) { return undefined; } const displays = value.flatMap((entry): KernelContentDisplay[] => { if (!entry || typeof entry !== "object") { return []; } const record = entry as Record; if (typeof record.content !== "string") { return []; } const title = typeof record.title === "string" ? record.title : undefined; const format = record.format === "diff" || record.format === "markdown" || record.format === "text" || record.format === "json" ? record.format : undefined; return [{ content: record.content, title, format }]; }); return displays.length > 0 ? displays : undefined; } function isDisplayConfirmation(text: string | undefined): boolean { if (!text) { return false; } const stripped = stripReprQuotes(text.trim()); return stripped.startsWith("Displayed ") && stripped.includes(" to user"); } /** Strip one layer of repr quotes so an `execute_result` string compares cleanly. */ function stripReprQuotes(text: string): string { const trimmed = text.trim(); if ( trimmed.length >= 2 && ((trimmed.startsWith("'") && trimmed.endsWith("'")) || (trimmed.startsWith('"') && trimmed.endsWith('"'))) ) { return trimmed.slice(1, -1); } return trimmed; } /** True when `text` is just the edit skill's "Edited " confirmation for one of `diffs`. */ function isEditConfirmation(text: string | undefined, diffs: readonly DiffDisplay[]): boolean { if (!text) { return false; } const stripped = stripReprQuotes(text); return diffs.some((diff) => stripped === `Edited ${diff.path}`); } /** * True when `text` is the `agent_message.send` receipt dict for one of the sent * messages already summarized above the output, so the raw receipt isn't shown. * Matches only a single-receipt repr — the receipt dict always starts with its * `id` key — so broadcast `{'receipts': [...]}` results (which can carry error * entries with no summary line) and results that merely mention an ID still render. */ function isAgentMessageReceipt(text: string | undefined, messages: readonly SentAgentMessageDisplay[]): boolean { if (!text || messages.length === 0) { return false; } const stripped = stripReprQuotes(text); return messages.some( (message) => stripped.startsWith(`{'id': '${message.id}'`) || stripped.startsWith(`{"id": "${message.id}"`), ); } function readErrorDetails(value: unknown): IpythonErrorDetails | undefined { if (!value || typeof value !== "object") { return undefined; } const record = value as Record; if (typeof record.ename !== "string") { return undefined; } return { ename: record.ename, evalue: typeof record.evalue === "string" ? record.evalue : "", traceback: Array.isArray(record.traceback) ? record.traceback.filter((line): line is string => typeof line === "string") : [], }; } function isImageBlock(block: IPythonCellContentBlock): boolean { return block.type === "image" && typeof block.data === "string" && typeof block.mimeType === "string"; } function textFromBlocks(blocks: readonly IPythonCellContentBlock[] | undefined): string { if (!blocks) { return ""; } return blocks .filter((block) => block.type === "text" && typeof block.text === "string") .map((block) => block.text ?? "") .join("\n"); } function splitTraceback(text: string, errorName: string | undefined): TracebackParts | undefined { const normalized = normalizeErrorDetails(text); if (!normalized.trim()) { return undefined; } const lines = normalized.split("\n"); let tracebackIndex = lines.findIndex((line) => line.includes("Traceback (most recent call last):")); if (tracebackIndex < 0 && errorName) { tracebackIndex = lines.findIndex((line) => line.trim().startsWith(`${errorName}:`)); } if (tracebackIndex < 0) { return undefined; } const output = lines.slice(0, tracebackIndex).join("\n").trimEnd(); const traceback = lines.slice(tracebackIndex).join("\n").trim(); const preview = summarizeErrorDetails(traceback); return { output, traceback, preview: preview === "Error" && errorName ? errorName : preview }; } function formatIpythonErrorSummary(error: IpythonErrorDetails): string { const normalizedValue = normalizeErrorDetails(error.evalue); if (!normalizedValue.trim()) { return error.ename; } const value = summarizeErrorDetails(normalizedValue); if (value === "Error") { return error.ename; } return visibleWidth(value) <= 48 ? `${error.ename}: ${value}` : error.ename; } export class IPythonCellComponent implements Component { private readonly renderCache = new VersionedRenderCache(); private state: IPythonCellState; private stateVersion = 0; constructor(state: IPythonCellState) { this.state = state; } update(state: IPythonCellState): void { this.state = state; this.stateVersion += 1; } invalidate(): void { this.renderCache.invalidate(); } render(width: number): string[] { const safeWidth = Math.max(1, width); const details = readDetails(this.state.details); // Fold the animation frame into the cache key while running (offset within // a stateVersion slot so it never collides with another version). const frames = WORKING_ICON_FRAMES.length; const cacheVersion = this.statusKind(details) === "running" ? this.stateVersion * frames + (getWorkingPulseFrame() % frames) : this.stateVersion * frames; const cached = this.renderCache.get(safeWidth, cacheVersion); if (cached) { return cached; } // The top line is identical whether collapsed or expanded — same marker, // counts, duration, and expand hint — so toggling never shifts the layout // or indentation; expanding only attaches code and output below it. // Cached by state version so unrelated repaints don't re-render (flicker). const lines = [truncateToWidth(` ${this.collapsedLine(details)}`, safeWidth, "")]; const hasCode = this.state.expanded ? this.renderCode(lines, safeWidth) : false; if ((details.diffs?.length ?? 0) > 0) { this.renderDiffs(lines, safeWidth, details.diffs ?? [], hasCode); } if ((details.displays?.length ?? 0) > 0) { this.renderDisplays(lines, safeWidth, details.displays ?? []); } if ((details.sentAgentMessages?.length ?? 0) > 0) { this.renderSentAgentMessages(lines, safeWidth, details.sentAgentMessages ?? []); } if (!this.state.expanded) { return this.renderCache.set(safeWidth, cacheVersion, lines); } this.renderOutput(lines, safeWidth, details, hasCode); return this.renderCache.set(safeWidth, cacheVersion, lines); } private collapsedLine(details: IpythonDetails): string { const code = this.state.code.trimEnd(); const isBashCell = parseIpythonBashCell(code) !== undefined; const preview = previewIpythonCode(code); const languageLabel = isBashCell && preview.language !== "bash" ? `bash · ${preview.language}` : preview.language; const parts = [`${this.marker(details)} ${theme.fg("muted", languageLabel)}`]; if (preview.text) { // Collapsed preview stays plain and dim so the one-line summary reads as // quiet metadata; the expanded block below keeps full highlighting. parts.push(theme.fg("dim", preview.text)); } else if (!this.state.executionStarted) { parts.push(theme.fg("dim", "waiting for code")); } const counts = this.lineCounts(details); if (counts) { parts.push(theme.fg("dim", counts)); } const duration = details.durationMs === undefined ? undefined : formatToolDuration(details.durationMs); if (duration) { parts.push( theme.fg("dim", this.state.backgroundShell || this.state.shellCompletion ? `cell ${duration}` : duration), ); } const errorName = !this.state.isPartial ? (details.error?.ename ?? details.errorEname) : undefined; if (errorName) { parts.push(theme.fg("error", errorName)); } if (this.state.shellCompletionAmbiguous) parts.push(theme.fg("dim", "completion unmatched")); const shellExit = this.state.shellCompletion?.details.exitCode ?? this.state.backgroundShell?.exitCode; if (shellExit !== undefined && shellExit !== 0) parts.push(theme.fg("error", `exit ${shellExit}`)); return parts.join(theme.fg("dim", " · ")); } /** Status marker — color carries running/done/error; ✓/✗ once finished. */ private marker(details: IpythonDetails): string { switch (this.statusKind(details)) { case "error": return theme.fg("error", "✗"); case "aborted": return theme.fg("warning", "✗"); case "done": return theme.fg("success", "✓"); case "running": return theme.fg("bashMode", workingIconFrame(getWorkingPulseFrame())); default: // queued return theme.fg("muted", "◇"); } } // `↑in ↓out lines` — the "lines" unit disambiguates from the token counts on // the activity line. Output is omitted for edits (the diff shows on expand). private lineCounts(details: IpythonDetails): string | undefined { const bashCell = parseIpythonBashCell(this.state.code); const body = (bashCell?.body ?? this.state.code).split(/\r?\n/); const input = body.filter((line) => line.trim().length > 0).length; const hasDiffs = (details.diffs?.length ?? 0) > 0; const sentMessages = details.sentAgentMessages ?? []; const result = isAgentMessageReceipt(details.result, sentMessages) ? undefined : details.result; const structured = [details.stdout, details.stderr, result, details.backgroundOutput] .filter((value): value is string => typeof value === "string" && value.trim().length > 0) .join("\n"); const blocksText = textFromBlocks(this.state.content); const fallback = isAgentMessageReceipt(blocksText, sentMessages) ? "" : blocksText; const outputText = (structured || fallback).trim(); const output = hasDiffs || !outputText ? 0 : outputText.split("\n").length; const segments: string[] = []; if (input > 0) { segments.push(`↑ ${input}`); } if (output > 0) { segments.push(`↓ ${output}`); } return segments.length > 0 ? `${segments.join(" ")} lines` : undefined; } private statusKind(details: IpythonDetails): "error" | "aborted" | "running" | "queued" | "done" { const status = details.status; if (this.state.shellCompletionAmbiguous) return "queued"; if ((this.state.backgroundShell || this.state.shellCompletion) && !this.state.isPartial) { const exitCode = this.state.shellCompletion?.details.exitCode ?? this.state.backgroundShell?.exitCode; return exitCode === undefined ? "running" : exitCode === 0 ? "done" : "error"; } if (this.state.isError || status === "error") { return "error"; } if (status === "aborted") { return "aborted"; } // Keyed off the result, not executionStarted, so calls rehydrated from a // past session (which never saw the live start) render done, not running. if (!this.state.isPartial && (status !== undefined || this.state.executionStarted || this.hasResult(details))) { return "done"; } if (this.state.isPartial || this.state.executionStarted) { return "running"; } return "queued"; } private hasResult(details: IpythonDetails): boolean { return ( details.stdout !== undefined || details.stderr !== undefined || details.result !== undefined || details.error !== undefined || (details.diffs?.length ?? 0) > 0 || (details.displays?.length ?? 0) > 0 || (details.sentAgentMessages?.length ?? 0) > 0 || (this.state.content?.length ?? 0) > 0 ); } // Only runs when expanded — shows the full source below the fixed top line. private renderCode(lines: string[], width: number): boolean { const code = this.state.code.trimEnd(); if (!code) { this.addWrapped(lines, theme.fg("dim", "╰─ "), theme.fg("muted", "waiting for code"), width); return false; } const isBashCell = parseIpythonBashCell(code) !== undefined; const rawLines = code.split("\n"); // Reopen inherited ANSI styles on each source line before gutters reset them. const sourceWidth = rawLines.reduce((max, line) => Math.max(max, visibleWidth(line)), 1); const highlightedLines = isBashCell ? [] : wrapTextWithAnsi(highlightCode(code, "python").join("\n"), sourceWidth); const statementLines = isBashCell ? rawLines : pythonStatementLines(code); for (const [index, rawLine] of rawLines.entries()) { const prefix = index === 0 ? theme.fg("dim", "╰─ ") : OUTPUT_INDENT; const highlighted = isBashCell || MAGIC_LINE_PATTERN.test(statementLines[index] ?? "") || parseIpythonBashCell(statementLines[index] ?? "") !== undefined ? theme.fg("bashMode", rawLine) : (highlightedLines[index] ?? theme.fg("mdCodeBlock", rawLine)); this.addWrapped(lines, prefix, highlighted || " ", width); } return true; } // Only runs when expanded — shows full output below the code, no previews. private renderOutput(lines: string[], width: number, details: IpythonDetails, hasCode: boolean): void { const blocks = this.state.content ?? []; const text = textFromBlocks(blocks); const imageCount = blocks.filter(isImageBlock).length; const hasStructuredOutput = details.stdout !== undefined || details.stderr !== undefined || details.result !== undefined || details.error !== undefined; const traceback = !hasStructuredOutput && (this.state.isError || details.status === "error") ? splitTraceback(text, details.errorEname) : undefined; let outputStarted = false; let renderedTextOutput = false; let outputMarkerPending = true; const outputPrefix = (): string => { if (!outputMarkerPending) return OUTPUT_INDENT; outputMarkerPending = false; return theme.fg("dim", " › "); }; const diffs = details.diffs ?? []; const sentMessages = details.sentAgentMessages ?? []; const startOutput = (): void => { if (outputStarted) { return; } outputStarted = true; if (hasCode) { this.addBlank(lines, width); } }; if (hasStructuredOutput) { if ( details.stdout?.trim() && !isEditConfirmation(details.stdout, diffs) && !isDisplayConfirmation(details.stdout) ) { startOutput(); renderedTextOutput = true; this.renderOutputText(lines, width, normalizeErrorDetails(details.stdout), "out", outputPrefix); } if (details.stderr?.trim()) { startOutput(); renderedTextOutput = true; this.renderOutputText(lines, width, normalizeErrorDetails(details.stderr), "err", outputPrefix); } if ( details.result?.trim() && !isEditConfirmation(details.result, diffs) && !isDisplayConfirmation(details.result) && !isAgentMessageReceipt(details.result, sentMessages) ) { startOutput(); renderedTextOutput = true; this.renderOutputText(lines, width, normalizeErrorDetails(details.result), "out", outputPrefix); } } else if (traceback) { if (traceback.output) { startOutput(); renderedTextOutput = true; this.renderOutputText(lines, width, traceback.output, "out", outputPrefix); } } else if (text.trim() && !isAgentMessageReceipt(text, sentMessages)) { startOutput(); renderedTextOutput = true; this.renderOutputText( lines, width, normalizeErrorDetails(text), this.state.isError ? "err" : "out", outputPrefix, ); } // Without structured fields the fallback content text above already contains the appended background block. const backgroundOutput = hasStructuredOutput && details.backgroundOutput?.trim() ? details.backgroundOutput : undefined; if (backgroundOutput) { // Suppresses the placeholders below when background output is the cell's only output; rendered after the traceback to match the model-facing order. renderedTextOutput = true; } if (!renderedTextOutput && this.state.isPartial) { startOutput(); this.addWrapped(lines, outputPrefix(), theme.fg("muted", "waiting for output..."), width); } else if (!renderedTextOutput && this.state.executionStarted && !this.state.argsComplete) { startOutput(); this.addWrapped(lines, outputPrefix(), theme.fg("muted", "waiting for output..."), width); } else if ( !renderedTextOutput && !traceback && !details.error && diffs.length === 0 && (details.displays?.length ?? 0) === 0 && (details.sentAgentMessages?.length ?? 0) === 0 && this.state.executionStarted && imageCount === 0 ) { startOutput(); this.addWrapped(lines, outputPrefix(), theme.fg("muted", "no output"), width); } if (details.error) { startOutput(); this.renderTraceback( lines, width, details.error.traceback.join("\n") || formatIpythonErrorSummary(details.error), outputPrefix, ); } else if (traceback) { startOutput(); this.renderTraceback(lines, width, traceback.traceback, outputPrefix); } if (backgroundOutput) { startOutput(); this.addWrapped(lines, outputPrefix(), theme.fg("muted", "background output (unattributed)"), width); this.renderOutputText(lines, width, normalizeErrorDetails(backgroundOutput), "err", outputPrefix); } if (imageCount > 0) { startOutput(); const text = this.state.showImages ? `${imageCount} image${imageCount === 1 ? "" : "s"} rendered below` : `${imageCount} image${imageCount === 1 ? "" : "s"} hidden`; this.addWrapped(lines, outputPrefix(), theme.fg("muted", text), width); } } private renderSentAgentMessages(lines: string[], width: number, messages: readonly SentAgentMessageDisplay[]): void { for (const message of messages) { const label = message.deliveryStatus === "delivered" ? "Agent message sent" : "Agent message queued"; const recipient = formatAgentMessageParticipant("sent", message.receiverRole, message.target); // Bodies follow the agent-message key, not the tool-output key. const hint = expandCollapseHint("app.messages.expand", this.state.agentMessagesExpanded === true); const summary = truncateToWidth( `${agentMessageSummaryLine(label, recipient)} ${hint}`, Math.max(1, width - 1), "…", ); if (this.state.agentMessagesExpanded) { this.addBlank(lines, width); this.addPlain(lines, summary); for (const line of agentMessageBodyLines(message.message, width)) lines.push(line); continue; } this.addPlain(lines, summary); } } private renderDisplays(lines: string[], width: number, displays: readonly KernelContentDisplay[]): void { const seen = new Set(); const uniqueDisplays = displays.filter((d) => { const key = `${d.format || ""}:${d.title || ""}:${d.content}`; if (seen.has(key)) return false; seen.add(key); return true; }); for (const display of uniqueDisplays) { this.addBlank(lines, width); const label = display.format === "diff" ? "reading diff" : (display.format ?? "display"); lines.push(theme.fg("accent", `◆ ${label}`)); if (display.title) { for (const titleLine of wrapTextWithAnsi(theme.bold(display.title), Math.max(1, width - 2))) { lines.push(` ${titleLine}`); } this.addBlank(lines, width); } if (display.format === "diff") { for (const row of renderRichDiff(display.content, width)) { lines.push(row); } } else { for (const line of display.content.split("\n")) { this.addWrapped(lines, " ", theme.fg("toolOutput", line || " "), width); } } } } // The `╰─ +N -M` summary line renders in both states; ctrl+j only // attaches or removes the indented diff rows underneath it. private renderDiffs(lines: string[], width: number, diffs: readonly DiffDisplay[], hasCode: boolean): void { const diffsByPath = new Map(); for (const diff of diffs) { const existing = diffsByPath.get(diff.path); if (existing) existing.push(diff); else diffsByPath.set(diff.path, [diff]); } if (hasCode) { this.addPlain(lines, ""); } for (const [path, edits] of diffsByPath) { this.renderFileDiff(lines, width, path, edits); } } private renderFileDiff(lines: string[], width: number, path: string, edits: readonly DiffDisplay[]): void { const language = getLanguageFromPath(path); // The outer inset matches ordinary chat text; renderer gutters stay intact. const indent = FILE_CHANGE_DIFF_INDENT.slice(0, Math.max(0, width - 1)); const contentWidth = Math.max(1, width - indent.length); let added = 0; let removed = 0; const rows: string[] = []; edits.forEach((edit, index) => { const { diff: diffText } = generateDiffString(edit.oldStr, edit.newStr, 4, edit.startLine ?? 1); const counts = countChangedLines(diffText); added += counts.added; removed += counts.removed; if (!this.state.editDiffsExpanded) { return; } if (index > 0) { rows.push(`${indent}${renderDiffSeparator(contentWidth)}`); } // Append, not spread: a huge edit's diff can exceed the JS arg-count limit. for (const row of renderRichDiff(diffText, contentWidth, { language })) { rows.push(`${indent}${row}`); } }); lines.push(formatFileChangeSummaryLine(path, this.state.cwd, { added, removed }, width)); for (const row of rows) { lines.push(row); } } private renderOutputText( lines: string[], width: number, text: string, label: "out" | "err", outputPrefix: () => string, ): void { const color = label === "err" ? "muted" : "toolOutput"; for (const line of text.split("\n")) { this.addWrapped(lines, outputPrefix(), theme.fg(color, line || " "), width); } } private renderTraceback(lines: string[], width: number, traceback: string, outputPrefix: () => string): void { for (const line of traceback.split("\n")) { this.addWrapped(lines, outputPrefix(), theme.fg("muted", line || " "), width); } } // Backgroundless line, indented one space to align under the fixed top line. private addWrapped(lines: string[], prefix: string, text: string, width: number): void { const available = Math.max(1, width - 1 - visibleWidth(prefix)); const wrapped = wrapTextWithAnsi(text, available); for (const [index, line] of (wrapped.length > 0 ? wrapped : [""]).entries()) { const linePrefix = index === 0 ? prefix : " ".repeat(visibleWidth(prefix)); // Truncate the composed line so a narrow pane can't exceed width (fatal in the renderer). lines.push(truncateToWidth(` ${linePrefix}${closeOpenSgr(line)}`, width, "")); } } private addBlank(lines: string[], _width: number): void { lines.push(""); } // No-background line, indented one space to align with the summary line above. private addPlain(lines: string[], text: string): void { lines.push(` ${text}`); } }