Files
oh-my-pi/packages/coding-agent/src/modes/components/tool-execution.ts
T
can1357 0988ad2763 fix(ai): removed streaming throttles for immediate assistant delta delivery
- Removed delta-event throttling and merge logic so AssistantMessageEventStream now enqueues assistant deltas without batching.
- Stopped debouncing preview-diff generation in ToolExecutionComponent so apply_patch argument updates trigger immediate preview refresh.
- Added tests covering immediate delta queueing and confirming streaming arg updates no longer schedule setTimeout callbacks.
2026-05-06 21:42:10 +02:00

782 lines
26 KiB
TypeScript

import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import { sanitizeText } from "@oh-my-pi/pi-natives";
import {
Box,
type Component,
Container,
getImageDimensions,
Image,
ImageProtocol,
imageFallback,
Spacer,
TERMINAL,
Text,
type TUI,
} from "@oh-my-pi/pi-tui";
import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
import { EDIT_MODE_STRATEGIES, type EditMode, type PerFileDiffPreview } from "../../edit";
import type { Theme } from "../../modes/theme/theme";
import { theme } from "../../modes/theme/theme";
import { BASH_DEFAULT_PREVIEW_LINES } from "../../tools/bash";
import { EVAL_DEFAULT_PREVIEW_LINES } from "../../tools/eval";
import {
formatArgsInline,
JSON_TREE_MAX_DEPTH_COLLAPSED,
JSON_TREE_MAX_DEPTH_EXPANDED,
JSON_TREE_MAX_LINES_COLLAPSED,
JSON_TREE_MAX_LINES_EXPANDED,
JSON_TREE_SCALAR_LEN_COLLAPSED,
JSON_TREE_SCALAR_LEN_EXPANDED,
renderJsonTreeLines,
} from "../../tools/json-tree";
import { formatExpandHint, replaceTabs, resolveImageOptions, truncateToWidth } from "../../tools/render-utils";
import { toolRenderers } from "../../tools/renderers";
import { renderStatusLine } from "../../tui";
import { convertToPng } from "../../utils/image-convert";
import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
import { renderDiff } from "./diff";
function ensureInvalidate(component: unknown): Component {
const c = component as { render: Component["render"]; invalidate?: () => void };
if (!c.invalidate) {
c.invalidate = () => {};
}
return c as Component;
}
function cloneToolArgs<T>(args: T): T {
if (args === null || args === undefined) return args;
try {
return structuredClone(args);
} catch {
return args;
}
}
function isEditLikeToolName(toolName: string): boolean {
return toolName === "edit" || toolName === "apply_patch";
}
function resolveEditModeForTool(toolName: string, tool: AgentTool | undefined): EditMode | undefined {
if (toolName === "apply_patch") return "apply_patch";
if (toolName !== "edit") return undefined;
return (tool as { mode?: EditMode } | undefined)?.mode;
}
export interface ToolExecutionOptions {
showImages?: boolean; // default: true (only used if terminal supports images)
editFuzzyThreshold?: number;
editAllowFuzzy?: boolean;
hashlineAutoDropPureInsertDuplicates?: boolean;
}
export interface ToolExecutionHandle {
updateArgs(args: any, toolCallId?: string): void;
updateResult(
result: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
details?: any;
isError?: boolean;
},
isPartial?: boolean,
toolCallId?: string,
): void;
setArgsComplete(toolCallId?: string): void;
setExpanded(expanded: boolean): void;
}
/**
* Component that renders a tool call with its result (updateable)
*/
export class ToolExecutionComponent extends Container {
#contentBox: Box; // Used for custom tools and bash visual truncation
#contentText: Text; // For built-in tools (with its own padding/bg)
#multiFileBoxes: (Box | Spacer)[] = []; // Extra boxes for multi-file edit results
#imageComponents: Image[] = [];
#imageSpacers: Spacer[] = [];
#toolName: string;
#toolLabel: string;
#args: any;
#expanded = false;
#showImages: boolean;
#editFuzzyThreshold: number | undefined;
#editAllowFuzzy: boolean | undefined;
#hashlineAutoDropPureInsertDuplicates: boolean | undefined;
#isPartial = true;
#tool?: AgentTool;
#ui: TUI;
#cwd: string;
#result?: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
isError?: boolean;
details?: any;
};
// Edit preview state
#editMode?: EditMode;
#editDiffPreview?: PerFileDiffPreview[];
#editDiffAbort?: AbortController;
#editDiffLastArgsKey?: string;
// Cached converted images for Kitty protocol (which requires PNG), keyed by index
#convertedImages: Map<number, { data: string; mimeType: string }> = new Map();
// Spinner animation for partial task results
#spinnerFrame?: number;
#spinnerInterval?: NodeJS.Timeout;
// Track if args are still being streamed (for edit/write spinner)
#argsComplete = false;
#renderState: {
spinnerFrame?: number;
expanded: boolean;
isPartial: boolean;
renderContext?: Record<string, unknown>;
} = {
expanded: false,
isPartial: true,
};
constructor(
toolName: string,
args: any,
options: ToolExecutionOptions = {},
tool: AgentTool | undefined,
ui: TUI,
cwd: string = getProjectDir(),
_toolCallId?: string,
) {
super();
this.#toolName = toolName;
this.#toolLabel = tool?.label ?? toolName;
this.#showImages = options.showImages ?? true;
this.#editFuzzyThreshold = options.editFuzzyThreshold;
this.#editAllowFuzzy = options.editAllowFuzzy;
this.#hashlineAutoDropPureInsertDuplicates = options.hashlineAutoDropPureInsertDuplicates;
this.#tool = tool;
this.#ui = ui;
this.#cwd = cwd;
this.#args = cloneToolArgs(args);
this.addChild(new Spacer(1));
// Always create both - contentBox for custom tools/bash/tools with renderers, contentText for other built-ins
this.#contentBox = new Box(1, 1, (text: string) => theme.bg("toolPendingBg", text));
this.#contentText = new Text("", 1, 1, (text: string) => theme.bg("toolPendingBg", text));
// Use Box for custom tools or built-in tools that have renderers
const hasRenderer = toolName in toolRenderers;
const hasCustomRenderer = !!(tool?.renderCall || tool?.renderResult);
if (hasCustomRenderer || hasRenderer) {
this.addChild(this.#contentBox);
} else {
this.addChild(this.#contentText);
}
this.#editMode = resolveEditModeForTool(toolName, tool);
this.#updateDisplay();
void this.#runPreviewDiff();
}
updateArgs(args: any, _toolCallId?: string): void {
this.#args = cloneToolArgs(args);
this.#updateSpinnerAnimation();
void this.#runPreviewDiff();
this.#updateDisplay();
}
/**
* Signal that args are complete (tool is about to execute).
* This triggers an immediate final diff computation for edit-like tools.
*/
setArgsComplete(_toolCallId?: string): void {
this.#argsComplete = true;
this.#updateSpinnerAnimation();
void this.#runPreviewDiff();
}
async #runPreviewDiff(): Promise<void> {
const editMode = this.#editMode;
if (!editMode) return;
const strategy = EDIT_MODE_STRATEGIES[editMode];
if (!strategy) return;
const args = this.#args;
if (args == null || typeof args !== "object") return;
const partialJson = (args as { __partialJson?: string }).__partialJson;
let effectiveArgs: unknown;
try {
effectiveArgs = strategy.extractCompleteEdits(args, partialJson);
} catch {
effectiveArgs = args;
}
// Coalesce duplicate computes for identical args.
let argsKey: string;
try {
argsKey = JSON.stringify(effectiveArgs);
} catch {
argsKey = String(Date.now());
}
if (argsKey === this.#editDiffLastArgsKey) return;
this.#editDiffLastArgsKey = argsKey;
this.#editDiffAbort?.abort();
const controller = new AbortController();
this.#editDiffAbort = controller;
try {
const previews = await strategy.computeDiffPreview(effectiveArgs, {
cwd: this.#cwd,
signal: controller.signal,
fuzzyThreshold: this.#editFuzzyThreshold,
allowFuzzy: this.#editAllowFuzzy,
hashlineAutoDropPureInsertDuplicates: this.#hashlineAutoDropPureInsertDuplicates,
});
if (controller.signal.aborted) return;
if (previews) {
this.#editDiffPreview = previews;
this.#updateDisplay();
this.#ui.requestRender();
}
} catch (err) {
if (controller.signal.aborted) return;
logger.warn("Edit preview diff failed", { tool: this.#toolName, error: String(err) });
}
}
updateResult(
result: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
details?: any;
isError?: boolean;
},
isPartial = false,
_toolCallId?: string,
): void {
this.#result = result;
this.#isPartial = isPartial;
// When tool is complete, ensure args are marked complete so spinner stops
if (!isPartial) {
this.#argsComplete = true;
}
this.#updateSpinnerAnimation();
this.#updateDisplay();
// Convert non-PNG images to PNG for Kitty protocol (async)
this.#maybeConvertImagesForKitty();
}
/**
* Get all image blocks from result content and details.images.
* Some tools (like generate_image) store images in details to avoid bloating model context.
*/
#getAllImageBlocks(): Array<{ data?: string; mimeType?: string }> {
if (!this.#result) return [];
const contentImages = this.#result.content?.filter((c: any) => c.type === "image") || [];
const detailImages = this.#result.details?.images || [];
return [...contentImages, ...detailImages];
}
/**
* Convert non-PNG images to PNG for Kitty graphics protocol.
* Kitty requires PNG format (f=100), so JPEG/GIF/WebP won't display.
*/
#maybeConvertImagesForKitty(): void {
// Only needed for Kitty protocol
if (TERMINAL.imageProtocol !== ImageProtocol.Kitty) return;
if (!this.#result) return;
const imageBlocks = this.#getAllImageBlocks();
for (let i = 0; i < imageBlocks.length; i++) {
const img = imageBlocks[i];
if (!img.data || !img.mimeType) continue;
// Skip if already PNG or already converted
if (img.mimeType === "image/png") continue;
if (this.#convertedImages.has(i)) continue;
// Convert async - catch errors from processing
const index = i;
convertToPng(img.data, img.mimeType)
.then(converted => {
if (converted) {
this.#convertedImages.set(index, converted);
this.#updateDisplay();
this.#ui.requestRender();
}
})
.catch(() => {
// Ignore conversion failures - display will use original image format
});
}
}
/**
* Start or stop spinner animation based on whether this is a partial task result.
*/
#updateSpinnerAnimation(): void {
// Spinner for: task tool with partial result, or edit/write while args streaming
const isStreamingArgs = !this.#argsComplete && (isEditLikeToolName(this.#toolName) || this.#toolName === "write");
const isBackgroundAsyncTask =
this.#toolName === "task" &&
(this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
const isPartialTask = this.#isPartial && this.#toolName === "task" && !isBackgroundAsyncTask;
const needsSpinner = isStreamingArgs || isPartialTask;
if (needsSpinner && !this.#spinnerInterval) {
this.#spinnerInterval = setInterval(() => {
const frameCount = theme.spinnerFrames.length;
if (frameCount === 0) return;
this.#spinnerFrame = ((this.#spinnerFrame ?? -1) + 1) % frameCount;
this.#renderState.spinnerFrame = this.#spinnerFrame;
this.#ui.requestRender();
}, 80);
} else if (!needsSpinner && this.#spinnerInterval) {
clearInterval(this.#spinnerInterval);
this.#spinnerInterval = undefined;
}
}
/**
* Stop spinner animation and cleanup resources.
*/
stopAnimation(): void {
if (this.#spinnerInterval) {
clearInterval(this.#spinnerInterval);
this.#spinnerInterval = undefined;
this.#spinnerFrame = undefined;
}
this.#editDiffAbort?.abort();
this.#editDiffAbort = undefined;
}
setExpanded(expanded: boolean): void {
this.#expanded = expanded;
this.#updateDisplay();
}
setShowImages(show: boolean): void {
this.#showImages = show;
this.#updateDisplay();
}
override invalidate(): void {
super.invalidate();
this.#updateDisplay();
}
#updateDisplay(): void {
// Set background based on state
const bgFn = this.#isPartial
? (text: string) => theme.bg("toolPendingBg", text)
: this.#result?.isError
? (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;
const mergeCallAndResult = Boolean((tool as { mergeCallAndResult?: boolean }).mergeCallAndResult);
// Custom tools use Box for flexible component rendering
const inline = Boolean((tool as { inline?: boolean }).inline);
this.#contentBox.setBgFn(inline ? undefined : bgFn);
this.#contentBox.clear();
// Render call component
const shouldRenderCall = !this.#result || !mergeCallAndResult;
if (shouldRenderCall && tool.renderCall) {
try {
const callComponent = tool.renderCall(this.#getCallArgsForRender(), this.#renderState, theme);
if (callComponent) {
this.#contentBox.addChild(ensureInvalidate(callComponent));
}
} 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));
}
} else {
// No custom renderCall, show tool name
this.#contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0));
}
// Render result component if we have a result
if (this.#result && tool.renderResult) {
try {
const renderResult = tool.renderResult as (
result: { content: Array<{ type: string; text?: string }>; details?: unknown; isError?: boolean },
options: { expanded: boolean; isPartial: boolean; spinnerFrame?: number },
theme: Theme,
args?: unknown,
) => Component;
const resultComponent = renderResult(
{
content: this.#result.content as any,
details: this.#result.details,
isError: this.#result.isError,
},
this.#renderState,
theme,
this.#args,
);
if (resultComponent) {
this.#contentBox.addChild(ensureInvalidate(resultComponent));
}
} 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) {
this.#contentBox.addChild(new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
}
}
} else if (this.#result) {
// Has result but no custom renderResult
const output = this.#getTextOutput();
if (output) {
this.#contentBox.addChild(new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
}
}
} else if (this.#toolName in toolRenderers) {
// Built-in tools with renderers
const renderer = toolRenderers[this.#toolName];
// Clean up previous multi-file boxes
for (const box of this.#multiFileBoxes) {
this.removeChild(box);
}
this.#multiFileBoxes = [];
// Check for multi-file edit results
const perFileResults = this.#result?.details?.perFileResults as
| Array<{ path: string; isError?: boolean }>
| undefined;
if (perFileResults && perFileResults.length > 1) {
// Multi-file: render each file as its own Box (identical to separate tool calls)
this.#contentBox.setBgFn(undefined);
this.#contentBox.clear();
const renderContext = this.#buildRenderContext();
this.#renderState.renderContext = renderContext;
for (let i = 0; i < perFileResults.length; i++) {
const fileResult = perFileResults[i];
if (i > 0) {
const spacer = new Spacer(1);
this.#multiFileBoxes.push(spacer);
this.addChild(spacer);
}
const fileBgFn = fileResult.isError
? (text: string) => theme.bg("toolErrorBg", text)
: (text: string) => theme.bg("toolSuccessBg", text);
const fileBox = new Box(1, 1, fileBgFn);
try {
const resultComponent = renderer.renderResult(
{ content: [], details: fileResult, isError: fileResult.isError },
this.#renderState,
theme,
);
if (resultComponent) {
fileBox.addChild(ensureInvalidate(resultComponent));
}
} catch (err) {
logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
}
this.#multiFileBoxes.push(fileBox);
this.addChild(fileBox);
}
// Show pending indicator for remaining files
const totalFiles = this.#args?.edits
? new Set((this.#args.edits as any[]).map((e: any) => e?.path).filter(Boolean)).size
: 0;
const remaining = Math.max(0, totalFiles - perFileResults.length);
if (remaining > 0 && this.#isPartial) {
const pendingSpacer = new Spacer(1);
this.#multiFileBoxes.push(pendingSpacer);
this.addChild(pendingSpacer);
const pendingBox = new Box(1, 1, (text: string) => theme.bg("toolPendingBg", text));
const pendingText = renderStatusLine(
{
icon: "pending",
title: "Edit",
description: theme.fg("dim", `${remaining} more file${remaining > 1 ? "s" : ""} pending…`),
},
theme,
);
pendingBox.addChild(new Text(pendingText, 0, 0));
this.#multiFileBoxes.push(pendingBox);
this.addChild(pendingBox);
}
} else {
// Single-file or no result: standard rendering
// Inline renderers skip background styling
this.#contentBox.setBgFn(renderer.inline ? undefined : bgFn);
this.#contentBox.clear();
const renderContext = this.#buildRenderContext();
this.#renderState.renderContext = renderContext;
const shouldRenderCall = !this.#result || !renderer.mergeCallAndResult;
if (shouldRenderCall) {
// Render call component
try {
const callComponent = renderer.renderCall(this.#getCallArgsForRender(), this.#renderState, theme);
if (callComponent) {
this.#contentBox.addChild(ensureInvalidate(callComponent));
}
} 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));
}
}
// Render result component if we have a result
if (this.#result) {
try {
const resultComponent = renderer.renderResult(
{
content: this.#result.content as any,
details: this.#result.details,
isError: this.#result.isError,
},
this.#renderState,
theme,
this.#getCallArgsForRender(),
);
if (resultComponent) {
this.#contentBox.addChild(ensureInvalidate(resultComponent));
}
} 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) {
this.#contentBox.addChild(new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
}
}
}
}
} else {
// Other built-in tools: use Text directly with caching
this.#contentText.setCustomBgFn(bgFn);
this.#contentText.setText(this.#formatToolExecution());
}
// Handle images (same for both custom and built-in)
for (const img of this.#imageComponents) {
this.removeChild(img);
}
this.#imageComponents = [];
for (const spacer of this.#imageSpacers) {
this.removeChild(spacer);
}
this.#imageSpacers = [];
if (this.#result) {
const imageBlocks = this.#getAllImageBlocks();
for (let i = 0; i < imageBlocks.length; i++) {
const img = imageBlocks[i];
if (TERMINAL.imageProtocol && this.#showImages && img.data && img.mimeType) {
// Use converted PNG for Kitty protocol if available
const converted = this.#convertedImages.get(i);
const imageData = converted?.data ?? img.data;
const imageMimeType = converted?.mimeType ?? img.mimeType;
// For Kitty, skip non-PNG images that haven't been converted yet
if (TERMINAL.imageProtocol === ImageProtocol.Kitty && imageMimeType !== "image/png") {
continue;
}
const spacer = new Spacer(1);
this.addChild(spacer);
this.#imageSpacers.push(spacer);
const imageComponent = new Image(
imageData,
imageMimeType,
{ fallbackColor: (s: string) => theme.fg("toolOutput", s) },
resolveImageOptions(),
);
this.#imageComponents.push(imageComponent);
this.addChild(imageComponent);
}
}
}
}
#getCallArgsForRender(): any {
if (!isEditLikeToolName(this.#toolName)) {
return this.#args;
}
const previews = this.#editDiffPreview;
if (!previews || previews.length === 0) {
return this.#args;
}
// Single-file previews feed the existing `previewDiff` channel consumed
// by `formatStreamingDiff` in the renderer.
const first = previews[0];
if (!first?.diff) {
return this.#args;
}
return { ...(this.#args as Record<string, unknown>), previewDiff: first.diff };
}
/**
* Build render context for tools that need extra state (bash, python, edit)
*/
#buildRenderContext(): Record<string, unknown> {
const context: Record<string, unknown> = {};
const normalizeTimeoutSeconds = (value: unknown, maxSeconds: number): number | undefined => {
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
return Math.max(1, Math.min(maxSeconds, value));
};
if (this.#toolName === "bash") {
// Bash needs render context even before a result exists. The renderer uses the pending-call args
// plus this context to keep the inline command preview visible while tool-call JSON is still streaming.
if (this.#result) {
// Pass raw output and expanded state - renderer handles width-aware truncation
const output = this.#getTextOutput().trimEnd();
context.output = output;
}
context.expanded = this.#expanded;
context.previewLines = BASH_DEFAULT_PREVIEW_LINES;
context.timeout = normalizeTimeoutSeconds(this.#args?.timeout, 3600);
} else if (this.#toolName === "eval" && this.#result) {
const output = this.#getTextOutput().trimEnd();
context.output = output;
context.expanded = this.#expanded;
context.previewLines = EVAL_DEFAULT_PREVIEW_LINES;
} else if (isEditLikeToolName(this.#toolName)) {
context.editMode = this.#editMode;
const previews = this.#editDiffPreview;
if (previews && previews.length > 0) {
const first = previews[0];
if (first?.diff || first?.error) {
context.editDiffPreview = first.error
? { error: first.error }
: { diff: first.diff ?? "", firstChangedLine: first.firstChangedLine };
}
if (previews.length > 1) {
context.perFileDiffPreview = previews;
}
}
context.renderDiff = renderDiff;
}
return context;
}
#getTextOutput(): string {
if (!this.#result) return "";
const textBlocks = this.#result.content?.filter((c: any) => c.type === "text") || [];
const imageBlocks = this.#getAllImageBlocks();
let output = textBlocks
.map((c: any) => {
return sanitizeWithOptionalSixelPassthrough(c.text || "", sanitizeText);
})
.join("\n");
if (imageBlocks.length > 0 && (!TERMINAL.imageProtocol || !this.#showImages)) {
const imageIndicators = imageBlocks
.map((img: any) => {
const dims = img.data ? (getImageDimensions(img.data, img.mimeType) ?? undefined) : undefined;
return imageFallback(img.mimeType, dims);
})
.join("\n");
output = output ? `${output}\n${imageIndicators}` : imageIndicators;
}
return output;
}
/**
* Format a generic tool execution (fallback for tools without custom renderers)
*/
#formatToolExecution(): string {
const lines: string[] = [];
const icon = this.#isPartial ? "pending" : this.#result?.isError ? "error" : "success";
lines.push(renderStatusLine({ icon, title: this.#toolLabel }, theme));
const argsObject = this.#args && typeof this.#args === "object" ? (this.#args as Record<string, unknown>) : null;
if (!this.#expanded && argsObject && Object.keys(argsObject).length > 0) {
const preview = formatArgsInline(argsObject, 70);
if (preview) {
lines.push(` ${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", preview)}`);
}
}
if (this.#expanded && this.#args !== undefined) {
lines.push("");
lines.push(theme.fg("dim", "Args"));
const tree = renderJsonTreeLines(
this.#args,
theme,
JSON_TREE_MAX_DEPTH_EXPANDED,
JSON_TREE_MAX_LINES_EXPANDED,
JSON_TREE_SCALAR_LEN_EXPANDED,
);
lines.push(...tree.lines);
if (tree.truncated) {
lines.push(theme.fg("dim", "…"));
}
lines.push("");
}
if (!this.#result) {
return lines.join("\n");
}
const textContent = this.#getTextOutput().trimEnd();
if (!textContent) {
lines.push(theme.fg("dim", "(no output)"));
return lines.join("\n");
}
if (textContent.startsWith("{") || textContent.startsWith("[")) {
try {
const parsed = JSON.parse(textContent);
const maxDepth = this.#expanded ? JSON_TREE_MAX_DEPTH_EXPANDED : JSON_TREE_MAX_DEPTH_COLLAPSED;
const maxLines = this.#expanded ? JSON_TREE_MAX_LINES_EXPANDED : JSON_TREE_MAX_LINES_COLLAPSED;
const maxScalarLen = this.#expanded ? JSON_TREE_SCALAR_LEN_EXPANDED : JSON_TREE_SCALAR_LEN_COLLAPSED;
const tree = renderJsonTreeLines(parsed, theme, maxDepth, maxLines, maxScalarLen);
if (tree.lines.length > 0) {
lines.push(...tree.lines);
if (!this.#expanded) {
lines.push(formatExpandHint(theme, this.#expanded, true));
} else if (tree.truncated) {
lines.push(theme.fg("dim", "…"));
}
return lines.join("\n");
}
} catch {
// Fall through to raw output
}
}
const outputLines = textContent.split("\n");
const maxOutputLines = this.#expanded ? 12 : 4;
const displayLines = outputLines.slice(0, maxOutputLines);
for (const line of displayLines) {
lines.push(theme.fg("toolOutput", truncateToWidth(replaceTabs(line), 80)));
}
if (outputLines.length > maxOutputLines) {
const remaining = outputLines.length - maxOutputLines;
lines.push(`${theme.fg("dim", `… ${remaining} more lines`)} ${formatExpandHint(theme, this.#expanded, true)}`);
} else if (!this.#expanded) {
lines.push(formatExpandHint(theme, this.#expanded, true));
}
return lines.join("\n");
}
}