57c89cf503
- Extracted OAuth identifier logic into public functions extractOAuthCredentialIdentifiers and extractOAuthTokenIdentifiers. - Replaced single credentialIdentity string with multi-identifier resolveCredentialIdentifiers returning string[] for flexible matching. - Changed credential deduplication from email-based to accountId-based matching in replaceAuthCredentialsForProvider. - Updated auth-storage tests to verify accountId-prioritized deduplication behavior across soft-disable and hard-delete scenarios. - Added documentation comments in coding-agent modules explaining partial JSON preservation for streaming tool previews. - Documented streaming tool preview requirements and render paths in AGENTS.md.
699 lines
23 KiB
TypeScript
699 lines
23 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 type { Theme } from "../../modes/theme/theme";
|
|
import { theme } from "../../modes/theme/theme";
|
|
import { computeEditDiff, computeHashlineDiff, computePatchDiff, type DiffError, type DiffResult } from "../../patch";
|
|
import { BASH_DEFAULT_PREVIEW_LINES } from "../../tools/bash";
|
|
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,
|
|
stripInternalArgs,
|
|
} from "../../tools/json-tree";
|
|
import { PYTHON_DEFAULT_PREVIEW_LINES } from "../../tools/python";
|
|
import { formatExpandHint, replaceTabs, 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;
|
|
}
|
|
}
|
|
|
|
export interface ToolExecutionOptions {
|
|
showImages?: boolean; // default: true (only used if terminal supports images)
|
|
editFuzzyThreshold?: number;
|
|
editAllowFuzzy?: 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)
|
|
#imageComponents: Image[] = [];
|
|
#imageSpacers: Spacer[] = [];
|
|
#toolName: string;
|
|
#toolLabel: string;
|
|
#args: any;
|
|
#expanded = false;
|
|
#showImages: boolean;
|
|
#editFuzzyThreshold: number | undefined;
|
|
#editAllowFuzzy: 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;
|
|
};
|
|
// Cached edit diff preview (computed when args arrive, before tool executes)
|
|
#editDiffPreview?: DiffResult | DiffError;
|
|
#editDiffArgsKey?: string; // Track which args the preview is for
|
|
// 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 = 0;
|
|
#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>;
|
|
} = {
|
|
spinnerFrame: 0,
|
|
expanded: false,
|
|
isPartial: true,
|
|
};
|
|
|
|
constructor(
|
|
toolName: string,
|
|
args: any,
|
|
options: ToolExecutionOptions = {},
|
|
tool: AgentTool | undefined,
|
|
ui: TUI,
|
|
cwd: string = getProjectDir(),
|
|
) {
|
|
super();
|
|
this.#toolName = toolName;
|
|
this.#toolLabel = tool?.label ?? toolName;
|
|
this.#args = cloneToolArgs(args);
|
|
this.#showImages = options.showImages ?? true;
|
|
this.#editFuzzyThreshold = options.editFuzzyThreshold;
|
|
this.#editAllowFuzzy = options.editAllowFuzzy;
|
|
this.#tool = tool;
|
|
this.#ui = ui;
|
|
this.#cwd = cwd;
|
|
|
|
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.#updateDisplay();
|
|
}
|
|
|
|
updateArgs(args: any, _toolCallId?: string): void {
|
|
this.#args = cloneToolArgs(args);
|
|
this.#updateSpinnerAnimation();
|
|
this.#updateDisplay();
|
|
}
|
|
|
|
/**
|
|
* Signal that args are complete (tool is about to execute).
|
|
* This triggers diff computation for edit tool.
|
|
*/
|
|
setArgsComplete(_toolCallId?: string): void {
|
|
this.#argsComplete = true;
|
|
this.#updateSpinnerAnimation();
|
|
this.#maybeComputeEditDiff();
|
|
}
|
|
|
|
/**
|
|
* Compute edit diff preview when we have complete args.
|
|
* This runs async and updates display when done.
|
|
*/
|
|
#maybeComputeEditDiff(): void {
|
|
if (this.#toolName !== "edit") return;
|
|
|
|
const path = this.#args?.path;
|
|
const op = this.#args?.op;
|
|
|
|
if (op) {
|
|
const diff = this.#args?.diff;
|
|
const rename = this.#args?.rename;
|
|
if (!path) return;
|
|
|
|
const argsKey = JSON.stringify({ path, op, rename, diff });
|
|
if (this.#editDiffArgsKey === argsKey) return;
|
|
this.#editDiffArgsKey = argsKey;
|
|
|
|
computePatchDiff({ path, op, rename, diff }, this.#cwd, {
|
|
fuzzyThreshold: this.#editFuzzyThreshold,
|
|
allowFuzzy: this.#editAllowFuzzy,
|
|
}).then(result => {
|
|
if (this.#editDiffArgsKey === argsKey) {
|
|
this.#editDiffPreview = result;
|
|
this.#updateDisplay();
|
|
this.#ui.requestRender();
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
const edits = this.#args?.edits;
|
|
const move = this.#args?.move;
|
|
if (path && Array.isArray(edits)) {
|
|
const argsKey = JSON.stringify({ path, edits, move });
|
|
if (this.#editDiffArgsKey === argsKey) return;
|
|
this.#editDiffArgsKey = argsKey;
|
|
|
|
computeHashlineDiff({ path, edits, move }, this.#cwd).then(result => {
|
|
if (this.#editDiffArgsKey === argsKey) {
|
|
this.#editDiffPreview = result;
|
|
this.#updateDisplay();
|
|
this.#ui.requestRender();
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
const oldText = this.#args?.old_text;
|
|
const newText = this.#args?.new_text;
|
|
const all = this.#args?.all;
|
|
|
|
// Need all three params to compute diff
|
|
if (!path || oldText === undefined || newText === undefined) return;
|
|
|
|
// Create a key to track which args this computation is for
|
|
const argsKey = JSON.stringify({ path, oldText, newText, all });
|
|
|
|
// Skip if we already computed for these exact args
|
|
if (this.#editDiffArgsKey === argsKey) return;
|
|
|
|
this.#editDiffArgsKey = argsKey;
|
|
|
|
// Compute diff async
|
|
computeEditDiff(path, oldText, newText, this.#cwd, true, all, this.#editFuzzyThreshold).then(result => {
|
|
// Only update if args haven't changed since we started
|
|
if (this.#editDiffArgsKey === argsKey) {
|
|
this.#editDiffPreview = result;
|
|
this.#updateDisplay();
|
|
this.#ui.requestRender();
|
|
}
|
|
});
|
|
}
|
|
|
|
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 && (this.#toolName === "edit" || 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) % frameCount;
|
|
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);
|
|
this.#spinnerInterval = undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop spinner animation and cleanup resources.
|
|
*/
|
|
stopAnimation(): void {
|
|
if (this.#spinnerInterval) {
|
|
clearInterval(this.#spinnerInterval);
|
|
this.#spinnerInterval = 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];
|
|
// Inline renderers skip background styling
|
|
this.#contentBox.setBgFn(renderer.inline ? undefined : bgFn);
|
|
this.#contentBox.clear();
|
|
|
|
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 {
|
|
// 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,
|
|
},
|
|
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) },
|
|
{ maxWidthCells: 60 },
|
|
);
|
|
this.#imageComponents.push(imageComponent);
|
|
this.addChild(imageComponent);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#getCallArgsForRender(): any {
|
|
if (this.#toolName !== "edit") {
|
|
return this.#args;
|
|
}
|
|
if (!this.#editDiffPreview || !("diff" in this.#editDiffPreview) || !this.#editDiffPreview.diff) {
|
|
return this.#args;
|
|
}
|
|
return { ...(this.#args as Record<string, unknown>), previewDiff: this.#editDiffPreview.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 === "python" && this.#result) {
|
|
const output = this.#getTextOutput().trimEnd();
|
|
context.output = output;
|
|
context.expanded = this.#expanded;
|
|
context.previewLines = PYTHON_DEFAULT_PREVIEW_LINES;
|
|
context.timeout = normalizeTimeoutSeconds(this.#args?.timeout, 600);
|
|
} else if (this.#toolName === "edit") {
|
|
// Edit needs diff preview and renderDiff function
|
|
context.editDiffPreview = this.#editDiffPreview;
|
|
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 && typeof this.#args === "object" && !Array.isArray(this.#args)
|
|
? stripInternalArgs(this.#args as Record<string, unknown>)
|
|
: 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");
|
|
}
|
|
}
|