fix(coding-agent): built-in tools work like extension tools

Export readToolDefinition / createReadToolDefinition and the equivalent built-in ToolDefinition APIs from @mariozechner/pi-coding-agent.
This commit is contained in:
Mario Zechner
2026-03-22 04:20:28 +01:00
parent 80f527ec22
commit 235b247f1f
32 changed files with 2594 additions and 1408 deletions

View File

@@ -3,14 +3,21 @@ import { createWriteStream, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Container, Text, truncateToWidth } from "@mariozechner/pi-tui";
import { type Static, Type } from "@sinclair/typebox";
import { spawn } from "child_process";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate.js";
import { theme } from "../../modes/interactive/theme/theme.js";
import { waitForChildProcess } from "../../utils/child-process.js";
import { getShellConfig, getShellEnv, killProcessTree } from "../../utils/shell.js";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
import { getTextOutput, invalidArgText, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js";
/**
* Generate a unique temp file path for bash output
* Generate a unique temp file path for bash output.
*/
function getTempFilePath(): string {
const id = randomBytes(8).toString("hex");
@@ -31,14 +38,14 @@ export interface BashToolDetails {
/**
* Pluggable operations for the bash tool.
* Override these to delegate command execution to remote systems (e.g., SSH).
* Override these to delegate command execution to remote systems (for example SSH).
*/
export interface BashOperations {
/**
* Execute a command and stream output.
* @param command - The command to execute
* @param cwd - Working directory
* @param options - Execution options
* @param command The command to execute
* @param cwd Working directory
* @param options Execution options
* @returns Promise resolving to exit code (null if killed)
*/
exec: (
@@ -56,81 +63,58 @@ export interface BashOperations {
/**
* Create bash operations using pi's built-in local shell execution backend.
*
* This is useful for extensions that intercept user_bash and want to keep
* pi's standard local shell behavior while still wrapping or rewriting
* commands before execution.
* This is useful for extensions that intercept user_bash and still want pi's
* standard local shell behavior while wrapping or rewriting commands.
*/
export function createLocalBashOperations(): BashOperations {
return {
exec: (command, cwd, { onData, signal, timeout, env }) => {
return new Promise((resolve, reject) => {
const { shell, args } = getShellConfig();
if (!existsSync(cwd)) {
reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`));
return;
}
const child = spawn(shell, [...args, command], {
cwd,
detached: true,
env: env ?? getShellEnv(),
stdio: ["ignore", "pipe", "pipe"],
});
let timedOut = false;
// Set timeout if provided
let timeoutHandle: NodeJS.Timeout | undefined;
// Set timeout if provided.
if (timeout !== undefined && timeout > 0) {
timeoutHandle = setTimeout(() => {
timedOut = true;
if (child.pid) {
killProcessTree(child.pid);
}
if (child.pid) killProcessTree(child.pid);
}, timeout * 1000);
}
// Stream stdout and stderr
if (child.stdout) {
child.stdout.on("data", onData);
}
if (child.stderr) {
child.stderr.on("data", onData);
}
// Handle abort signal - kill entire process tree
// Stream stdout and stderr.
child.stdout?.on("data", onData);
child.stderr?.on("data", onData);
// Handle abort signal by killing the entire process tree.
const onAbort = () => {
if (child.pid) {
killProcessTree(child.pid);
}
if (child.pid) killProcessTree(child.pid);
};
if (signal) {
if (signal.aborted) {
onAbort();
} else {
signal.addEventListener("abort", onAbort, { once: true });
}
if (signal.aborted) onAbort();
else signal.addEventListener("abort", onAbort, { once: true });
}
// Handle shell spawn errors and wait for the process to terminate without hanging
// on inherited stdio handles held by detached descendants.
waitForChildProcess(child)
.then((code) => {
if (timeoutHandle) clearTimeout(timeoutHandle);
if (signal) signal.removeEventListener("abort", onAbort);
if (signal?.aborted) {
reject(new Error("aborted"));
return;
}
if (timedOut) {
reject(new Error(`timeout:${timeout}`));
return;
}
resolve({ exitCode: code });
})
.catch((err) => {
@@ -152,85 +136,182 @@ export interface BashSpawnContext {
export type BashSpawnHook = (context: BashSpawnContext) => BashSpawnContext;
function resolveSpawnContext(command: string, cwd: string, spawnHook?: BashSpawnHook): BashSpawnContext {
const baseContext: BashSpawnContext = {
command,
cwd,
env: { ...getShellEnv() },
};
const baseContext: BashSpawnContext = { command, cwd, env: { ...getShellEnv() } };
return spawnHook ? spawnHook(baseContext) : baseContext;
}
export interface BashToolOptions {
/** Custom operations for command execution. Default: local shell */
operations?: BashOperations;
/** Command prefix prepended to every command (e.g., "shopt -s expand_aliases" for alias support) */
/** Command prefix prepended to every command (for example shell setup commands) */
commandPrefix?: string;
/** Hook to adjust command, cwd, or env before execution */
spawnHook?: BashSpawnHook;
}
export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool<typeof bashSchema> {
const BASH_PREVIEW_LINES = 5;
type BashRenderState = {
startedAt: number | undefined;
endedAt: number | undefined;
interval: NodeJS.Timeout | undefined;
};
type BashResultRenderState = {
cachedWidth: number | undefined;
cachedLines: string[] | undefined;
cachedSkipped: number | undefined;
};
class BashResultRenderComponent extends Container {
state: BashResultRenderState = {
cachedWidth: undefined,
cachedLines: undefined,
cachedSkipped: undefined,
};
}
function formatDuration(ms: number): string {
return `${(ms / 1000).toFixed(1)}s`;
}
function formatBashCall(args: { command?: string; timeout?: number } | undefined): string {
const command = str(args?.command);
const timeout = args?.timeout as number | undefined;
const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : "";
const commandDisplay = command === null ? invalidArgText(theme) : command ? command : theme.fg("toolOutput", "...");
return theme.fg("toolTitle", theme.bold(`$ ${commandDisplay}`)) + timeoutSuffix;
}
function rebuildBashResultRenderComponent(
component: BashResultRenderComponent,
result: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
details?: BashToolDetails;
},
options: ToolRenderResultOptions,
showImages: boolean,
startedAt: number | undefined,
endedAt: number | undefined,
): void {
const state = component.state;
component.clear();
const output = getTextOutput(result as any, showImages).trim();
if (output) {
const styledOutput = output
.split("\n")
.map((line) => theme.fg("toolOutput", line))
.join("\n");
if (options.expanded) {
component.addChild(new Text(`\n${styledOutput}`, 0, 0));
} else {
component.addChild({
render: (width: number) => {
if (state.cachedLines === undefined || state.cachedWidth !== width) {
const preview = truncateToVisualLines(styledOutput, BASH_PREVIEW_LINES, width);
state.cachedLines = preview.visualLines;
state.cachedSkipped = preview.skippedCount;
state.cachedWidth = width;
}
if (state.cachedSkipped && state.cachedSkipped > 0) {
const hint =
theme.fg("muted", `... (${state.cachedSkipped} earlier lines,`) +
` ${keyHint("app.tools.expand", "to expand")})`;
return ["", truncateToWidth(hint, width, "..."), ...(state.cachedLines ?? [])];
}
return ["", ...(state.cachedLines ?? [])];
},
invalidate: () => {
state.cachedWidth = undefined;
state.cachedLines = undefined;
state.cachedSkipped = undefined;
},
});
}
}
const truncation = result.details?.truncation;
const fullOutputPath = result.details?.fullOutputPath;
if (truncation?.truncated || fullOutputPath) {
const warnings: string[] = [];
if (fullOutputPath) {
warnings.push(`Full output: ${fullOutputPath}`);
}
if (truncation?.truncated) {
if (truncation.truncatedBy === "lines") {
warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
} else {
warnings.push(
`Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`,
);
}
}
component.addChild(new Text(`\n${theme.fg("warning", `[${warnings.join(". ")}]`)}`, 0, 0));
}
if (startedAt !== undefined) {
const label = options.isPartial ? "Elapsed" : "Took";
const endTime = endedAt ?? Date.now();
component.addChild(new Text(`\n${theme.fg("muted", `${label} ${formatDuration(endTime - startedAt)}`)}`, 0, 0));
}
}
export function createBashToolDefinition(
cwd: string,
options?: BashToolOptions,
): ToolDefinition<typeof bashSchema, BashToolDetails | undefined, BashRenderState> {
const ops = options?.operations ?? createLocalBashOperations();
const commandPrefix = options?.commandPrefix;
const spawnHook = options?.spawnHook;
return {
name: "bash",
label: "bash",
description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`,
promptSnippet: "Execute bash commands (ls, grep, find, etc.)",
parameters: bashSchema,
execute: async (
_toolCallId: string,
async execute(
_toolCallId,
{ command, timeout }: { command: string; timeout?: number },
signal?: AbortSignal,
onUpdate?,
) => {
// Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support)
_ctx?,
) {
const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command;
const spawnContext = resolveSpawnContext(resolvedCommand, cwd, spawnHook);
if (onUpdate) {
onUpdate({ content: [], details: undefined });
}
return new Promise((resolve, reject) => {
// We'll stream to a temp file if output gets large
let tempFilePath: string | undefined;
let tempFileStream: ReturnType<typeof createWriteStream> | undefined;
let totalBytes = 0;
// Keep a rolling buffer of the last chunk for tail truncation
const chunks: Buffer[] = [];
let chunksBytes = 0;
// Keep more than we need so we have enough for truncation
const maxChunksBytes = DEFAULT_MAX_BYTES * 2;
const handleData = (data: Buffer) => {
totalBytes += data.length;
// Start writing to temp file once we exceed the threshold
// Start writing to a temp file once output exceeds the in-memory threshold.
if (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) {
tempFilePath = getTempFilePath();
tempFileStream = createWriteStream(tempFilePath);
// Write all buffered chunks to the file
for (const chunk of chunks) {
tempFileStream.write(chunk);
}
// Write all buffered chunks to the file.
for (const chunk of chunks) tempFileStream.write(chunk);
}
// Write to temp file if we have one
if (tempFileStream) {
tempFileStream.write(data);
}
// Keep rolling buffer of recent data
// Write to temp file if we have one.
if (tempFileStream) tempFileStream.write(data);
// Keep a rolling buffer of recent output for tail truncation.
chunks.push(data);
chunksBytes += data.length;
// Trim old chunks if buffer is too large
// Trim old chunks if the rolling buffer grows too large.
while (chunksBytes > maxChunksBytes && chunks.length > 1) {
const removed = chunks.shift()!;
chunksBytes -= removed.length;
}
// Stream partial output to callback (truncated rolling buffer)
// Stream partial output using the rolling tail buffer.
if (onUpdate) {
const fullBuffer = Buffer.concat(chunks);
const fullText = fullBuffer.toString("utf-8");
@@ -252,34 +333,22 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo
env: spawnContext.env,
})
.then(({ exitCode }) => {
// Close temp file stream
if (tempFileStream) {
tempFileStream.end();
}
// Combine all buffered chunks
// Close temp file stream before building the final result.
if (tempFileStream) tempFileStream.end();
// Combine the rolling buffer chunks.
const fullBuffer = Buffer.concat(chunks);
const fullOutput = fullBuffer.toString("utf-8");
// Apply tail truncation
// Apply tail truncation for the final display payload.
const truncation = truncateTail(fullOutput);
let outputText = truncation.content || "(no output)";
// Build details with truncation info
let details: BashToolDetails | undefined;
if (truncation.truncated) {
details = {
truncation,
fullOutputPath: tempFilePath,
};
// Build actionable notice
// Build truncation details and an actionable notice.
details = { truncation, fullOutputPath: tempFilePath };
const startLine = truncation.totalLines - truncation.outputLines + 1;
const endLine = truncation.totalLines;
if (truncation.lastLinePartial) {
// Edge case: last line alone > 30KB
// Edge case: the last line alone is larger than the byte limit.
const lastLineSize = formatSize(Buffer.byteLength(fullOutput.split("\n").pop() || "", "utf-8"));
outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${tempFilePath}]`;
} else if (truncation.truncatedBy === "lines") {
@@ -288,7 +357,6 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${tempFilePath}]`;
}
}
if (exitCode !== 0 && exitCode !== null) {
outputText += `\n\nCommand exited with code ${exitCode}`;
reject(new Error(outputText));
@@ -297,15 +365,10 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo
}
})
.catch((err: Error) => {
// Close temp file stream
if (tempFileStream) {
tempFileStream.end();
}
// Combine all buffered chunks for error output
// Close temp file stream and include buffered output in the error message.
if (tempFileStream) tempFileStream.end();
const fullBuffer = Buffer.concat(chunks);
let output = fullBuffer.toString("utf-8");
if (err.message === "aborted") {
if (output) output += "\n\n";
output += "Command aborted";
@@ -321,8 +384,48 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo
});
});
},
renderCall(args, _theme, context) {
const state = context.state;
if (context.executionStarted && state.startedAt === undefined) {
state.startedAt = Date.now();
state.endedAt = undefined;
}
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatBashCall(args));
return text;
},
renderResult(result, options, _theme, context) {
const state = context.state;
if (state.startedAt !== undefined && options.isPartial && !state.interval) {
state.interval = setInterval(() => context.invalidate(), 1000);
}
if (!options.isPartial || context.isError) {
state.endedAt ??= Date.now();
if (state.interval) {
clearInterval(state.interval);
state.interval = undefined;
}
}
const component =
(context.lastComponent as BashResultRenderComponent | undefined) ?? new BashResultRenderComponent();
rebuildBashResultRenderComponent(
component,
result as any,
options,
context.showImages,
state.startedAt,
state.endedAt,
);
component.invalidate();
return component;
},
};
}
/** Default bash tool using process.cwd() - for backwards compatibility */
export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool<typeof bashSchema> {
return wrapToolDefinition(createBashToolDefinition(cwd, options));
}
/** Default bash tool using process.cwd() for backwards compatibility. */
export const bashToolDefinition = createBashToolDefinition(process.cwd());
export const bashTool = createBashTool(process.cwd());

View File

@@ -1,9 +1,15 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Container, Text } from "@mariozechner/pi-tui";
import { type Static, Type } from "@sinclair/typebox";
import { constants } from "fs";
import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises";
import { renderDiff } from "../../modes/interactive/components/diff.js";
import type { ToolDefinition } from "../extensions/types.js";
import {
computeEditDiff,
detectLineEnding,
type EditDiffError,
type EditDiffResult,
fuzzyFindText,
generateDiffString,
normalizeForFuzzyMatch,
@@ -13,6 +19,13 @@ import {
} from "./edit-diff.js";
import { withFileMutationQueue } from "./file-mutation-queue.js";
import { resolveToCwd } from "./path-utils.js";
import { invalidArgText, shortenPath, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
type EditRenderState = {
argsKey?: string;
preview?: EditDiffResult | EditDiffError;
};
const editSchema = Type.Object({
path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
@@ -31,7 +44,7 @@ export interface EditToolDetails {
/**
* Pluggable operations for the edit tool.
* Override these to delegate file editing to remote systems (e.g., SSH).
* Override these to delegate file editing to remote systems (for example SSH).
*/
export interface EditOperations {
/** Read file contents as a Buffer */
@@ -53,20 +66,75 @@ export interface EditToolOptions {
operations?: EditOperations;
}
export function createEditTool(cwd: string, options?: EditToolOptions): AgentTool<typeof editSchema> {
const ops = options?.operations ?? defaultEditOperations;
function formatEditCall(
args: { path?: string; file_path?: string; oldText?: string; newText?: string } | undefined,
state: EditRenderState,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
): string {
const invalidArg = invalidArgText(theme);
const rawPath = str(args?.file_path ?? args?.path);
const path = rawPath !== null ? shortenPath(rawPath) : null;
const pathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
let text = `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`;
if (state.preview) {
if ("error" in state.preview) {
text += `\n\n${theme.fg("error", state.preview.error)}`;
} else if (state.preview.diff) {
text += `\n\n${renderDiff(state.preview.diff, { filePath: rawPath ?? undefined })}`;
}
}
return text;
}
function formatEditResult(
args: { path?: string; file_path?: string; oldText?: string; newText?: string } | undefined,
state: EditRenderState,
result: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
details?: EditToolDetails;
},
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
isError: boolean,
): string | undefined {
const rawPath = str(args?.file_path ?? args?.path);
if (isError) {
const errorText = result.content
.filter((c) => c.type === "text")
.map((c) => c.text || "")
.join("\n");
return errorText ? `\n${theme.fg("error", errorText)}` : undefined;
}
const previewDiff = state.preview && !("error" in state.preview) ? state.preview.diff : undefined;
const resultDiff = result.details?.diff;
if (!resultDiff || resultDiff === previewDiff) {
return undefined;
}
return `\n${renderDiff(resultDiff, { filePath: rawPath ?? undefined })}`;
}
export function createEditToolDefinition(
cwd: string,
options?: EditToolOptions,
): ToolDefinition<typeof editSchema, EditToolDetails | undefined, EditRenderState> {
const ops = options?.operations ?? defaultEditOperations;
return {
name: "edit",
label: "edit",
description:
"Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.",
promptSnippet: "Make surgical edits to files (find exact text and replace)",
promptGuidelines: ["Use edit for precise changes (old text must match exactly)."],
parameters: editSchema,
execute: async (
_toolCallId: string,
async execute(
_toolCallId,
{ path, oldText, newText }: { path: string; oldText: string; newText: string },
signal?: AbortSignal,
) => {
_onUpdate?,
_ctx?,
) {
const absolutePath = resolveToCwd(path, cwd);
return withFileMutationQueue(
@@ -76,7 +144,7 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
content: Array<{ type: "text"; text: string }>;
details: EditToolDetails | undefined;
}>((resolve, reject) => {
// Check if already aborted
// Check if already aborted.
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
@@ -84,7 +152,7 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
let aborted = false;
// Set up abort handler
// Set up abort handler.
const onAbort = () => {
aborted = true;
reject(new Error("Operation aborted"));
@@ -94,10 +162,10 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
signal.addEventListener("abort", onAbort, { once: true });
}
// Perform the edit operation
// Perform the edit operation.
(async () => {
try {
// Check if file exists
// Check if file exists.
try {
await ops.access(absolutePath);
} catch {
@@ -108,21 +176,21 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
return;
}
// Check if aborted before reading
// Check if aborted before reading.
if (aborted) {
return;
}
// Read the file
// Read the file.
const buffer = await ops.readFile(absolutePath);
const rawContent = buffer.toString("utf-8");
// Check if aborted after reading
// Check if aborted after reading.
if (aborted) {
return;
}
// Strip BOM before matching (LLM won't include invisible BOM in oldText)
// Strip BOM before matching. The model will not include an invisible BOM in oldText.
const { bom, text: content } = stripBom(rawContent);
const originalEnding = detectLineEnding(content);
@@ -130,7 +198,7 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
const normalizedOldText = normalizeToLF(oldText);
const normalizedNewText = normalizeToLF(newText);
// Find the old text using fuzzy matching (tries exact match first, then fuzzy)
// Find the old text using fuzzy matching. This tries exact match first, then a normalized fallback.
const matchResult = fuzzyFindText(normalizedContent, normalizedOldText);
if (!matchResult.found) {
@@ -145,7 +213,7 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
return;
}
// Count occurrences using fuzzy-normalized content for consistency
// Count occurrences using fuzzy-normalized content for consistency with the matcher.
const fuzzyContent = normalizeForFuzzyMatch(normalizedContent);
const fuzzyOldText = normalizeForFuzzyMatch(normalizedOldText);
const occurrences = fuzzyContent.split(fuzzyOldText).length - 1;
@@ -162,20 +230,20 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
return;
}
// Check if aborted before writing
// Check if aborted before writing.
if (aborted) {
return;
}
// Perform replacement using the matched text position
// When fuzzy matching was used, contentForReplacement is the normalized version
// Perform replacement using the matched text position.
// When fuzzy matching was used, contentForReplacement is the normalized version.
const baseContent = matchResult.contentForReplacement;
const newContent =
baseContent.substring(0, matchResult.index) +
normalizedNewText +
baseContent.substring(matchResult.index + matchResult.matchLength);
// Verify the replacement actually changed something
// Verify the replacement actually changed something.
if (baseContent === newContent) {
if (signal) {
signal.removeEventListener("abort", onAbort);
@@ -191,12 +259,12 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
await ops.writeFile(absolutePath, finalContent);
// Check if aborted after writing
// Check if aborted after writing.
if (aborted) {
return;
}
// Clean up abort handler
// Clean up abort handler.
if (signal) {
signal.removeEventListener("abort", onAbort);
}
@@ -212,7 +280,7 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine },
});
} catch (error: any) {
// Clean up abort handler
// Clean up abort handler.
if (signal) {
signal.removeEventListener("abort", onAbort);
}
@@ -225,8 +293,43 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
}),
);
},
renderCall(args, theme, context) {
const isSingleMode =
typeof args?.path === "string" && typeof args?.oldText === "string" && typeof args?.newText === "string";
if (context.argsComplete && isSingleMode) {
const argsKey = JSON.stringify({ path: args.path, oldText: args.oldText, newText: args.newText });
if (context.state.argsKey !== argsKey) {
context.state.argsKey = argsKey;
computeEditDiff(args.path!, args.oldText!, args.newText!, context.cwd).then((preview) => {
if (context.state.argsKey === argsKey) {
context.state.preview = preview;
context.invalidate();
}
});
}
}
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatEditCall(args, context.state, theme));
return text;
},
renderResult(result, _options, theme, context) {
const output = formatEditResult(context.args, context.state, result as any, theme, context.isError);
if (!output) {
const component = (context.lastComponent as Container | undefined) ?? new Container();
component.clear();
return component;
}
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(output);
return text;
},
};
}
/** Default edit tool using process.cwd() - for backwards compatibility */
export function createEditTool(cwd: string, options?: EditToolOptions): AgentTool<typeof editSchema> {
return wrapToolDefinition(createEditToolDefinition(cwd, options));
}
/** Default edit tool using process.cwd() for backwards compatibility. */
export const editToolDefinition = createEditToolDefinition(process.cwd());
export const editTool = createEditTool(process.cwd());

View File

@@ -1,11 +1,16 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Text } from "@mariozechner/pi-tui";
import { type Static, Type } from "@sinclair/typebox";
import { spawnSync } from "child_process";
import { existsSync } from "fs";
import { globSync } from "glob";
import path from "path";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
import { ensureTool } from "../../utils/tools-manager.js";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
import { resolveToCwd } from "./path-utils.js";
import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
function toPosixPath(value: string): string {
@@ -31,41 +36,97 @@ export interface FindToolDetails {
/**
* Pluggable operations for the find tool.
* Override these to delegate file search to remote systems (e.g., SSH).
* Override these to delegate file search to remote systems (for example SSH).
*/
export interface FindOperations {
/** Check if path exists */
exists: (absolutePath: string) => Promise<boolean> | boolean;
/** Find files matching glob pattern. Returns relative paths. */
/** Find files matching glob pattern. Returns relative or absolute paths. */
glob: (pattern: string, cwd: string, options: { ignore: string[]; limit: number }) => Promise<string[]> | string[];
}
const defaultFindOperations: FindOperations = {
exists: existsSync,
glob: (_pattern, _searchCwd, _options) => {
// This is a placeholder - actual fd execution happens in execute
return [];
},
// This is a placeholder. Actual fd execution happens in execute() when no custom glob is provided.
glob: () => [],
};
export interface FindToolOptions {
/** Custom operations for find. Default: local filesystem + fd */
/** Custom operations for find. Default: local filesystem plus fd */
operations?: FindOperations;
}
export function createFindTool(cwd: string, options?: FindToolOptions): AgentTool<typeof findSchema> {
const customOps = options?.operations;
function formatFindCall(
args: { pattern: string; path?: string; limit?: number } | undefined,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
): string {
const pattern = str(args?.pattern);
const rawPath = str(args?.path);
const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
const limit = args?.limit;
const invalidArg = invalidArgText(theme);
let text =
theme.fg("toolTitle", theme.bold("find")) +
" " +
(pattern === null ? invalidArg : theme.fg("accent", pattern || "")) +
theme.fg("toolOutput", ` in ${path === null ? invalidArg : path}`);
if (limit !== undefined) {
text += theme.fg("toolOutput", ` (limit ${limit})`);
}
return text;
}
function formatFindResult(
result: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
details?: FindToolDetails;
},
options: ToolRenderResultOptions,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
showImages: boolean,
): string {
const output = getTextOutput(result, showImages).trim();
let text = "";
if (output) {
const lines = output.split("\n");
const maxLines = options.expanded ? lines.length : 20;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
if (remaining > 0) {
text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`;
}
}
const resultLimit = result.details?.resultLimitReached;
const truncation = result.details?.truncation;
if (resultLimit || truncation?.truncated) {
const warnings: string[] = [];
if (resultLimit) warnings.push(`${resultLimit} results limit`);
if (truncation?.truncated) warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
}
return text;
}
export function createFindToolDefinition(
cwd: string,
options?: FindToolOptions,
): ToolDefinition<typeof findSchema, FindToolDetails | undefined> {
const customOps = options?.operations;
return {
name: "find",
label: "find",
description: `Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} results or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`,
promptSnippet: "Find files by glob pattern (respects .gitignore)",
parameters: findSchema,
execute: async (
_toolCallId: string,
async execute(
_toolCallId,
{ pattern, path: searchDir, limit }: { pattern: string; path?: string; limit?: number },
signal?: AbortSignal,
) => {
_onUpdate?,
_ctx?,
) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Operation aborted"));
@@ -81,20 +142,17 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
const effectiveLimit = limit ?? DEFAULT_LIMIT;
const ops = customOps ?? defaultFindOperations;
// If custom operations provided with glob, use that
// If custom operations provide glob(), use that instead of fd.
if (customOps?.glob) {
if (!(await ops.exists(searchPath))) {
reject(new Error(`Path not found: ${searchPath}`));
return;
}
const results = await ops.glob(pattern, searchPath, {
ignore: ["**/node_modules/**", "**/.git/**"],
limit: effectiveLimit,
});
signal?.removeEventListener("abort", onAbort);
if (results.length === 0) {
resolve({
content: [{ type: "text", text: "No files found matching pattern" }],
@@ -103,36 +161,28 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
return;
}
// Relativize paths
// Relativize paths against the search root for stable output.
const relativized = results.map((p) => {
if (p.startsWith(searchPath)) {
return toPosixPath(p.slice(searchPath.length + 1));
}
if (p.startsWith(searchPath)) return toPosixPath(p.slice(searchPath.length + 1));
return toPosixPath(path.relative(searchPath, p));
});
const resultLimitReached = relativized.length >= effectiveLimit;
const rawOutput = relativized.join("\n");
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
let resultOutput = truncation.content;
const details: FindToolDetails = {};
const notices: string[] = [];
if (resultLimitReached) {
notices.push(`${effectiveLimit} results limit reached`);
details.resultLimitReached = effectiveLimit;
}
if (truncation.truncated) {
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
details.truncation = truncation;
}
if (notices.length > 0) {
resultOutput += `\n\n[${notices.join(". ")}]`;
}
resolve({
content: [{ type: "text", text: resultOutput }],
details: Object.keys(details).length > 0 ? details : undefined,
@@ -140,14 +190,14 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
return;
}
// Default: use fd
// Default implementation uses fd.
const fdPath = await ensureTool("fd", true);
if (!fdPath) {
reject(new Error("fd is not available and could not be downloaded"));
return;
}
// Build fd arguments
// Build fd arguments.
const args: string[] = [
"--glob",
"--color=never",
@@ -155,14 +205,10 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
"--max-results",
String(effectiveLimit),
];
// Include .gitignore files
// Include .gitignore files from the search tree.
const gitignoreFiles = new Set<string>();
const rootGitignore = path.join(searchPath, ".gitignore");
if (existsSync(rootGitignore)) {
gitignoreFiles.add(rootGitignore);
}
if (existsSync(rootGitignore)) gitignoreFiles.add(rootGitignore);
try {
const nestedGitignores = globSync("**/.gitignore", {
cwd: searchPath,
@@ -170,33 +216,21 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
absolute: true,
ignore: ["**/node_modules/**", "**/.git/**"],
});
for (const file of nestedGitignores) {
gitignoreFiles.add(file);
}
for (const file of nestedGitignores) gitignoreFiles.add(file);
} catch {
// Ignore glob errors
// ignore
}
for (const gitignorePath of gitignoreFiles) {
args.push("--ignore-file", gitignorePath);
}
for (const gitignorePath of gitignoreFiles) args.push("--ignore-file", gitignorePath);
args.push(pattern, searchPath);
const result = spawnSync(fdPath, args, {
encoding: "utf-8",
maxBuffer: 10 * 1024 * 1024,
});
const result = spawnSync(fdPath, args, { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 });
signal?.removeEventListener("abort", onAbort);
if (result.error) {
reject(new Error(`Failed to run fd: ${result.error.message}`));
return;
}
const output = result.stdout?.trim() || "";
if (result.status !== 0) {
const errorMsg = result.stderr?.trim() || `fd exited with code ${result.status}`;
if (!output) {
@@ -204,7 +238,6 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
return;
}
}
if (!output) {
resolve({
content: [{ type: "text", text: "No files found matching pattern" }],
@@ -215,11 +248,9 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
const lines = output.split("\n");
const relativized: string[] = [];
for (const rawLine of lines) {
const line = rawLine.replace(/\r$/, "").trim();
if (!line) continue;
const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
let relativePath = line;
if (line.startsWith(searchPath)) {
@@ -227,38 +258,29 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
} else {
relativePath = path.relative(searchPath, line);
}
if (hadTrailingSlash && !relativePath.endsWith("/")) {
relativePath += "/";
}
if (hadTrailingSlash && !relativePath.endsWith("/")) relativePath += "/";
relativized.push(toPosixPath(relativePath));
}
const resultLimitReached = relativized.length >= effectiveLimit;
const rawOutput = relativized.join("\n");
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
let resultOutput = truncation.content;
const details: FindToolDetails = {};
const notices: string[] = [];
if (resultLimitReached) {
notices.push(
`${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
);
details.resultLimitReached = effectiveLimit;
}
if (truncation.truncated) {
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
details.truncation = truncation;
}
if (notices.length > 0) {
resultOutput += `\n\n[${notices.join(". ")}]`;
}
resolve({
content: [{ type: "text", text: resultOutput }],
details: Object.keys(details).length > 0 ? details : undefined,
@@ -270,8 +292,23 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
})();
});
},
renderCall(args, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatFindCall(args, theme));
return text;
},
renderResult(result, options, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatFindResult(result as any, options, theme, context.showImages));
return text;
},
};
}
/** Default find tool using process.cwd() - for backwards compatibility */
export function createFindTool(cwd: string, options?: FindToolOptions): AgentTool<typeof findSchema> {
return wrapToolDefinition(createFindToolDefinition(cwd, options));
}
/** Default find tool using process.cwd() for backwards compatibility. */
export const findToolDefinition = createFindToolDefinition(process.cwd());
export const findTool = createFindTool(process.cwd());

View File

@@ -1,11 +1,16 @@
import { createInterface } from "node:readline";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Text } from "@mariozechner/pi-tui";
import { type Static, Type } from "@sinclair/typebox";
import { spawn } from "child_process";
import { readFileSync, statSync } from "fs";
import path from "path";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
import { ensureTool } from "../../utils/tools-manager.js";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
import { resolveToCwd } from "./path-utils.js";
import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
import {
DEFAULT_MAX_BYTES,
formatSize,
@@ -30,7 +35,6 @@ const grepSchema = Type.Object({
});
export type GrepToolInput = Static<typeof grepSchema>;
const DEFAULT_LIMIT = 100;
export interface GrepToolDetails {
@@ -41,10 +45,10 @@ export interface GrepToolDetails {
/**
* Pluggable operations for the grep tool.
* Override these to delegate search to remote systems (e.g., SSH).
* Override these to delegate search to remote systems (for example SSH).
*/
export interface GrepOperations {
/** Check if path is a directory. Throws if path doesn't exist. */
/** Check if path is a directory. Throws if path does not exist. */
isDirectory: (absolutePath: string) => Promise<boolean> | boolean;
/** Read file contents for context lines */
readFile: (absolutePath: string) => Promise<string> | string;
@@ -56,20 +60,78 @@ const defaultGrepOperations: GrepOperations = {
};
export interface GrepToolOptions {
/** Custom operations for grep. Default: local filesystem + ripgrep */
/** Custom operations for grep. Default: local filesystem plus ripgrep */
operations?: GrepOperations;
}
export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentTool<typeof grepSchema> {
const customOps = options?.operations;
function formatGrepCall(
args: { pattern: string; path?: string; glob?: string; limit?: number } | undefined,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
): string {
const pattern = str(args?.pattern);
const rawPath = str(args?.path);
const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
const glob = str(args?.glob);
const limit = args?.limit;
const invalidArg = invalidArgText(theme);
let text =
theme.fg("toolTitle", theme.bold("grep")) +
" " +
(pattern === null ? invalidArg : theme.fg("accent", `/${pattern || ""}/`)) +
theme.fg("toolOutput", ` in ${path === null ? invalidArg : path}`);
if (glob) text += theme.fg("toolOutput", ` (${glob})`);
if (limit !== undefined) text += theme.fg("toolOutput", ` limit ${limit}`);
return text;
}
function formatGrepResult(
result: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
details?: GrepToolDetails;
},
options: ToolRenderResultOptions,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
showImages: boolean,
): string {
const output = getTextOutput(result, showImages).trim();
let text = "";
if (output) {
const lines = output.split("\n");
const maxLines = options.expanded ? lines.length : 15;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
if (remaining > 0) {
text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`;
}
}
const matchLimit = result.details?.matchLimitReached;
const truncation = result.details?.truncation;
const linesTruncated = result.details?.linesTruncated;
if (matchLimit || truncation?.truncated || linesTruncated) {
const warnings: string[] = [];
if (matchLimit) warnings.push(`${matchLimit} matches limit`);
if (truncation?.truncated) warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
if (linesTruncated) warnings.push("some lines truncated");
text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
}
return text;
}
export function createGrepToolDefinition(
cwd: string,
options?: GrepToolOptions,
): ToolDefinition<typeof grepSchema, GrepToolDetails | undefined> {
const customOps = options?.operations;
return {
name: "grep",
label: "grep",
description: `Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Long lines are truncated to ${GREP_MAX_LINE_LENGTH} chars.`,
promptSnippet: "Search file contents for patterns (respects .gitignore)",
parameters: grepSchema,
execute: async (
_toolCallId: string,
async execute(
_toolCallId,
{
pattern,
path: searchDir,
@@ -88,13 +150,14 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo
limit?: number;
},
signal?: AbortSignal,
) => {
_onUpdate?,
_ctx?,
) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
}
let settled = false;
const settle = (fn: () => void) => {
if (!settled) {
@@ -113,17 +176,16 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo
const searchPath = resolveToCwd(searchDir || ".", cwd);
const ops = customOps ?? defaultGrepOperations;
let isDirectory: boolean;
try {
isDirectory = await ops.isDirectory(searchPath);
} catch (_err) {
} catch {
settle(() => reject(new Error(`Path not found: ${searchPath}`)));
return;
}
const contextValue = context && context > 0 ? context : 0;
const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT);
const formatPath = (filePath: string): string => {
if (isDirectory) {
const relative = path.relative(searchPath, filePath);
@@ -150,19 +212,9 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo
};
const args: string[] = ["--json", "--line-number", "--color=never", "--hidden"];
if (ignoreCase) {
args.push("--ignore-case");
}
if (literal) {
args.push("--fixed-strings");
}
if (glob) {
args.push("--glob", glob);
}
if (ignoreCase) args.push("--ignore-case");
if (literal) args.push("--fixed-strings");
if (glob) args.push("--glob", glob);
args.push(pattern, searchPath);
const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
@@ -179,21 +231,17 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo
rl.close();
signal?.removeEventListener("abort", onAbort);
};
const stopChild = (dueToLimit: boolean = false) => {
const stopChild = (dueToLimit = false) => {
if (!child.killed) {
killedDueToLimit = dueToLimit;
child.kill();
}
};
const onAbort = () => {
aborted = true;
stopChild();
};
signal?.addEventListener("abort", onAbort, { once: true });
child.stderr?.on("data", (chunk) => {
stderr += chunk.toString();
});
@@ -201,59 +249,38 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo
const formatBlock = async (filePath: string, lineNumber: number): Promise<string[]> => {
const relativePath = formatPath(filePath);
const lines = await getFileLines(filePath);
if (!lines.length) {
return [`${relativePath}:${lineNumber}: (unable to read file)`];
}
if (!lines.length) return [`${relativePath}:${lineNumber}: (unable to read file)`];
const block: string[] = [];
const start = contextValue > 0 ? Math.max(1, lineNumber - contextValue) : lineNumber;
const end = contextValue > 0 ? Math.min(lines.length, lineNumber + contextValue) : lineNumber;
for (let current = start; current <= end; current++) {
const lineText = lines[current - 1] ?? "";
const sanitized = lineText.replace(/\r/g, "");
const isMatchLine = current === lineNumber;
// Truncate long lines
// Truncate long lines so grep output stays compact.
const { text: truncatedText, wasTruncated } = truncateLine(sanitized);
if (wasTruncated) {
linesTruncated = true;
}
if (isMatchLine) {
block.push(`${relativePath}:${current}: ${truncatedText}`);
} else {
block.push(`${relativePath}-${current}- ${truncatedText}`);
}
if (wasTruncated) linesTruncated = true;
if (isMatchLine) block.push(`${relativePath}:${current}: ${truncatedText}`);
else block.push(`${relativePath}-${current}- ${truncatedText}`);
}
return block;
};
// Collect matches during streaming, format after
// Collect matches during streaming, then format them after rg exits.
const matches: Array<{ filePath: string; lineNumber: number }> = [];
rl.on("line", (line) => {
if (!line.trim() || matchCount >= effectiveLimit) {
return;
}
if (!line.trim() || matchCount >= effectiveLimit) return;
let event: any;
try {
event = JSON.parse(line);
} catch {
return;
}
if (event.type === "match") {
matchCount++;
const filePath = event.data?.path?.text;
const lineNumber = event.data?.line_number;
if (filePath && typeof lineNumber === "number") {
matches.push({ filePath, lineNumber });
}
if (filePath && typeof lineNumber === "number") matches.push({ filePath, lineNumber });
if (matchCount >= effectiveLimit) {
matchLimitReached = true;
stopChild(true);
@@ -265,21 +292,17 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo
cleanup();
settle(() => reject(new Error(`Failed to run ripgrep: ${error.message}`)));
});
child.on("close", async (code) => {
cleanup();
if (aborted) {
settle(() => reject(new Error("Operation aborted")));
return;
}
if (!killedDueToLimit && code !== 0 && code !== 1) {
const errorMsg = stderr.trim() || `ripgrep exited with code ${code}`;
settle(() => reject(new Error(errorMsg)));
return;
}
if (matchCount === 0) {
settle(() =>
resolve({ content: [{ type: "text", text: "No matches found" }], details: undefined }),
@@ -287,45 +310,36 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo
return;
}
// Format matches (async to support remote file reading)
// Format matches after streaming finishes so custom readFile() backends can be async.
for (const match of matches) {
const block = await formatBlock(match.filePath, match.lineNumber);
outputLines.push(...block);
}
// Apply byte truncation (no line limit since we already have match limit)
const rawOutput = outputLines.join("\n");
// Apply byte truncation. There is no line limit here because the match limit already capped rows.
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
let output = truncation.content;
const details: GrepToolDetails = {};
// Build notices
// Build actionable notices for truncation and match limits.
const notices: string[] = [];
if (matchLimitReached) {
notices.push(
`${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
);
details.matchLimitReached = effectiveLimit;
}
if (truncation.truncated) {
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
details.truncation = truncation;
}
if (linesTruncated) {
notices.push(
`Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines`,
);
details.linesTruncated = true;
}
if (notices.length > 0) {
output += `\n\n[${notices.join(". ")}]`;
}
if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
settle(() =>
resolve({
content: [{ type: "text", text: output }],
@@ -339,8 +353,23 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo
})();
});
},
renderCall(args, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatGrepCall(args, theme));
return text;
},
renderResult(result, options, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatGrepResult(result as any, options, theme, context.showImages));
return text;
},
};
}
/** Default grep tool using process.cwd() - for backwards compatibility */
export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentTool<typeof grepSchema> {
return wrapToolDefinition(createGrepToolDefinition(cwd, options));
}
/** Default grep tool using process.cwd() for backwards compatibility. */
export const grepToolDefinition = createGrepToolDefinition(process.cwd());
export const grepTool = createGrepTool(process.cwd());

View File

@@ -6,49 +6,61 @@ export {
type BashToolInput,
type BashToolOptions,
bashTool,
bashToolDefinition,
createBashTool,
createBashToolDefinition,
createLocalBashOperations,
} from "./bash.js";
export {
createEditTool,
createEditToolDefinition,
type EditOperations,
type EditToolDetails,
type EditToolInput,
type EditToolOptions,
editTool,
editToolDefinition,
} from "./edit.js";
export { withFileMutationQueue } from "./file-mutation-queue.js";
export {
createFindTool,
createFindToolDefinition,
type FindOperations,
type FindToolDetails,
type FindToolInput,
type FindToolOptions,
findTool,
findToolDefinition,
} from "./find.js";
export {
createGrepTool,
createGrepToolDefinition,
type GrepOperations,
type GrepToolDetails,
type GrepToolInput,
type GrepToolOptions,
grepTool,
grepToolDefinition,
} from "./grep.js";
export {
createLsTool,
createLsToolDefinition,
type LsOperations,
type LsToolDetails,
type LsToolInput,
type LsToolOptions,
lsTool,
lsToolDefinition,
} from "./ls.js";
export {
createReadTool,
createReadToolDefinition,
type ReadOperations,
type ReadToolDetails,
type ReadToolInput,
type ReadToolOptions,
readTool,
readToolDefinition,
} from "./read.js";
export {
DEFAULT_MAX_BYTES,
@@ -62,31 +74,42 @@ export {
} from "./truncate.js";
export {
createWriteTool,
createWriteToolDefinition,
type WriteOperations,
type WriteToolInput,
type WriteToolOptions,
writeTool,
writeToolDefinition,
} from "./write.js";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { type BashToolOptions, bashTool, createBashTool } from "./bash.js";
import { createEditTool, editTool } from "./edit.js";
import { createFindTool, findTool } from "./find.js";
import { createGrepTool, grepTool } from "./grep.js";
import { createLsTool, lsTool } from "./ls.js";
import { createReadTool, type ReadToolOptions, readTool } from "./read.js";
import { createWriteTool, writeTool } from "./write.js";
import type { ToolDefinition } from "../extensions/types.js";
import {
type BashToolOptions,
bashTool,
bashToolDefinition,
createBashTool,
createBashToolDefinition,
} from "./bash.js";
import { createEditTool, createEditToolDefinition, editTool, editToolDefinition } from "./edit.js";
import { createFindTool, createFindToolDefinition, findTool, findToolDefinition } from "./find.js";
import { createGrepTool, createGrepToolDefinition, grepTool, grepToolDefinition } from "./grep.js";
import { createLsTool, createLsToolDefinition, lsTool, lsToolDefinition } from "./ls.js";
import {
createReadTool,
createReadToolDefinition,
type ReadToolOptions,
readTool,
readToolDefinition,
} from "./read.js";
import { createWriteTool, createWriteToolDefinition, writeTool, writeToolDefinition } from "./write.js";
/** Tool type (AgentTool from pi-ai) */
export type Tool = AgentTool<any>;
export type ToolDef = ToolDefinition<any, any>;
// Default tools for full access mode (using process.cwd())
export const codingTools: Tool[] = [readTool, bashTool, editTool, writeTool];
// Read-only tools for exploration without modification (using process.cwd())
export const readOnlyTools: Tool[] = [readTool, grepTool, findTool, lsTool];
// All available tools (using process.cwd())
export const allTools = {
read: readTool,
bash: bashTool,
@@ -97,18 +120,53 @@ export const allTools = {
ls: lsTool,
};
export const allToolDefinitions = {
read: readToolDefinition,
bash: bashToolDefinition,
edit: editToolDefinition,
write: writeToolDefinition,
grep: grepToolDefinition,
find: findToolDefinition,
ls: lsToolDefinition,
};
export type ToolName = keyof typeof allTools;
export interface ToolsOptions {
/** Options for the read tool */
read?: ReadToolOptions;
/** Options for the bash tool */
bash?: BashToolOptions;
}
/**
* Create coding tools configured for a specific working directory.
*/
export function createCodingToolDefinitions(cwd: string, options?: ToolsOptions): ToolDef[] {
return [
createReadToolDefinition(cwd, options?.read),
createBashToolDefinition(cwd, options?.bash),
createEditToolDefinition(cwd),
createWriteToolDefinition(cwd),
];
}
export function createReadOnlyToolDefinitions(cwd: string, options?: ToolsOptions): ToolDef[] {
return [
createReadToolDefinition(cwd, options?.read),
createGrepToolDefinition(cwd),
createFindToolDefinition(cwd),
createLsToolDefinition(cwd),
];
}
export function createAllToolDefinitions(cwd: string, options?: ToolsOptions): Record<ToolName, ToolDef> {
return {
read: createReadToolDefinition(cwd, options?.read),
bash: createBashToolDefinition(cwd, options?.bash),
edit: createEditToolDefinition(cwd),
write: createWriteToolDefinition(cwd),
grep: createGrepToolDefinition(cwd),
find: createFindToolDefinition(cwd),
ls: createLsToolDefinition(cwd),
};
}
export function createCodingTools(cwd: string, options?: ToolsOptions): Tool[] {
return [
createReadTool(cwd, options?.read),
@@ -118,16 +176,10 @@ export function createCodingTools(cwd: string, options?: ToolsOptions): Tool[] {
];
}
/**
* Create read-only tools configured for a specific working directory.
*/
export function createReadOnlyTools(cwd: string, options?: ToolsOptions): Tool[] {
return [createReadTool(cwd, options?.read), createGrepTool(cwd), createFindTool(cwd), createLsTool(cwd)];
}
/**
* Create all tools configured for a specific working directory.
*/
export function createAllTools(cwd: string, options?: ToolsOptions): Record<ToolName, Tool> {
return {
read: createReadTool(cwd, options?.read),

View File

@@ -1,8 +1,13 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Text } from "@mariozechner/pi-tui";
import { type Static, Type } from "@sinclair/typebox";
import { existsSync, readdirSync, statSync } from "fs";
import nodePath from "path";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
import { resolveToCwd } from "./path-utils.js";
import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
const lsSchema = Type.Object({
@@ -21,12 +26,12 @@ export interface LsToolDetails {
/**
* Pluggable operations for the ls tool.
* Override these to delegate directory listing to remote systems (e.g., SSH).
* Override these to delegate directory listing to remote systems (for example SSH).
*/
export interface LsOperations {
/** Check if path exists */
exists: (absolutePath: string) => Promise<boolean> | boolean;
/** Get file/directory stats. Throws if not found. */
/** Get file or directory stats. Throws if not found. */
stat: (absolutePath: string) => Promise<{ isDirectory: () => boolean }> | { isDirectory: () => boolean };
/** Read directory entries */
readdir: (absolutePath: string) => Promise<string[]> | string[];
@@ -43,19 +48,72 @@ export interface LsToolOptions {
operations?: LsOperations;
}
export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool<typeof lsSchema> {
const ops = options?.operations ?? defaultLsOperations;
function formatLsCall(
args: { path?: string; limit?: number } | undefined,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
): string {
const rawPath = str(args?.path);
const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
const limit = args?.limit;
const invalidArg = invalidArgText(theme);
let text = `${theme.fg("toolTitle", theme.bold("ls"))} ${path === null ? invalidArg : theme.fg("accent", path)}`;
if (limit !== undefined) {
text += theme.fg("toolOutput", ` (limit ${limit})`);
}
return text;
}
function formatLsResult(
result: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
details?: LsToolDetails;
},
options: ToolRenderResultOptions,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
showImages: boolean,
): string {
const output = getTextOutput(result, showImages).trim();
let text = "";
if (output) {
const lines = output.split("\n");
const maxLines = options.expanded ? lines.length : 20;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
if (remaining > 0) {
text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`;
}
}
const entryLimit = result.details?.entryLimitReached;
const truncation = result.details?.truncation;
if (entryLimit || truncation?.truncated) {
const warnings: string[] = [];
if (entryLimit) warnings.push(`${entryLimit} entries limit`);
if (truncation?.truncated) warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
}
return text;
}
export function createLsToolDefinition(
cwd: string,
options?: LsToolOptions,
): ToolDefinition<typeof lsSchema, LsToolDetails | undefined> {
const ops = options?.operations ?? defaultLsOperations;
return {
name: "ls",
label: "ls",
description: `List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to ${DEFAULT_LIMIT} entries or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`,
promptSnippet: "List directory contents",
parameters: lsSchema,
execute: async (
_toolCallId: string,
async execute(
_toolCallId,
{ path, limit }: { path?: string; limit?: number },
signal?: AbortSignal,
) => {
_onUpdate?,
_ctx?,
) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Operation aborted"));
@@ -70,20 +128,20 @@ export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool<ty
const dirPath = resolveToCwd(path || ".", cwd);
const effectiveLimit = limit ?? DEFAULT_LIMIT;
// Check if path exists
// Check if path exists.
if (!(await ops.exists(dirPath))) {
reject(new Error(`Path not found: ${dirPath}`));
return;
}
// Check if path is a directory
// Check if path is a directory.
const stat = await ops.stat(dirPath);
if (!stat.isDirectory()) {
reject(new Error(`Not a directory: ${dirPath}`));
return;
}
// Read directory entries
// Read directory entries.
let entries: string[];
try {
entries = await ops.readdir(dirPath);
@@ -92,13 +150,12 @@ export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool<ty
return;
}
// Sort alphabetically (case-insensitive)
// Sort alphabetically, case-insensitive.
entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
// Format entries with directory indicators
// Format entries with directory indicators.
const results: string[] = [];
let entryLimitReached = false;
for (const entry of entries) {
if (results.length >= effectiveLimit) {
entryLimitReached = true;
@@ -107,17 +164,13 @@ export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool<ty
const fullPath = nodePath.join(dirPath, entry);
let suffix = "";
try {
const entryStat = await ops.stat(fullPath);
if (entryStat.isDirectory()) {
suffix = "/";
}
if (entryStat.isDirectory()) suffix = "/";
} catch {
// Skip entries we can't stat
// Skip entries we cannot stat.
continue;
}
results.push(entry + suffix);
}
@@ -128,26 +181,21 @@ export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool<ty
return;
}
// Apply byte truncation (no line limit since we already have entry limit)
const rawOutput = results.join("\n");
// Apply byte truncation. There is no separate line limit because entry count is already capped.
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
let output = truncation.content;
const details: LsToolDetails = {};
// Build notices
// Build actionable notices for truncation and entry limits.
const notices: string[] = [];
if (entryLimitReached) {
notices.push(`${effectiveLimit} entries limit reached. Use limit=${effectiveLimit * 2} for more`);
details.entryLimitReached = effectiveLimit;
}
if (truncation.truncated) {
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
details.truncation = truncation;
}
if (notices.length > 0) {
output += `\n\n[${notices.join(". ")}]`;
}
@@ -163,8 +211,23 @@ export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool<ty
})();
});
},
renderCall(args, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatLsCall(args, theme));
return text;
},
renderResult(result, options, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatLsResult(result as any, options, theme, context.showImages));
return text;
},
};
}
/** Default ls tool using process.cwd() - for backwards compatibility */
export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool<typeof lsSchema> {
return wrapToolDefinition(createLsToolDefinition(cwd, options));
}
/** Default ls tool using process.cwd() for backwards compatibility. */
export const lsToolDefinition = createLsToolDefinition(process.cwd());
export const lsTool = createLsTool(process.cwd());

View File

@@ -1,11 +1,17 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
import { Text } from "@mariozechner/pi-tui";
import { type Static, Type } from "@sinclair/typebox";
import { constants } from "fs";
import { access as fsAccess, readFile as fsReadFile } from "fs/promises";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
import { getLanguageFromPath, highlightCode } from "../../modes/interactive/theme/theme.js";
import { formatDimensionNote, resizeImage } from "../../utils/image-resize.js";
import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
import { resolveReadPath } from "./path-utils.js";
import { getTextOutput, invalidArgText, replaceTabs, shortenPath, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
const readSchema = Type.Object({
@@ -22,14 +28,14 @@ export interface ReadToolDetails {
/**
* Pluggable operations for the read tool.
* Override these to delegate file reading to remote systems (e.g., SSH).
* Override these to delegate file reading to remote systems (for example SSH).
*/
export interface ReadOperations {
/** Read file contents as a Buffer */
readFile: (absolutePath: string) => Promise<Buffer>;
/** Check if file is readable (throw if not) */
access: (absolutePath: string) => Promise<void>;
/** Detect image MIME type, return null/undefined for non-images */
/** Detect image MIME type, return null or undefined for non-images */
detectImageMimeType?: (absolutePath: string) => Promise<string | null | undefined>;
}
@@ -46,104 +52,143 @@ export interface ReadToolOptions {
operations?: ReadOperations;
}
export function createReadTool(cwd: string, options?: ReadToolOptions): AgentTool<typeof readSchema> {
function formatReadCall(
args: { path?: string; file_path?: string; offset?: number; limit?: number } | undefined,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
): string {
const rawPath = str(args?.file_path ?? args?.path);
const path = rawPath !== null ? shortenPath(rawPath) : null;
const offset = args?.offset;
const limit = args?.limit;
const invalidArg = invalidArgText(theme);
let pathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
if (offset !== undefined || limit !== undefined) {
const startLine = offset ?? 1;
const endLine = limit !== undefined ? startLine + limit - 1 : "";
pathDisplay += theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
}
return `${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}`;
}
function trimTrailingEmptyLines(lines: string[]): string[] {
let end = lines.length;
while (end > 0 && lines[end - 1] === "") {
end--;
}
return lines.slice(0, end);
}
function formatReadResult(
args: { path?: string; file_path?: string; offset?: number; limit?: number } | undefined,
result: { content: (TextContent | ImageContent)[]; details?: ReadToolDetails },
options: ToolRenderResultOptions,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
showImages: boolean,
): string {
const rawPath = str(args?.file_path ?? args?.path);
const output = getTextOutput(result as any, showImages);
const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
const renderedLines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n");
const lines = trimTrailingEmptyLines(renderedLines);
const maxLines = options.expanded ? lines.length : 10;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
let text = `\n${displayLines.map((line) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line)))).join("\n")}`;
if (remaining > 0) {
text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`;
}
const truncation = result.details?.truncation;
if (truncation?.truncated) {
if (truncation.firstLineExceedsLimit) {
text += `\n${theme.fg("warning", `[First line exceeds ${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit]`)}`;
} else if (truncation.truncatedBy === "lines") {
text += `\n${theme.fg("warning", `[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${truncation.maxLines ?? DEFAULT_MAX_LINES} line limit)]`)}`;
} else {
text += `\n${theme.fg("warning", `[Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)]`)}`;
}
}
return text;
}
export function createReadToolDefinition(
cwd: string,
options?: ReadToolOptions,
): ToolDefinition<typeof readSchema, ReadToolDetails | undefined> {
const autoResizeImages = options?.autoResizeImages ?? true;
const ops = options?.operations ?? defaultReadOperations;
return {
name: "read",
label: "read",
description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`,
promptSnippet: "Read file contents",
promptGuidelines: ["Use read to examine files instead of cat or sed."],
parameters: readSchema,
execute: async (
_toolCallId: string,
async execute(
_toolCallId,
{ path, offset, limit }: { path: string; offset?: number; limit?: number },
signal?: AbortSignal,
) => {
_onUpdate?,
_ctx?,
) {
const absolutePath = resolveReadPath(path, cwd);
return new Promise<{ content: (TextContent | ImageContent)[]; details: ReadToolDetails | undefined }>(
(resolve, reject) => {
// Check if already aborted
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
}
let aborted = false;
// Set up abort handler
const onAbort = () => {
aborted = true;
reject(new Error("Operation aborted"));
};
signal?.addEventListener("abort", onAbort, { once: true });
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
// Perform the read operation
(async () => {
try {
// Check if file exists
// Check if file exists and is readable.
await ops.access(absolutePath);
// Check if aborted before reading
if (aborted) {
return;
}
if (aborted) return;
const mimeType = ops.detectImageMimeType ? await ops.detectImageMimeType(absolutePath) : undefined;
// Read the file based on type
let content: (TextContent | ImageContent)[];
let details: ReadToolDetails | undefined;
if (mimeType) {
// Read as image (binary)
// Read image as binary.
const buffer = await ops.readFile(absolutePath);
const base64 = buffer.toString("base64");
if (autoResizeImages) {
// Resize image if needed
// Resize image if needed before sending it back to the model.
const resized = await resizeImage({ type: "image", data: base64, mimeType });
const dimensionNote = formatDimensionNote(resized);
let textNote = `Read image file [${resized.mimeType}]`;
if (dimensionNote) {
textNote += `\n${dimensionNote}`;
}
if (dimensionNote) textNote += `\n${dimensionNote}`;
content = [
{ type: "text", text: textNote },
{ type: "image", data: resized.data, mimeType: resized.mimeType },
];
} else {
const textNote = `Read image file [${mimeType}]`;
content = [
{ type: "text", text: textNote },
{ type: "text", text: `Read image file [${mimeType}]` },
{ type: "image", data: base64, mimeType },
];
}
} else {
// Read as text
// Read text content.
const buffer = await ops.readFile(absolutePath);
const textContent = buffer.toString("utf-8");
const allLines = textContent.split("\n");
const totalFileLines = allLines.length;
// Apply offset if specified (1-indexed to 0-indexed)
// Apply offset if specified. Convert from 1-indexed input to 0-indexed array access.
const startLine = offset ? Math.max(0, offset - 1) : 0;
const startLineDisplay = startLine + 1; // For display (1-indexed)
// Check if offset is out of bounds
const startLineDisplay = startLine + 1;
// Check if offset is out of bounds.
if (startLine >= allLines.length) {
throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`);
}
// If limit is specified by user, use it; otherwise we'll let truncateHead decide
let selectedContent: string;
let userLimitedLines: number | undefined;
// If limit is specified by the user, honor it first. Otherwise truncateHead decides.
if (limit !== undefined) {
const endLine = Math.min(startLine + limit, allLines.length);
selectedContent = allLines.slice(startLine, endLine).join("\n");
@@ -151,24 +196,19 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo
} else {
selectedContent = allLines.slice(startLine).join("\n");
}
// Apply truncation (respects both line and byte limits)
// Apply truncation, respecting both line and byte limits.
const truncation = truncateHead(selectedContent);
let outputText: string;
if (truncation.firstLineExceedsLimit) {
// First line at offset exceeds 30KB - tell model to use bash
// First line alone exceeds the byte limit. Point the model at a bash fallback.
const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8"));
outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;
details = { truncation };
} else if (truncation.truncated) {
// Truncation occurred - build actionable notice
// Truncation occurred. Build an actionable continuation notice.
const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
const nextOffset = endLineDisplay + 1;
outputText = truncation.content;
if (truncation.truncatedBy === "lines") {
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;
} else {
@@ -176,47 +216,45 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo
}
details = { truncation };
} else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
// User specified limit, there's more content, but no truncation
// User-specified limit stopped early, but the file still has more content.
const remaining = allLines.length - (startLine + userLimitedLines);
const nextOffset = startLine + userLimitedLines + 1;
outputText = truncation.content;
outputText += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
outputText = `${truncation.content}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
} else {
// No truncation, no user limit exceeded
// No truncation and no remaining user-limited content.
outputText = truncation.content;
}
content = [{ type: "text", text: outputText }];
}
// Check if aborted after reading
if (aborted) {
return;
}
// Clean up abort handler
if (signal) {
signal.removeEventListener("abort", onAbort);
}
if (aborted) return;
signal?.removeEventListener("abort", onAbort);
resolve({ content, details });
} catch (error: any) {
// Clean up abort handler
if (signal) {
signal.removeEventListener("abort", onAbort);
}
if (!aborted) {
reject(error);
}
signal?.removeEventListener("abort", onAbort);
if (!aborted) reject(error);
}
})();
},
);
},
renderCall(args, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatReadCall(args, theme));
return text;
},
renderResult(result, options, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatReadResult(context.args, result as any, options, theme, context.showImages));
return text;
},
};
}
/** Default read tool using process.cwd() - for backwards compatibility */
export function createReadTool(cwd: string, options?: ReadToolOptions): AgentTool<typeof readSchema> {
return wrapToolDefinition(createReadToolDefinition(cwd, options));
}
/** Default read tool using process.cwd() for backwards compatibility. */
export const readToolDefinition = createReadToolDefinition(process.cwd());
export const readTool = createReadTool(process.cwd());

View File

@@ -0,0 +1,64 @@
import * as os from "node:os";
import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
import { getCapabilities, getImageDimensions, imageFallback } from "@mariozechner/pi-tui";
import stripAnsi from "strip-ansi";
import { sanitizeBinaryOutput } from "../../utils/shell.js";
export function shortenPath(path: unknown): string {
if (typeof path !== "string") return "";
const home = os.homedir();
if (path.startsWith(home)) {
return `~${path.slice(home.length)}`;
}
return path;
}
export function str(value: unknown): string | null {
if (typeof value === "string") return value;
if (value == null) return "";
return null;
}
export function replaceTabs(text: string): string {
return text.replace(/\t/g, " ");
}
export function normalizeDisplayText(text: string): string {
return text.replace(/\r/g, "");
}
export function getTextOutput(
result: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> } | undefined,
showImages: boolean,
): string {
if (!result) return "";
const textBlocks = result.content.filter((c) => c.type === "text");
const imageBlocks = result.content.filter((c) => c.type === "image");
let output = textBlocks.map((c) => sanitizeBinaryOutput(stripAnsi(c.text || "")).replace(/\r/g, "")).join("\n");
const caps = getCapabilities();
if (imageBlocks.length > 0 && (!caps.images || !showImages)) {
const imageIndicators = imageBlocks
.map((img) => {
const mimeType = img.mimeType ?? "image/unknown";
const dims =
img.data && img.mimeType ? (getImageDimensions(img.data, img.mimeType) ?? undefined) : undefined;
return imageFallback(mimeType, dims);
})
.join("\n");
output = output ? `${output}\n${imageIndicators}` : imageIndicators;
}
return output;
}
export type ToolRenderResultLike<TDetails> = {
content: (TextContent | ImageContent)[];
details: TDetails;
};
export function invalidArgText(theme: { fg: (name: any, text: string) => string }): string {
return theme.fg("error", "[invalid arg]");
}

View File

@@ -0,0 +1,41 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import type { ExtensionContext, ToolDefinition } from "../extensions/types.js";
/** Wrap a ToolDefinition into an AgentTool for the core runtime. */
export function wrapToolDefinition<TDetails = unknown>(
definition: ToolDefinition<any, TDetails>,
ctxFactory?: () => ExtensionContext,
): AgentTool<any, TDetails> {
return {
name: definition.name,
label: definition.label,
description: definition.description,
parameters: definition.parameters,
execute: (toolCallId, params, signal, onUpdate) =>
definition.execute(toolCallId, params, signal, onUpdate, ctxFactory?.() as ExtensionContext),
};
}
/** Wrap multiple ToolDefinitions into AgentTools for the core runtime. */
export function wrapToolDefinitions(
definitions: ToolDefinition<any, any>[],
ctxFactory?: () => ExtensionContext,
): AgentTool<any>[] {
return definitions.map((definition) => wrapToolDefinition(definition, ctxFactory));
}
/**
* Synthesize a minimal ToolDefinition from an AgentTool.
*
* This keeps AgentSession's internal registry definition-first even when a caller
* provides plain AgentTool overrides that do not include prompt metadata or renderers.
*/
export function createToolDefinitionFromAgentTool(tool: AgentTool<any>): ToolDefinition<any, unknown> {
return {
name: tool.name,
label: tool.label,
description: tool.description,
parameters: tool.parameters as any,
execute: async (toolCallId, params, signal, onUpdate) => tool.execute(toolCallId, params, signal, onUpdate),
};
}

View File

@@ -1,9 +1,15 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Container, Text } from "@mariozechner/pi-tui";
import { type Static, Type } from "@sinclair/typebox";
import { mkdir as fsMkdir, writeFile as fsWriteFile } from "fs/promises";
import { dirname } from "path";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
import { getLanguageFromPath, highlightCode } from "../../modes/interactive/theme/theme.js";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
import { withFileMutationQueue } from "./file-mutation-queue.js";
import { resolveToCwd } from "./path-utils.js";
import { invalidArgText, normalizeDisplayText, replaceTabs, shortenPath, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
const writeSchema = Type.Object({
path: Type.String({ description: "Path to the file to write (relative or absolute)" }),
@@ -14,12 +20,12 @@ export type WriteToolInput = Static<typeof writeSchema>;
/**
* Pluggable operations for the write tool.
* Override these to delegate file writing to remote systems (e.g., SSH).
* Override these to delegate file writing to remote systems (for example SSH).
*/
export interface WriteOperations {
/** Write content to a file */
writeFile: (absolutePath: string, content: string) => Promise<void>;
/** Create directory (recursively) */
/** Create directory recursively */
mkdir: (dir: string) => Promise<void>;
}
@@ -33,70 +39,191 @@ export interface WriteToolOptions {
operations?: WriteOperations;
}
export function createWriteTool(cwd: string, options?: WriteToolOptions): AgentTool<typeof writeSchema> {
const ops = options?.operations ?? defaultWriteOperations;
type WriteHighlightCache = {
rawPath: string | null;
lang: string;
rawContent: string;
normalizedLines: string[];
highlightedLines: string[];
};
class WriteCallRenderComponent extends Text {
cache?: WriteHighlightCache;
constructor() {
super("", 0, 0);
}
}
const WRITE_PARTIAL_FULL_HIGHLIGHT_LINES = 50;
function highlightSingleLine(line: string, lang: string): string {
const highlighted = highlightCode(line, lang);
return highlighted[0] ?? "";
}
function refreshWriteHighlightPrefix(cache: WriteHighlightCache): void {
const prefixCount = Math.min(WRITE_PARTIAL_FULL_HIGHLIGHT_LINES, cache.normalizedLines.length);
if (prefixCount === 0) return;
const prefixSource = cache.normalizedLines.slice(0, prefixCount).join("\n");
const prefixHighlighted = highlightCode(prefixSource, cache.lang);
for (let i = 0; i < prefixCount; i++) {
cache.highlightedLines[i] =
prefixHighlighted[i] ?? highlightSingleLine(cache.normalizedLines[i] ?? "", cache.lang);
}
}
function rebuildWriteHighlightCacheFull(rawPath: string | null, fileContent: string): WriteHighlightCache | undefined {
const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
if (!lang) return undefined;
const displayContent = normalizeDisplayText(fileContent);
const normalized = replaceTabs(displayContent);
return {
rawPath,
lang,
rawContent: fileContent,
normalizedLines: normalized.split("\n"),
highlightedLines: highlightCode(normalized, lang),
};
}
function updateWriteHighlightCacheIncremental(
cache: WriteHighlightCache | undefined,
rawPath: string | null,
fileContent: string,
): WriteHighlightCache | undefined {
const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
if (!lang) return undefined;
if (!cache) return rebuildWriteHighlightCacheFull(rawPath, fileContent);
if (cache.lang !== lang || cache.rawPath !== rawPath) return rebuildWriteHighlightCacheFull(rawPath, fileContent);
if (!fileContent.startsWith(cache.rawContent)) return rebuildWriteHighlightCacheFull(rawPath, fileContent);
if (fileContent.length === cache.rawContent.length) return cache;
const deltaRaw = fileContent.slice(cache.rawContent.length);
const deltaDisplay = normalizeDisplayText(deltaRaw);
const deltaNormalized = replaceTabs(deltaDisplay);
cache.rawContent = fileContent;
if (cache.normalizedLines.length === 0) {
cache.normalizedLines.push("");
cache.highlightedLines.push("");
}
const segments = deltaNormalized.split("\n");
const lastIndex = cache.normalizedLines.length - 1;
cache.normalizedLines[lastIndex] += segments[0];
cache.highlightedLines[lastIndex] = highlightSingleLine(cache.normalizedLines[lastIndex], cache.lang);
for (let i = 1; i < segments.length; i++) {
cache.normalizedLines.push(segments[i]);
cache.highlightedLines.push(highlightSingleLine(segments[i], cache.lang));
}
refreshWriteHighlightPrefix(cache);
return cache;
}
function trimTrailingEmptyLines(lines: string[]): string[] {
let end = lines.length;
while (end > 0 && lines[end - 1] === "") {
end--;
}
return lines.slice(0, end);
}
function formatWriteCall(
args: { path?: string; file_path?: string; content?: string } | undefined,
options: ToolRenderResultOptions,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
cache: WriteHighlightCache | undefined,
): string {
const rawPath = str(args?.file_path ?? args?.path);
const fileContent = str(args?.content);
const path = rawPath !== null ? shortenPath(rawPath) : null;
const invalidArg = invalidArgText(theme);
let text = `${theme.fg("toolTitle", theme.bold("write"))} ${path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...")}`;
if (fileContent === null) {
text += `\n\n${theme.fg("error", "[invalid content arg - expected string]")}`;
} else if (fileContent) {
const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
const renderedLines = lang
? (cache?.highlightedLines ?? highlightCode(replaceTabs(normalizeDisplayText(fileContent)), lang))
: normalizeDisplayText(fileContent).split("\n");
const lines = trimTrailingEmptyLines(renderedLines);
const totalLines = lines.length;
const maxLines = options.expanded ? lines.length : 10;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
text += `\n\n${displayLines.map((line) => (lang ? line : theme.fg("toolOutput", replaceTabs(line)))).join("\n")}`;
if (remaining > 0) {
text += `${theme.fg("muted", `\n... (${remaining} more lines, ${totalLines} total,`)} ${keyHint("app.tools.expand", "to expand")})`;
}
}
return text;
}
function formatWriteResult(
result: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; isError?: boolean },
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
): string | undefined {
if (!result.isError) {
return undefined;
}
const output = result.content
.filter((c) => c.type === "text")
.map((c) => c.text || "")
.join("\n");
if (!output) {
return undefined;
}
return `\n${theme.fg("error", output)}`;
}
export function createWriteToolDefinition(
cwd: string,
options?: WriteToolOptions,
): ToolDefinition<typeof writeSchema, undefined> {
const ops = options?.operations ?? defaultWriteOperations;
return {
name: "write",
label: "write",
description:
"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
promptSnippet: "Create or overwrite files",
promptGuidelines: ["Use write only for new files or complete rewrites."],
parameters: writeSchema,
execute: async (
_toolCallId: string,
async execute(
_toolCallId,
{ path, content }: { path: string; content: string },
signal?: AbortSignal,
) => {
_onUpdate?,
_ctx?,
) {
const absolutePath = resolveToCwd(path, cwd);
const dir = dirname(absolutePath);
return withFileMutationQueue(
absolutePath,
() =>
new Promise<{ content: Array<{ type: "text"; text: string }>; details: undefined }>(
(resolve, reject) => {
// Check if already aborted
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
}
let aborted = false;
// Set up abort handler
const onAbort = () => {
aborted = true;
reject(new Error("Operation aborted"));
};
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
// Perform the write operation
signal?.addEventListener("abort", onAbort, { once: true });
(async () => {
try {
// Create parent directories if needed
// Create parent directories if needed.
await ops.mkdir(dir);
// Check if aborted before writing
if (aborted) {
return;
}
// Write the file
if (aborted) return;
// Write the file contents.
await ops.writeFile(absolutePath, content);
// Check if aborted after writing
if (aborted) {
return;
}
// Clean up abort handler
if (signal) {
signal.removeEventListener("abort", onAbort);
}
if (aborted) return;
signal?.removeEventListener("abort", onAbort);
resolve({
content: [
{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` },
@@ -104,22 +231,55 @@ export function createWriteTool(cwd: string, options?: WriteToolOptions): AgentT
details: undefined,
});
} catch (error: any) {
// Clean up abort handler
if (signal) {
signal.removeEventListener("abort", onAbort);
}
if (!aborted) {
reject(error);
}
signal?.removeEventListener("abort", onAbort);
if (!aborted) reject(error);
}
})();
},
),
);
},
renderCall(args, theme, context) {
const renderArgs = args as { path?: string; file_path?: string; content?: string } | undefined;
const rawPath = str(renderArgs?.file_path ?? renderArgs?.path);
const fileContent = str(renderArgs?.content);
const component =
(context.lastComponent as WriteCallRenderComponent | undefined) ?? new WriteCallRenderComponent();
if (fileContent !== null) {
component.cache = context.argsComplete
? rebuildWriteHighlightCacheFull(rawPath, fileContent)
: updateWriteHighlightCacheIncremental(component.cache, rawPath, fileContent);
} else {
component.cache = undefined;
}
component.setText(
formatWriteCall(
renderArgs,
{ expanded: context.expanded, isPartial: context.isPartial },
theme,
component.cache,
),
);
return component;
},
renderResult(result, _options, theme, context) {
const output = formatWriteResult({ ...result, isError: context.isError }, theme);
if (!output) {
const component = (context.lastComponent as Container | undefined) ?? new Container();
component.clear();
return component;
}
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(output);
return text;
},
};
}
/** Default write tool using process.cwd() - for backwards compatibility */
export function createWriteTool(cwd: string, options?: WriteToolOptions): AgentTool<typeof writeSchema> {
return wrapToolDefinition(createWriteToolDefinition(cwd, options));
}
/** Default write tool using process.cwd() for backwards compatibility. */
export const writeToolDefinition = createWriteToolDefinition(process.cwd());
export const writeTool = createWriteTool(process.cwd());