diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index be1781393..f36b04539 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,7 +1,6 @@ # Changelog ## [Unreleased] - ### Added - Support for `PI_PACKAGE_DIR` environment variable to override package directory (useful for Nix/Guix store paths) @@ -30,6 +29,11 @@ ### Changed +- Improved render performance by introducing caching for tool output blocks and search results to avoid redundant text width and padding computations +- Enhanced read tool grouping to reset when non-tool content (text/thinking blocks) appears between read calls, preventing unintended coalescing +- Improved string preview formatting in scalar values to show line counts and truncation indicators for multi-line strings +- Refactored tool execution component to use shared mutable render state for spinner frames and expansion state, reducing closure overhead +- Enhanced error handling in tool renderers with logging for renderer failures instead of silent fallbacks - Made shell command execution in configuration values asynchronous to prevent blocking the TUI - Improved `@` prefix normalization to only strip leading `@` for well-known path syntaxes (absolute paths, home directory, internal URL shorthands) to avoid mangling literal paths - Enhanced git URL parsing to strip credentials from repository URLs and validate URL-encoded hash fragments @@ -54,6 +58,7 @@ ### Fixed +- Fixed truncation indicator in truncate tool to use ellipsis character (…) instead of verbose '[truncated]' suffix - Fixed concurrent shell command execution by de-duplicating in-flight requests for the same command - Fixed git URL parsing to properly handle URL-encoded characters in hash fragments and reject invalid encodings - Fixed task executor to properly handle agents calling `submit_result` with null data by treating it as missing and attempting to extract output from conversation text rather than silently failing diff --git a/packages/coding-agent/src/lsp/render.ts b/packages/coding-agent/src/lsp/render.ts index 21e0a5319..4e8fe144d 100644 --- a/packages/coding-agent/src/lsp/render.ts +++ b/packages/coding-agent/src/lsp/render.ts @@ -19,7 +19,8 @@ import { TRUNCATE_LENGTHS, truncateToWidth, } from "../tools/render-utils"; -import { renderOutputBlock, renderStatusLine } from "../tui"; +import { renderStatusLine } from "../tui"; +import { CachedOutputBlock } from "../tui/output-block"; import type { LspParams, LspToolDetails } from "./types"; // ============================================================================= @@ -114,43 +115,16 @@ export function renderResult( const text = content.text; const lines = text.split("\n"); - const expanded = options.expanded; - - let label = "Result"; - let state: "success" | "warning" | "error" = "success"; - let bodyLines: string[] = []; + // Static type detection (result content doesn't change between renders) const codeBlockMatch = text.match(/```(\w*)\n([\s\S]*?)```/); - if (codeBlockMatch) { - label = "Hover"; - bodyLines = renderHover(codeBlockMatch, text, lines, expanded, theme); - } else { - const errorMatch = text.match(/(\d+)\s+error\(s\)/); - const warningMatch = text.match(/(\d+)\s+warning\(s\)/); - if (errorMatch || warningMatch || text.includes(theme.status.error)) { - label = "Diagnostics"; - const errorCount = errorMatch ? Number.parseInt(errorMatch[1], 10) : 0; - const warnCount = warningMatch ? Number.parseInt(warningMatch[1], 10) : 0; - state = errorCount > 0 ? "error" : warnCount > 0 ? "warning" : "success"; - bodyLines = renderDiagnostics(errorMatch, warningMatch, lines, expanded, theme); - } else { - const refMatch = text.match(/(\d+)\s+reference\(s\)/); - if (refMatch) { - label = "References"; - bodyLines = renderReferences(refMatch, lines, expanded, theme); - } else { - const symbolsMatch = text.match(/Symbols in (.+):/); - if (symbolsMatch) { - label = "Symbols"; - bodyLines = renderSymbols(symbolsMatch, lines, expanded, theme); - } else { - label = "Response"; - bodyLines = renderGeneric(text, lines, expanded, theme); - } - } - } - } + const errorMatch = text.match(/(\d+)\s+error\(s\)/); + const warningMatch = text.match(/(\d+)\s+warning\(s\)/); + const refMatch = text.match(/(\d+)\s+reference\(s\)/); + const symbolsMatch = text.match(/Symbols in (.+):/); + const hasStatusError = text.includes(theme.status.error); + // Static request info const request = args ?? result.details?.request; const requestLines: string[] = []; if (request?.file) { @@ -175,14 +149,44 @@ export function renderResult( requestLines.push(theme.fg("dim", `include declaration: ${request.include_declaration ? "true" : "false"}`)); } - const actionLabel = (request?.action ?? result.details?.action ?? label.toLowerCase()).replace(/_/g, " "); - const status = options.isPartial ? "running" : result.isError ? "error" : "success"; - const icon = formatStatusIcon(status, theme, options.spinnerFrame); - const header = `${icon} LSP ${actionLabel}`; + const outputBlock = new CachedOutputBlock(); return { - render: (width: number) => - renderOutputBlock( + render(width: number): string[] { + // Read mutable state at render time + const { expanded, isPartial, spinnerFrame } = options; + + // Determine label, state, bodyLines based on type + current expanded + let label = "Result"; + let state: "success" | "warning" | "error" = "success"; + let bodyLines: string[] = []; + + if (codeBlockMatch) { + label = "Hover"; + bodyLines = renderHover(codeBlockMatch, text, lines, expanded, theme); + } else if (errorMatch || warningMatch || hasStatusError) { + label = "Diagnostics"; + const errorCount = errorMatch ? Number.parseInt(errorMatch[1], 10) : 0; + const warnCount = warningMatch ? Number.parseInt(warningMatch[1], 10) : 0; + state = errorCount > 0 ? "error" : warnCount > 0 ? "warning" : "success"; + bodyLines = renderDiagnostics(errorMatch, warningMatch, lines, expanded, theme); + } else if (refMatch) { + label = "References"; + bodyLines = renderReferences(refMatch, lines, expanded, theme); + } else if (symbolsMatch) { + label = "Symbols"; + bodyLines = renderSymbols(symbolsMatch, lines, expanded, theme); + } else { + label = "Response"; + bodyLines = renderGeneric(text, lines, expanded, theme); + } + + const actionLabel = (request?.action ?? result.details?.action ?? label.toLowerCase()).replace(/_/g, " "); + const status = isPartial ? "running" : result.isError ? "error" : "success"; + const icon = formatStatusIcon(status, theme, spinnerFrame); + const header = `${icon} LSP ${actionLabel}`; + + return outputBlock.render( { header, state, @@ -194,8 +198,11 @@ export function renderResult( applyBg: false, }, theme, - ), - invalidate: () => {}, + ); + }, + invalidate() { + outputBlock.invalidate(); + }, }; } diff --git a/packages/coding-agent/src/modes/components/tool-execution.ts b/packages/coding-agent/src/modes/components/tool-execution.ts index 59e88539a..477bdc40d 100644 --- a/packages/coding-agent/src/modes/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/components/tool-execution.ts @@ -12,7 +12,7 @@ import { Text, type TUI, } from "@oh-my-pi/pi-tui"; -import { sanitizeText } from "@oh-my-pi/pi-utils"; +import { logger, sanitizeText } from "@oh-my-pi/pi-utils"; import type { Theme } from "../../modes/theme/theme"; import { theme } from "../../modes/theme/theme"; import { computeEditDiff, computePatchDiff, type EditDiffError, type EditDiffResult } from "../../patch"; @@ -89,6 +89,16 @@ export class ToolExecutionComponent extends Container { private spinnerInterval: ReturnType | null = null; // Track if args are still being streamed (for edit/write spinner) private argsComplete = false; + private renderState: { + spinnerFrame: number; + expanded: boolean; + isPartial: boolean; + renderContext?: Record; + } = { + spinnerFrame: 0, + expanded: false, + isPartial: true, + }; constructor( toolName: string, @@ -280,8 +290,9 @@ export class ToolExecutionComponent extends Container { const frameCount = theme.spinnerFrames.length; if (frameCount === 0) return; this.spinnerFrame = (this.spinnerFrame + 1) % frameCount; - this.updateDisplay(); + this.renderState.spinnerFrame = this.spinnerFrame; this.ui.requestRender(); + // NO updateDisplay() — existing component closures read from renderState }, 80); } else if (!needsSpinner && this.spinnerInterval) { clearInterval(this.spinnerInterval); @@ -322,6 +333,11 @@ export class ToolExecutionComponent extends Container { ? (text: string) => theme.bg("toolErrorBg", text) : (text: string) => theme.bg("toolSuccessBg", text); + // Sync shared mutable render state for component closures + this.renderState.expanded = this.expanded; + this.renderState.isPartial = this.isPartial; + this.renderState.spinnerFrame = this.spinnerFrame; + // Check for custom tool rendering if (this.tool && (this.tool.renderCall || this.tool.renderResult)) { const tool = this.tool; @@ -344,7 +360,8 @@ export class ToolExecutionComponent extends Container { } this.contentBox.addChild(component); } - } catch { + } catch (err) { + logger.warn("Tool renderer failed", { tool: this.toolName, error: String(err) }); // Fall back to default on error this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolLabel)), 0, 0)); } @@ -364,7 +381,7 @@ export class ToolExecutionComponent extends Container { ) => Component; const resultComponent = renderResult( { content: this.result.content as any, details: this.result.details, isError: this.result.isError }, - { expanded: this.expanded, isPartial: this.isPartial, spinnerFrame: this.spinnerFrame }, + this.renderState, theme, this.args, ); @@ -376,7 +393,8 @@ export class ToolExecutionComponent extends Container { } this.contentBox.addChild(component); } - } catch { + } catch (err) { + logger.warn("Tool renderer failed", { tool: this.toolName, error: String(err) }); // Fall back to showing raw output on error const output = this.getTextOutput(); if (output) { @@ -401,9 +419,7 @@ export class ToolExecutionComponent extends Container { if (shouldRenderCall) { // Render call component try { - const callComponent = renderer.renderCall(this.args, theme, { - spinnerFrame: this.spinnerFrame, - }); + const callComponent = renderer.renderCall(this.args, theme, this.renderState); if (callComponent) { // Ensure component has invalidate() method for Component interface const component = callComponent as any; @@ -412,7 +428,8 @@ export class ToolExecutionComponent extends Container { } this.contentBox.addChild(component); } - } catch { + } catch (err) { + logger.warn("Tool renderer failed", { tool: this.toolName, error: String(err) }); // Fall back to default on error this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolLabel)), 0, 0)); } @@ -423,15 +440,11 @@ export class ToolExecutionComponent extends Container { try { // Build render context for tools that need extra state const renderContext = this.buildRenderContext(); + this.renderState.renderContext = renderContext; const resultComponent = renderer.renderResult( { content: this.result.content as any, details: this.result.details, isError: this.result.isError }, - { - expanded: this.expanded, - isPartial: this.isPartial, - spinnerFrame: this.spinnerFrame, - renderContext, - }, + this.renderState, theme, this.args, // Pass args for tools that need them ); @@ -443,7 +456,8 @@ export class ToolExecutionComponent extends Container { } this.contentBox.addChild(component); } - } catch { + } catch (err) { + logger.warn("Tool renderer failed", { tool: this.toolName, error: String(err) }); // Fall back to showing raw output on error const output = this.getTextOutput(); if (output) { diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index a3632befb..f2a72f331 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -97,6 +97,7 @@ export class EventController { this.ctx.ui.requestRender(); } else if (event.message.role === "assistant") { this.lastThinkingCount = 0; + this.resetReadGroup(); this.ctx.streamingComponent = new AssistantMessageComponent(undefined, this.ctx.hideThinkingBlock); this.ctx.streamingMessage = event.message; this.ctx.chatContainer.addChild(this.ctx.streamingComponent); @@ -118,11 +119,26 @@ export class EventController { this.lastThinkingCount = thinkingCount; } + let seenNonToolContent = false; for (const content of this.ctx.streamingMessage.content) { - if (content.type !== "toolCall") continue; + if (content.type !== "toolCall") { + // Track text/thinking blocks between tool calls so read groups + // don't coalesce across narrative boundaries. + if ( + (content.type === "text" && content.text?.trim()) || + (content.type === "thinking" && (content as any).thinking?.trim()) + ) { + seenNonToolContent = true; + } + continue; + } if (!this.ctx.pendingTools.has(content.id)) { if (content.name === "read") { + if (seenNonToolContent) { + this.resetReadGroup(); + seenNonToolContent = false; + } const group = this.getReadGroup(); group.updateArgs(content.arguments, content.id); this.ctx.pendingTools.set(content.id, group); diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index 19b06ada0..8c1447749 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -203,7 +203,16 @@ export class UiHelpers { // Render tool call components for (const content of message.content) { - if (content.type !== "toolCall") continue; + if (content.type !== "toolCall") { + // Text/thinking blocks between tool calls break read grouping + if ( + (content.type === "text" && content.text?.trim()) || + (content.type === "thinking" && (content as any).thinking?.trim()) + ) { + readGroup = null; + } + continue; + } if (content.name === "read") { if (!readGroup) { diff --git a/packages/coding-agent/src/patch/shared.ts b/packages/coding-agent/src/patch/shared.ts index 1113ca40f..50a1258c8 100644 --- a/packages/coding-agent/src/patch/shared.ts +++ b/packages/coding-agent/src/patch/shared.ts @@ -13,12 +13,13 @@ import { formatExpandHint, formatStatusIcon, getDiffStats, + PREVIEW_LIMITS, shortenPath, ToolUIKit, truncateDiffByHunk, } from "../tools/render-utils"; import type { RenderCallOptions } from "../tools/renderers"; -import { renderStatusLine } from "../tui"; +import { Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui"; import type { DiffError, DiffResult, Operation } from "./types"; // ═══════════════════════════════════════════════════════════════════════════ @@ -85,8 +86,6 @@ export interface EditRenderContext { renderDiff?: (diffText: string, options?: { filePath?: string }) => string; } -const EDIT_DIFF_PREVIEW_HUNKS = 2; -const EDIT_DIFF_PREVIEW_LINES = 24; const EDIT_STREAMING_PREVIEW_LINES = 12; function countLines(text: string): number { @@ -140,7 +139,7 @@ function renderDiffSection( hiddenLines, } = expanded ? { text: diff, hiddenHunks: 0, hiddenLines: 0 } - : truncateDiffByHunk(diff, EDIT_DIFF_PREVIEW_HUNKS, EDIT_DIFF_PREVIEW_LINES); + : truncateDiffByHunk(diff, PREVIEW_LIMITS.DIFF_COLLAPSED_HUNKS, PREVIEW_LIMITS.DIFF_COLLAPSED_LINES); text += `\n\n${renderDiffFn(truncatedDiff, { filePath: rawPath })}`; if (!expanded && (hiddenHunks > 0 || hiddenLines > 0)) { @@ -209,75 +208,88 @@ export const editToolRenderer = { args?: EditRenderArgs, ): Component { const ui = new ToolUIKit(uiTheme); - const { expanded, renderContext } = options; const rawPath = args?.file_path || args?.path || ""; const filePath = shortenPath(rawPath); const editLanguage = getLanguageFromPath(rawPath) ?? "text"; const editIcon = uiTheme.fg("muted", uiTheme.getLangIcon(editLanguage)); - const editDiffPreview = renderContext?.editDiffPreview; - const renderDiffFn = renderContext?.renderDiff ?? ((t: string) => t); - // Get op and rename from args or details const op = args?.op || result.details?.op; const rename = args?.rename || result.details?.rename; - - // Build path display with line number if available - let pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…"); - const firstChangedLine = - (editDiffPreview && "firstChangedLine" in editDiffPreview ? editDiffPreview.firstChangedLine : undefined) || - (result.details && !result.isError ? result.details.firstChangedLine : undefined); - if (firstChangedLine) { - pathDisplay += uiTheme.fg("warning", `:${firstChangedLine}`); - } - - // Add arrow for rename operations - if (rename) { - pathDisplay += ` ${uiTheme.fg("dim", "→")} ${uiTheme.fg("accent", shortenPath(rename))}`; - } - - // Show operation type for patch mode const opTitle = op === "create" ? "Create" : op === "delete" ? "Delete" : "Edit"; - const header = renderStatusLine( - { - icon: result.isError ? "error" : "success", - title: opTitle, - description: `${editIcon} ${pathDisplay}`, + + // Pre-compute metadata line (static across renders) + const metadataLine = + op !== "delete" + ? `\n${formatMetadataLine(countLines(args?.newText ?? args?.oldText ?? args?.diff ?? args?.patch ?? ""), editLanguage, uiTheme)}` + : ""; + + // Pre-compute error text (static) + const errorText = result.isError ? (result.content?.find(c => c.type === "text")?.text ?? "") : ""; + + let cached: RenderCache | undefined; + + return { + render(width) { + const { expanded, renderContext } = options; + const editDiffPreview = renderContext?.editDiffPreview; + const renderDiffFn = renderContext?.renderDiff ?? ((t: string) => t); + const key = new Hasher().bool(expanded).u32(width).digest(); + if (cached?.key === key) return cached.lines; + + // Build path display with line number + let pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…"); + const firstChangedLine = + (editDiffPreview && "firstChangedLine" in editDiffPreview + ? editDiffPreview.firstChangedLine + : undefined) || (result.details && !result.isError ? result.details.firstChangedLine : undefined); + if (firstChangedLine) { + pathDisplay += uiTheme.fg("warning", `:${firstChangedLine}`); + } + + // Add arrow for rename operations + if (rename) { + pathDisplay += ` ${uiTheme.fg("dim", "→")} ${uiTheme.fg("accent", shortenPath(rename))}`; + } + + const header = renderStatusLine( + { + icon: result.isError ? "error" : "success", + title: opTitle, + description: `${editIcon} ${pathDisplay}`, + }, + uiTheme, + ); + let text = header; + text += metadataLine; + + if (result.isError) { + if (errorText) { + text += `\n\n${uiTheme.fg("error", errorText)}`; + } + } else if (result.details?.diff) { + text += renderDiffSection(result.details.diff, rawPath, expanded, uiTheme, ui, renderDiffFn); + } else if (editDiffPreview) { + if ("error" in editDiffPreview) { + text += `\n\n${uiTheme.fg("error", editDiffPreview.error)}`; + } else if (editDiffPreview.diff) { + text += renderDiffSection(editDiffPreview.diff, rawPath, expanded, uiTheme, ui, renderDiffFn); + } + } + + // Show LSP diagnostics if available + if (result.details?.diagnostics) { + text += ui.formatDiagnostics(result.details.diagnostics, expanded, (fp: string) => + uiTheme.getLangIcon(getLanguageFromPath(fp)), + ); + } + + const lines = width > 0 ? text.split("\n").map(line => truncateToWidth(line, width)) : text.split("\n"); + cached = { key, lines }; + return lines; }, - uiTheme, - ); - let text = header; - - // Skip metadata line for delete operations - if (op !== "delete") { - const editLineCount = countLines(args?.newText ?? args?.oldText ?? args?.diff ?? args?.patch ?? ""); - text += `\n${formatMetadataLine(editLineCount, editLanguage, uiTheme)}`; - } - - if (result.isError) { - // Show error from result - const errorText = result.content?.find(c => c.type === "text")?.text ?? ""; - if (errorText) { - text += `\n\n${uiTheme.fg("error", errorText)}`; - } - } else if (result.details?.diff) { - // Prefer actual diff after execution - text += renderDiffSection(result.details.diff, rawPath, expanded, uiTheme, ui, renderDiffFn); - } else if (editDiffPreview) { - // Use cached diff preview when no actual diff is available - if ("error" in editDiffPreview) { - text += `\n\n${uiTheme.fg("error", editDiffPreview.error)}`; - } else if (editDiffPreview.diff) { - text += renderDiffSection(editDiffPreview.diff, rawPath, expanded, uiTheme, ui, renderDiffFn); - } - } - - // Show LSP diagnostics if available - if (result.details?.diagnostics) { - text += ui.formatDiagnostics(result.details.diagnostics, expanded, (fp: string) => - uiTheme.getLangIcon(getLanguageFromPath(fp)), - ); - } - - return new Text(text, 0, 0); + invalidate() { + cached = undefined; + }, + }; }, }; diff --git a/packages/coding-agent/src/task/render.ts b/packages/coding-agent/src/task/render.ts index 87b04635f..163a2e707 100644 --- a/packages/coding-agent/src/task/render.ts +++ b/packages/coding-agent/src/task/render.ts @@ -24,7 +24,7 @@ import { type ReportFindingDetails, type SubmitReviewDetails, } from "../tools/review"; -import { renderStatusLine } from "../tui"; +import { Hasher, type RenderCache, renderStatusLine } from "../tui"; import { subprocessToolRegistry } from "./subprocess-tool-registry"; import type { AgentProgress, SingleResult, TaskParams, TaskToolDetails } from "./types"; @@ -382,7 +382,13 @@ function formatScalarInline(value: unknown, maxLen: number, _theme: Theme): stri if (value === undefined) return "undefined"; if (typeof value === "boolean") return String(value); if (typeof value === "number") return String(value); - if (typeof value === "string") return `"${truncateToWidth(value, maxLen)}"`; + if (typeof value === "string") { + const firstLine = value.split("\n")[0].trim(); + if (firstLine.length === 0) return `"" (${value.split("\n").length} lines)`; + const preview = truncateToWidth(firstLine, maxLen); + if (value.includes("\n")) return `"${preview}…" (${value.split("\n").length} lines)`; + return `"${preview}"`; + } if (Array.isArray(value)) return `[${value.length} items]`; if (typeof value === "object") { const keys = Object.keys(value); @@ -845,74 +851,88 @@ export function renderResult( options: RenderResultOptions, theme: Theme, ): Component { - const { expanded, isPartial, spinnerFrame } = options; const fallbackText = result.content.find(c => c.type === "text")?.text ?? ""; const details = result.details; if (!details) { - // Fallback to simple text const text = result.content.find(c => c.type === "text")?.text || ""; return new Text(theme.fg("dim", truncateToWidth(text, 100)), 0, 0); } - const lines: string[] = []; + let cached: RenderCache | undefined; - if (isPartial && details.progress) { - // Streaming progress view - details.progress.forEach((progress, i) => { - const isLast = i === details.progress!.length - 1; - lines.push(...renderAgentProgress(progress, isLast, expanded, theme, spinnerFrame)); - }); - } else if (details.results.length > 0) { - // Final results view - details.results.forEach((res, i) => { - const isLast = i === details.results.length - 1; - lines.push(...renderAgentResult(res, isLast, expanded, theme)); - }); + return { + render(width) { + const { expanded, isPartial, spinnerFrame } = options; + const key = new Hasher() + .bool(expanded) + .bool(isPartial) + .u32(spinnerFrame ?? 0) + .u32(width) + .digest(); + if (cached?.key === key) return cached.lines; - // Summary line - const abortedCount = details.results.filter(r => r.aborted).length; - const successCount = details.results.filter(r => !r.aborted && r.exitCode === 0).length; - const failCount = details.results.length - successCount - abortedCount; - let summary = `${theme.fg("dim", "Total:")} `; - if (abortedCount > 0) { - summary += theme.fg("error", `${abortedCount} aborted`); - if (successCount > 0 || failCount > 0) summary += theme.sep.dot; - } - if (successCount > 0) { - summary += theme.fg("success", `${successCount} succeeded`); - if (failCount > 0) summary += theme.sep.dot; - } - if (failCount > 0) { - summary += theme.fg("error", `${failCount} failed`); - } - summary += `${theme.sep.dot}${theme.fg("dim", formatDuration(details.totalDurationMs))}`; - lines.push(summary); + const lines: string[] = []; - // Artifacts suppressed from user view - available via session file - } + if (isPartial && details.progress) { + details.progress.forEach((progress, i) => { + const isLast = i === details.progress!.length - 1; + lines.push(...renderAgentProgress(progress, isLast, expanded, theme, spinnerFrame)); + }); + } else if (details.results.length > 0) { + details.results.forEach((res, i) => { + const isLast = i === details.results.length - 1; + lines.push(...renderAgentResult(res, isLast, expanded, theme)); + }); - if (lines.length === 0) { - const text = fallbackText.trim() ? fallbackText : "No results"; - return new Text(theme.fg("dim", truncateToWidth(text, 140)), 0, 0); - } - - if (fallbackText.trim()) { - const summaryLines = fallbackText.split("\n"); - const markerIndex = summaryLines.findIndex( - line => line.includes("") || line.startsWith("Applied patches:"), - ); - if (markerIndex >= 0) { - const extra = summaryLines.slice(markerIndex); - for (const line of extra) { - if (!line.trim()) continue; - lines.push(theme.fg("dim", line)); + const abortedCount = details.results.filter(r => r.aborted).length; + const successCount = details.results.filter(r => !r.aborted && r.exitCode === 0).length; + const failCount = details.results.length - successCount - abortedCount; + let summary = `${theme.fg("dim", "Total:")} `; + if (abortedCount > 0) { + summary += theme.fg("error", `${abortedCount} aborted`); + if (successCount > 0 || failCount > 0) summary += theme.sep.dot; + } + if (successCount > 0) { + summary += theme.fg("success", `${successCount} succeeded`); + if (failCount > 0) summary += theme.sep.dot; + } + if (failCount > 0) { + summary += theme.fg("error", `${failCount} failed`); + } + summary += `${theme.sep.dot}${theme.fg("dim", formatDuration(details.totalDurationMs))}`; + lines.push(summary); } - } - } - const indented = lines.map(line => (line.length > 0 ? ` ${line}` : "")); - return new Text(indented.join("\n"), 0, 0); + if (lines.length === 0) { + const text = fallbackText.trim() ? fallbackText : "No results"; + const result = [theme.fg("dim", truncateToWidth(text, 140))]; + cached = { key, lines: result }; + return result; + } + + if (fallbackText.trim()) { + const summaryLines = fallbackText.split("\n"); + const markerIndex = summaryLines.findIndex( + line => line.includes("") || line.startsWith("Applied patches:"), + ); + if (markerIndex >= 0) { + const extra = summaryLines.slice(markerIndex); + for (const line of extra) { + if (!line.trim()) continue; + lines.push(theme.fg("dim", line)); + } + } + } + + const indented = lines.map(line => (line.length > 0 ? ` ${line}` : "")); + cached = { key, lines: indented }; + return indented; + }, + invalidate() { + cached = undefined; + }, + }; } function isTaskToolDetails(value: unknown): value is TaskToolDetails { diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index 8b6398e59..f0a26a514 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -446,7 +446,7 @@ export const askToolRenderer = { renderResult( result: { content: Array<{ type: string; text?: string }>; details?: AskToolDetails }, - _opts: RenderResultOptions, + _options: RenderResultOptions, uiTheme: Theme, ): Component { const { details } = result; @@ -454,7 +454,13 @@ export const askToolRenderer = { const txt = result.content[0]; const fallback = txt?.type === "text" && txt.text ? txt.text : ""; const header = renderStatusLine({ icon: "warning", title: "Ask" }, uiTheme); - return new Text([header, uiTheme.fg("dim", fallback)].join("\n"), 0, 0); + const renderedLines = [header, uiTheme.fg("dim", fallback)]; + return { + render() { + return renderedLines; + }, + invalidate() {}, + }; } // Multi-part results @@ -506,13 +512,24 @@ export const askToolRenderer = { } } - return new Text(lines.join("\n"), 0, 0); + return { + render() { + return lines; + }, + invalidate() {}, + }; } // Single question result if (!details.question) { const txt = result.content[0]; - return new Text(txt?.type === "text" && txt.text ? txt.text : "", 0, 0); + const renderedLines = txt?.type === "text" && txt.text ? txt.text.split("\n") : [""]; + return { + render() { + return renderedLines; + }, + invalidate() {}, + }; } const hasSelection = details.customInput || (details.selectedOptions && details.selectedOptions.length > 0); @@ -535,6 +552,12 @@ export const askToolRenderer = { text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`; } - return new Text(text, 0, 0); + const renderedLines = text.split("\n"); + return { + render() { + return renderedLines; + }, + invalidate() {}, + }; }, }; diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index 7c3737c99..d0ee147fd 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -9,7 +9,8 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { truncateToVisualLines } from "../modes/components/visual-truncate"; import type { Theme } from "../modes/theme/theme"; import bashDescription from "../prompts/tools/bash.md" with { type: "text" }; -import { renderOutputBlock, renderStatusLine } from "../tui"; +import { renderStatusLine } from "../tui"; +import { CachedOutputBlock } from "../tui/output-block"; import type { ToolSession } from "."; import { checkBashInterception } from "./bash-interceptor"; import { applyHeadTail, normalizeBashCommand } from "./bash-normalize"; @@ -223,46 +224,49 @@ export const bashToolRenderer = { const cmdText = args ? formatBashCommand(args, uiTheme) : undefined; const isError = result.isError === true; const header = renderStatusLine({ icon: isError ? "error" : "success", title: "Bash" }, uiTheme); - const { renderContext } = options; const details = result.details; - const expanded = renderContext?.expanded ?? options.expanded; - const previewLines = renderContext?.previewLines ?? BASH_DEFAULT_PREVIEW_LINES; - - // Get output from context (preferred) or fall back to result content - const output = renderContext?.output ?? result.content?.find(c => c.type === "text")?.text ?? ""; - const displayOutput = output.trimEnd(); - const showingFullOutput = expanded && renderContext?.isFullOutput === true; - - // Build truncation warning lines (static, doesn't depend on width) const truncation = details?.meta?.truncation; - const timeoutSeconds = renderContext?.timeout; - const timeoutLine = - typeof timeoutSeconds === "number" - ? uiTheme.fg( - "dim", - `${uiTheme.format.bracketLeft}Timeout: ${timeoutSeconds}s${uiTheme.format.bracketRight}`, - ) - : undefined; - let warningLine: string | undefined; - if (truncation && !showingFullOutput) { - const warnings: string[] = []; - if (truncation?.artifactId) { - warnings.push(`Full output: artifact://${truncation.artifactId}`); - } - if (truncation.truncatedBy === "lines") { - warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`); - } else { - warnings.push( - `Truncated: ${truncation.outputLines} lines shown (${formatBytes(truncation.outputBytes)} limit)`, - ); - } - if (warnings.length > 0) { - warningLine = uiTheme.fg("warning", wrapBrackets(warnings.join(". "), uiTheme)); - } - } + const outputBlock = new CachedOutputBlock(); return { render: (width: number): string[] => { + // REACTIVE: read mutable options at render time + const { renderContext } = options; + const expanded = renderContext?.expanded ?? options.expanded; + const previewLines = renderContext?.previewLines ?? BASH_DEFAULT_PREVIEW_LINES; + + // Get output from context (preferred) or fall back to result content + const output = renderContext?.output ?? result.content?.find(c => c.type === "text")?.text ?? ""; + const displayOutput = output.trimEnd(); + const showingFullOutput = expanded && renderContext?.isFullOutput === true; + + // Build truncation warning + const timeoutSeconds = renderContext?.timeout; + const timeoutLine = + typeof timeoutSeconds === "number" + ? uiTheme.fg( + "dim", + `${uiTheme.format.bracketLeft}Timeout: ${timeoutSeconds}s${uiTheme.format.bracketRight}`, + ) + : undefined; + let warningLine: string | undefined; + if (truncation && !showingFullOutput) { + const warnings: string[] = []; + if (truncation?.artifactId) { + warnings.push(`Full output: artifact://${truncation.artifactId}`); + } + if (truncation.truncatedBy === "lines") { + warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`); + } else { + warnings.push( + `Truncated: ${truncation.outputLines} lines shown (${formatBytes(truncation.outputBytes)} limit)`, + ); + } + if (warnings.length > 0) { + warningLine = uiTheme.fg("warning", wrapBrackets(warnings.join(". "), uiTheme)); + } + } + const outputLines: string[] = []; const hasOutput = displayOutput.trim().length > 0; if (hasOutput) { @@ -289,7 +293,7 @@ export const bashToolRenderer = { if (timeoutLine) outputLines.push(timeoutLine); if (warningLine) outputLines.push(warningLine); - return renderOutputBlock( + return outputBlock.render( { header, state: isError ? "error" : "success", @@ -302,7 +306,9 @@ export const bashToolRenderer = { uiTheme, ); }, - invalidate: () => {}, + invalidate: () => { + outputBlock.invalidate(); + }, }; }, mergeCallAndResult: true, diff --git a/packages/coding-agent/src/tools/calculator.ts b/packages/coding-agent/src/tools/calculator.ts index 8282906df..029b966d0 100644 --- a/packages/coding-agent/src/tools/calculator.ts +++ b/packages/coding-agent/src/tools/calculator.ts @@ -7,7 +7,7 @@ import { renderPromptTemplate } from "../config/prompt-templates"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; import calculatorDescription from "../prompts/tools/calculator.md" with { type: "text" }; -import { renderStatusLine, renderTreeList } from "../tui"; +import { type RenderCache, renderStatusLine, renderTreeList } from "../tui"; import type { ToolSession } from "."; import { formatCount, @@ -466,7 +466,7 @@ export const calculatorToolRenderer = { */ renderResult( result: { content: Array<{ type: string; text?: string }>; details?: CalculatorToolDetails; isError?: boolean }, - { expanded }: RenderResultOptions, + options: RenderResultOptions, uiTheme: Theme, args?: CalculatorRenderArgs, ): Component { @@ -474,7 +474,13 @@ export const calculatorToolRenderer = { const textContent = result.content?.find(c => c.type === "text")?.text ?? ""; if (result.isError) { const header = renderStatusLine({ icon: "error", title: "Calc" }, uiTheme); - return new Text([header, formatErrorMessage(textContent, uiTheme)].join("\n"), 0, 0); + const renderedLines = [header, formatErrorMessage(textContent, uiTheme)]; + return { + render() { + return renderedLines; + }, + invalidate() {}, + }; } // Prefer structured details; fall back to parsing text content @@ -491,7 +497,13 @@ export const calculatorToolRenderer = { if (outputs.length === 0) { const header = renderStatusLine({ icon: "warning", title: "Calc" }, uiTheme); - return new Text([header, formatEmptyMessage("No results", uiTheme)].join("\n"), 0, 0); + const renderedLines = [header, formatEmptyMessage("No results", uiTheme)]; + return { + render() { + return renderedLines; + }, + invalidate() {}, + }; } const description = args?.calculations?.[0]?.expression @@ -501,18 +513,32 @@ export const calculatorToolRenderer = { { icon: "success", title: "Calc", description, meta: [formatCount("result", outputs.length)] }, uiTheme, ); - const lines = renderTreeList( - { - items: outputs, - expanded, - maxCollapsed: COLLAPSED_LIST_LIMIT, - itemType: "result", - renderItem: output => uiTheme.fg("toolOutput", output), - }, - uiTheme, - ); - return new Text([header, ...lines].join("\n"), 0, 0); + let cached: RenderCache | undefined; + + return { + render(_width) { + const { expanded } = options; + const key = expanded ? 1n : 0n; + if (cached?.key === key) return cached.lines; + const treeLines = renderTreeList( + { + items: outputs, + expanded, + maxCollapsed: COLLAPSED_LIST_LIMIT, + itemType: "result", + renderItem: output => uiTheme.fg("toolOutput", output), + }, + uiTheme, + ); + const lines = [header, ...treeLines]; + cached = { key, lines }; + return lines; + }, + invalidate() { + cached = undefined; + }, + }; }, mergeCallAndResult: true, }; diff --git a/packages/coding-agent/src/tools/fetch.ts b/packages/coding-agent/src/tools/fetch.ts index dc6419633..a9b484706 100644 --- a/packages/coding-agent/src/tools/fetch.ts +++ b/packages/coding-agent/src/tools/fetch.ts @@ -10,7 +10,8 @@ import { renderPromptTemplate } from "../config/prompt-templates"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { type Theme, theme } from "../modes/theme/theme"; import fetchDescription from "../prompts/tools/fetch.md" with { type: "text" }; -import { renderOutputBlock, renderStatusLine } from "../tui"; +import { renderStatusLine } from "../tui"; +import { CachedOutputBlock } from "../tui/output-block"; import { ensureTool } from "../utils/tools-manager"; import { specialHandlers } from "../web/scrapers"; import type { RenderResult } from "../web/scrapers/types"; @@ -980,7 +981,6 @@ export function renderFetchResult( options: RenderResultOptions, uiTheme: Theme = theme, ): Component { - const { expanded } = options; const details = result.details; if (!details) { @@ -1031,20 +1031,32 @@ export function renderFetchResult( metadataLines.push(`${uiTheme.fg("muted", "Notes:")} ${details.notes.join("; ")}`); } - const previewLimit = expanded ? 12 : 3; - const previewList = applyListLimit(contentLines, { headLimit: previewLimit }); - const previewLines = previewList.items.map(line => truncate(line.trimEnd(), 120, "…")); - const remaining = Math.max(0, contentLines.length - previewLines.length); - const contentPreviewLines = - previewLines.length > 0 ? previewLines.map(line => uiTheme.fg("dim", line)) : [uiTheme.fg("dim", "(no content)")]; - if (remaining > 0) { - const hint = formatExpandHint(uiTheme, expanded, true); - contentPreviewLines.push(uiTheme.fg("muted", `… ${remaining} more lines${hint ? ` ${hint}` : ""}`)); - } + const outputBlock = new CachedOutputBlock(); + let lastExpanded: boolean | undefined; + let contentPreviewLines: string[] | undefined; return { - render: (width: number) => - renderOutputBlock( + render: (width: number) => { + const { expanded } = options; + + if (contentPreviewLines === undefined || lastExpanded !== expanded) { + const previewLimit = expanded ? 12 : 3; + const previewList = applyListLimit(contentLines, { headLimit: previewLimit }); + const previewLines = previewList.items.map(line => truncate(line.trimEnd(), 120, "…")); + const remaining = Math.max(0, contentLines.length - previewLines.length); + contentPreviewLines = + previewLines.length > 0 + ? previewLines.map(line => uiTheme.fg("dim", line)) + : [uiTheme.fg("dim", "(no content)")]; + if (remaining > 0) { + const hint = formatExpandHint(uiTheme, expanded, true); + contentPreviewLines.push(uiTheme.fg("muted", `… ${remaining} more lines${hint ? ` ${hint}` : ""}`)); + } + lastExpanded = expanded; + outputBlock.invalidate(); + } + + return outputBlock.render( { header, state: truncated ? "warning" : "success", @@ -1056,8 +1068,13 @@ export function renderFetchResult( applyBg: false, }, uiTheme, - ), - invalidate: () => {}, + ); + }, + invalidate: () => { + outputBlock.invalidate(); + contentPreviewLines = undefined; + lastExpanded = undefined; + }, }; } diff --git a/packages/coding-agent/src/tools/find.ts b/packages/coding-agent/src/tools/find.ts index 924a41521..c1b0717c6 100644 --- a/packages/coding-agent/src/tools/find.ts +++ b/packages/coding-agent/src/tools/find.ts @@ -11,7 +11,7 @@ import { renderPromptTemplate } from "../config/prompt-templates"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; import findDescription from "../prompts/tools/find.md" with { type: "text" }; -import { renderFileList, renderStatusLine, renderTreeList } from "../tui"; +import { type RenderCache, renderFileList, renderStatusLine, renderTreeList } from "../tui"; import type { ToolSession } from "."; import { applyListLimit } from "./list-limit"; import type { OutputMeta } from "./output-meta"; @@ -412,7 +412,7 @@ export const findToolRenderer = { renderResult( result: { content: Array<{ type: string; text?: string }>; details?: FindToolDetails; isError?: boolean }, - { expanded }: RenderResultOptions, + options: RenderResultOptions, uiTheme: Theme, args?: FindRenderArgs, ): Component { @@ -446,17 +446,30 @@ export const findToolRenderer = { }, uiTheme, ); - const listLines = renderTreeList( - { - items: lines, - expanded, - maxCollapsed: COLLAPSED_LIST_LIMIT, - itemType: "file", - renderItem: line => uiTheme.fg("accent", line), + let cached: RenderCache | undefined; + return { + render(_width: number): string[] { + const { expanded } = options; + const key = expanded ? 1n : 0n; + if (cached?.key === key) return cached.lines; + const listLines = renderTreeList( + { + items: lines, + expanded, + maxCollapsed: COLLAPSED_LIST_LIMIT, + itemType: "file", + renderItem: line => uiTheme.fg("accent", line), + }, + uiTheme, + ); + const result = [header, ...listLines]; + cached = { key, lines: result }; + return result; }, - uiTheme, - ); - return new Text([header, ...listLines].join("\n"), 0, 0); + invalidate() { + cached = undefined; + }, + }; } const fileCount = details?.fileCount ?? 0; @@ -480,15 +493,6 @@ export const findToolRenderer = { uiTheme, ); - const fileLines = renderFileList( - { - files: files.map(entry => ({ path: entry, isDirectory: entry.endsWith("/") })), - expanded, - maxCollapsed: COLLAPSED_LIST_LIMIT, - }, - uiTheme, - ); - const truncationReasons: string[] = []; if (details?.resultLimitReached) truncationReasons.push(`limit ${details.resultLimitReached} results`); if (limits?.resultLimit) truncationReasons.push(`limit ${limits.resultLimit.reached} results`); @@ -501,7 +505,28 @@ export const findToolRenderer = { extraLines.push(uiTheme.fg("warning", `truncated: ${truncationReasons.join(", ")}`)); } - return new Text([header, ...fileLines, ...extraLines].join("\n"), 0, 0); + let cached: RenderCache | undefined; + return { + render(_width: number): string[] { + const { expanded } = options; + const key = expanded ? 1n : 0n; + if (cached?.key === key) return cached.lines; + const fileLines = renderFileList( + { + files: files.map(entry => ({ path: entry, isDirectory: entry.endsWith("/") })), + expanded, + maxCollapsed: COLLAPSED_LIST_LIMIT, + }, + uiTheme, + ); + const result = [header, ...fileLines, ...extraLines]; + cached = { key, lines: result }; + return result; + }, + invalidate() { + cached = undefined; + }, + }; }, mergeCallAndResult: true, }; diff --git a/packages/coding-agent/src/tools/grep.ts b/packages/coding-agent/src/tools/grep.ts index 83c1b108a..a2a34f782 100644 --- a/packages/coding-agent/src/tools/grep.ts +++ b/packages/coding-agent/src/tools/grep.ts @@ -10,7 +10,7 @@ import { renderPromptTemplate } from "../config/prompt-templates"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; import grepDescription from "../prompts/tools/grep.md" with { type: "text" }; -import { renderStatusLine, renderTreeList } from "../tui"; +import { type RenderCache, renderStatusLine, renderTreeList } from "../tui"; import type { ToolSession } from "."; import type { OutputMeta } from "./output-meta"; import { resolveToCwd } from "./path-utils"; @@ -324,7 +324,7 @@ export const grepToolRenderer = { renderResult( result: { content: Array<{ type: string; text?: string }>; details?: GrepToolDetails; isError?: boolean }, - { expanded }: RenderResultOptions, + options: RenderResultOptions, uiTheme: Theme, args?: GrepRenderArgs, ): Component { @@ -348,17 +348,30 @@ export const grepToolRenderer = { { icon: "success", title: "Grep", description, meta: [formatCount("item", lines.length)] }, uiTheme, ); - const listLines = renderTreeList( - { - items: lines, - expanded, - maxCollapsed: COLLAPSED_TEXT_LIMIT, - itemType: "item", - renderItem: line => uiTheme.fg("toolOutput", line), + let cached: RenderCache | undefined; + return { + render(_width: number): string[] { + const { expanded } = options; + const key = expanded ? 1n : 0n; + if (cached?.key === key) return cached.lines; + const listLines = renderTreeList( + { + items: lines, + expanded, + maxCollapsed: COLLAPSED_TEXT_LIMIT, + itemType: "item", + renderItem: line => uiTheme.fg("toolOutput", line), + }, + uiTheme, + ); + const result = [header, ...listLines]; + cached = { key, lines: result }; + return result; }, - uiTheme, - ); - return new Text([header, ...listLines].join("\n"), 0, 0); + invalidate() { + cached = undefined; + }, + }; } const matchCount = details?.matchCount ?? 0; @@ -424,18 +437,6 @@ export const grepToolRenderer = { return count; }; - const maxCollapsed = expanded ? matchGroups.length : getCollapsedMatchLimit(matchGroups, COLLAPSED_TEXT_LIMIT); - const matchLines = renderTreeList( - { - items: matchGroups, - expanded, - maxCollapsed, - itemType: "match", - renderItem: group => group.map(line => uiTheme.fg("toolOutput", line)), - }, - uiTheme, - ); - const truncationReasons: string[] = []; if (limits?.matchLimit) truncationReasons.push(`limit ${limits.matchLimit.reached} matches`); if (limits?.resultLimit) truncationReasons.push(`limit ${limits.resultLimit.reached} results`); @@ -446,7 +447,33 @@ export const grepToolRenderer = { const extraLines = truncationReasons.length > 0 ? [uiTheme.fg("warning", `truncated: ${truncationReasons.join(", ")}`)] : []; - return new Text([header, ...matchLines, ...extraLines].join("\n"), 0, 0); + let cached: RenderCache | undefined; + return { + render(_width: number): string[] { + const { expanded } = options; + const key = expanded ? 1n : 0n; + if (cached?.key === key) return cached.lines; + const maxCollapsed = expanded + ? matchGroups.length + : getCollapsedMatchLimit(matchGroups, COLLAPSED_TEXT_LIMIT); + const matchLines = renderTreeList( + { + items: matchGroups, + expanded, + maxCollapsed, + itemType: "match", + renderItem: group => group.map(line => uiTheme.fg("toolOutput", line)), + }, + uiTheme, + ); + const result = [header, ...matchLines, ...extraLines]; + cached = { key, lines: result }; + return result; + }, + invalidate() { + cached = undefined; + }, + }; }, mergeCallAndResult: true, }; diff --git a/packages/coding-agent/src/tools/notebook.ts b/packages/coding-agent/src/tools/notebook.ts index e80596169..036435968 100644 --- a/packages/coding-agent/src/tools/notebook.ts +++ b/packages/coding-agent/src/tools/notebook.ts @@ -7,7 +7,7 @@ import { type Static, Type } from "@sinclair/typebox"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; import type { ToolSession } from "../sdk"; -import { renderCodeCell, renderStatusLine } from "../tui"; +import { Hasher, type RenderCache, renderCodeCell, renderStatusLine } from "../tui"; import { resolveToCwd } from "./path-utils"; import { formatCount, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils"; @@ -221,7 +221,7 @@ export const notebookToolRenderer = { renderResult( result: { content: Array<{ type: string; text?: string }>; details?: NotebookToolDetails }, - { expanded }: RenderResultOptions, + options: RenderResultOptions, uiTheme: Theme, args?: NotebookRenderArgs, ): Component { @@ -252,9 +252,16 @@ export const notebookToolRenderer = { const notebookPath = args?.notebookPath ?? args?.notebook_path; const notebookLabel = notebookPath ? `${actionLabel} ${notebookPath}` : "Notebook"; + let cached: RenderCache | undefined; + return { - render: (width: number) => - renderCodeCell( + render: (width: number): string[] => { + // REACTIVE: read mutable options at render time + const { expanded } = options; + const key = new Hasher().bool(expanded).u32(width).digest(); + if (cached?.key === key) return cached.lines; + + const lines = renderCodeCell( { code: codeText, language, @@ -266,8 +273,14 @@ export const notebookToolRenderer = { width, }, uiTheme, - ), - invalidate: () => {}, + ); + + cached = { key, lines }; + return lines; + }, + invalidate: () => { + cached = undefined; + }, }; }, mergeCallAndResult: true, diff --git a/packages/coding-agent/src/tools/python.ts b/packages/coding-agent/src/tools/python.ts index 9784a41f6..d5e3b07ee 100644 --- a/packages/coding-agent/src/tools/python.ts +++ b/packages/coding-agent/src/tools/python.ts @@ -832,22 +832,20 @@ export const pythonToolRenderer = { uiTheme: Theme, ): Component { const ui = new ToolUIKit(uiTheme); - const { renderContext } = options; const details = result.details; - const expanded = renderContext?.expanded ?? options.expanded; - const previewLines = renderContext?.previewLines ?? PYTHON_DEFAULT_PREVIEW_LINES; - const output = renderContext?.output ?? (result.content?.find(c => c.type === "text")?.text ?? "").trimEnd(); + const output = + options.renderContext?.output ?? (result.content?.find(c => c.type === "text")?.text ?? "").trimEnd(); const jsonOutputs = details?.jsonOutputs ?? []; const jsonLines = jsonOutputs.flatMap((value, index) => { const header = `JSON output ${index + 1}`; - const treeLines = renderJsonTree(value, uiTheme, expanded); + const treeLines = renderJsonTree(value, uiTheme, options.renderContext?.expanded ?? options.expanded); return [header, ...treeLines]; }); const truncation = details?.meta?.truncation; - const timeoutSeconds = renderContext?.timeout; + const timeoutSeconds = options.renderContext?.timeout; const timeoutLine = typeof timeoutSeconds === "number" ? uiTheme.fg("dim", ui.wrapBrackets(`Timeout: ${timeoutSeconds}s`)) @@ -875,13 +873,12 @@ export const pythonToolRenderer = { // Cache state following Box pattern let cached: { key: string; width: number; result: string[] } | undefined; - const buildCacheKey = (spinnerFrame: number | undefined): string => { - return `${expanded}|${previewLines}|${spinnerFrame}`; - }; - return { render: (width: number): string[] => { - const key = buildCacheKey(options.spinnerFrame); + // Read mutable state at render time + const expanded = options.renderContext?.expanded ?? options.expanded; + const previewLines = options.renderContext?.previewLines ?? PYTHON_DEFAULT_PREVIEW_LINES; + const key = `${expanded}|${previewLines}|${options.spinnerFrame}`; if (cached && cached.key === key && cached.width === width) { return cached.result; } @@ -951,7 +948,11 @@ export const pythonToolRenderer = { const combinedOutput = [displayOutput, ...jsonLines].filter(Boolean).join("\n"); const statusEvents = details?.statusEvents ?? []; - const statusLines = renderStatusEvents(statusEvents, uiTheme, expanded); + const statusLines = renderStatusEvents( + statusEvents, + uiTheme, + options.renderContext?.expanded ?? options.expanded, + ); if (!combinedOutput && statusLines.length === 0) { const lines = [timeoutLine, warningLine].filter(Boolean) as string[]; @@ -965,7 +966,7 @@ export const pythonToolRenderer = { return new Text(lines.join("\n"), 0, 0); } - if (expanded) { + if (options.renderContext?.expanded ?? options.expanded) { const styledOutput = combinedOutput .split("\n") .map(line => uiTheme.fg("toolOutput", line)) @@ -988,14 +989,18 @@ export const pythonToolRenderer = { let cachedWidth: number | undefined; let cachedLines: string[] | undefined; let cachedSkipped: number | undefined; + let cachedPreviewLines: number | undefined; return { render: (width: number): string[] => { - if (cachedLines === undefined || cachedWidth !== width) { + // Read mutable state at render time + const previewLines = options.renderContext?.previewLines ?? PYTHON_DEFAULT_PREVIEW_LINES; + if (cachedLines === undefined || cachedWidth !== width || cachedPreviewLines !== previewLines) { const result = truncateToVisualLines(textContent, previewLines, width); cachedLines = result.visualLines; cachedSkipped = result.skippedCount; cachedWidth = width; + cachedPreviewLines = previewLines; } const outputLines: string[] = []; if (cachedSkipped && cachedSkipped > 0) { @@ -1025,6 +1030,7 @@ export const pythonToolRenderer = { cachedWidth = undefined; cachedLines = undefined; cachedSkipped = undefined; + cachedPreviewLines = undefined; }, }; }, diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index c4e403a75..fd9134301 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -14,7 +14,8 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { getLanguageFromPath, type Theme } from "../modes/theme/theme"; import readDescription from "../prompts/tools/read.md" with { type: "text" }; import type { ToolSession } from "../sdk"; -import { renderCodeCell, renderOutputBlock, renderStatusLine } from "../tui"; +import { renderCodeCell, renderStatusLine } from "../tui"; +import { CachedOutputBlock } from "../tui/output-block"; import { formatDimensionNote, resizeImage } from "../utils/image-resize"; import { detectSupportedImageMimeTypeFromFile } from "../utils/mime"; import { ensureTool } from "../utils/tools-manager"; @@ -1115,9 +1116,10 @@ export const readToolRenderer = { ); const detailLines = contentText ? contentText.split("\n").map(line => uiTheme.fg("toolOutput", line)) : []; const lines = [...detailLines, ...warningLines]; + const outputBlock = new CachedOutputBlock(); return { render: (width: number) => - renderOutputBlock( + outputBlock.render( { header, state: "success", @@ -1131,7 +1133,7 @@ export const readToolRenderer = { }, uiTheme, ), - invalidate: () => {}, + invalidate: () => outputBlock.invalidate(), }; } @@ -1141,9 +1143,12 @@ export const readToolRenderer = { const endLine = args.limit !== undefined ? startLine + args.limit - 1 : ""; title += `:${startLine}${endLine ? `-${endLine}` : ""}`; } + let cachedWidth: number | undefined; + let cachedLines: string[] | undefined; return { - render: (width: number) => - renderCodeCell( + render: (width: number) => { + if (cachedLines && cachedWidth === width) return cachedLines; + cachedLines = renderCodeCell( { code: contentText, language: lang, @@ -1154,8 +1159,14 @@ export const readToolRenderer = { width, }, uiTheme, - ), - invalidate: () => {}, + ); + cachedWidth = width; + return cachedLines; + }, + invalidate: () => { + cachedWidth = undefined; + cachedLines = undefined; + }, }; }, mergeCallAndResult: true, diff --git a/packages/coding-agent/src/tools/render-utils.ts b/packages/coding-agent/src/tools/render-utils.ts index d4b717d9b..bf5a3303a 100644 --- a/packages/coding-agent/src/tools/render-utils.ts +++ b/packages/coding-agent/src/tools/render-utils.ts @@ -7,7 +7,6 @@ import * as os from "node:os"; import { type Ellipsis, truncateToWidth } from "@oh-my-pi/pi-tui"; import type { Theme } from "../modes/theme/theme"; -import { getTreeBranch } from "../tui/utils"; export { Ellipsis, truncateToWidth } from "@oh-my-pi/pi-tui"; @@ -27,6 +26,10 @@ export const PREVIEW_LIMITS = { OUTPUT_COLLAPSED: 3, /** Output preview lines in expanded view */ OUTPUT_EXPANDED: 10, + /** Max hunks shown when collapsed (edit tool) */ + DIFF_COLLAPSED_HUNKS: 8, + /** Max diff lines shown when collapsed (edit tool) */ + DIFF_COLLAPSED_LINES: 40, } as const; /** Truncation lengths for different content types */ @@ -679,42 +682,3 @@ function pluralize(label: string, count: number): string { if (/[^aeiou]y$/i.test(label)) return `${label.slice(0, -1)}ies`; return `${label}s`; } - -// ============================================================================= -// Tree Rendering Utilities -// ============================================================================= -/** - * Render a list of items with tree branches, handling truncation. - * - * @param items - Full list of items to render - * @param expanded - Whether view is expanded - * @param maxCollapsed - Max items to show when collapsed - * @param renderItem - Function to render a single item - * @param itemType - Type name for "more X" message (e.g., "file", "entry") - * @param theme - Theme instance - * @returns Array of formatted lines - */ -export function renderTreeList( - items: T[], - expanded: boolean, - maxCollapsed: number, - renderItem: (item: T, branch: string, isLast: boolean, theme: Theme) => string, - itemType: string, - theme: Theme, -): string[] { - const lines: string[] = []; - const maxItems = expanded ? items.length : Math.min(items.length, maxCollapsed); - - for (let i = 0; i < maxItems; i++) { - const isLast = i === maxItems - 1 && (expanded || items.length <= maxCollapsed); - const branch = getTreeBranch(isLast, theme); - lines.push(renderItem(items[i], branch, isLast, theme)); - } - - if (!expanded && items.length > maxCollapsed) { - const remaining = items.length - maxCollapsed; - lines.push(` ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", formatMoreItems(remaining, itemType))}`); - } - - return lines; -} diff --git a/packages/coding-agent/src/tools/ssh.ts b/packages/coding-agent/src/tools/ssh.ts index cd6d1dddd..650ce9985 100644 --- a/packages/coding-agent/src/tools/ssh.ts +++ b/packages/coding-agent/src/tools/ssh.ts @@ -12,7 +12,8 @@ import sshDescriptionBase from "../prompts/tools/ssh.md" with { type: "text" }; import type { SSHHostInfo } from "../ssh/connection-manager"; import { ensureHostInfo, getHostInfoForHost } from "../ssh/connection-manager"; import { executeSSH } from "../ssh/ssh-executor"; -import { renderOutputBlock, renderStatusLine } from "../tui"; +import { renderStatusLine } from "../tui"; +import { CachedOutputBlock } from "../tui/output-block"; import type { ToolSession } from "."; import type { OutputMeta } from "./output-meta"; import { allocateOutputArtifact, createTailBuffer } from "./output-utils"; @@ -249,7 +250,6 @@ export const sshToolRenderer = { uiTheme: Theme, args?: SshRenderArgs, ): Component { - const { expanded, renderContext } = options; const details = result.details; const host = args?.host || "…"; const command = args?.command || "…"; @@ -257,59 +257,62 @@ export const sshToolRenderer = { { icon: "success", title: "SSH", description: `[${host}] $ ${command}` }, uiTheme, ); - const outputLines: string[] = []; - const textContent = result.content?.find(c => c.type === "text")?.text ?? ""; - const output = textContent.trimEnd(); - - if (output) { - if (expanded) { - outputLines.push(...output.split("\n").map(line => uiTheme.fg("toolOutput", line))); - } else if (renderContext?.visualLines) { - const { visualLines, skippedCount = 0, totalVisualLines = visualLines.length } = renderContext; - if (skippedCount > 0) { - outputLines.push( - uiTheme.fg( - "dim", - `… (${skippedCount} earlier lines, showing ${visualLines.length} of ${totalVisualLines}) (ctrl+o to expand)`, - ), - ); - } - const styledVisual = visualLines.map(line => - line.includes("\x1b[") ? line : uiTheme.fg("toolOutput", line), - ); - outputLines.push(...styledVisual); - } else { - const outputLinesRaw = output.split("\n"); - const maxLines = 5; - const displayLines = outputLinesRaw.slice(0, maxLines); - const remaining = outputLinesRaw.length - maxLines; - outputLines.push(...displayLines.map(line => uiTheme.fg("toolOutput", line))); - if (remaining > 0) { - outputLines.push(uiTheme.fg("dim", `… (${remaining} more lines) (ctrl+o to expand)`)); - } - } - } - const truncation = details?.meta?.truncation; - if (truncation) { - const warnings: string[] = []; - if (truncation.artifactId) { - warnings.push(`Full output: artifact://${truncation.artifactId}`); - } - if (truncation.truncatedBy === "lines") { - warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`); - } else { - warnings.push( - `Truncated: ${truncation.outputLines} lines shown (${formatBytes(truncation.outputBytes)} limit)`, - ); - } - outputLines.push(uiTheme.fg("warning", wrapBrackets(warnings.join(". "), uiTheme))); - } + const outputBlock = new CachedOutputBlock(); return { - render: (width: number) => - renderOutputBlock( + render: (width: number): string[] => { + // REACTIVE: read mutable options at render time + const { expanded, renderContext } = options; + const output = textContent.trimEnd(); + const outputLines: string[] = []; + + if (output) { + if (expanded) { + outputLines.push(...output.split("\n").map(line => uiTheme.fg("toolOutput", line))); + } else if (renderContext?.visualLines) { + const { visualLines, skippedCount = 0, totalVisualLines = visualLines.length } = renderContext; + if (skippedCount > 0) { + outputLines.push( + uiTheme.fg( + "dim", + `… (${skippedCount} earlier lines, showing ${visualLines.length} of ${totalVisualLines}) (ctrl+o to expand)`, + ), + ); + } + const styledVisual = visualLines.map(line => + line.includes("\x1b[") ? line : uiTheme.fg("toolOutput", line), + ); + outputLines.push(...styledVisual); + } else { + const outputLinesRaw = output.split("\n"); + const maxLines = 5; + const displayLines = outputLinesRaw.slice(0, maxLines); + const remaining = outputLinesRaw.length - maxLines; + outputLines.push(...displayLines.map(line => uiTheme.fg("toolOutput", line))); + if (remaining > 0) { + outputLines.push(uiTheme.fg("dim", `… (${remaining} more lines) (ctrl+o to expand)`)); + } + } + } + + if (truncation) { + const warnings: string[] = []; + if (truncation.artifactId) { + warnings.push(`Full output: artifact://${truncation.artifactId}`); + } + if (truncation.truncatedBy === "lines") { + warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`); + } else { + warnings.push( + `Truncated: ${truncation.outputLines} lines shown (${formatBytes(truncation.outputBytes)} limit)`, + ); + } + outputLines.push(uiTheme.fg("warning", wrapBrackets(warnings.join(". "), uiTheme))); + } + + return outputBlock.render( { header, state: "success", @@ -317,8 +320,11 @@ export const sshToolRenderer = { width, }, uiTheme, - ), - invalidate: () => {}, + ); + }, + invalidate: () => { + outputBlock.invalidate(); + }, }; }, mergeCallAndResult: true, diff --git a/packages/coding-agent/src/tools/todo-write.ts b/packages/coding-agent/src/tools/todo-write.ts index 7bae56130..95dfab75a 100644 --- a/packages/coding-agent/src/tools/todo-write.ts +++ b/packages/coding-agent/src/tools/todo-write.ts @@ -11,7 +11,7 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; import todoWriteDescription from "../prompts/tools/todo-write.md" with { type: "text" }; import type { ToolSession } from "../sdk"; -import { renderStatusLine, renderTreeList } from "../tui"; +import { type RenderCache, renderStatusLine, renderTreeList } from "../tui"; import { PREVIEW_LIMITS } from "./render-utils"; const todoWriteSchema = Type.Object({ @@ -227,7 +227,6 @@ export const todoWriteToolRenderer = { uiTheme: Theme, _args?: TodoWriteRenderArgs, ): Component { - const { expanded } = options; const todos = result.details?.todos ?? []; const header = renderStatusLine( { icon: "success", title: "Todo Write", meta: [`${todos.length} items`] }, @@ -235,20 +234,39 @@ export const todoWriteToolRenderer = { ); if (todos.length === 0) { const fallback = result.content?.find(c => c.type === "text")?.text ?? "No todos"; - return new Text([header, uiTheme.fg("dim", fallback)].join("\n"), 0, 0); + const renderedLines = [header, uiTheme.fg("dim", fallback)]; + return { + render() { + return renderedLines; + }, + invalidate() {}, + }; } - const lines = renderTreeList( - { - items: todos, - expanded, - maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS, - itemType: "todo", - renderItem: todo => formatTodoLine(todo, uiTheme, ""), - }, - uiTheme, - ); + let cached: RenderCache | undefined; - return new Text([header, ...lines].join("\n"), 0, 0); + return { + render(_width) { + const { expanded } = options; + const key = expanded ? 1n : 0n; + if (cached?.key === key) return cached.lines; + const treeLines = renderTreeList( + { + items: todos, + expanded, + maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS, + itemType: "todo", + renderItem: todo => formatTodoLine(todo, uiTheme, ""), + }, + uiTheme, + ); + const lines = [header, ...treeLines]; + cached = { key, lines }; + return lines; + }, + invalidate() { + cached = undefined; + }, + }; }, mergeCallAndResult: true, }; diff --git a/packages/coding-agent/src/tools/truncate.ts b/packages/coding-agent/src/tools/truncate.ts index 914fe3bee..01d93735e 100644 --- a/packages/coding-agent/src/tools/truncate.ts +++ b/packages/coding-agent/src/tools/truncate.ts @@ -287,7 +287,7 @@ export function truncateLine( if (line.length <= maxChars) { return { text: line, wasTruncated: false }; } - return { text: `${line.slice(0, maxChars)}... [truncated]`, wasTruncated: true }; + return { text: `${line.slice(0, maxChars)}…`, wasTruncated: true }; } // ============================================================================= diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index e045a5802..13068c29b 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -15,7 +15,7 @@ import { createLspWritethrough, type FileDiagnosticsResult, type WritethroughCal import { getLanguageFromPath, type Theme } from "../modes/theme/theme"; import writeDescription from "../prompts/tools/write.md" with { type: "text" }; import type { ToolSession } from "../sdk"; -import { renderStatusLine } from "../tui"; +import { type RenderCache, renderStatusLine } from "../tui"; import { type OutputMeta, outputMeta } from "./output-meta"; import { enforcePlanModeWrite, resolvePlanPath } from "./plan-mode-guard"; import { @@ -210,7 +210,7 @@ export const writeToolRenderer = { renderResult( result: { content: Array<{ type: string; text?: string }>; details?: WriteToolDetails }, - { expanded }: RenderResultOptions, + options: RenderResultOptions, uiTheme: Theme, args?: WriteRenderArgs, ): Component { @@ -232,29 +232,42 @@ export const writeToolRenderer = { }, uiTheme, ); - let text = header; + const metadataLine = formatMetadataLine(lineCount, lang ?? "text", uiTheme); + const diagnostics = result.details?.diagnostics; - // Add metadata line - text += `\n${formatMetadataLine(lineCount, lang ?? "text", uiTheme)}`; + let cached: RenderCache | undefined; - // Show content preview (collapsed tail, expandable) - text += renderContentPreview(fileContent, expanded, uiTheme, ui); + return { + render(_width: number) { + const { expanded } = options; + const key = expanded ? 1n : 0n; + if (cached?.key === key) return cached.lines; - // Show diagnostics if available - if (result.details?.diagnostics) { - const diagText = formatDiagnostics(result.details.diagnostics, expanded, uiTheme, fp => - uiTheme.getLangIcon(getLanguageFromPath(fp)), - ); - if (diagText.trim()) { - const diagLines = diagText.split("\n"); - const firstNonEmpty = diagLines.findIndex(line => line.trim()); - if (firstNonEmpty >= 0) { - text += `\n${diagLines.slice(firstNonEmpty).join("\n")}`; + let text = header; + text += `\n${metadataLine}`; + text += renderContentPreview(fileContent, expanded, uiTheme, ui); + + if (diagnostics) { + const diagText = formatDiagnostics(diagnostics, expanded, uiTheme, fp => + uiTheme.getLangIcon(getLanguageFromPath(fp)), + ); + if (diagText.trim()) { + const diagLines = diagText.split("\n"); + const firstNonEmpty = diagLines.findIndex(line => line.trim()); + if (firstNonEmpty >= 0) { + text += `\n${diagLines.slice(firstNonEmpty).join("\n")}`; + } + } } - } - } - return new Text(text, 0, 0); + const lines = text.split("\n"); + cached = { key, lines }; + return lines; + }, + invalidate() { + cached = undefined; + }, + }; }, mergeCallAndResult: true, }; diff --git a/packages/coding-agent/src/tui/output-block.ts b/packages/coding-agent/src/tui/output-block.ts index c4f5e9346..070177f3b 100644 --- a/packages/coding-agent/src/tui/output-block.ts +++ b/packages/coding-agent/src/tui/output-block.ts @@ -4,7 +4,8 @@ import { padding, visibleWidth } from "@oh-my-pi/pi-tui"; import type { Theme } from "../modes/theme/theme"; import type { State } from "./types"; -import { getStateBgColor, padToWidth, truncateToWidth } from "./utils"; +import type { RenderCache } from "./utils"; +import { getStateBgColor, Hasher, padToWidth, truncateToWidth } from "./utils"; export interface OutputBlockOptions { header?: string; @@ -84,3 +85,46 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st return lines; } + +/** + * Cached wrapper around `renderOutputBlock`. + * + * Since output blocks are re-rendered on every frame (via `render(width)` closures), + * but their content rarely changes, this cache avoids redundant `visibleWidth()` and + * `padding()` computations on ~99% of render calls. + */ +export class CachedOutputBlock { + private cache?: RenderCache; + + /** Render with caching. Returns cached result if options haven't changed. */ + render(options: OutputBlockOptions, theme: Theme): string[] { + const key = this.buildKey(options); + if (this.cache?.key === key) return this.cache.lines; + const lines = renderOutputBlock(options, theme); + this.cache = { key, lines }; + return lines; + } + + /** Invalidate the cache, forcing a rebuild on next render. */ + invalidate(): void { + this.cache = undefined; + } + + private buildKey(options: OutputBlockOptions): bigint { + const h = new Hasher(); + h.u32(options.width); + h.optional(options.header); + h.optional(options.headerMeta); + h.optional(options.state); + h.bool(options.applyBg ?? true); + if (options.sections) { + for (const s of options.sections) { + h.optional(s.label); + for (const line of s.lines) { + h.str(line); + } + } + } + return h.digest(); + } +} diff --git a/packages/coding-agent/src/tui/utils.ts b/packages/coding-agent/src/tui/utils.ts index 055127a0d..6429b4faa 100644 --- a/packages/coding-agent/src/tui/utils.ts +++ b/packages/coding-agent/src/tui/utils.ts @@ -7,6 +7,74 @@ import type { IconType, State } from "./types"; export { truncateToWidth } from "@oh-my-pi/pi-tui"; +/** Cached typed-array scratch space for hashing non-string primitives. */ +const hashBuf = new ArrayBuffer(8); +const hashView = new DataView(hashBuf); +const hashBytes1 = new Uint8Array(hashBuf, 0, 1); +const hashBytes4 = new Uint8Array(hashBuf, 0, 4); +const hashBytes8 = new Uint8Array(hashBuf, 0, 8); + +/** + * Incremental xxHash64 key builder. + * + * Chains `Bun.hash.xxHash64` calls via seeding — each fed value + * mixes into the running hash without intermediate string allocations. + * Accepts strings, numbers (u32), booleans, bigints, and `undefined`/`null` + * (hashed as a sentinel byte) natively. + */ +export class Hasher { + private h = 0n; + + /** Feed a string. */ + str(s: string): this { + this.h = Bun.hash.xxHash64(s, this.h); + return this; + } + + /** Feed an unsigned 32-bit integer. */ + u32(n: number): this { + hashView.setUint32(0, n); + this.h = Bun.hash.xxHash64(hashBytes4, this.h); + return this; + } + + /** Feed a 64-bit bigint. */ + u64(n: bigint): this { + hashView.setBigUint64(0, n); + this.h = Bun.hash.xxHash64(hashBytes8, this.h); + return this; + } + + /** Feed a boolean (single byte: 1 = true, 0 = false). */ + bool(b: boolean): this { + hashView.setUint8(0, b ? 1 : 0); + this.h = Bun.hash.xxHash64(hashBytes1, this.h); + return this; + } + + /** Feed a value that may be `undefined` or `null` (hashed as a 0xFF sentinel byte). */ + optional(v: string | undefined | null): this { + if (v == null) { + hashView.setUint8(0, 0xff); + this.h = Bun.hash.xxHash64(hashBytes1, this.h); + } else { + this.h = Bun.hash.xxHash64(v, this.h); + } + return this; + } + + /** Return the final hash digest. */ + digest(): bigint { + return this.h; + } +} + +/** Render-cache entry used by tool renderers. */ +export interface RenderCache { + key: bigint; + lines: string[]; +} + export function buildTreePrefix(ancestors: boolean[], theme: Theme): string { return ancestors.map(hasNext => (hasNext ? `${theme.tree.vertical} ` : " ")).join(""); } diff --git a/packages/coding-agent/src/web/search/render.ts b/packages/coding-agent/src/web/search/render.ts index ab8f42c04..0164e0df0 100644 --- a/packages/coding-agent/src/web/search/render.ts +++ b/packages/coding-agent/src/web/search/render.ts @@ -21,7 +21,8 @@ import { TRUNCATE_LENGTHS, truncateToWidth, } from "../../tools/render-utils"; -import { renderOutputBlock, renderStatusLine, renderTreeList } from "../../tui"; +import { renderStatusLine, renderTreeList } from "../../tui"; +import { CachedOutputBlock } from "../../tui/output-block"; import type { SearchResponse } from "./types"; const MAX_COLLAPSED_ANSWER_LINES = PREVIEW_LIMITS.COLLAPSED_LINES; @@ -79,7 +80,6 @@ export function renderSearchResult( maxAnswerLines?: number; }, ): Component { - const { expanded } = options; const details = result.details; // Handle error case @@ -90,7 +90,7 @@ export function renderSearchResult( const rawText = result.content?.find(block => block.type === "text")?.text?.trim() ?? ""; const response = details?.response; if (!response) { - return renderFallbackText(rawText, expanded, theme); + return renderFallbackText(rawText, options.expanded, theme); } const sources = Array.isArray(response.sources) ? response.sources : []; @@ -112,12 +112,6 @@ export function renderSearchResult( .map(l => l.trim()) : []; const totalAnswerLines = answerLines.length; - const answerLimit = expanded ? MAX_EXPANDED_ANSWER_LINES : MAX_COLLAPSED_ANSWER_LINES; - const answerPreview = contentText - ? args?.allowLongAnswer - ? answerLines.slice(0, args.maxAnswerLines ?? answerLines.length) - : getPreviewLines(contentText, answerLimit, MAX_ANSWER_LINE_LEN) - : []; const providerLabel = provider !== "none" ? getSearchProvider(provider).label : "None"; const queryPreview = args?.query @@ -135,46 +129,6 @@ export function renderSearchResult( theme, ); - const remainingAnswer = totalAnswerLines - answerPreview.length; - - const sourceTree = renderTreeList( - { - items: sources, - expanded, - maxCollapsed: MAX_COLLAPSED_ITEMS, - itemType: "source", - renderItem: src => { - const titleText = - typeof src.title === "string" && src.title.trim() - ? src.title - : typeof src.url === "string" && src.url.trim() - ? src.url - : "Untitled"; - const title = truncateToWidth(titleText, 70); - const url = typeof src.url === "string" ? src.url : ""; - const domain = url ? getDomain(url) : ""; - const age = formatAge(src.ageSeconds) || (typeof src.publishedDate === "string" ? src.publishedDate : ""); - const metaParts: string[] = []; - if (domain) metaParts.push(theme.fg("dim", `(${domain})`)); - if (typeof src.author === "string" && src.author.trim()) metaParts.push(theme.fg("muted", src.author)); - if (age) metaParts.push(theme.fg("muted", age)); - const metaSep = theme.fg("dim", theme.sep.dot); - const metaSuffix = metaParts.length > 0 ? ` ${metaParts.join(metaSep)}` : ""; - const lines: string[] = [`${theme.fg("accent", title)}${metaSuffix}`]; - const snippetText = typeof src.snippet === "string" ? src.snippet : ""; - if (snippetText.trim()) { - const snippetLines = getPreviewLines(snippetText, MAX_SNIPPET_LINES, MAX_SNIPPET_LINE_LEN); - for (const snippetLine of snippetLines) { - lines.push(theme.fg("muted", `${theme.format.dash} ${snippetLine}`)); - } - } - if (url) lines.push(theme.fg("mdLinkUrl", url)); - return lines; - }, - }, - theme, - ); - const metaLines: string[] = []; metaLines.push(`${theme.fg("muted", "Provider:")} ${theme.fg("text", providerLabel)}`); if (response.model) metaLines.push(`${theme.fg("muted", "Model:")} ${theme.fg("text", response.model)}`); @@ -202,9 +156,94 @@ export function renderSearchResult( metaLines.push(`${theme.fg("muted", "Queries:")} ${theme.fg("text", queryList.join("; "))}${suffix}`); } + const outputBlock = new CachedOutputBlock(); + return { - render: (width: number) => - renderOutputBlock( + render(width: number): string[] { + // Read mutable state at render time + const { expanded } = options; + + // Expanded-dependent computations + const answerLimit = expanded ? MAX_EXPANDED_ANSWER_LINES : MAX_COLLAPSED_ANSWER_LINES; + const answerPreview = contentText + ? args?.allowLongAnswer + ? answerLines.slice(0, args.maxAnswerLines ?? answerLines.length) + : getPreviewLines(contentText, answerLimit, MAX_ANSWER_LINE_LEN) + : []; + const remainingAnswer = totalAnswerLines - answerPreview.length; + + const sourceTree = renderTreeList( + { + items: sources, + expanded, + maxCollapsed: MAX_COLLAPSED_ITEMS, + itemType: "source", + renderItem: src => { + const titleText = + typeof src.title === "string" && src.title.trim() + ? src.title + : typeof src.url === "string" && src.url.trim() + ? src.url + : "Untitled"; + const title = truncateToWidth(titleText, 70); + const url = typeof src.url === "string" ? src.url : ""; + const domain = url ? getDomain(url) : ""; + const age = + formatAge(src.ageSeconds) || (typeof src.publishedDate === "string" ? src.publishedDate : ""); + const metaParts: string[] = []; + if (domain) metaParts.push(theme.fg("dim", `(${domain})`)); + if (typeof src.author === "string" && src.author.trim()) + metaParts.push(theme.fg("muted", src.author)); + if (age) metaParts.push(theme.fg("muted", age)); + const metaSep = theme.fg("dim", theme.sep.dot); + const metaSuffix = metaParts.length > 0 ? ` ${metaParts.join(metaSep)}` : ""; + const srcLines: string[] = [`${theme.fg("accent", title)}${metaSuffix}`]; + const snippetText = typeof src.snippet === "string" ? src.snippet : ""; + if (snippetText.trim()) { + const snippetLines = getPreviewLines(snippetText, MAX_SNIPPET_LINES, MAX_SNIPPET_LINE_LEN); + for (const snippetLine of snippetLines) { + srcLines.push(theme.fg("muted", `${theme.format.dash} ${snippetLine}`)); + } + } + if (url) srcLines.push(theme.fg("mdLinkUrl", url)); + return srcLines; + }, + }, + theme, + ); + + // Build answer section + const answerState = sourceCount > 0 ? "success" : "warning"; + const borderColor: "warning" | "dim" = answerState === "warning" ? "warning" : "dim"; + const border = (t: string) => theme.fg(borderColor, t); + const contentPrefix = border(`${theme.boxSharp.vertical} `); + const contentSuffix = border(theme.boxSharp.vertical); + const contentWidth = Math.max(0, width - visibleWidth(contentPrefix) - visibleWidth(contentSuffix)); + const answerTreeLines = answerPreview.length > 0 ? answerPreview : ["No answer text returned"]; + const answerTree = renderTreeList( + { + items: answerTreeLines, + expanded: true, + maxCollapsed: answerTreeLines.length, + itemType: "line", + renderItem: (line, context) => { + const coloredLine = + line === "No answer text returned" ? theme.fg("muted", line) : theme.fg("dim", line); + if (!args?.allowLongAnswer) { + return coloredLine; + } + const prefixWidth = visibleWidth(context.continuePrefix); + const wrapWidth = Math.max(10, contentWidth - prefixWidth); + return wrapTextWithAnsi(coloredLine, wrapWidth); + }, + }, + theme, + ); + if (remainingAnswer > 0) { + answerTree.push(theme.fg("muted", formatMoreItems(remainingAnswer, "line"))); + } + + return outputBlock.render( { header, state: sourceCount > 0 ? "success" : "warning", @@ -218,43 +257,7 @@ export function renderSearchResult( : []), { label: theme.fg("toolTitle", "Answer"), - lines: (() => { - const state = sourceCount > 0 ? "success" : "warning"; - const borderColor: "warning" | "dim" = state === "warning" ? "warning" : "dim"; - const border = (text: string) => theme.fg(borderColor, text); - const contentPrefix = border(`${theme.boxSharp.vertical} `); - const contentSuffix = border(theme.boxSharp.vertical); - const contentWidth = Math.max( - 0, - width - visibleWidth(contentPrefix) - visibleWidth(contentSuffix), - ); - const answerTreeLines = answerPreview.length > 0 ? answerPreview : ["No answer text returned"]; - const answerTree = renderTreeList( - { - items: answerTreeLines, - expanded: true, - maxCollapsed: answerTreeLines.length, - itemType: "line", - renderItem: (line, context) => { - const coloredLine = - line === "No answer text returned" - ? theme.fg("muted", line) - : theme.fg("dim", line); - if (!args?.allowLongAnswer) { - return coloredLine; - } - const prefixWidth = visibleWidth(context.continuePrefix); - const wrapWidth = Math.max(10, contentWidth - prefixWidth); - return wrapTextWithAnsi(coloredLine, wrapWidth); - }, - }, - theme, - ); - if (remainingAnswer > 0) { - answerTree.push(theme.fg("muted", formatMoreItems(remainingAnswer, "line"))); - } - return answerTree; - })(), + lines: answerTree, }, { label: theme.fg("toolTitle", "Sources"), @@ -265,8 +268,11 @@ export function renderSearchResult( width, }, theme, - ), - invalidate: () => {}, + ); + }, + invalidate() { + outputBlock.invalidate(); + }, }; } diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index c1ccda2cb..cebe2a562 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -914,7 +914,9 @@ export class TUI extends Container { // Capture terminal dimensions at start to ensure consistency throughout render const width = this.terminal.columns; const height = this.terminal.rows; - const previousRenderedLineCount = this.previousLines.length; + // NOTE: previousLines.length is the last frame's content length. + // We intentionally key clear-on-shrink logic off maxLinesRendered (working area), + // not previousLines.length. let viewportTop = Math.max(0, this.maxLinesRendered - height); let prevViewportTop = this.previousViewportTop; let hardwareCursorRow = this.hardwareCursorRow; @@ -998,11 +1000,12 @@ export class TUI extends Container { return; } - // Content shrunk below the working area and no overlays - re-render to clear empty rows - // (overlays need the padding, so only do this when no overlays are active) - // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK env var - if (this.clearOnShrink && newLines.length < previousRenderedLineCount && this.overlayStack.length === 0) { - logRedraw(`clearOnShrink (previousRenderedLineCount=${previousRenderedLineCount})`); + // Content shrunk below the working area and no overlays - re-render to clear empty rows. + // We compare against maxLinesRendered (working area), not previousLines.length, since + // previousLines will already reflect the shrunk content after one differential render. + // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK env var. + if (this.clearOnShrink && newLines.length < this.maxLinesRendered && this.overlayStack.length === 0) { + logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`); this.fullRedrawCount += 1; let buffer = "\x1b[?2026h"; // Begin synchronized output buffer += "\x1b[2J\x1b[H"; // Clear screen and move cursor to home @@ -1078,7 +1081,8 @@ export class TUI extends Container { this.terminal.write(buffer2); this.cursorRow = Math.max(0, newLines.length - 1); this.hardwareCursorRow = this.cursorRow; - this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length); + // Screen was cleared; reset working area to current render. + this.maxLinesRendered = newLines.length; this.previousViewportTop = Math.max(0, this.maxLinesRendered - height); this.positionHardwareCursor(cursorPos, newLines.length); this.previousLines = newLines;