import type { BashToolDetails, EditToolDetails, ExtensionAPI, FindToolDetails, GrepToolDetails, LsToolDetails, ReadToolDetails, ToolRendererResolver, ToolRenderers, ToolRenderResultOptions, } from "@selesai/code"; import { formatSize } from "@selesai/code"; import { Container, Spacer, Text, type Component } from "@earendil-works/pi-tui"; import { renderBashCall } from "./bash-display.js"; import { createCodemodeToolRenderers } from "./codemode-display.js"; import { boxToolCall, boxToolResult } from "./tool-call-box.js"; import { logToolDisplayDebug } from "./debug-logger.js"; import { compactOutputLines, countNonEmptyLines, extractTextOutput, isLikelyQuietCommand, pluralize, previewLines, sanitizeAnsiForThemedOutput, shortenPath, splitLines, } from "./render-utils.js"; import { renderEditDiffResult, renderWriteDiffResult } from "./diff-renderer.js"; import { buildPendingEditPreviewData, buildPendingWritePreviewData, readWorkspaceUtf8File, type PendingDiffPreviewData, } from "./pending-diff-preview.js"; import { getTextField, isNativeMcpToolName, toRecord, } from "./tool-metadata.js"; import { BUILT_IN_TOOL_OVERRIDE_NAMES } from "./types.js"; import { normalizeCustomToolOverrideEntry } from "./config-store.js"; import type { BuiltInToolOverrideName, CustomToolOverrideConfig, ToolDisplayConfig, } from "./types.js"; import { countWriteContentLines, getWriteContentSizeBytes, shouldRenderWriteCallSummary, } from "./write-display-utils.js"; type ConfigGetter = () => ToolDisplayConfig; interface RenderTheme { fg(color: string, text: string): string; bg?(color: string, text: string): string; bold(text: string): string; getBgAnsi?(color: string): string; } interface RtkCompactionInfo { applied: boolean; techniques: string[]; truncated: boolean; originalLineCount?: number; compactedLineCount?: number; } interface ToolRenderContextLike { args?: unknown; toolCallId?: string; state?: unknown; cwd?: string; argsComplete?: boolean; isError?: boolean; isPartial?: boolean; expanded?: boolean; outputPad?: number; } export interface WriteExecutionMeta { previousContent?: string; fileExistedBeforeWrite: boolean; } interface PendingDiffPreviewState { key?: string; data?: PendingDiffPreviewData; } const RTK_COMPACTION_LABEL = "compacted by RTK"; export const WRITE_EXECUTION_META_LIMIT = 100; const WRITE_EXECUTION_META_STATE_KEY = "__piToolDisplayWriteExecutionMeta"; const EDIT_PENDING_PREVIEW_STATE_KEY = "__piToolDisplayEditPendingPreview"; const WRITE_PENDING_PREVIEW_STATE_KEY = "__piToolDisplayWritePendingPreview"; function boxToolRenderComponent( component: unknown, boxed: boolean, theme: RenderTheme, context: ToolRenderContextLike | undefined, box: typeof boxToolCall, ): unknown { return boxed && component ? box( component as unknown as Component & { invalidate?: () => void }, theme as never, true, { outputPad: context?.outputPad }, ) : component; } /** * The host applies `outputPad` and its own shell only for `renderShell: "default"`. * The boxed frame draws the padding inside itself, so it needs `"self"` and the * caller's `outputPad`; otherwise the host's shell stays in charge. The flag is * frozen per resolver so the shell and the component cannot disagree mid-row. */ function withToolDisplayShell(renderers: ToolRenderers, getConfig: ConfigGetter): ToolRenderers { const boxed = getConfig().boxedToolCalls; return { renderShell: boxed ? "self" : "default", renderCall(args, theme, context) { return boxToolRenderComponent( renderers.renderCall?.(args, theme, context), boxed, theme as RenderTheme, context as ToolRenderContextLike | undefined, boxToolCall, ) as Component; }, renderResult(result, options, theme, context) { return boxToolRenderComponent( renderers.renderResult?.(result, options, theme, context), boxed, theme as RenderTheme, context as ToolRenderContextLike | undefined, boxToolResult, ) as Component; }, }; } function captureExistingWriteContent( cwd: string, rawPath: unknown, ): { existed: boolean; content?: string } { if (typeof rawPath !== "string" || !rawPath.trim()) { return { existed: false }; } const existing = readWorkspaceUtf8File(cwd, rawPath); return { existed: existing.exists, content: existing.content, }; } /** Namespaced key for the write diff source in a persisted write tool result's `details`. */ export const WRITE_TOOL_DISPLAY_DETAILS_KEY = "toolDisplay"; /** Largest previous content persisted in `details`; bigger files keep only the in-memory source. */ export const WRITE_PREVIOUS_CONTENT_DETAILS_LIMIT_BYTES = 64 * 1024; /** The bounded write record in `details.toolDisplay`, so a diff survives reload and resume. */ export interface WriteToolDisplayDetails { fileExistedBeforeWrite: boolean; previousContent?: string; previousContentOmitted?: true; previousBytes?: number; } export function buildWriteToolDisplayDetails(meta: WriteExecutionMeta): WriteToolDisplayDetails { const { previousContent } = meta; if (previousContent === undefined) { return meta.fileExistedBeforeWrite ? { fileExistedBeforeWrite: true, previousContentOmitted: true } : { fileExistedBeforeWrite: false }; } const previousBytes = Buffer.byteLength(previousContent, "utf8"); if (previousBytes <= WRITE_PREVIOUS_CONTENT_DETAILS_LIMIT_BYTES) { return { fileExistedBeforeWrite: meta.fileExistedBeforeWrite, previousContent }; } return { fileExistedBeforeWrite: meta.fileExistedBeforeWrite, previousContentOmitted: true, previousBytes, }; } export function getWriteToolDisplayDetails(details: unknown): WriteToolDisplayDetails | undefined { const record = toRecord(toRecord(details)[WRITE_TOOL_DISPLAY_DETAILS_KEY]); if (typeof record.fileExistedBeforeWrite !== "boolean") { return undefined; } const result: WriteToolDisplayDetails = { fileExistedBeforeWrite: record.fileExistedBeforeWrite, }; if (typeof record.previousContent === "string") { result.previousContent = record.previousContent; } if (record.previousContentOmitted === true) { result.previousContentOmitted = true; } if (typeof record.previousBytes === "number" && Number.isFinite(record.previousBytes)) { result.previousBytes = record.previousBytes; } return result; } /** Preserve existing details fields; the record lives under one namespaced key. */ export function withWriteToolDisplayDetails( details: unknown, record: WriteToolDisplayDetails, ): Record { return { ...toRecord(details), [WRITE_TOOL_DISPLAY_DETAILS_KEY]: record, }; } function formatExpandHint(theme: RenderTheme): string { return theme.fg("muted", " • Ctrl+O to expand"); } function formatTruncationHint(remaining: number, expanded: boolean, theme: RenderTheme): string { if (remaining <= 0) { return ""; } const hint = expanded ? "" : " • Ctrl+O to expand"; return `\n${theme.fg("muted", `... (${remaining} more ${pluralize(remaining, "line")}${hint})`)}`; } function buildPreviewText( lines: string[], maxLines: number, theme: RenderTheme, expanded: boolean, ): string { if (lines.length === 0) { return theme.fg("muted", "↳ (no output)"); } const { shown, remaining } = previewLines(lines, maxLines); let text = shown .map((line) => theme.fg("toolOutput", sanitizeAnsiForThemedOutput(line))) .join("\n"); text += formatTruncationHint(remaining, expanded, theme); return text; } function prepareOutputLines( rawText: string, options: ToolRenderResultOptions, ): string[] { return compactOutputLines(splitLines(rawText), { expanded: options.expanded, maxCollapsedConsecutiveEmptyLines: 1, }); } function formatBashNoOutputLine( command: string | undefined, theme: RenderTheme, ): string { if (isLikelyQuietCommand(command)) { return theme.fg("muted", "↳ command completed (no output)"); } return theme.fg("muted", "↳ (no output)"); } function truncationHint( details: { truncation?: { truncated?: boolean } } | undefined, ): string { return details?.truncation?.truncated ? " • truncated" : ""; } function countTextLines(value: unknown): number { if (typeof value !== "string") { return 0; } return splitLines(value).length; } function getStringField(value: unknown, field: string): string | undefined { const raw = toRecord(value)[field]; return typeof raw === "string" ? raw : undefined; } function getNumericField(value: unknown, field: string): number | undefined { const raw = toRecord(value)[field]; return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined; } function getToolPathArg(value: unknown): string | undefined { return getStringField(value, "file_path") ?? getStringField(value, "path"); } function getToolContentArg(value: unknown): string | undefined { return getStringField(value, "content"); } function getEditPayloadLineCount(value: unknown): number { const record = toRecord(value); const lines = record.lines; if (Array.isArray(lines)) { return lines.filter((line): line is string => typeof line === "string").length; } if (typeof lines === "string") { return countTextLines(lines); } return countTextLines(record.newText); } function getEditLineCount(value: unknown): number { const record = toRecord(value); const edits = Array.isArray(record.edits) ? record.edits as unknown[] : []; if (edits.length > 0) { return edits.reduce((total, edit) => { return total + getEditPayloadLineCount(edit); }, 0); } return getEditPayloadLineCount(record); } function isToolError( result: unknown, context?: ToolRenderContextLike, ): boolean { return context?.isError === true || toRecord(result).isError === true; } function toStateRecord(value: unknown): Record | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) { return undefined; } return value as Record; } export function recordWriteExecutionMeta( pendingMetaByToolCallId: Map, toolCallId: string, meta: WriteExecutionMeta, ): void { pendingMetaByToolCallId.delete(toolCallId); pendingMetaByToolCallId.set(toolCallId, meta); while (pendingMetaByToolCallId.size > WRITE_EXECUTION_META_LIMIT) { const oldestToolCallId: string | undefined = pendingMetaByToolCallId.keys().next().value as string | undefined; if (oldestToolCallId === undefined) { return; } pendingMetaByToolCallId.delete(oldestToolCallId); } } export function clearWriteExecutionMeta( pendingMetaByToolCallId: Map, ): void { pendingMetaByToolCallId.clear(); } export function getWriteExecutionMeta( context: ToolRenderContextLike | undefined, pendingMetaByToolCallId: Map, ): WriteExecutionMeta | undefined { if (!context) { return undefined; } const carrier = toStateRecord(context.state); const existing = carrier ? toRecord(carrier[WRITE_EXECUTION_META_STATE_KEY]) : undefined; if (existing && Object.keys(existing).length > 0) { return existing as unknown as WriteExecutionMeta; } if (!context.toolCallId) { return undefined; } const pending = pendingMetaByToolCallId.get(context.toolCallId); if (!pending) { return undefined; } if (carrier) { const storedMeta: WriteExecutionMeta = { ...pending }; carrier[WRITE_EXECUTION_META_STATE_KEY] = storedMeta; pendingMetaByToolCallId.delete(context.toolCallId); return storedMeta; } return pending; } function getPendingDiffPreviewState( context: ToolRenderContextLike | undefined, stateKey: string, ): PendingDiffPreviewState | undefined { const carrier = toStateRecord(context?.state); if (!carrier) { return undefined; } const current = carrier[stateKey]; if (current && typeof current === "object" && !Array.isArray(current)) { return current as PendingDiffPreviewState; } const next: PendingDiffPreviewState = {}; carrier[stateKey] = next; return next; } function resolvePendingDiffPreview( context: ToolRenderContextLike | undefined, stateKey: string, previewKey: string | undefined, compute: () => PendingDiffPreviewData | undefined, ): PendingDiffPreviewData | undefined { const previewState = getPendingDiffPreviewState(context, stateKey); if (!previewState) { return compute(); } if (previewState.key !== previewKey) { previewState.key = previewKey; previewState.data = previewKey ? compute() : undefined; } return previewState.data; } function buildPendingDiffCallComponent( summaryText: string, previewData: PendingDiffPreviewData | undefined, context: ToolRenderContextLike | undefined, config: ToolDisplayConfig, theme: RenderTheme, ): Text | Container { if (!context?.isPartial || !previewData) { return textResult(summaryText); } const container = new Container(); container.addChild(new Text(summaryText, 0, 0)); container.addChild(new Spacer(1)); if (previewData.notice || typeof previewData.nextContent !== "string") { container.addChild(new Text(theme.fg("warning", previewData.notice || "Preview unavailable."), 0, 0)); return container; } container.addChild( renderWriteDiffResult( previewData.nextContent, { expanded: context.expanded === true, filePath: previewData.filePath, previousContent: previewData.previousContent, fileExistedBeforeWrite: previewData.fileExistedBeforeWrite, headerLabel: previewData.headerLabel, }, config, theme, "", ), ); return container; } function formatLineCountSuffix( lineCount: number, theme: RenderTheme, ): string { return theme.fg("muted", ` (${lineCount} ${pluralize(lineCount, "line")})`); } function formatWriteCallSuffix( lineCount: number, sizeBytes: number, theme: RenderTheme, ): string { return theme.fg( "muted", ` (${lineCount} ${pluralize(lineCount, "line")} • ${formatSize(sizeBytes)})`, ); } function formatInProgressLineCount( action: string, lineCount: number, theme: RenderTheme, ): string { return theme.fg("warning", `${action}...`) + formatLineCountSuffix(lineCount, theme); } function normalizePositiveInteger(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : undefined; } function toStringArray(value: unknown): string[] { if (!Array.isArray(value)) { return []; } return value .filter((entry): entry is string => typeof entry === "string") .map((entry) => entry.trim()) .filter((entry) => entry.length > 0); } function getRtkCompactionInfo(details: unknown): RtkCompactionInfo | undefined { const detailRecord = toRecord(details); const metadataRecord = toRecord(detailRecord.metadata); const topLevel = toRecord(detailRecord.rtkCompaction); const nested = toRecord(metadataRecord.rtkCompaction); const source = Object.keys(topLevel).length > 0 ? topLevel : Object.keys(nested).length > 0 ? nested : undefined; if (!source) { return undefined; } const techniques = toStringArray(source.techniques); const info: RtkCompactionInfo = { applied: source.applied === true, techniques, truncated: source.truncated === true, originalLineCount: normalizePositiveInteger(source.originalLineCount), compactedLineCount: normalizePositiveInteger(source.compactedLineCount), }; if ( !info.applied && info.techniques.length === 0 && !info.truncated && info.originalLineCount === undefined && info.compactedLineCount === undefined ) { return undefined; } return info; } function formatRtkTechniqueList(techniques: string[]): string { if (techniques.length === 0) { return ""; } const visible = techniques.slice(0, 3).join(", "); const hidden = techniques.length - 3; return hidden > 0 ? `${visible}, +${hidden} more` : visible; } function getRtkCompactionInfoIfApplied( details: unknown, config: ToolDisplayConfig, ): RtkCompactionInfo | undefined { if (!config.showRtkCompactionHints) { return undefined; } const info = getRtkCompactionInfo(details); return info?.applied ? info : undefined; } function withRtkCompactionInfo( params: RtkHintParams, handler: (info: RtkCompactionInfo) => string, ): string { const info = getRtkCompactionInfoIfApplied(params.details, params.config); return info ? handler(info) : ""; } interface RtkHintParams { details: unknown; config: ToolDisplayConfig; theme: RenderTheme; } interface PreviewHintContext { lines: string[]; config: ToolDisplayConfig; theme: RenderTheme; options: ToolRenderResultOptions; details: unknown; } interface McpPreviewHintContext extends PreviewHintContext { truncation: { truncated: boolean; fullOutputPath?: string }; } function handlePartialResult( options: ToolRenderResultOptions, theme: RenderTheme, message: string, ): Text | undefined { return options.isPartial ? partialResultText(theme, message) : undefined; } function renderSearchPreview(ctx: PreviewHintContext, expandedOnly = false): Text { return renderPreviewText(ctx.lines, ctx.config, ctx.theme, ctx.options, (p) => appendPreviewHints(p, ctx), expandedOnly); } function renderMcpPreview(ctx: McpPreviewHintContext, expandedOnly = false): Text { return renderPreviewText(ctx.lines, ctx.config, ctx.theme, ctx.options, (p) => appendMcpPreviewHints(p, ctx), expandedOnly); } function formatRtkSummarySuffix(params: RtkHintParams): string { const { theme } = params; return withRtkCompactionInfo(params, (info) => { const segments: string[] = [RTK_COMPACTION_LABEL]; const techniqueText = formatRtkTechniqueList(info.techniques); if (techniqueText) { segments.push(techniqueText); } if (info.truncated) { segments.push("RTK removed content"); } if (segments.length === 0) { return ""; } return theme.fg("warning", ` • ${segments.join(" • ")}`); }); } function getExpandedPreviewLineLimit( lines: string[], config: ToolDisplayConfig, ): number { const limit = Math.max(0, config.expandedPreviewMaxLines); if (limit === 0) { return lines.length; } return Math.min(lines.length, limit); } function formatExpandedPreviewCapHint( lines: string[], config: ToolDisplayConfig, theme: RenderTheme, ): string { const cap = Math.max(0, config.expandedPreviewMaxLines); if (cap === 0 || lines.length <= cap) { return ""; } return `\n${theme.fg("warning", `(display capped at ${cap} lines by tool-display setting)`)}`; } function formatRtkPreviewHint(params: RtkHintParams): string { const { theme } = params; return withRtkCompactionInfo(params, (info) => { const hints: string[] = []; const techniqueText = formatRtkTechniqueList(info.techniques); if (techniqueText) { hints.push(`${RTK_COMPACTION_LABEL}: ${techniqueText}`); } else { hints.push(`${RTK_COMPACTION_LABEL} applied`); } if ( info.originalLineCount !== undefined && info.compactedLineCount !== undefined && info.originalLineCount > info.compactedLineCount ) { hints.push(`${info.compactedLineCount}/${info.originalLineCount} lines kept`); } if (info.truncated) { hints.push("RTK removed content"); } return hints.length > 0 ? `\n${theme.fg("warning", `(${hints.join(" • ")})`)}` : ""; }); } function appendRtkAndExpandedHints(preview: string, ctx: PreviewHintContext): string { preview += formatRtkPreviewHint(ctx); if (ctx.options.expanded) { preview += formatExpandedPreviewCapHint(ctx.lines, ctx.config, ctx.theme); } return preview; } function appendMcpPreviewHints(preview: string, ctx: McpPreviewHintContext): string { const { config, theme, details, lines, options, truncation } = ctx; if (config.showTruncationHints && (truncation.truncated || truncation.fullOutputPath)) { const hints: string[] = []; if (truncation.truncated) { hints.push("truncated by backend limits"); } if (truncation.fullOutputPath) { hints.push(`full output: ${truncation.fullOutputPath}`); } preview += `\n${theme.fg("warning", `(${hints.join(" • ")})`)}`; } return appendRtkAndExpandedHints(preview, ctx); } function appendPreviewHints(preview: string, ctx: PreviewHintContext): string { const { config, theme, details } = ctx; if (config.showTruncationHints && toRecord(toRecord(details).truncation).truncated) { preview += `\n${theme.fg("warning", "(truncated by backend limits)")}`; } return appendRtkAndExpandedHints(preview, ctx); } function renderPreviewText( lines: string[], config: ToolDisplayConfig, theme: RenderTheme, options: ToolRenderResultOptions, appendHints: (preview: string) => string, expandedOnly: boolean = false, ): Text { const useExpanded = expandedOnly || options.expanded; const maxLines = useExpanded ? getExpandedPreviewLineLimit(lines, config) : config.previewLines; const preview = buildPreviewText(lines, maxLines, theme, useExpanded); return textResult(appendHints(preview)); } function formatReadSummary( lines: string[], details: ReadToolDetails | undefined, theme: RenderTheme, showTruncationHints: boolean, ): string { const lineCount = lines.length; let summary = theme.fg( "muted", `↳ loaded ${lineCount} ${pluralize(lineCount, "line")}`, ); summary += theme.fg( "warning", showTruncationHints ? truncationHint(details) : "", ); return summary; } function formatSearchSummary( lines: string[], unitLabel: string, details: { truncation?: { truncated?: boolean } } | undefined, theme: RenderTheme, showTruncationHints: boolean, pluralLabel?: string, ): string { const count = countNonEmptyLines(lines); let summary = theme.fg( "muted", `↳ ${count} ${pluralize(count, unitLabel, pluralLabel)} returned`, ); summary += theme.fg( "warning", showTruncationHints ? truncationHint(details) : "", ); return summary; } function formatBashSummary( lines: string[], _details: BashToolDetails | undefined, theme: RenderTheme, _showTruncationHints: boolean, ): string { const lineCount = lines.length; return theme.fg( "muted", `↳ ${lineCount} ${pluralize(lineCount, "line")} returned`, ); } function formatBashTruncationHints( details: BashToolDetails | undefined, theme: RenderTheme, ): string { if (!details) { return ""; } const hints: string[] = []; if (details.truncation?.truncated) { hints.push("output truncated"); } if (details.fullOutputPath) { hints.push(`full output: ${details.fullOutputPath}`); } if (hints.length === 0) { return ""; } return `\n${theme.fg("warning", `(${hints.join(" • ")})`)}`; } function getBashPreviewLineLimit( lines: string[], options: ToolRenderResultOptions, config: ToolDisplayConfig, ): number { if (options.expanded) { return getExpandedPreviewLineLimit(lines, config); } return config.bashOutputMode === "opencode" ? config.bashCollapsedLines : config.previewLines; } type ToolRenderInput = { content?: Array<{ type: string; text?: string }>; details?: unknown; }; function textResult(text: string): Text { return new Text(text, 0, 0); } function partialResultText(theme: RenderTheme, label: string): Text { return textResult(theme.fg("warning", label)); } function renderBashPreviewWithHints( lines: string[], maxLines: number, config: ToolDisplayConfig, theme: RenderTheme, options: ToolRenderResultOptions, details: BashToolDetails | undefined, ): Text { let preview = buildPreviewText(lines, maxLines, theme, options.expanded); if (config.showTruncationHints) { preview += formatBashTruncationHints(details, theme); } if (options.expanded) { preview += formatExpandedPreviewCapHint(lines, config, theme); } return textResult(preview); } function prepareBashLivePreview( rawOutput: string, options: ToolRenderResultOptions, config: ToolDisplayConfig, ): { lines: string[]; maxLines: number } | undefined { const lines = prepareOutputLines(rawOutput, options); if (lines.length === 0) { return undefined; } const maxLines = getBashPreviewLineLimit(lines, options, config); if (!options.expanded && maxLines === 0) { return undefined; } return { lines, maxLines }; } function renderBashLivePreview( rawOutput: string, options: ToolRenderResultOptions, config: ToolDisplayConfig, theme: RenderTheme, details: BashToolDetails | undefined, ): Text { const prepared = prepareBashLivePreview(rawOutput, options, config); if (!prepared) { return textResult(""); } return renderBashPreviewWithHints(prepared.lines, prepared.maxLines, config, theme, options, details); } function renderBashErrorResult( rawOutput: string, options: ToolRenderResultOptions, config: ToolDisplayConfig, theme: RenderTheme, details: BashToolDetails | undefined, ): Text { const lines = prepareOutputLines(rawOutput, options); let text = theme.fg("error", "↳ command failed"); if (lines.length > 0) { const maxLines = getBashPreviewLineLimit(lines, options, config); if (options.expanded || maxLines > 0) { const { shown, remaining } = previewLines(lines, maxLines); text += `\n${shown .map((line) => theme.fg("error", sanitizeAnsiForThemedOutput(line))) .join("\n")}`; text += formatTruncationHint(remaining, options.expanded, theme); } } if (config.showTruncationHints) { text += formatBashTruncationHints(details, theme); } if (options.expanded && lines.length > 0) { text += formatExpandedPreviewCapHint(lines, config, theme); } return textResult(text); } function renderSearchResult( result: ToolRenderInput, options: ToolRenderResultOptions, config: ToolDisplayConfig, theme: RenderTheme, unitLabel: string, details: GrepToolDetails | FindToolDetails | LsToolDetails | undefined, pluralLabel?: string, ): Text { if (options.isPartial) { return partialResultText(theme, "running..."); } const lines = prepareOutputLines(extractTextOutput(result), options); if (config.searchOutputMode === "hidden") { return textResult(""); } const hintCtx: PreviewHintContext = { lines, config, theme, options, details }; if (config.searchOutputMode === "count") { if (options.expanded) { return renderSearchPreview(hintCtx, true); } let summary = formatSearchSummary( lines, unitLabel, details, theme, config.showTruncationHints, pluralLabel, ); summary += formatExpandHint(theme); summary += formatRtkSummarySuffix({ details, config, theme }); return textResult(summary); } return renderSearchPreview(hintCtx); } function resolveMcpProxyCallTarget(args: Record): string { const tool = getTextField(args, "tool"); const connect = getTextField(args, "connect"); const describe = getTextField(args, "describe"); const search = getTextField(args, "search"); const server = getTextField(args, "server"); if (tool) { return server ? `call ${server}:${tool}` : `call ${tool}`; } if (connect) { return `connect ${connect}`; } if (describe) { return server ? `describe ${describe} @${server}` : `describe ${describe}`; } if (search) { return server ? `search "${search}" @${server}` : `search "${search}"`; } if (server) { return `tools ${server}`; } return "status"; } function formatArgCountSuffix(argCount: number, theme: RenderTheme): string { return argCount === 0 ? theme.fg("muted", " (no args)") : theme.fg("muted", ` (${argCount} ${pluralize(argCount, "arg")})`); } function formatMcpCallLine( toolName: string, toolLabel: string, args: Record, theme: RenderTheme, ): Text { const argCount = Object.keys(args).length; const argSuffix = formatArgCountSuffix(argCount, theme); const target = toolName === "mcp" ? resolveMcpProxyCallTarget(args) : toolLabel.startsWith("MCP ") ? toolLabel.slice("MCP ".length) : toolLabel; return new Text( `${theme.fg("toolTitle", theme.bold("MCP"))} ${theme.fg("accent", target)}${argSuffix}`, 0, 0, ); } function getMcpTruncationDetails(details: unknown): { truncated: boolean; fullOutputPath?: string; } { const detailRecord = toRecord(details); const truncation = toRecord(detailRecord.truncation); const fullOutputPath = typeof truncation.fullOutputPath === "string" ? truncation.fullOutputPath : typeof detailRecord.fullOutputPath === "string" ? detailRecord.fullOutputPath : undefined; return { truncated: truncation.truncated === true, fullOutputPath, }; } function renderMcpResult( result: ToolRenderInput, options: ToolRenderResultOptions, config: ToolDisplayConfig, theme: RenderTheme, ): Text { const partial = handlePartialResult(options, theme, "running..."); if (partial) { return partial; } const lines = prepareOutputLines(extractTextOutput(result), options); const truncation = getMcpTruncationDetails(result.details); const mcpCtx: McpPreviewHintContext = { lines, config, theme, options, details: result.details, truncation }; if (config.mcpOutputMode === "hidden") { // Hidden keeps the row to its call line until the user expands it. return options.expanded ? renderMcpPreview(mcpCtx) : textResult(""); } if (config.mcpOutputMode === "summary") { if (options.expanded) { return renderMcpPreview(mcpCtx, true); } const lineCount = countNonEmptyLines(lines); let summary = theme.fg( "muted", `↳ ${lineCount} ${pluralize(lineCount, "line")} returned`, ); summary += formatExpandHint(theme); if (config.showTruncationHints && truncation.truncated) { summary += theme.fg("warning", " • truncated"); } summary += formatRtkSummarySuffix({ details: result.details, config, theme }); return textResult(summary); } return renderMcpPreview(mcpCtx); } function isBuiltInToolName(toolName: string): boolean { return (BUILT_IN_TOOL_OVERRIDE_NAMES as readonly string[]).includes(toolName); } function getRuntimeCustomToolOverride( toolName: string, config: ToolDisplayConfig, ): CustomToolOverrideConfig | undefined { if (!toolName || isBuiltInToolName(toolName)) { return undefined; } const overrides = toRecord((config as unknown as Record).customToolOverrides); return normalizeCustomToolOverrideEntry(overrides[toolName]); } function formatGenericToolCallLine( toolName: string, args: unknown, theme: RenderTheme, ): Text { const argRecord = toRecord(args); const argCount = Object.keys(argRecord).length; const argSuffix = formatArgCountSuffix(argCount, theme); return new Text( `${theme.fg("toolTitle", theme.bold(toolName))}${argSuffix}`, 0, 0, ); } function getSearchScope(args: Record): string { return shortenPath((args.path as string) || "."); } function formatSearchCallLine( toolName: string, accent: string, mutedSuffix: string, theme: RenderTheme, ): Text { return new Text( `${theme.fg("toolTitle", theme.bold(toolName))} ${theme.fg("accent", accent)}${theme.fg("muted", mutedSuffix)}`, 0, 0, ); } function renderCustomToolResult( result: ToolRenderInput, options: ToolRenderResultOptions, config: ToolDisplayConfig, outputMode: CustomToolOverrideConfig["outputMode"], theme: RenderTheme, ): Text { return renderMcpResult( result as ToolRenderInput, options, { ...config, mcpOutputMode: outputMode }, theme, ); } function renderReadDisplayCall( args: unknown, theme: RenderTheme, ): Text { const path = shortenPath(getToolPathArg(args)); const offset = getNumericField(args, "offset"); const limit = getNumericField(args, "limit"); let suffix = ""; if (offset !== undefined || limit !== undefined) { const from = offset ?? 1; const to = limit !== undefined ? from + limit - 1 : undefined; suffix = to ? `:${from}-${to}` : `:${from}`; } const line = `${theme.fg("toolTitle", theme.bold("read"))} ${theme.fg("accent", path || "...")}${theme.fg("warning", suffix)}`; return textResult(line); } function renderReadDisplayResult( result: ToolRenderInput, options: ToolRenderResultOptions, config: ToolDisplayConfig, theme: RenderTheme, ): Text { if (options.isPartial) { return partialResultText(theme, "reading..."); } if (config.readOutputMode === "hidden") { return textResult(""); } const details = result.details as ReadToolDetails | undefined; const rawOutput = extractTextOutput(result); const lines = prepareOutputLines(rawOutput, options); const hintCtx: PreviewHintContext = { lines, config, theme, options, details }; if (config.readOutputMode === "summary") { if (options.expanded) { return renderSearchPreview(hintCtx, true); } const summaryLines = compactOutputLines(splitLines(rawOutput), { expanded: true, }); let summary = formatReadSummary( summaryLines, details, theme, config.showTruncationHints, ); summary += formatExpandHint(theme); summary += formatRtkSummarySuffix({ details: result.details, config, theme }); return textResult(summary); } return renderSearchPreview(hintCtx); } function renderEditDisplayCall( args: unknown, theme: RenderTheme, context: ToolRenderContextLike | undefined, getConfig: ConfigGetter, ): Text | Container { const path = shortenPath(getToolPathArg(args)); const lineCount = getEditLineCount(args); const summaryText = `${theme.fg("toolTitle", theme.bold("edit"))} ${theme.fg("accent", path || "...")}${formatLineCountSuffix(lineCount, theme)}`; if (!context?.argsComplete || !context.isPartial) { return textResult(summaryText); } const previewKey = JSON.stringify({ path: getToolPathArg(args) ?? null, edits: toRecord(args).edits ?? null, oldText: getStringField(args, "oldText") ?? null, newText: getStringField(args, "newText") ?? null, }); const previewData = resolvePendingDiffPreview( context, EDIT_PENDING_PREVIEW_STATE_KEY, previewKey, () => buildPendingEditPreviewData(args, context.cwd), ); return buildPendingDiffCallComponent(summaryText, previewData, context, getConfig(), theme); } function renderEditDisplayResult( result: ToolRenderInput & { isError?: boolean }, options: ToolRenderResultOptions, theme: RenderTheme, context: ToolRenderContextLike | undefined, getConfig: ConfigGetter, ): unknown { const lineCount = getEditLineCount(context?.args); const { fallbackText, earlyResult } = handleEditOrWriteResult(result, options, context, theme, lineCount, "editing", "Edit failed."); if (earlyResult) { return earlyResult; } const config = getConfig(); const details = result.details as EditToolDetails | undefined; return renderEditDiffResult( details, { expanded: options.expanded, filePath: getToolPathArg(context?.args) }, config, theme, fallbackText, ); } function handleEditOrWriteResult( result: ToolRenderInput, options: ToolRenderResultOptions, context: ToolRenderContextLike | undefined, theme: RenderTheme, lineCount: number, progressLabel: string, errorMessage: string, ): { fallbackText: string; earlyResult: Text | undefined } { if (options.isPartial) { return { fallbackText: "", earlyResult: new Text(formatInProgressLineCount(progressLabel, lineCount, theme), 0, 0) }; } const fallbackText = extractTextOutput(result); if (isToolError(result, context)) { return { fallbackText, earlyResult: textResult(theme.fg("error", fallbackText || errorMessage)) }; } return { fallbackText, earlyResult: undefined }; } function createShellToolRenderers(getConfig: ConfigGetter): ToolRenderers { return { renderCall(args, theme, context) { return renderBashCall(args as never, theme as never, context as never); }, renderResult(result, options, theme, context) { const config = getConfig(); const details = (result as ToolRenderInput).details as BashToolDetails | undefined; const rawOutput = extractTextOutput(result as ToolRenderInput); if (options.isPartial) { return renderBashLivePreview(rawOutput, options, config, theme as RenderTheme, details); } if (isToolError(result, context as ToolRenderContextLike | undefined)) { return renderBashErrorResult(rawOutput, options, config, theme as RenderTheme, details); } const lines = prepareOutputLines(rawOutput, options); if (lines.length === 0) { let text = formatBashNoOutputLine( getStringField((context as ToolRenderContextLike | undefined)?.args, "command"), theme as RenderTheme, ); if (config.showTruncationHints) { text += formatBashTruncationHints(details, theme as RenderTheme); } return textResult(text); } if (config.bashOutputMode === "summary") { if (options.expanded) { const maxLines = getExpandedPreviewLineLimit(lines, config); return renderBashPreviewWithHints(lines, maxLines, config, theme as RenderTheme, options, details); } let summary = formatBashSummary( lines, details, theme as RenderTheme, config.showTruncationHints, ); summary += formatExpandHint(theme as RenderTheme); if (config.showTruncationHints) { summary += formatBashTruncationHints(details, theme as RenderTheme); } return textResult(summary); } if (config.bashOutputMode === "preview") { const maxLines = options.expanded ? getExpandedPreviewLineLimit(lines, config) : config.previewLines; return renderBashPreviewWithHints(lines, maxLines, config, theme as RenderTheme, options, details); } if (!options.expanded && config.bashCollapsedLines === 0) { let hidden = (theme as RenderTheme).fg("muted", "↳ output hidden"); if (config.showTruncationHints) { hidden += formatBashTruncationHints(details, theme as RenderTheme); } return textResult(hidden); } const maxLines = options.expanded ? lines.length : config.bashCollapsedLines; let text = buildPreviewText(lines, maxLines, theme as RenderTheme, options.expanded); if (config.showTruncationHints) { text += formatBashTruncationHints(details, theme as RenderTheme); } return textResult(text); }, }; } function createWriteToolRenderers( getConfig: ConfigGetter, writeExecutionMetaByToolCallId: Map, ): ToolRenderers { return { renderCall(args, theme, context) { const content = getToolContentArg(args); const lineCount = countWriteContentLines(content); const sizeBytes = getWriteContentSizeBytes(content); const path = shortenPath(getToolPathArg(args)); const suffix = shouldRenderWriteCallSummary({ hasContent: content !== undefined, hasDetailedResultHeader: false, }) ? formatWriteCallSuffix(lineCount, sizeBytes, theme as RenderTheme) : ""; const summaryText = `${(theme as RenderTheme).fg("toolTitle", (theme as RenderTheme).bold("write"))} ${(theme as RenderTheme).fg("accent", path || "...")}${suffix}`; if (!(context as ToolRenderContextLike | undefined)?.argsComplete || !(context as ToolRenderContextLike | undefined)?.isPartial) { return textResult(summaryText); } const previewKey = JSON.stringify({ path: getToolPathArg(args) ?? null, content: content ?? null }); const previewData = resolvePendingDiffPreview( context as ToolRenderContextLike, WRITE_PENDING_PREVIEW_STATE_KEY, previewKey, () => buildPendingWritePreviewData(args, (context as ToolRenderContextLike).cwd as string), ); return buildPendingDiffCallComponent(summaryText, previewData, context as ToolRenderContextLike, getConfig(), theme as RenderTheme); }, renderResult(result, options, theme, context) { const content = getToolContentArg((context as ToolRenderContextLike | undefined)?.args); const lineCount = countWriteContentLines(content); const { fallbackText, earlyResult } = handleEditOrWriteResult( result as ToolRenderInput, options, context as ToolRenderContextLike | undefined, theme as RenderTheme, lineCount, "writing", "Write failed.", ); if (earlyResult) { return earlyResult; } const config = getConfig(); // The in-memory map is the live source; the persisted record covers reload and resume. const liveMeta = getWriteExecutionMeta(context as ToolRenderContextLike | undefined, writeExecutionMetaByToolCallId); const persistedMeta = liveMeta === undefined ? getWriteToolDisplayDetails((result as ToolRenderInput).details) : undefined; const previousContent = liveMeta?.previousContent ?? persistedMeta?.previousContent; const fileExistedBeforeWrite = liveMeta?.fileExistedBeforeWrite ?? persistedMeta?.fileExistedBeforeWrite ?? false; // A file that existed but has no captured previous text (too large, unreadable, or an old // true-only persisted record) must use the omission renderer instead of an additions diff. const previousContentOmitted = persistedMeta?.previousContentOmitted === true || (fileExistedBeforeWrite && typeof previousContent !== "string"); return renderWriteDiffResult( content, { expanded: options.expanded, filePath: getToolPathArg((context as ToolRenderContextLike | undefined)?.args), previousContent, previousContentOmitted, previousBytes: persistedMeta?.previousBytes, fileExistedBeforeWrite, }, config, theme as never, fallbackText, ); }, }; } function createBuiltInToolRenderers( toolName: BuiltInToolOverrideName, getConfig: ConfigGetter, writeExecutionMetaByToolCallId: Map, ): ToolRenderers { switch (toolName) { case "read": return { renderCall(args, theme) { return renderReadDisplayCall(args, theme as RenderTheme); }, renderResult(result, options, theme) { return renderReadDisplayResult(result as ToolRenderInput, options, getConfig(), theme as RenderTheme); }, }; case "grep": return { renderCall(args, theme) { const input = toRecord(args); const scope = getSearchScope(input); const globSuffix = input.glob ? ` (${input.glob})` : ""; const limitSuffix = input.limit !== undefined ? ` limit ${input.limit}` : ""; return formatSearchCallLine("grep", `/${input.pattern}/`, ` in ${scope}${globSuffix}${limitSuffix}`, theme as RenderTheme); }, renderResult(result, options, theme) { return renderSearchResult(result as ToolRenderInput, options, getConfig(), theme as RenderTheme, "match", (result as ToolRenderInput).details, "matches"); }, }; case "find": return { renderCall(args, theme) { const input = toRecord(args); const { scope, limitSuffix } = buildSearchCallSuffix(input); return formatSearchCallLine("find", input.pattern as string, ` in ${scope}${limitSuffix}`, theme as RenderTheme); }, renderResult(result, options, theme) { return renderSearchResult(result as ToolRenderInput, options, getConfig(), theme as RenderTheme, "result", (result as ToolRenderInput).details); }, }; case "ls": return { renderCall(args, theme) { const { scope, limitSuffix } = buildSearchCallSuffix(toRecord(args)); return formatSearchCallLine("ls", scope, limitSuffix, theme as RenderTheme); }, renderResult(result, options, theme) { return renderSearchResult(result as ToolRenderInput, options, getConfig(), theme as RenderTheme, "entry", (result as ToolRenderInput).details, "entries"); }, }; case "edit": return { renderCall(args, theme, context) { return renderEditDisplayCall(args, theme as RenderTheme, context as ToolRenderContextLike | undefined, getConfig); }, renderResult(result, options, theme, context) { return renderEditDisplayResult(result as never, options, theme as RenderTheme, context as ToolRenderContextLike | undefined, getConfig) as Component; }, }; case "write": return createWriteToolRenderers(getConfig, writeExecutionMetaByToolCallId); case "bash": case "powershell": return createShellToolRenderers(getConfig); } } function buildSearchCallSuffix(args: Record): { scope: string; limitSuffix: string } { return { scope: getSearchScope(args), limitSuffix: args.limit !== undefined ? ` (limit ${args.limit})` : "" }; } function createCustomToolRenderers( toolName: string, override: CustomToolOverrideConfig, getConfig: ConfigGetter, ): ToolRenderers { return { renderCall(args, theme) { if (override.kind === "mcp") { return formatMcpCallLine("mcp", "MCP Proxy", toRecord(args), theme as RenderTheme); } return formatGenericToolCallLine(toolName, args, theme as RenderTheme); }, renderResult(result, options, theme) { return renderCustomToolResult( result as ToolRenderInput, options, getConfig(), override.outputMode, theme as RenderTheme, ); }, }; } /** `mcp____` as the `server/tool` label the native renderer displays. */ function getNativeMcpToolLabel(toolName: string): string { const match = /^mcp__(.+?)__(.+)$/.exec(toolName); return match ? `${match[1]}/${match[2]}` : toolName; } function createNativeMcpToolRenderers(toolName: string, getConfig: ConfigGetter): ToolRenderers { const label = getNativeMcpToolLabel(toolName); return { renderCall(args, theme) { return formatMcpCallLine(toolName, label, toRecord(args), theme as RenderTheme); }, renderResult(result, options, theme) { return renderMcpResult(result as ToolRenderInput, options, getConfig(), theme as RenderTheme); }, }; } /** * Raw renderers for a nested codemode call, or undefined when this extension * does not render that tool. This is the same precedence as the resolver: * built-ins, exact custom overrides, native `mcp__*` names. */ function createNestedToolRenderers( toolName: string, getConfig: ConfigGetter, writeExecutionMetaByToolCallId: Map, ): ToolRenderers | undefined { const config = getConfig(); if (!config.enabled) { return undefined; } if (isBuiltInToolName(toolName)) { const builtInName = toolName as BuiltInToolOverrideName; if (!config.registerToolOverrides[builtInName]) { return undefined; } return createBuiltInToolRenderers(builtInName, getConfig, writeExecutionMetaByToolCallId); } const customOverride = getRuntimeCustomToolOverride(toolName, config); if (customOverride?.enabled) { return createCustomToolRenderers(toolName, customOverride, getConfig); } if (isNativeMcpToolName(toolName)) { return createNativeMcpToolRenderers(toolName, getConfig); } return undefined; } export interface ToolRendererResolverDeps { getConfig: ConfigGetter; writeExecutionMetaByToolCallId: Map; } /** * The single render-only resolver for pi-tool-display. * * It never re-registers or mutates a tool, so every session keeps its own tool * definitions (prompt metadata, parameters, execute). Built-ins, native MCP * names, configured custom overrides and codemode are rendered here; everything * else falls through to `next()`, which returns the remaining resolvers and then * the registered tool. */ export function createToolRendererResolver(deps: ToolRendererResolverDeps): ToolRendererResolver { const { getConfig, writeExecutionMetaByToolCallId } = deps; return (toolName, next) => { const config = getConfig(); if (!config.enabled) { return next(); } const nestedRenderers = createNestedToolRenderers(toolName, getConfig, writeExecutionMetaByToolCallId); if (nestedRenderers) { return withToolDisplayShell(nestedRenderers, getConfig); } if (toolName === "codemode") { if (!config.codemodeDisplay) { return next(); } return withToolDisplayShell( createCodemodeToolRenderers({ getConfig, getHostRenderers: next, getNestedToolRenderers: (name) => createNestedToolRenderers(name, getConfig, writeExecutionMetaByToolCallId), hasLiveWriteMeta: (toolCallId, state) => getWriteExecutionMeta({ toolCallId, state }, writeExecutionMetaByToolCallId) !== undefined, hasWriteDisplayDetails: (details) => getWriteToolDisplayDetails(details) !== undefined, }), getConfig, ); } return next(); }; } export function registerToolDisplayOverrides( pi: ExtensionAPI, getConfig: ConfigGetter, ): void { const writeExecutionMetaByToolCallId = new Map(); const isWriteDisplayEnabled = (): boolean => { const config = getConfig(); return config.enabled && config.registerToolOverrides.write; }; // Render-only: the session keeps its own tool definitions, their settings // options and their execution context. One resolver covers every tool that // pi-tool-display renders. pi.registerToolRenderer(createToolRendererResolver({ getConfig, writeExecutionMetaByToolCallId })); pi.on("tool_call", async (event, ctx) => { if (event.toolName !== "write") { return; } if (!isWriteDisplayEnabled()) { writeExecutionMetaByToolCallId.delete(event.toolCallId); return; } try { const previous = captureExistingWriteContent(ctx.cwd, getToolPathArg(event.input)); recordWriteExecutionMeta(writeExecutionMetaByToolCallId, event.toolCallId, { fileExistedBeforeWrite: previous.existed, previousContent: previous.content, }); } catch (error) { logToolDisplayDebug("Write previous-content capture failed.", error); } }); pi.on("tool_result", async (event) => { if (event.toolName !== "write") { return; } if (!isWriteDisplayEnabled()) { writeExecutionMetaByToolCallId.delete(event.toolCallId); return; } const meta = writeExecutionMetaByToolCallId.get(event.toolCallId); if (!meta) { return; } // The record is persisted for reload and resume; the bounded map stays the live source. return { details: withWriteToolDisplayDetails(event.details, buildWriteToolDisplayDetails(meta)), }; }); pi.on("session_start", async () => { clearWriteExecutionMeta(writeExecutionMetaByToolCallId); }); pi.on("before_agent_start", async () => { clearWriteExecutionMeta(writeExecutionMetaByToolCallId); }); }