Files
oh-my-pi/packages/coding-agent/src/modes/components/tool-execution.ts
T
can1357 57c89cf503 refactor(auth-storage): restructured credential matching to use accountId
- 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.
2026-03-08 15:58:52 +01:00

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");
}
}