Files
oh-my-pi/packages/coding-agent/src/core/tools/renderers.ts
T
can1357 0315cef75d refactor(coding-agent): extracted shared render-utils to eliminate duplication
- Added shared render-utils module with standardized formatting functions for truncation, bytes, tokens, duration, and tree rendering.
- Extracted resolveOmpCommand() helper to centralize cross-platform subprocess command resolution.
- Consolidated duplicate utility functions across grep, find, ls, notebook, ask, output, exa, lsp, and web search renderers.
- Updated web search tool to support 'auto' as explicit provider option for auto-detection.
2026-01-05 07:35:35 +01:00

839 lines
28 KiB
TypeScript

/**
* TUI renderers for built-in tools.
*
* These provide rich visualization for tool calls and results in the TUI.
*/
import type { Component } from "@oh-my-pi/pi-tui";
import { Text } from "@oh-my-pi/pi-tui";
import { getLanguageFromPath, type Theme } from "../../modes/interactive/theme/theme";
import type { RenderResultOptions } from "../custom-tools/types";
import type { AskToolDetails } from "./ask";
import type { FindToolDetails } from "./find";
import type { GrepToolDetails } from "./grep";
import type { LsToolDetails } from "./ls";
import { renderCall as renderLspCall, renderResult as renderLspResult } from "./lsp/render";
import type { LspToolDetails } from "./lsp/types";
import type { NotebookToolDetails } from "./notebook";
import type { OutputToolDetails } from "./output";
import {
formatBytes,
formatCount,
formatExpandHint,
formatMoreItems,
PREVIEW_LIMITS,
TRUNCATE_LENGTHS,
truncate,
} from "./render-utils";
import { renderCall as renderTaskCall, renderResult as renderTaskResult } from "./task/render";
import type { TaskToolDetails } from "./task/types";
import { renderWebFetchCall, renderWebFetchResult, type WebFetchToolDetails } from "./web-fetch";
import { renderWebSearchCall, renderWebSearchResult, type WebSearchRenderDetails } from "./web-search/render";
// Tree drawing characters
interface ToolRenderer<TArgs = any, TDetails = any> {
renderCall(args: TArgs, theme: Theme): Component;
renderResult(
result: { content: Array<{ type: string; text?: string }>; details?: TDetails },
options: RenderResultOptions,
theme: Theme,
): Component;
}
const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
const COLLAPSED_TEXT_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
function formatMeta(meta: string[], theme: Theme): string {
return meta.length > 0 ? ` ${theme.fg("muted", meta.join(theme.sep.dot))}` : "";
}
function formatScope(scopePath: string | undefined, theme: Theme): string {
return scopePath ? ` ${theme.fg("muted", `in ${scopePath}`)}` : "";
}
function formatTruncationSuffix(truncated: boolean, theme: Theme): string {
return truncated ? theme.fg("warning", " (truncated)") : "";
}
function renderErrorMessage(_toolLabel: string, message: string, theme: Theme): Text {
const clean = message.replace(/^Error:\s*/, "").trim();
return new Text(
`${theme.styledSymbol("status.error", "error")} ${theme.fg("error", `Error: ${clean || "Unknown error"}`)}`,
0,
0,
);
}
function renderEmptyMessage(_toolLabel: string, message: string, theme: Theme): Text {
return new Text(`${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", message)}`, 0, 0);
}
// ============================================================================
// Grep Renderer
// ============================================================================
interface GrepArgs {
pattern: string;
path?: string;
glob?: string;
type?: string;
ignoreCase?: boolean;
caseSensitive?: boolean;
literal?: boolean;
multiline?: boolean;
context?: number;
limit?: number;
outputMode?: string;
}
const grepRenderer: ToolRenderer<GrepArgs, GrepToolDetails> = {
renderCall(args, theme) {
const label = theme.fg("toolTitle", theme.bold("Grep"));
let text = `${label} ${theme.fg("accent", args.pattern || "?")}`;
const meta: string[] = [];
if (args.path) meta.push(`in ${args.path}`);
if (args.glob) meta.push(`glob:${args.glob}`);
if (args.type) meta.push(`type:${args.type}`);
if (args.outputMode && args.outputMode !== "files_with_matches") meta.push(`mode:${args.outputMode}`);
if (args.caseSensitive) {
meta.push("case:sensitive");
} else if (args.ignoreCase) {
meta.push("case:insensitive");
}
if (args.literal) meta.push("literal");
if (args.multiline) meta.push("multiline");
if (args.context !== undefined) meta.push(`context:${args.context}`);
if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
text += formatMeta(meta, theme);
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme) {
const label = "Grep";
const details = result.details;
if (details?.error) {
return renderErrorMessage(label, details.error, theme);
}
const hasDetailedData = details?.matchCount !== undefined || details?.fileCount !== undefined;
if (!hasDetailedData) {
const textContent = result.content?.find((c) => c.type === "text")?.text;
if (!textContent || textContent === "No matches found") {
return renderEmptyMessage(label, "No matches found", theme);
}
const lines = textContent.split("\n").filter((line) => line.trim() !== "");
const maxLines = expanded ? lines.length : Math.min(lines.length, COLLAPSED_TEXT_LIMIT);
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
const hasMore = remaining > 0;
const icon = theme.styledSymbol("status.success", "success");
const summary = formatCount("item", lines.length);
const expandHint = formatExpandHint(expanded, hasMore, theme);
let text = `${icon} ${theme.fg("dim", summary)}${expandHint}`;
for (let i = 0; i < displayLines.length; i++) {
const isLast = i === displayLines.length - 1 && remaining === 0;
const branch = isLast ? theme.tree.last : theme.tree.branch;
text += `\n ${theme.fg("dim", branch)} ${theme.fg("toolOutput", displayLines[i])}`;
}
if (remaining > 0) {
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
"muted",
formatMoreItems(remaining, "item", theme),
)}`;
}
return new Text(text, 0, 0);
}
const matchCount = details?.matchCount ?? 0;
const fileCount = details?.fileCount ?? 0;
const mode = details?.mode ?? "files_with_matches";
const truncated = details?.truncated ?? details?.truncation?.truncated ?? false;
const files = details?.files ?? [];
if (matchCount === 0) {
return renderEmptyMessage(label, "No matches found", theme);
}
const icon = theme.styledSymbol("status.success", "success");
const summaryParts =
mode === "files_with_matches"
? [formatCount("file", fileCount)]
: [formatCount("match", matchCount), formatCount("file", fileCount)];
const summaryText = summaryParts.join(theme.sep.dot);
const scopeLabel = formatScope(details?.scopePath, theme);
const fileEntries: Array<{ path: string; count?: number }> = details?.fileMatches?.length
? details.fileMatches.map((entry) => ({ path: entry.path, count: entry.count }))
: files.map((path) => ({ path }));
const maxFiles = expanded ? fileEntries.length : Math.min(fileEntries.length, COLLAPSED_LIST_LIMIT);
const hasMoreFiles = fileEntries.length > maxFiles;
const expandHint = formatExpandHint(expanded, hasMoreFiles, theme);
let text = `${icon} ${theme.fg("dim", summaryText)}${formatTruncationSuffix(
truncated,
theme,
)}${scopeLabel}${expandHint}`;
const truncationReasons: string[] = [];
if (details?.matchLimitReached) {
truncationReasons.push(`limit ${details.matchLimitReached} matches`);
}
if (details?.headLimitReached) {
truncationReasons.push(`head limit ${details.headLimitReached}`);
}
if (details?.truncation?.truncated) {
truncationReasons.push("size limit");
}
if (details?.linesTruncated) {
truncationReasons.push("line length");
}
const hasTruncation = truncationReasons.length > 0;
if (fileEntries.length > 0) {
for (let i = 0; i < maxFiles; i++) {
const entry = fileEntries[i];
const isLast = i === maxFiles - 1 && !hasMoreFiles && !hasTruncation;
const branch = isLast ? theme.tree.last : theme.tree.branch;
const isDir = entry.path.endsWith("/");
const entryPath = isDir ? entry.path.slice(0, -1) : entry.path;
const lang = isDir ? undefined : getLanguageFromPath(entryPath);
const entryIcon = isDir
? theme.fg("accent", theme.icon.folder)
: theme.fg("muted", theme.getLangIcon(lang));
const countLabel =
entry.count !== undefined
? ` ${theme.fg("dim", `(${entry.count} match${entry.count !== 1 ? "es" : ""})`)}`
: "";
text += `\n ${theme.fg("dim", branch)} ${entryIcon} ${theme.fg("accent", entry.path)}${countLabel}`;
}
if (hasMoreFiles) {
const moreFilesBranch = hasTruncation ? theme.tree.branch : theme.tree.last;
text += `\n ${theme.fg("dim", moreFilesBranch)} ${theme.fg(
"muted",
formatMoreItems(fileEntries.length - maxFiles, "file", theme),
)}`;
}
}
if (hasTruncation) {
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
"warning",
`truncated: ${truncationReasons.join(", ")}`,
)}`;
}
return new Text(text, 0, 0);
},
};
// ============================================================================
// Find Renderer
// ============================================================================
interface FindArgs {
pattern: string;
path?: string;
type?: string;
hidden?: boolean;
sortByMtime?: boolean;
limit?: number;
}
const findRenderer: ToolRenderer<FindArgs, FindToolDetails> = {
renderCall(args, theme) {
const label = theme.fg("toolTitle", theme.bold("Find"));
let text = `${label} ${theme.fg("accent", args.pattern || "*")}`;
const meta: string[] = [];
if (args.path) meta.push(`in ${args.path}`);
if (args.type && args.type !== "all") meta.push(`type:${args.type}`);
if (args.hidden) meta.push("hidden");
if (args.sortByMtime) meta.push("sort:mtime");
if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
text += formatMeta(meta, theme);
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme) {
const label = "Find";
const details = result.details;
if (details?.error) {
return renderErrorMessage(label, details.error, theme);
}
const hasDetailedData = details?.fileCount !== undefined;
const textContent = result.content?.find((c) => c.type === "text")?.text;
if (!hasDetailedData) {
if (!textContent || textContent.includes("No files matching") || textContent.trim() === "") {
return renderEmptyMessage(label, "No files found", theme);
}
const lines = textContent.split("\n").filter((l) => l.trim());
const maxLines = expanded ? lines.length : Math.min(lines.length, COLLAPSED_LIST_LIMIT);
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
const hasMore = remaining > 0;
const icon = theme.styledSymbol("status.success", "success");
const summary = formatCount("file", lines.length);
const expandHint = formatExpandHint(expanded, hasMore, theme);
let text = `${icon} ${theme.fg("dim", summary)}${expandHint}`;
for (let i = 0; i < displayLines.length; i++) {
const isLast = i === displayLines.length - 1 && remaining === 0;
const branch = isLast ? theme.tree.last : theme.tree.branch;
text += `\n ${theme.fg("dim", branch)} ${theme.fg("accent", displayLines[i])}`;
}
if (remaining > 0) {
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
"muted",
formatMoreItems(remaining, "file", theme),
)}`;
}
return new Text(text, 0, 0);
}
const fileCount = details?.fileCount ?? 0;
const truncated = details?.truncated ?? details?.truncation?.truncated ?? false;
const files = details?.files ?? [];
if (fileCount === 0) {
return renderEmptyMessage(label, "No files found", theme);
}
const icon = theme.styledSymbol("status.success", "success");
const summaryText = formatCount("file", fileCount);
const scopeLabel = formatScope(details?.scopePath, theme);
const maxFiles = expanded ? files.length : Math.min(files.length, COLLAPSED_LIST_LIMIT);
const hasMoreFiles = files.length > maxFiles;
const expandHint = formatExpandHint(expanded, hasMoreFiles, theme);
let text = `${icon} ${theme.fg("dim", summaryText)}${formatTruncationSuffix(
truncated,
theme,
)}${scopeLabel}${expandHint}`;
const truncationReasons: string[] = [];
if (details?.resultLimitReached) {
truncationReasons.push(`limit ${details.resultLimitReached} results`);
}
if (details?.truncation?.truncated) {
truncationReasons.push("size limit");
}
const hasTruncation = truncationReasons.length > 0;
if (files.length > 0) {
for (let i = 0; i < maxFiles; i++) {
const isLast = i === maxFiles - 1 && !hasMoreFiles && !hasTruncation;
const branch = isLast ? theme.tree.last : theme.tree.branch;
const entry = files[i];
const isDir = entry.endsWith("/");
const entryPath = isDir ? entry.slice(0, -1) : entry;
const lang = isDir ? undefined : getLanguageFromPath(entryPath);
const entryIcon = isDir
? theme.fg("accent", theme.icon.folder)
: theme.fg("muted", theme.getLangIcon(lang));
text += `\n ${theme.fg("dim", branch)} ${entryIcon} ${theme.fg("accent", entry)}`;
}
if (hasMoreFiles) {
const moreFilesBranch = hasTruncation ? theme.tree.branch : theme.tree.last;
text += `\n ${theme.fg("dim", moreFilesBranch)} ${theme.fg(
"muted",
formatMoreItems(files.length - maxFiles, "file", theme),
)}`;
}
}
if (hasTruncation) {
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
"warning",
`truncated: ${truncationReasons.join(", ")}`,
)}`;
}
return new Text(text, 0, 0);
},
};
// ============================================================================
// Notebook Renderer
// ============================================================================
interface NotebookArgs {
action: string;
notebookPath: string;
cellNumber?: number;
cellType?: string;
content?: string;
}
function normalizeCellLines(lines: string[]): string[] {
return lines.map((line) => (line.endsWith("\n") ? line.slice(0, -1) : line));
}
function renderCellPreview(lines: string[], expanded: boolean, theme: Theme): string {
const normalized = normalizeCellLines(lines);
if (normalized.length === 0) {
return `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", "(empty cell)")}`;
}
const maxLines = expanded ? normalized.length : Math.min(normalized.length, COLLAPSED_TEXT_LIMIT);
let text = "";
for (let i = 0; i < maxLines; i++) {
const isLast = i === maxLines - 1 && (expanded || normalized.length <= maxLines);
const branch = isLast ? theme.tree.last : theme.tree.branch;
const line = normalized[i];
text += `\n ${theme.fg("dim", branch)} ${theme.fg("toolOutput", line)}`;
}
const remaining = normalized.length - maxLines;
if (remaining > 0) {
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", formatMoreItems(remaining, "line", theme))}`;
}
return text;
}
const notebookRenderer: ToolRenderer<NotebookArgs, NotebookToolDetails> = {
renderCall(args, theme) {
const label = theme.fg("toolTitle", theme.bold("Notebook"));
let text = `${label} ${theme.fg("accent", args.action || "?")}`;
const meta: string[] = [];
meta.push(`in ${args.notebookPath || "?"}`);
if (args.cellNumber !== undefined) meta.push(`cell:${args.cellNumber}`);
if (args.cellType) meta.push(`type:${args.cellType}`);
text += formatMeta(meta, theme);
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme) {
const label = "Notebook";
const details = result.details;
const content = result.content?.[0];
if (content?.type === "text" && content.text?.startsWith("Error:")) {
return renderErrorMessage(label, content.text, theme);
}
const action = details?.action ?? "edit";
const cellIndex = details?.cellIndex;
const cellType = details?.cellType;
const totalCells = details?.totalCells;
const cellSource = details?.cellSource;
const lineCount = cellSource?.length;
const canExpand = cellSource !== undefined && cellSource.length > COLLAPSED_TEXT_LIMIT;
const icon = theme.styledSymbol("status.success", "success");
const actionLabel = action === "insert" ? "Inserted" : action === "delete" ? "Deleted" : "Edited";
const cellLabel = cellType || "cell";
const summaryParts = [`${actionLabel} ${cellLabel} at index ${cellIndex ?? "?"}`];
if (lineCount !== undefined) summaryParts.push(formatCount("line", lineCount));
if (totalCells !== undefined) summaryParts.push(`${totalCells} total`);
const summaryText = summaryParts.join(theme.sep.dot);
const expandHint = formatExpandHint(expanded, canExpand, theme);
let text = `${icon} ${theme.fg("dim", summaryText)}${expandHint}`;
if (cellSource) {
text += renderCellPreview(cellSource, expanded, theme);
}
return new Text(text, 0, 0);
},
};
// ============================================================================
// Ask Renderer
// ============================================================================
interface AskArgs {
question: string;
options?: Array<{ label: string }>;
multi?: boolean;
}
const askRenderer: ToolRenderer<AskArgs, AskToolDetails> = {
renderCall(args, theme) {
if (!args.question) {
return renderErrorMessage("Ask", "No question provided", theme);
}
const label = theme.fg("toolTitle", theme.bold("Ask"));
let text = `${label} ${theme.fg("accent", args.question)}`;
const meta: string[] = [];
if (args.multi) meta.push("multi");
if (args.options?.length) meta.push(`options:${args.options.length}`);
text += formatMeta(meta, theme);
if (args.options?.length) {
for (let i = 0; i < args.options.length; i++) {
const opt = args.options[i];
const isLast = i === args.options.length - 1;
const branch = isLast ? theme.tree.last : theme.tree.branch;
text += `\n ${theme.fg("dim", branch)} ${theme.fg(
"dim",
theme.checkbox.unchecked,
)} ${theme.fg("muted", opt.label)}`;
}
}
return new Text(text, 0, 0);
},
renderResult(result, _opts, theme) {
const { details } = result;
if (!details) {
const txt = result.content[0];
return new Text(txt?.type === "text" && txt.text ? txt.text : "", 0, 0);
}
const hasSelection = details.customInput || details.selectedOptions.length > 0;
const statusIcon = hasSelection
? theme.styledSymbol("status.success", "success")
: theme.styledSymbol("status.warning", "warning");
let text = `${statusIcon} ${theme.fg("accent", details.question)}`;
if (details.customInput) {
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.styledSymbol(
"status.success",
"success",
)} ${theme.fg("toolOutput", details.customInput)}`;
} else if (details.selectedOptions.length > 0) {
const selected = details.selectedOptions;
for (let i = 0; i < selected.length; i++) {
const isLast = i === selected.length - 1;
const branch = isLast ? theme.tree.last : theme.tree.branch;
text += `\n ${theme.fg("dim", branch)} ${theme.fg(
"success",
theme.checkbox.checked,
)} ${theme.fg("toolOutput", selected[i])}`;
}
} else {
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.styledSymbol(
"status.warning",
"warning",
)} ${theme.fg("warning", "Cancelled")}`;
}
return new Text(text, 0, 0);
},
};
// ============================================================================
// Export
// ============================================================================
// ============================================================================
// LSP Renderer
// ============================================================================
interface LspArgs {
action: string;
file?: string;
files?: string[];
line?: number;
column?: number;
}
const lspRenderer: ToolRenderer<LspArgs, LspToolDetails> = {
renderCall: renderLspCall,
renderResult: renderLspResult,
};
// ============================================================================
// Output Renderer
// ============================================================================
interface OutputArgs {
ids: string[];
format?: "raw" | "json" | "stripped";
}
type OutputEntry = OutputToolDetails["outputs"][number];
function formatOutputMeta(entry: OutputEntry, theme: Theme): string {
const metaParts = [formatCount("line", entry.lineCount), formatBytes(entry.charCount)];
if (entry.provenance) {
metaParts.push(`agent ${entry.provenance.agent}(${entry.provenance.index})`);
}
return theme.fg("dim", metaParts.join(theme.sep.dot));
}
const outputRenderer: ToolRenderer<OutputArgs, OutputToolDetails> = {
renderCall(args, theme) {
const ids = args.ids?.join(", ") ?? "?";
const label = theme.fg("toolTitle", theme.bold("Output"));
let text = `${label} ${theme.fg("accent", ids)}`;
const meta: string[] = [];
if (args.format && args.format !== "raw") meta.push(`format:${args.format}`);
text += formatMeta(meta, theme);
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme) {
const label = "Output";
const details = result.details;
if (details?.notFound?.length) {
const icon = theme.styledSymbol("status.error", "error");
let text = `${icon} ${theme.fg("error", `Error: Not found: ${details.notFound.join(", ")}`)}`;
if (details.availableIds?.length) {
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
"muted",
`Available: ${details.availableIds.join(", ")}`,
)}`;
} else {
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
"muted",
"No outputs available in current session",
)}`;
}
return new Text(text, 0, 0);
}
const outputs = details?.outputs ?? [];
if (outputs.length === 0) {
const textContent = result.content?.find((c) => c.type === "text")?.text;
return renderEmptyMessage(label, textContent || "No outputs", theme);
}
const icon = theme.styledSymbol("status.success", "success");
const summary = `read ${formatCount("output", outputs.length)}`;
const previewLimit = expanded ? 3 : 1;
const maxOutputs = expanded ? outputs.length : Math.min(outputs.length, 5);
const hasMoreOutputs = outputs.length > maxOutputs;
const hasMorePreview = outputs.some((o) => (o.previewLines?.length ?? 0) > previewLimit);
const expandHint = formatExpandHint(expanded, hasMoreOutputs || hasMorePreview, theme);
let text = `${icon} ${theme.fg("dim", summary)}${expandHint}`;
for (let i = 0; i < maxOutputs; i++) {
const o = outputs[i];
const isLast = i === maxOutputs - 1 && !hasMoreOutputs;
const branch = isLast ? theme.tree.last : theme.tree.branch;
text += `\n ${theme.fg("dim", branch)} ${theme.fg("accent", o.id)} ${formatOutputMeta(o, theme)}`;
const previewLines = o.previewLines ?? [];
const shownPreview = previewLines.slice(0, previewLimit);
if (shownPreview.length > 0) {
const childPrefix = isLast ? " " : ` ${theme.fg("dim", theme.tree.vertical)} `;
for (const line of shownPreview) {
const previewText = truncate(line, TRUNCATE_LENGTHS.CONTENT, theme.format.ellipsis);
text += `\n${childPrefix}${theme.fg("dim", theme.tree.hook)} ${theme.fg(
"muted",
"preview:",
)} ${theme.fg("toolOutput", previewText)}`;
}
}
}
if (hasMoreOutputs) {
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
"muted",
formatMoreItems(outputs.length - maxOutputs, "output", theme),
)}`;
}
return new Text(text, 0, 0);
},
};
// ============================================================================
// Task Renderer
// ============================================================================
const taskRenderer: ToolRenderer<any, TaskToolDetails> = {
renderCall: renderTaskCall,
renderResult: renderTaskResult,
};
// ============================================================================
// Ls Renderer
// ============================================================================
interface LsArgs {
path?: string;
limit?: number;
}
const lsRenderer: ToolRenderer<LsArgs, LsToolDetails> = {
renderCall(args, theme) {
const label = theme.fg("toolTitle", theme.bold("Ls"));
let text = `${label} ${theme.fg("accent", args.path || ".")}`;
const meta: string[] = [];
if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
text += formatMeta(meta, theme);
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme) {
const label = "Ls";
const details = result.details;
const textContent = result.content?.find((c) => c.type === "text")?.text ?? "";
if (
(!textContent || textContent.trim() === "" || textContent.trim() === "(empty directory)") &&
(!details?.entries || details.entries.length === 0)
) {
return renderEmptyMessage(label, "Empty directory", theme);
}
let entries: string[] = details?.entries ? [...details.entries] : [];
if (entries.length === 0) {
const rawLines = textContent.split("\n").filter((l: string) => l.trim());
entries = rawLines.filter((line) => !/^\[.*\]$/.test(line.trim()));
}
if (entries.length === 0) {
return renderEmptyMessage(label, "Empty directory", theme);
}
let dirCount = details?.dirCount;
let fileCount = details?.fileCount;
if (dirCount === undefined || fileCount === undefined) {
dirCount = 0;
fileCount = 0;
for (const entry of entries) {
if (entry.endsWith("/")) {
dirCount += 1;
} else {
fileCount += 1;
}
}
}
const truncated = Boolean(details?.truncation?.truncated || details?.entryLimitReached);
const icon = truncated
? theme.styledSymbol("status.warning", "warning")
: theme.styledSymbol("status.success", "success");
const summaryText = [formatCount("dir", dirCount ?? 0), formatCount("file", fileCount ?? 0)].join(theme.sep.dot);
const maxEntries = expanded ? entries.length : Math.min(entries.length, COLLAPSED_LIST_LIMIT);
const hasMoreEntries = entries.length > maxEntries;
const expandHint = formatExpandHint(expanded, hasMoreEntries, theme);
let text = `${icon} ${theme.fg("dim", summaryText)}${formatTruncationSuffix(truncated, theme)}${expandHint}`;
const truncationReasons: string[] = [];
if (details?.entryLimitReached) {
truncationReasons.push(`entry limit ${details.entryLimitReached}`);
}
if (details?.truncation?.truncated) {
truncationReasons.push(`output cap ${formatBytes(details.truncation.maxBytes)}`);
}
const hasTruncation = truncationReasons.length > 0;
for (let i = 0; i < maxEntries; i++) {
const entry = entries[i];
const isLast = i === maxEntries - 1 && !hasMoreEntries && !hasTruncation;
const branch = isLast ? theme.tree.last : theme.tree.branch;
const isDir = entry.endsWith("/");
const entryPath = isDir ? entry.slice(0, -1) : entry;
const lang = isDir ? undefined : getLanguageFromPath(entryPath);
const entryIcon = isDir ? theme.fg("accent", theme.icon.folder) : theme.fg("muted", theme.getLangIcon(lang));
const entryColor = isDir ? "accent" : "toolOutput";
text += `\n ${theme.fg("dim", branch)} ${entryIcon} ${theme.fg(entryColor, entry)}`;
}
if (hasMoreEntries) {
const moreEntriesBranch = hasTruncation ? theme.tree.branch : theme.tree.last;
text += `\n ${theme.fg("dim", moreEntriesBranch)} ${theme.fg(
"muted",
formatMoreItems(entries.length - maxEntries, "entry", theme),
)}`;
}
if (hasTruncation) {
text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
"warning",
`truncated: ${truncationReasons.join(", ")}`,
)}`;
}
return new Text(text, 0, 0);
},
};
// ============================================================================
// Web Fetch Renderer
// ============================================================================
interface WebFetchArgs {
url: string;
timeout?: number;
raw?: boolean;
}
const webFetchRenderer: ToolRenderer<WebFetchArgs, WebFetchToolDetails> = {
renderCall: renderWebFetchCall,
renderResult: renderWebFetchResult,
};
// ============================================================================
// Web Search Renderer
// ============================================================================
interface WebSearchArgs {
query: string;
provider?: string;
[key: string]: unknown;
}
const webSearchRenderer: ToolRenderer<WebSearchArgs, WebSearchRenderDetails> = {
renderCall: renderWebSearchCall,
renderResult: renderWebSearchResult,
};
// ============================================================================
// Export
// ============================================================================
export const toolRenderers: Record<
string,
{
renderCall: (args: any, theme: Theme) => Component;
renderResult: (result: any, options: RenderResultOptions, theme: Theme) => Component;
}
> = {
ask: askRenderer,
grep: grepRenderer,
find: findRenderer,
notebook: notebookRenderer,
ls: lsRenderer,
lsp: lspRenderer,
output: outputRenderer,
task: taskRenderer,
web_fetch: webFetchRenderer,
web_search: webSearchRenderer,
};