update
This commit is contained in:
1 parent
1261c2431f
commit
dac59f56c2
680 files changed
+192574
No files matched your search
@@ -0,0 +1,450 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
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,
|
||||
trackDetachedChildPid,
|
||||
untrackDetachedChildPid,
|
||||
} 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.
|
||||
*/
|
||||
function getTempFilePath(): string {
|
||||
const id = randomBytes(8).toString("hex");
|
||||
return join(tmpdir(), `pi-bash-${id}.log`);
|
||||
}
|
||||
|
||||
const bashSchema = Type.Object({
|
||||
command: Type.String({ description: "Bash command to execute" }),
|
||||
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
|
||||
});
|
||||
|
||||
export type BashToolInput = Static<typeof bashSchema>;
|
||||
|
||||
export interface BashToolDetails {
|
||||
truncation?: TruncationResult;
|
||||
fullOutputPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pluggable operations for the bash tool.
|
||||
* 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
|
||||
* @returns Promise resolving to exit code (null if killed)
|
||||
*/
|
||||
exec: (
|
||||
command: string,
|
||||
cwd: string,
|
||||
options: {
|
||||
onData: (data: Buffer) => void;
|
||||
signal?: AbortSignal;
|
||||
timeout?: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
},
|
||||
) => Promise<{ exitCode: number | null }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create bash operations using pi's built-in local shell execution backend.
|
||||
*
|
||||
* 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"],
|
||||
});
|
||||
if (child.pid) trackDetachedChildPid(child.pid);
|
||||
let timedOut = false;
|
||||
let timeoutHandle: NodeJS.Timeout | undefined;
|
||||
// Set timeout if provided.
|
||||
if (timeout !== undefined && timeout > 0) {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
timedOut = true;
|
||||
if (child.pid) killProcessTree(child.pid);
|
||||
}, timeout * 1000);
|
||||
}
|
||||
// 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 (signal) {
|
||||
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 (child.pid) untrackDetachedChildPid(child.pid);
|
||||
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) => {
|
||||
if (child.pid) untrackDetachedChildPid(child.pid);
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
if (signal) signal.removeEventListener("abort", onAbort);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface BashSpawnContext {
|
||||
command: string;
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
export type BashSpawnHook = (context: BashSpawnContext) => BashSpawnContext;
|
||||
|
||||
function resolveSpawnContext(command: string, cwd: string, spawnHook?: BashSpawnHook): BashSpawnContext {
|
||||
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 (for example shell setup commands) */
|
||||
commandPrefix?: string;
|
||||
/** Hook to adjust command, cwd, or env before execution */
|
||||
spawnHook?: BashSpawnHook;
|
||||
}
|
||||
|
||||
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,
|
||||
async execute(
|
||||
_toolCallId,
|
||||
{ command, timeout }: { command: string; timeout?: number },
|
||||
signal?: AbortSignal,
|
||||
onUpdate?,
|
||||
_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) => {
|
||||
let tempFilePath: string | undefined;
|
||||
let tempFileStream: ReturnType<typeof createWriteStream> | undefined;
|
||||
let totalBytes = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
let chunksBytes = 0;
|
||||
const maxChunksBytes = DEFAULT_MAX_BYTES * 2;
|
||||
|
||||
const ensureTempFile = () => {
|
||||
if (tempFilePath) return;
|
||||
tempFilePath = getTempFilePath();
|
||||
tempFileStream = createWriteStream(tempFilePath);
|
||||
for (const chunk of chunks) tempFileStream.write(chunk);
|
||||
};
|
||||
|
||||
const handleData = (data: Buffer) => {
|
||||
totalBytes += data.length;
|
||||
// Start writing to a temp file once output exceeds the in-memory threshold.
|
||||
if (totalBytes > DEFAULT_MAX_BYTES) {
|
||||
ensureTempFile();
|
||||
}
|
||||
// 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 the rolling buffer grows too large.
|
||||
while (chunksBytes > maxChunksBytes && chunks.length > 1) {
|
||||
const removed = chunks.shift()!;
|
||||
chunksBytes -= removed.length;
|
||||
}
|
||||
// Stream partial output using the rolling tail buffer.
|
||||
if (onUpdate) {
|
||||
const fullBuffer = Buffer.concat(chunks);
|
||||
const fullText = fullBuffer.toString("utf-8");
|
||||
const truncation = truncateTail(fullText);
|
||||
if (truncation.truncated) {
|
||||
ensureTempFile();
|
||||
}
|
||||
onUpdate({
|
||||
content: [{ type: "text", text: truncation.content || "" }],
|
||||
details: {
|
||||
truncation: truncation.truncated ? truncation : undefined,
|
||||
fullOutputPath: tempFilePath,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
ops.exec(spawnContext.command, spawnContext.cwd, {
|
||||
onData: handleData,
|
||||
signal,
|
||||
timeout,
|
||||
env: spawnContext.env,
|
||||
})
|
||||
.then(({ exitCode }) => {
|
||||
// Combine the rolling buffer chunks.
|
||||
const fullBuffer = Buffer.concat(chunks);
|
||||
const fullOutput = fullBuffer.toString("utf-8");
|
||||
// Apply tail truncation for the final display payload.
|
||||
const truncation = truncateTail(fullOutput);
|
||||
if (truncation.truncated) {
|
||||
ensureTempFile();
|
||||
}
|
||||
// Close temp file stream before building the final result.
|
||||
if (tempFileStream) tempFileStream.end();
|
||||
let outputText = truncation.content || "(no output)";
|
||||
let details: BashToolDetails | undefined;
|
||||
if (truncation.truncated) {
|
||||
// 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: 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") {
|
||||
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${tempFilePath}]`;
|
||||
} else {
|
||||
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));
|
||||
} else {
|
||||
resolve({ content: [{ type: "text", text: outputText }], details });
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
// 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";
|
||||
reject(new Error(output));
|
||||
} else if (err.message.startsWith("timeout:")) {
|
||||
const timeoutSecs = err.message.split(":")[1];
|
||||
if (output) output += "\n\n";
|
||||
output += `Command timed out after ${timeoutSecs} seconds`;
|
||||
reject(new Error(output));
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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());
|
||||
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* Shared diff computation utilities for the edit tool.
|
||||
* Used by both edit.ts (for execution) and tool-execution.ts (for preview rendering).
|
||||
*/
|
||||
|
||||
import * as Diff from "diff";
|
||||
import { constants } from "fs";
|
||||
import { access, readFile } from "fs/promises";
|
||||
import { resolveToCwd } from "./path-utils.js";
|
||||
|
||||
export function detectLineEnding(content: string): "\r\n" | "\n" {
|
||||
const crlfIdx = content.indexOf("\r\n");
|
||||
const lfIdx = content.indexOf("\n");
|
||||
if (lfIdx === -1) return "\n";
|
||||
if (crlfIdx === -1) return "\n";
|
||||
return crlfIdx < lfIdx ? "\r\n" : "\n";
|
||||
}
|
||||
|
||||
export function normalizeToLF(text: string): string {
|
||||
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
}
|
||||
|
||||
export function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string {
|
||||
return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize text for fuzzy matching. Applies progressive transformations:
|
||||
* - Strip trailing whitespace from each line
|
||||
* - Normalize smart quotes to ASCII equivalents
|
||||
* - Normalize Unicode dashes/hyphens to ASCII hyphen
|
||||
* - Normalize special Unicode spaces to regular space
|
||||
*/
|
||||
export function normalizeForFuzzyMatch(text: string): string {
|
||||
return (
|
||||
text
|
||||
.normalize("NFKC")
|
||||
// Strip trailing whitespace per line
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
.join("\n")
|
||||
// Smart single quotes → '
|
||||
.replace(/[\u2018\u2019\u201A\u201B]/g, "'")
|
||||
// Smart double quotes → "
|
||||
.replace(/[\u201C\u201D\u201E\u201F]/g, '"')
|
||||
// Various dashes/hyphens → -
|
||||
// U+2010 hyphen, U+2011 non-breaking hyphen, U+2012 figure dash,
|
||||
// U+2013 en-dash, U+2014 em-dash, U+2015 horizontal bar, U+2212 minus
|
||||
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-")
|
||||
// Special spaces → regular space
|
||||
// U+00A0 NBSP, U+2002-U+200A various spaces, U+202F narrow NBSP,
|
||||
// U+205F medium math space, U+3000 ideographic space
|
||||
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ")
|
||||
);
|
||||
}
|
||||
|
||||
export interface FuzzyMatchResult {
|
||||
/** Whether a match was found */
|
||||
found: boolean;
|
||||
/** The index where the match starts (in the content that should be used for replacement) */
|
||||
index: number;
|
||||
/** Length of the matched text */
|
||||
matchLength: number;
|
||||
/** Whether fuzzy matching was used (false = exact match) */
|
||||
usedFuzzyMatch: boolean;
|
||||
/**
|
||||
* The content to use for replacement operations.
|
||||
* When exact match: original content. When fuzzy match: normalized content.
|
||||
*/
|
||||
contentForReplacement: string;
|
||||
}
|
||||
|
||||
export interface Edit {
|
||||
oldText: string;
|
||||
newText: string;
|
||||
}
|
||||
|
||||
interface MatchedEdit {
|
||||
editIndex: number;
|
||||
matchIndex: number;
|
||||
matchLength: number;
|
||||
newText: string;
|
||||
}
|
||||
|
||||
export interface AppliedEditsResult {
|
||||
baseContent: string;
|
||||
newContent: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find oldText in content, trying exact match first, then fuzzy match.
|
||||
* When fuzzy matching is used, the returned contentForReplacement is the
|
||||
* fuzzy-normalized version of the content (trailing whitespace stripped,
|
||||
* Unicode quotes/dashes normalized to ASCII).
|
||||
*/
|
||||
export function fuzzyFindText(content: string, oldText: string): FuzzyMatchResult {
|
||||
// Try exact match first
|
||||
const exactIndex = content.indexOf(oldText);
|
||||
if (exactIndex !== -1) {
|
||||
return {
|
||||
found: true,
|
||||
index: exactIndex,
|
||||
matchLength: oldText.length,
|
||||
usedFuzzyMatch: false,
|
||||
contentForReplacement: content,
|
||||
};
|
||||
}
|
||||
|
||||
// Try fuzzy match - work entirely in normalized space
|
||||
const fuzzyContent = normalizeForFuzzyMatch(content);
|
||||
const fuzzyOldText = normalizeForFuzzyMatch(oldText);
|
||||
const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText);
|
||||
|
||||
if (fuzzyIndex === -1) {
|
||||
return {
|
||||
found: false,
|
||||
index: -1,
|
||||
matchLength: 0,
|
||||
usedFuzzyMatch: false,
|
||||
contentForReplacement: content,
|
||||
};
|
||||
}
|
||||
|
||||
// When fuzzy matching, we work in the normalized space for replacement.
|
||||
// This means the output will have normalized whitespace/quotes/dashes,
|
||||
// which is acceptable since we're fixing minor formatting differences anyway.
|
||||
return {
|
||||
found: true,
|
||||
index: fuzzyIndex,
|
||||
matchLength: fuzzyOldText.length,
|
||||
usedFuzzyMatch: true,
|
||||
contentForReplacement: fuzzyContent,
|
||||
};
|
||||
}
|
||||
|
||||
/** Strip UTF-8 BOM if present, return both the BOM (if any) and the text without it */
|
||||
export function stripBom(content: string): { bom: string; text: string } {
|
||||
return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content };
|
||||
}
|
||||
|
||||
function countOccurrences(content: string, oldText: string): number {
|
||||
const fuzzyContent = normalizeForFuzzyMatch(content);
|
||||
const fuzzyOldText = normalizeForFuzzyMatch(oldText);
|
||||
return fuzzyContent.split(fuzzyOldText).length - 1;
|
||||
}
|
||||
|
||||
function getNotFoundError(path: string, editIndex: number, totalEdits: number): Error {
|
||||
if (totalEdits === 1) {
|
||||
return new Error(
|
||||
`Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`,
|
||||
);
|
||||
}
|
||||
return new Error(
|
||||
`Could not find edits[${editIndex}] in ${path}. The oldText must match exactly including all whitespace and newlines.`,
|
||||
);
|
||||
}
|
||||
|
||||
function getDuplicateError(path: string, editIndex: number, totalEdits: number, occurrences: number): Error {
|
||||
if (totalEdits === 1) {
|
||||
return new Error(
|
||||
`Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
|
||||
);
|
||||
}
|
||||
return new Error(
|
||||
`Found ${occurrences} occurrences of edits[${editIndex}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`,
|
||||
);
|
||||
}
|
||||
|
||||
function getEmptyOldTextError(path: string, editIndex: number, totalEdits: number): Error {
|
||||
if (totalEdits === 1) {
|
||||
return new Error(`oldText must not be empty in ${path}.`);
|
||||
}
|
||||
return new Error(`edits[${editIndex}].oldText must not be empty in ${path}.`);
|
||||
}
|
||||
|
||||
function getNoChangeError(path: string, totalEdits: number): Error {
|
||||
if (totalEdits === 1) {
|
||||
return new Error(
|
||||
`No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`,
|
||||
);
|
||||
}
|
||||
return new Error(`No changes made to ${path}. The replacements produced identical content.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one or more exact-text replacements to LF-normalized content.
|
||||
*
|
||||
* All edits are matched against the same original content. Replacements are
|
||||
* then applied in reverse order so offsets remain stable. If any edit needs
|
||||
* fuzzy matching, the operation runs in fuzzy-normalized content space to
|
||||
* preserve current single-edit behavior.
|
||||
*/
|
||||
export function applyEditsToNormalizedContent(
|
||||
normalizedContent: string,
|
||||
edits: Edit[],
|
||||
path: string,
|
||||
): AppliedEditsResult {
|
||||
const normalizedEdits = edits.map((edit) => ({
|
||||
oldText: normalizeToLF(edit.oldText),
|
||||
newText: normalizeToLF(edit.newText),
|
||||
}));
|
||||
|
||||
for (let i = 0; i < normalizedEdits.length; i++) {
|
||||
if (normalizedEdits[i].oldText.length === 0) {
|
||||
throw getEmptyOldTextError(path, i, normalizedEdits.length);
|
||||
}
|
||||
}
|
||||
|
||||
const initialMatches = normalizedEdits.map((edit) => fuzzyFindText(normalizedContent, edit.oldText));
|
||||
const baseContent = initialMatches.some((match) => match.usedFuzzyMatch)
|
||||
? normalizeForFuzzyMatch(normalizedContent)
|
||||
: normalizedContent;
|
||||
|
||||
const matchedEdits: MatchedEdit[] = [];
|
||||
for (let i = 0; i < normalizedEdits.length; i++) {
|
||||
const edit = normalizedEdits[i];
|
||||
const matchResult = fuzzyFindText(baseContent, edit.oldText);
|
||||
if (!matchResult.found) {
|
||||
throw getNotFoundError(path, i, normalizedEdits.length);
|
||||
}
|
||||
|
||||
const occurrences = countOccurrences(baseContent, edit.oldText);
|
||||
if (occurrences > 1) {
|
||||
throw getDuplicateError(path, i, normalizedEdits.length, occurrences);
|
||||
}
|
||||
|
||||
matchedEdits.push({
|
||||
editIndex: i,
|
||||
matchIndex: matchResult.index,
|
||||
matchLength: matchResult.matchLength,
|
||||
newText: edit.newText,
|
||||
});
|
||||
}
|
||||
|
||||
matchedEdits.sort((a, b) => a.matchIndex - b.matchIndex);
|
||||
for (let i = 1; i < matchedEdits.length; i++) {
|
||||
const previous = matchedEdits[i - 1];
|
||||
const current = matchedEdits[i];
|
||||
if (previous.matchIndex + previous.matchLength > current.matchIndex) {
|
||||
throw new Error(
|
||||
`edits[${previous.editIndex}] and edits[${current.editIndex}] overlap in ${path}. Merge them into one edit or target disjoint regions.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let newContent = baseContent;
|
||||
for (let i = matchedEdits.length - 1; i >= 0; i--) {
|
||||
const edit = matchedEdits[i];
|
||||
newContent =
|
||||
newContent.substring(0, edit.matchIndex) +
|
||||
edit.newText +
|
||||
newContent.substring(edit.matchIndex + edit.matchLength);
|
||||
}
|
||||
|
||||
if (baseContent === newContent) {
|
||||
throw getNoChangeError(path, normalizedEdits.length);
|
||||
}
|
||||
|
||||
return { baseContent, newContent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unified diff string with line numbers and context.
|
||||
* Returns both the diff string and the first changed line number (in the new file).
|
||||
*/
|
||||
export function generateDiffString(
|
||||
oldContent: string,
|
||||
newContent: string,
|
||||
contextLines = 4,
|
||||
): { diff: string; firstChangedLine: number | undefined } {
|
||||
const parts = Diff.diffLines(oldContent, newContent);
|
||||
const output: string[] = [];
|
||||
|
||||
const oldLines = oldContent.split("\n");
|
||||
const newLines = newContent.split("\n");
|
||||
const maxLineNum = Math.max(oldLines.length, newLines.length);
|
||||
const lineNumWidth = String(maxLineNum).length;
|
||||
|
||||
let oldLineNum = 1;
|
||||
let newLineNum = 1;
|
||||
let lastWasChange = false;
|
||||
let firstChangedLine: number | undefined;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const raw = part.value.split("\n");
|
||||
if (raw[raw.length - 1] === "") {
|
||||
raw.pop();
|
||||
}
|
||||
|
||||
if (part.added || part.removed) {
|
||||
// Capture the first changed line (in the new file)
|
||||
if (firstChangedLine === undefined) {
|
||||
firstChangedLine = newLineNum;
|
||||
}
|
||||
|
||||
// Show the change
|
||||
for (const line of raw) {
|
||||
if (part.added) {
|
||||
const lineNum = String(newLineNum).padStart(lineNumWidth, " ");
|
||||
output.push(`+${lineNum} ${line}`);
|
||||
newLineNum++;
|
||||
} else {
|
||||
// removed
|
||||
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
|
||||
output.push(`-${lineNum} ${line}`);
|
||||
oldLineNum++;
|
||||
}
|
||||
}
|
||||
lastWasChange = true;
|
||||
} else {
|
||||
// Context lines - only show a few before/after changes
|
||||
const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
|
||||
const hasLeadingChange = lastWasChange;
|
||||
const hasTrailingChange = nextPartIsChange;
|
||||
|
||||
if (hasLeadingChange && hasTrailingChange) {
|
||||
if (raw.length <= contextLines * 2) {
|
||||
for (const line of raw) {
|
||||
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
|
||||
output.push(` ${lineNum} ${line}`);
|
||||
oldLineNum++;
|
||||
newLineNum++;
|
||||
}
|
||||
} else {
|
||||
const leadingLines = raw.slice(0, contextLines);
|
||||
const trailingLines = raw.slice(raw.length - contextLines);
|
||||
const skippedLines = raw.length - leadingLines.length - trailingLines.length;
|
||||
|
||||
for (const line of leadingLines) {
|
||||
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
|
||||
output.push(` ${lineNum} ${line}`);
|
||||
oldLineNum++;
|
||||
newLineNum++;
|
||||
}
|
||||
|
||||
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
|
||||
oldLineNum += skippedLines;
|
||||
newLineNum += skippedLines;
|
||||
|
||||
for (const line of trailingLines) {
|
||||
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
|
||||
output.push(` ${lineNum} ${line}`);
|
||||
oldLineNum++;
|
||||
newLineNum++;
|
||||
}
|
||||
}
|
||||
} else if (hasLeadingChange) {
|
||||
const shownLines = raw.slice(0, contextLines);
|
||||
const skippedLines = raw.length - shownLines.length;
|
||||
|
||||
for (const line of shownLines) {
|
||||
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
|
||||
output.push(` ${lineNum} ${line}`);
|
||||
oldLineNum++;
|
||||
newLineNum++;
|
||||
}
|
||||
|
||||
if (skippedLines > 0) {
|
||||
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
|
||||
oldLineNum += skippedLines;
|
||||
newLineNum += skippedLines;
|
||||
}
|
||||
} else if (hasTrailingChange) {
|
||||
const skippedLines = Math.max(0, raw.length - contextLines);
|
||||
if (skippedLines > 0) {
|
||||
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
|
||||
oldLineNum += skippedLines;
|
||||
newLineNum += skippedLines;
|
||||
}
|
||||
|
||||
for (const line of raw.slice(skippedLines)) {
|
||||
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
|
||||
output.push(` ${lineNum} ${line}`);
|
||||
oldLineNum++;
|
||||
newLineNum++;
|
||||
}
|
||||
} else {
|
||||
// Skip these context lines entirely
|
||||
oldLineNum += raw.length;
|
||||
newLineNum += raw.length;
|
||||
}
|
||||
|
||||
lastWasChange = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { diff: output.join("\n"), firstChangedLine };
|
||||
}
|
||||
|
||||
export interface EditDiffResult {
|
||||
diff: string;
|
||||
firstChangedLine: number | undefined;
|
||||
}
|
||||
|
||||
export interface EditDiffError {
|
||||
error: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the diff for one or more edit operations without applying them.
|
||||
* Used for preview rendering in the TUI before the tool executes.
|
||||
*/
|
||||
export async function computeEditsDiff(
|
||||
path: string,
|
||||
edits: Edit[],
|
||||
cwd: string,
|
||||
): Promise<EditDiffResult | EditDiffError> {
|
||||
const absolutePath = resolveToCwd(path, cwd);
|
||||
|
||||
try {
|
||||
// Check if file exists and is readable
|
||||
try {
|
||||
await access(absolutePath, constants.R_OK);
|
||||
} catch {
|
||||
return { error: `File not found: ${path}` };
|
||||
}
|
||||
|
||||
// Read the file
|
||||
const rawContent = await readFile(absolutePath, "utf-8");
|
||||
|
||||
// Strip BOM before matching (LLM won't include invisible BOM in oldText)
|
||||
const { text: content } = stripBom(rawContent);
|
||||
const normalizedContent = normalizeToLF(content);
|
||||
const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path);
|
||||
|
||||
// Generate the diff
|
||||
return generateDiffString(baseContent, newContent);
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the diff for a single edit operation without applying it.
|
||||
* Kept as a convenience wrapper for single-edit callers.
|
||||
*/
|
||||
export async function computeEditDiff(
|
||||
path: string,
|
||||
oldText: string,
|
||||
newText: string,
|
||||
cwd: string,
|
||||
): Promise<EditDiffResult | EditDiffError> {
|
||||
return computeEditsDiff(path, [{ oldText, newText }], cwd);
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { Box, Container, Spacer, 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 {
|
||||
applyEditsToNormalizedContent,
|
||||
computeEditsDiff,
|
||||
detectLineEnding,
|
||||
type Edit,
|
||||
type EditDiffError,
|
||||
type EditDiffResult,
|
||||
generateDiffString,
|
||||
normalizeToLF,
|
||||
restoreLineEndings,
|
||||
stripBom,
|
||||
} 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 EditPreview = EditDiffResult | EditDiffError;
|
||||
|
||||
type EditRenderState = {
|
||||
callComponent?: EditCallRenderComponent;
|
||||
};
|
||||
|
||||
const replaceEditSchema = Type.Object(
|
||||
{
|
||||
oldText: Type.String({
|
||||
description:
|
||||
"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.",
|
||||
}),
|
||||
newText: Type.String({ description: "Replacement text for this targeted edit." }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const editSchema = Type.Object(
|
||||
{
|
||||
path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
|
||||
edits: Type.Array(replaceEditSchema, {
|
||||
description:
|
||||
"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.",
|
||||
}),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export type EditToolInput = Static<typeof editSchema>;
|
||||
type LegacyEditToolInput = EditToolInput & {
|
||||
oldText?: unknown;
|
||||
newText?: unknown;
|
||||
};
|
||||
|
||||
export interface EditToolDetails {
|
||||
/** Unified diff of the changes made */
|
||||
diff: string;
|
||||
/** Line number of the first change in the new file (for editor navigation) */
|
||||
firstChangedLine?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pluggable operations for the edit tool.
|
||||
* Override these to delegate file editing to remote systems (for example SSH).
|
||||
*/
|
||||
export interface EditOperations {
|
||||
/** Read file contents as a Buffer */
|
||||
readFile: (absolutePath: string) => Promise<Buffer>;
|
||||
/** Write content to a file */
|
||||
writeFile: (absolutePath: string, content: string) => Promise<void>;
|
||||
/** Check if file is readable and writable (throw if not) */
|
||||
access: (absolutePath: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const defaultEditOperations: EditOperations = {
|
||||
readFile: (path) => fsReadFile(path),
|
||||
writeFile: (path, content) => fsWriteFile(path, content, "utf-8"),
|
||||
access: (path) => fsAccess(path, constants.R_OK | constants.W_OK),
|
||||
};
|
||||
|
||||
export interface EditToolOptions {
|
||||
/** Custom operations for file editing. Default: local filesystem */
|
||||
operations?: EditOperations;
|
||||
}
|
||||
|
||||
function prepareEditArguments(input: unknown): EditToolInput {
|
||||
if (!input || typeof input !== "object") {
|
||||
return input as EditToolInput;
|
||||
}
|
||||
|
||||
const args = input as LegacyEditToolInput;
|
||||
if (typeof args.oldText !== "string" || typeof args.newText !== "string") {
|
||||
return input as EditToolInput;
|
||||
}
|
||||
|
||||
const edits = Array.isArray(args.edits) ? [...args.edits] : [];
|
||||
edits.push({ oldText: args.oldText, newText: args.newText });
|
||||
const { oldText: _oldText, newText: _newText, ...rest } = args;
|
||||
return { ...rest, edits } as EditToolInput;
|
||||
}
|
||||
|
||||
function validateEditInput(input: EditToolInput): { path: string; edits: Edit[] } {
|
||||
if (!Array.isArray(input.edits) || input.edits.length === 0) {
|
||||
throw new Error("Edit tool input is invalid. edits must contain at least one replacement.");
|
||||
}
|
||||
return { path: input.path, edits: input.edits };
|
||||
}
|
||||
|
||||
type RenderableEditArgs = {
|
||||
path?: string;
|
||||
file_path?: string;
|
||||
edits?: Edit[];
|
||||
oldText?: string;
|
||||
newText?: string;
|
||||
};
|
||||
|
||||
type EditToolResultLike = {
|
||||
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
|
||||
details?: EditToolDetails;
|
||||
};
|
||||
|
||||
type EditCallRenderComponent = Box & {
|
||||
preview?: EditPreview;
|
||||
previewArgsKey?: string;
|
||||
previewPending?: boolean;
|
||||
settledError?: boolean;
|
||||
};
|
||||
|
||||
function createEditCallRenderComponent(): EditCallRenderComponent {
|
||||
return Object.assign(new Box(1, 1, (text: string) => text), {
|
||||
preview: undefined as EditPreview | undefined,
|
||||
previewArgsKey: undefined as string | undefined,
|
||||
previewPending: false,
|
||||
settledError: false,
|
||||
});
|
||||
}
|
||||
|
||||
function getEditCallRenderComponent(state: EditRenderState, lastComponent: unknown): EditCallRenderComponent {
|
||||
if (lastComponent instanceof Box) {
|
||||
const component = lastComponent as EditCallRenderComponent;
|
||||
state.callComponent = component;
|
||||
return component;
|
||||
}
|
||||
if (state.callComponent) {
|
||||
return state.callComponent;
|
||||
}
|
||||
const component = createEditCallRenderComponent();
|
||||
state.callComponent = component;
|
||||
return component;
|
||||
}
|
||||
|
||||
function getRenderablePreviewInput(args: RenderableEditArgs | undefined): { path: string; edits: Edit[] } | null {
|
||||
if (!args) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const path = typeof args.path === "string" ? args.path : typeof args.file_path === "string" ? args.file_path : null;
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
Array.isArray(args.edits) &&
|
||||
args.edits.length > 0 &&
|
||||
args.edits.every((edit) => typeof edit?.oldText === "string" && typeof edit?.newText === "string")
|
||||
) {
|
||||
return { path, edits: args.edits };
|
||||
}
|
||||
|
||||
if (typeof args.oldText === "string" && typeof args.newText === "string") {
|
||||
return { path, edits: [{ oldText: args.oldText, newText: args.newText }] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatEditCall(
|
||||
args: RenderableEditArgs | undefined,
|
||||
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", "...");
|
||||
return `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`;
|
||||
}
|
||||
|
||||
function formatEditResult(
|
||||
args: RenderableEditArgs | undefined,
|
||||
preview: EditPreview | undefined,
|
||||
result: EditToolResultLike,
|
||||
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
|
||||
isError: boolean,
|
||||
): string | undefined {
|
||||
const rawPath = str(args?.file_path ?? args?.path);
|
||||
const previewDiff = preview && !("error" in preview) ? preview.diff : undefined;
|
||||
const previewError = preview && "error" in preview ? preview.error : undefined;
|
||||
if (isError) {
|
||||
const errorText = result.content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => c.text || "")
|
||||
.join("\n");
|
||||
if (!errorText || errorText === previewError) {
|
||||
return undefined;
|
||||
}
|
||||
return theme.fg("error", errorText);
|
||||
}
|
||||
|
||||
const resultDiff = result.details?.diff;
|
||||
if (resultDiff && resultDiff !== previewDiff) {
|
||||
return renderDiff(resultDiff, { filePath: rawPath ?? undefined });
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getEditHeaderBg(
|
||||
preview: EditPreview | undefined,
|
||||
settledError: boolean | undefined,
|
||||
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
|
||||
): (text: string) => string {
|
||||
if (preview) {
|
||||
if ("error" in preview) {
|
||||
return (text: string) => theme.bg("toolErrorBg", text);
|
||||
}
|
||||
return (text: string) => theme.bg("toolSuccessBg", text);
|
||||
}
|
||||
if (settledError) {
|
||||
return (text: string) => theme.bg("toolErrorBg", text);
|
||||
}
|
||||
return (text: string) => theme.bg("toolPendingBg", text);
|
||||
}
|
||||
|
||||
function buildEditCallComponent(
|
||||
component: EditCallRenderComponent,
|
||||
args: RenderableEditArgs | undefined,
|
||||
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
|
||||
): EditCallRenderComponent {
|
||||
component.setBgFn(getEditHeaderBg(component.preview, component.settledError, theme));
|
||||
component.clear();
|
||||
component.addChild(new Text(formatEditCall(args, theme), 0, 0));
|
||||
|
||||
if (!component.preview) {
|
||||
return component;
|
||||
}
|
||||
|
||||
const body =
|
||||
"error" in component.preview ? theme.fg("error", component.preview.error) : renderDiff(component.preview.diff);
|
||||
component.addChild(new Spacer(1));
|
||||
component.addChild(new Text(body, 0, 0));
|
||||
return component;
|
||||
}
|
||||
|
||||
function setEditPreview(
|
||||
component: EditCallRenderComponent,
|
||||
preview: EditPreview,
|
||||
argsKey: string | undefined,
|
||||
): boolean {
|
||||
const current = component.preview;
|
||||
const changed =
|
||||
current === undefined ||
|
||||
("error" in current && "error" in preview
|
||||
? current.error !== preview.error
|
||||
: "error" in current !== "error" in preview) ||
|
||||
(!("error" in current) &&
|
||||
!("error" in preview) &&
|
||||
(current.diff !== preview.diff || current.firstChangedLine !== preview.firstChangedLine));
|
||||
component.preview = preview;
|
||||
component.previewArgsKey = argsKey;
|
||||
component.previewPending = false;
|
||||
return changed;
|
||||
}
|
||||
|
||||
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 single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.",
|
||||
promptSnippet:
|
||||
"Make precise file edits with exact text replacement, including multiple disjoint edits in one call",
|
||||
promptGuidelines: [
|
||||
"Use edit for precise changes (edits[].oldText must match exactly)",
|
||||
"When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls",
|
||||
"Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.",
|
||||
"Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.",
|
||||
],
|
||||
parameters: editSchema,
|
||||
renderShell: "self",
|
||||
prepareArguments: prepareEditArguments,
|
||||
async execute(_toolCallId, input: EditToolInput, signal?: AbortSignal, _onUpdate?, _ctx?) {
|
||||
const { path, edits } = validateEditInput(input);
|
||||
const absolutePath = resolveToCwd(path, cwd);
|
||||
|
||||
return withFileMutationQueue(
|
||||
absolutePath,
|
||||
() =>
|
||||
new Promise<{
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
details: EditToolDetails | 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 edit operation.
|
||||
void (async () => {
|
||||
try {
|
||||
// Check if file exists.
|
||||
try {
|
||||
await ops.access(absolutePath);
|
||||
} catch {
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
reject(new Error(`File not found: ${path}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if aborted before reading.
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the file.
|
||||
const buffer = await ops.readFile(absolutePath);
|
||||
const rawContent = buffer.toString("utf-8");
|
||||
|
||||
// Check if aborted after reading.
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip BOM before matching. The model will not include an invisible BOM in oldText.
|
||||
const { bom, text: content } = stripBom(rawContent);
|
||||
const originalEnding = detectLineEnding(content);
|
||||
const normalizedContent = normalizeToLF(content);
|
||||
const { baseContent, newContent } = applyEditsToNormalizedContent(
|
||||
normalizedContent,
|
||||
edits,
|
||||
path,
|
||||
);
|
||||
|
||||
// Check if aborted before writing.
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
|
||||
await ops.writeFile(absolutePath, finalContent);
|
||||
|
||||
// Check if aborted after writing.
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up abort handler.
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
const diffResult = generateDiffString(baseContent, newContent);
|
||||
resolve({
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Successfully replaced ${edits.length} block(s) in ${path}.`,
|
||||
},
|
||||
],
|
||||
details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine },
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
// Clean up abort handler.
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
if (!aborted) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
})();
|
||||
}),
|
||||
);
|
||||
},
|
||||
renderCall(args, theme, context) {
|
||||
const component = getEditCallRenderComponent(context.state, context.lastComponent);
|
||||
const previewInput = getRenderablePreviewInput(args as RenderableEditArgs | undefined);
|
||||
const argsKey = previewInput
|
||||
? JSON.stringify({ path: previewInput.path, edits: previewInput.edits })
|
||||
: undefined;
|
||||
|
||||
if (component.previewArgsKey !== argsKey) {
|
||||
component.preview = undefined;
|
||||
component.previewArgsKey = argsKey;
|
||||
component.previewPending = false;
|
||||
component.settledError = false;
|
||||
}
|
||||
|
||||
if (context.argsComplete && previewInput && !component.preview && !component.previewPending) {
|
||||
component.previewPending = true;
|
||||
const requestKey = argsKey;
|
||||
void computeEditsDiff(previewInput.path, previewInput.edits, context.cwd).then((preview) => {
|
||||
if (component.previewArgsKey === requestKey) {
|
||||
setEditPreview(component, preview, requestKey);
|
||||
context.invalidate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return buildEditCallComponent(component, args, theme);
|
||||
},
|
||||
renderResult(result, _options, theme, context) {
|
||||
const callComponent = context.state.callComponent;
|
||||
const previewInput = getRenderablePreviewInput(context.args as RenderableEditArgs | undefined);
|
||||
const argsKey = previewInput
|
||||
? JSON.stringify({ path: previewInput.path, edits: previewInput.edits })
|
||||
: undefined;
|
||||
const typedResult = result as EditToolResultLike;
|
||||
const resultDiff = !context.isError ? typedResult.details?.diff : undefined;
|
||||
let changed = false;
|
||||
if (callComponent) {
|
||||
if (typeof resultDiff === "string") {
|
||||
changed =
|
||||
setEditPreview(
|
||||
callComponent,
|
||||
{ diff: resultDiff, firstChangedLine: typedResult.details?.firstChangedLine },
|
||||
argsKey,
|
||||
) || changed;
|
||||
}
|
||||
if (callComponent.settledError !== context.isError) {
|
||||
callComponent.settledError = context.isError;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
context.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
const output = formatEditResult(context.args, callComponent?.preview, typedResult, theme, context.isError);
|
||||
const component = (context.lastComponent as Container | undefined) ?? new Container();
|
||||
component.clear();
|
||||
if (!output) {
|
||||
return component;
|
||||
}
|
||||
component.addChild(new Spacer(1));
|
||||
component.addChild(new Text(output, 1, 0));
|
||||
return component;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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());
|
||||
@@ -0,0 +1,39 @@
|
||||
import { realpathSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const fileMutationQueues = new Map<string, Promise<void>>();
|
||||
|
||||
function getMutationQueueKey(filePath: string): string {
|
||||
const resolvedPath = resolve(filePath);
|
||||
try {
|
||||
return realpathSync.native(resolvedPath);
|
||||
} catch {
|
||||
return resolvedPath;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize file mutation operations targeting the same file.
|
||||
* Operations for different files still run in parallel.
|
||||
*/
|
||||
export async function withFileMutationQueue<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
|
||||
const key = getMutationQueueKey(filePath);
|
||||
const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve();
|
||||
|
||||
let releaseNext!: () => void;
|
||||
const nextQueue = new Promise<void>((resolveQueue) => {
|
||||
releaseNext = resolveQueue;
|
||||
});
|
||||
const chainedQueue = currentQueue.then(() => nextQueue);
|
||||
fileMutationQueues.set(key, chainedQueue);
|
||||
|
||||
await currentQueue;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
releaseNext();
|
||||
if (fileMutationQueues.get(key) === chainedQueue) {
|
||||
fileMutationQueues.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
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 { existsSync } 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, type TruncationResult, truncateHead } from "./truncate.js";
|
||||
|
||||
function toPosixPath(value: string): string {
|
||||
return value.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
const findSchema = Type.Object({
|
||||
pattern: Type.String({
|
||||
description: "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'",
|
||||
}),
|
||||
path: Type.Optional(Type.String({ description: "Directory to search in (default: current directory)" })),
|
||||
limit: Type.Optional(Type.Number({ description: "Maximum number of results (default: 1000)" })),
|
||||
});
|
||||
|
||||
export type FindToolInput = Static<typeof findSchema>;
|
||||
|
||||
const DEFAULT_LIMIT = 1000;
|
||||
|
||||
export interface FindToolDetails {
|
||||
truncation?: TruncationResult;
|
||||
resultLimitReached?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pluggable operations for the find tool.
|
||||
* 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 or absolute paths. */
|
||||
glob: (pattern: string, cwd: string, options: { ignore: string[]; limit: number }) => Promise<string[]> | string[];
|
||||
}
|
||||
|
||||
const defaultFindOperations: FindOperations = {
|
||||
exists: existsSync,
|
||||
// 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 plus fd */
|
||||
operations?: FindOperations;
|
||||
}
|
||||
|
||||
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,
|
||||
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"));
|
||||
return;
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
let stopChild: (() => void) | undefined;
|
||||
const settle = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
stopChild = undefined;
|
||||
fn();
|
||||
};
|
||||
const onAbort = () => {
|
||||
stopChild?.();
|
||||
settle(() => reject(new Error("Operation aborted")));
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const searchPath = resolveToCwd(searchDir || ".", cwd);
|
||||
const effectiveLimit = limit ?? DEFAULT_LIMIT;
|
||||
const ops = customOps ?? defaultFindOperations;
|
||||
|
||||
// If custom operations provide glob(), use that instead of fd.
|
||||
if (customOps?.glob) {
|
||||
if (!(await ops.exists(searchPath))) {
|
||||
settle(() => reject(new Error(`Path not found: ${searchPath}`)));
|
||||
return;
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
settle(() => reject(new Error("Operation aborted")));
|
||||
return;
|
||||
}
|
||||
const results = await ops.glob(pattern, searchPath, {
|
||||
ignore: ["**/node_modules/**", "**/.git/**"],
|
||||
limit: effectiveLimit,
|
||||
});
|
||||
if (signal?.aborted) {
|
||||
settle(() => reject(new Error("Operation aborted")));
|
||||
return;
|
||||
}
|
||||
if (results.length === 0) {
|
||||
settle(() =>
|
||||
resolve({
|
||||
content: [{ type: "text", text: "No files found matching pattern" }],
|
||||
details: undefined,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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));
|
||||
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(". ")}]`;
|
||||
}
|
||||
settle(() =>
|
||||
resolve({
|
||||
content: [{ type: "text", text: resultOutput }],
|
||||
details: Object.keys(details).length > 0 ? details : undefined,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Default implementation uses fd.
|
||||
const fdPath = await ensureTool("fd", true);
|
||||
if (signal?.aborted) {
|
||||
settle(() => reject(new Error("Operation aborted")));
|
||||
return;
|
||||
}
|
||||
if (!fdPath) {
|
||||
settle(() => reject(new Error("fd is not available and could not be downloaded")));
|
||||
return;
|
||||
}
|
||||
|
||||
// Build fd arguments. --no-require-git makes fd apply hierarchical .gitignore
|
||||
// semantics whether or not the search path is inside a git repository, without
|
||||
// leaking sibling-directory rules the way --ignore-file (a global source) would.
|
||||
const args: string[] = [
|
||||
"--glob",
|
||||
"--color=never",
|
||||
"--hidden",
|
||||
"--no-require-git",
|
||||
"--max-results",
|
||||
String(effectiveLimit),
|
||||
];
|
||||
|
||||
// fd --glob matches against the basename unless --full-path is set; in --full-path
|
||||
// mode it matches against the absolute candidate path, so a path-containing
|
||||
// pattern like 'src/**/*.spec.ts' needs a leading '**/' to match anything.
|
||||
let effectivePattern = pattern;
|
||||
if (pattern.includes("/")) {
|
||||
args.push("--full-path");
|
||||
if (!pattern.startsWith("/") && !pattern.startsWith("**/") && pattern !== "**") {
|
||||
effectivePattern = `**/${pattern}`;
|
||||
}
|
||||
}
|
||||
args.push(effectivePattern, searchPath);
|
||||
|
||||
const child = spawn(fdPath, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
const rl = createInterface({ input: child.stdout });
|
||||
let stderr = "";
|
||||
const lines: string[] = [];
|
||||
|
||||
stopChild = () => {
|
||||
if (!child.killed) {
|
||||
child.kill();
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
rl.close();
|
||||
};
|
||||
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
rl.on("line", (line) => {
|
||||
lines.push(line);
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
cleanup();
|
||||
settle(() => reject(new Error(`Failed to run fd: ${error.message}`)));
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
cleanup();
|
||||
if (signal?.aborted) {
|
||||
settle(() => reject(new Error("Operation aborted")));
|
||||
return;
|
||||
}
|
||||
const output = lines.join("\n");
|
||||
if (code !== 0) {
|
||||
const errorMsg = stderr.trim() || `fd exited with code ${code}`;
|
||||
if (!output) {
|
||||
settle(() => reject(new Error(errorMsg)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!output) {
|
||||
settle(() =>
|
||||
resolve({
|
||||
content: [{ type: "text", text: "No files found matching pattern" }],
|
||||
details: undefined,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
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)) {
|
||||
relativePath = line.slice(searchPath.length + 1);
|
||||
} else {
|
||||
relativePath = path.relative(searchPath, line);
|
||||
}
|
||||
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(". ")}]`;
|
||||
}
|
||||
settle(() =>
|
||||
resolve({
|
||||
content: [{ type: "text", text: resultOutput }],
|
||||
details: Object.keys(details).length > 0 ? details : undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
if (signal?.aborted) {
|
||||
settle(() => reject(new Error("Operation aborted")));
|
||||
return;
|
||||
}
|
||||
const error = e instanceof Error ? e : new Error(String(e));
|
||||
settle(() => reject(error));
|
||||
}
|
||||
})();
|
||||
});
|
||||
},
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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());
|
||||
@@ -0,0 +1,388 @@
|
||||
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,
|
||||
GREP_MAX_LINE_LENGTH,
|
||||
type TruncationResult,
|
||||
truncateHead,
|
||||
truncateLine,
|
||||
} from "./truncate.js";
|
||||
|
||||
const grepSchema = Type.Object({
|
||||
pattern: Type.String({ description: "Search pattern (regex or literal string)" }),
|
||||
path: Type.Optional(Type.String({ description: "Directory or file to search (default: current directory)" })),
|
||||
glob: Type.Optional(Type.String({ description: "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'" })),
|
||||
ignoreCase: Type.Optional(Type.Boolean({ description: "Case-insensitive search (default: false)" })),
|
||||
literal: Type.Optional(
|
||||
Type.Boolean({ description: "Treat pattern as literal string instead of regex (default: false)" }),
|
||||
),
|
||||
context: Type.Optional(
|
||||
Type.Number({ description: "Number of lines to show before and after each match (default: 0)" }),
|
||||
),
|
||||
limit: Type.Optional(Type.Number({ description: "Maximum number of matches to return (default: 100)" })),
|
||||
});
|
||||
|
||||
export type GrepToolInput = Static<typeof grepSchema>;
|
||||
const DEFAULT_LIMIT = 100;
|
||||
|
||||
export interface GrepToolDetails {
|
||||
truncation?: TruncationResult;
|
||||
matchLimitReached?: number;
|
||||
linesTruncated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pluggable operations for the grep tool.
|
||||
* Override these to delegate search to remote systems (for example SSH).
|
||||
*/
|
||||
export interface GrepOperations {
|
||||
/** 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;
|
||||
}
|
||||
|
||||
const defaultGrepOperations: GrepOperations = {
|
||||
isDirectory: (p) => statSync(p).isDirectory(),
|
||||
readFile: (p) => readFileSync(p, "utf-8"),
|
||||
};
|
||||
|
||||
export interface GrepToolOptions {
|
||||
/** Custom operations for grep. Default: local filesystem plus ripgrep */
|
||||
operations?: GrepOperations;
|
||||
}
|
||||
|
||||
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,
|
||||
async execute(
|
||||
_toolCallId,
|
||||
{
|
||||
pattern,
|
||||
path: searchDir,
|
||||
glob,
|
||||
ignoreCase,
|
||||
literal,
|
||||
context,
|
||||
limit,
|
||||
}: {
|
||||
pattern: string;
|
||||
path?: string;
|
||||
glob?: string;
|
||||
ignoreCase?: boolean;
|
||||
literal?: boolean;
|
||||
context?: number;
|
||||
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) {
|
||||
settled = true;
|
||||
fn();
|
||||
}
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const rgPath = await ensureTool("rg", true);
|
||||
if (!rgPath) {
|
||||
settle(() => reject(new Error("ripgrep (rg) is not available and could not be downloaded")));
|
||||
return;
|
||||
}
|
||||
|
||||
const searchPath = resolveToCwd(searchDir || ".", cwd);
|
||||
const ops = customOps ?? defaultGrepOperations;
|
||||
let isDirectory: boolean;
|
||||
try {
|
||||
isDirectory = await ops.isDirectory(searchPath);
|
||||
} 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);
|
||||
if (relative && !relative.startsWith("..")) {
|
||||
return relative.replace(/\\/g, "/");
|
||||
}
|
||||
}
|
||||
return path.basename(filePath);
|
||||
};
|
||||
|
||||
const fileCache = new Map<string, string[]>();
|
||||
const getFileLines = async (filePath: string): Promise<string[]> => {
|
||||
let lines = fileCache.get(filePath);
|
||||
if (!lines) {
|
||||
try {
|
||||
const content = await ops.readFile(filePath);
|
||||
lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
} catch {
|
||||
lines = [];
|
||||
}
|
||||
fileCache.set(filePath, lines);
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
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);
|
||||
args.push(pattern, searchPath);
|
||||
|
||||
const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
const rl = createInterface({ input: child.stdout });
|
||||
let stderr = "";
|
||||
let matchCount = 0;
|
||||
let matchLimitReached = false;
|
||||
let linesTruncated = false;
|
||||
let aborted = false;
|
||||
let killedDueToLimit = false;
|
||||
const outputLines: string[] = [];
|
||||
|
||||
const cleanup = () => {
|
||||
rl.close();
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
};
|
||||
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();
|
||||
});
|
||||
|
||||
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)`];
|
||||
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 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}`);
|
||||
}
|
||||
return block;
|
||||
};
|
||||
|
||||
// Collect matches during streaming, then format them after rg exits.
|
||||
const matches: Array<{ filePath: string; lineNumber: number; lineText?: string }> = [];
|
||||
rl.on("line", (line) => {
|
||||
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;
|
||||
const lineText = event.data?.lines?.text;
|
||||
if (filePath && typeof lineNumber === "number")
|
||||
matches.push({ filePath, lineNumber, lineText });
|
||||
if (matchCount >= effectiveLimit) {
|
||||
matchLimitReached = true;
|
||||
stopChild(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
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 }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Format matches after streaming finishes so custom readFile() backends can be async.
|
||||
for (const match of matches) {
|
||||
if (contextValue === 0 && match.lineText !== undefined) {
|
||||
const relativePath = formatPath(match.filePath);
|
||||
const sanitized = match.lineText
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "")
|
||||
.replace(/\n$/, "");
|
||||
const { text: truncatedText, wasTruncated } = truncateLine(sanitized);
|
||||
if (wasTruncated) linesTruncated = true;
|
||||
outputLines.push(`${relativePath}:${match.lineNumber}: ${truncatedText}`);
|
||||
} else {
|
||||
const block = await formatBlock(match.filePath, match.lineNumber);
|
||||
outputLines.push(...block);
|
||||
}
|
||||
}
|
||||
|
||||
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 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(". ")}]`;
|
||||
settle(() =>
|
||||
resolve({
|
||||
content: [{ type: "text", text: output }],
|
||||
details: Object.keys(details).length > 0 ? details : undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
settle(() => reject(err as Error));
|
||||
}
|
||||
})();
|
||||
});
|
||||
},
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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());
|
||||
@@ -0,0 +1,193 @@
|
||||
export {
|
||||
type BashOperations,
|
||||
type BashSpawnContext,
|
||||
type BashSpawnHook,
|
||||
type BashToolDetails,
|
||||
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,
|
||||
DEFAULT_MAX_LINES,
|
||||
formatSize,
|
||||
type TruncationOptions,
|
||||
type TruncationResult,
|
||||
truncateHead,
|
||||
truncateLine,
|
||||
truncateTail,
|
||||
} 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 { 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";
|
||||
|
||||
export type Tool = AgentTool<any>;
|
||||
export type ToolDef = ToolDefinition<any, any>;
|
||||
|
||||
export const codingTools: Tool[] = [readTool, bashTool, editTool, writeTool];
|
||||
export const readOnlyTools: Tool[] = [readTool, grepTool, findTool, lsTool];
|
||||
|
||||
export const allTools = {
|
||||
read: readTool,
|
||||
bash: bashTool,
|
||||
edit: editTool,
|
||||
write: writeTool,
|
||||
grep: grepTool,
|
||||
find: findTool,
|
||||
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 {
|
||||
read?: ReadToolOptions;
|
||||
bash?: BashToolOptions;
|
||||
}
|
||||
|
||||
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),
|
||||
createBashTool(cwd, options?.bash),
|
||||
createEditTool(cwd),
|
||||
createWriteTool(cwd),
|
||||
];
|
||||
}
|
||||
|
||||
export function createReadOnlyTools(cwd: string, options?: ToolsOptions): Tool[] {
|
||||
return [createReadTool(cwd, options?.read), createGrepTool(cwd), createFindTool(cwd), createLsTool(cwd)];
|
||||
}
|
||||
|
||||
export function createAllTools(cwd: string, options?: ToolsOptions): Record<ToolName, Tool> {
|
||||
return {
|
||||
read: createReadTool(cwd, options?.read),
|
||||
bash: createBashTool(cwd, options?.bash),
|
||||
edit: createEditTool(cwd),
|
||||
write: createWriteTool(cwd),
|
||||
grep: createGrepTool(cwd),
|
||||
find: createFindTool(cwd),
|
||||
ls: createLsTool(cwd),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
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({
|
||||
path: Type.Optional(Type.String({ description: "Directory to list (default: current directory)" })),
|
||||
limit: Type.Optional(Type.Number({ description: "Maximum number of entries to return (default: 500)" })),
|
||||
});
|
||||
|
||||
export type LsToolInput = Static<typeof lsSchema>;
|
||||
|
||||
const DEFAULT_LIMIT = 500;
|
||||
|
||||
export interface LsToolDetails {
|
||||
truncation?: TruncationResult;
|
||||
entryLimitReached?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pluggable operations for the ls tool.
|
||||
* 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 or directory stats. Throws if not found. */
|
||||
stat: (absolutePath: string) => Promise<{ isDirectory: () => boolean }> | { isDirectory: () => boolean };
|
||||
/** Read directory entries */
|
||||
readdir: (absolutePath: string) => Promise<string[]> | string[];
|
||||
}
|
||||
|
||||
const defaultLsOperations: LsOperations = {
|
||||
exists: existsSync,
|
||||
stat: statSync,
|
||||
readdir: readdirSync,
|
||||
};
|
||||
|
||||
export interface LsToolOptions {
|
||||
/** Custom operations for directory listing. Default: local filesystem */
|
||||
operations?: LsOperations;
|
||||
}
|
||||
|
||||
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,
|
||||
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"));
|
||||
return;
|
||||
}
|
||||
|
||||
const onAbort = () => reject(new Error("Operation aborted"));
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const dirPath = resolveToCwd(path || ".", cwd);
|
||||
const effectiveLimit = limit ?? DEFAULT_LIMIT;
|
||||
|
||||
// Check if path exists.
|
||||
if (!(await ops.exists(dirPath))) {
|
||||
reject(new Error(`Path not found: ${dirPath}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// 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.
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await ops.readdir(dirPath);
|
||||
} catch (e: any) {
|
||||
reject(new Error(`Cannot read directory: ${e.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort alphabetically, case-insensitive.
|
||||
entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
|
||||
// Format entries with directory indicators.
|
||||
const results: string[] = [];
|
||||
let entryLimitReached = false;
|
||||
for (const entry of entries) {
|
||||
if (results.length >= effectiveLimit) {
|
||||
entryLimitReached = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const fullPath = nodePath.join(dirPath, entry);
|
||||
let suffix = "";
|
||||
try {
|
||||
const entryStat = await ops.stat(fullPath);
|
||||
if (entryStat.isDirectory()) suffix = "/";
|
||||
} catch {
|
||||
// Skip entries we cannot stat.
|
||||
continue;
|
||||
}
|
||||
results.push(entry + suffix);
|
||||
}
|
||||
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
|
||||
if (results.length === 0) {
|
||||
resolve({ content: [{ type: "text", text: "(empty directory)" }], details: undefined });
|
||||
return;
|
||||
}
|
||||
|
||||
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 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(". ")}]`;
|
||||
}
|
||||
|
||||
resolve({
|
||||
content: [{ type: "text", text: output }],
|
||||
details: Object.keys(details).length > 0 ? details : undefined,
|
||||
});
|
||||
} catch (e: any) {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
reject(e);
|
||||
}
|
||||
})();
|
||||
});
|
||||
},
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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());
|
||||
@@ -0,0 +1,94 @@
|
||||
import { accessSync, constants } from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import { isAbsolute, resolve as resolvePath } from "node:path";
|
||||
|
||||
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
||||
const NARROW_NO_BREAK_SPACE = "\u202F";
|
||||
function normalizeUnicodeSpaces(str: string): string {
|
||||
return str.replace(UNICODE_SPACES, " ");
|
||||
}
|
||||
|
||||
function tryMacOSScreenshotPath(filePath: string): string {
|
||||
return filePath.replace(/ (AM|PM)\./gi, `${NARROW_NO_BREAK_SPACE}$1.`);
|
||||
}
|
||||
|
||||
function tryNFDVariant(filePath: string): string {
|
||||
// macOS stores filenames in NFD (decomposed) form, try converting user input to NFD
|
||||
return filePath.normalize("NFD");
|
||||
}
|
||||
|
||||
function tryCurlyQuoteVariant(filePath: string): string {
|
||||
// macOS uses U+2019 (right single quotation mark) in screenshot names like "Capture d'écran"
|
||||
// Users typically type U+0027 (straight apostrophe)
|
||||
return filePath.replace(/'/g, "\u2019");
|
||||
}
|
||||
|
||||
function fileExists(filePath: string): boolean {
|
||||
try {
|
||||
accessSync(filePath, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAtPrefix(filePath: string): string {
|
||||
return filePath.startsWith("@") ? filePath.slice(1) : filePath;
|
||||
}
|
||||
|
||||
export function expandPath(filePath: string): string {
|
||||
const normalized = normalizeUnicodeSpaces(normalizeAtPrefix(filePath));
|
||||
if (normalized === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (normalized.startsWith("~/")) {
|
||||
return os.homedir() + normalized.slice(1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a path relative to the given cwd.
|
||||
* Handles ~ expansion and absolute paths.
|
||||
*/
|
||||
export function resolveToCwd(filePath: string, cwd: string): string {
|
||||
const expanded = expandPath(filePath);
|
||||
if (isAbsolute(expanded)) {
|
||||
return expanded;
|
||||
}
|
||||
return resolvePath(cwd, expanded);
|
||||
}
|
||||
|
||||
export function resolveReadPath(filePath: string, cwd: string): string {
|
||||
const resolved = resolveToCwd(filePath, cwd);
|
||||
|
||||
if (fileExists(resolved)) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Try macOS AM/PM variant (narrow no-break space before AM/PM)
|
||||
const amPmVariant = tryMacOSScreenshotPath(resolved);
|
||||
if (amPmVariant !== resolved && fileExists(amPmVariant)) {
|
||||
return amPmVariant;
|
||||
}
|
||||
|
||||
// Try NFD variant (macOS stores filenames in NFD form)
|
||||
const nfdVariant = tryNFDVariant(resolved);
|
||||
if (nfdVariant !== resolved && fileExists(nfdVariant)) {
|
||||
return nfdVariant;
|
||||
}
|
||||
|
||||
// Try curly quote variant (macOS uses U+2019 in screenshot names)
|
||||
const curlyVariant = tryCurlyQuoteVariant(resolved);
|
||||
if (curlyVariant !== resolved && fileExists(curlyVariant)) {
|
||||
return curlyVariant;
|
||||
}
|
||||
|
||||
// Try combined NFD + curly quote (for French macOS screenshots like "Capture d'écran")
|
||||
const nfdCurlyVariant = tryCurlyQuoteVariant(nfdVariant);
|
||||
if (nfdCurlyVariant !== resolved && fileExists(nfdCurlyVariant)) {
|
||||
return nfdCurlyVariant;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
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({
|
||||
path: Type.String({ description: "Path to the file to read (relative or absolute)" }),
|
||||
offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })),
|
||||
limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
|
||||
});
|
||||
|
||||
export type ReadToolInput = Static<typeof readSchema>;
|
||||
|
||||
export interface ReadToolDetails {
|
||||
truncation?: TruncationResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pluggable operations for the read tool.
|
||||
* 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 or undefined for non-images */
|
||||
detectImageMimeType?: (absolutePath: string) => Promise<string | null | undefined>;
|
||||
}
|
||||
|
||||
const defaultReadOperations: ReadOperations = {
|
||||
readFile: (path) => fsReadFile(path),
|
||||
access: (path) => fsAccess(path, constants.R_OK),
|
||||
detectImageMimeType: detectSupportedImageMimeTypeFromFile,
|
||||
};
|
||||
|
||||
export interface ReadToolOptions {
|
||||
/** Whether to auto-resize images to 2000x2000 max. Default: true */
|
||||
autoResizeImages?: boolean;
|
||||
/** Custom operations for file reading. Default: local filesystem */
|
||||
operations?: ReadOperations;
|
||||
}
|
||||
|
||||
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,
|
||||
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) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Operation aborted"));
|
||||
return;
|
||||
}
|
||||
let aborted = false;
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
reject(new Error("Operation aborted"));
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
// Check if file exists and is readable.
|
||||
await ops.access(absolutePath);
|
||||
if (aborted) return;
|
||||
const mimeType = ops.detectImageMimeType ? await ops.detectImageMimeType(absolutePath) : undefined;
|
||||
let content: (TextContent | ImageContent)[];
|
||||
let details: ReadToolDetails | undefined;
|
||||
if (mimeType) {
|
||||
// Read image as binary.
|
||||
const buffer = await ops.readFile(absolutePath);
|
||||
const base64 = buffer.toString("base64");
|
||||
if (autoResizeImages) {
|
||||
// Resize image if needed before sending it back to the model.
|
||||
const resized = await resizeImage({ type: "image", data: base64, mimeType });
|
||||
if (!resized) {
|
||||
content = [
|
||||
{
|
||||
type: "text",
|
||||
text: `Read image file [${mimeType}]\n[Image omitted: could not be resized below the inline image size limit.]`,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
const dimensionNote = formatDimensionNote(resized);
|
||||
let textNote = `Read image file [${resized.mimeType}]`;
|
||||
if (dimensionNote) textNote += `\n${dimensionNote}`;
|
||||
content = [
|
||||
{ type: "text", text: textNote },
|
||||
{ type: "image", data: resized.data, mimeType: resized.mimeType },
|
||||
];
|
||||
}
|
||||
} else {
|
||||
content = [
|
||||
{ type: "text", text: `Read image file [${mimeType}]` },
|
||||
{ type: "image", data: base64, mimeType },
|
||||
];
|
||||
}
|
||||
} else {
|
||||
// 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. Convert from 1-indexed input to 0-indexed array access.
|
||||
const startLine = offset ? Math.max(0, offset - 1) : 0;
|
||||
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)`);
|
||||
}
|
||||
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");
|
||||
userLimitedLines = endLine - startLine;
|
||||
} else {
|
||||
selectedContent = allLines.slice(startLine).join("\n");
|
||||
}
|
||||
// Apply truncation, respecting both line and byte limits.
|
||||
const truncation = truncateHead(selectedContent);
|
||||
let outputText: string;
|
||||
if (truncation.firstLineExceedsLimit) {
|
||||
// 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 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 {
|
||||
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`;
|
||||
}
|
||||
details = { truncation };
|
||||
} else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
|
||||
// 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}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
|
||||
} else {
|
||||
// No truncation and no remaining user-limited content.
|
||||
outputText = truncation.content;
|
||||
}
|
||||
content = [{ type: "text", text: outputText }];
|
||||
}
|
||||
|
||||
if (aborted) return;
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve({ content, details });
|
||||
} catch (error: any) {
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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());
|
||||
@@ -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]");
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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,
|
||||
prepareArguments: definition.prepareArguments,
|
||||
executionMode: definition.executionMode,
|
||||
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,
|
||||
prepareArguments: tool.prepareArguments,
|
||||
executionMode: tool.executionMode,
|
||||
execute: async (toolCallId, params, signal, onUpdate) => tool.execute(toolCallId, params, signal, onUpdate),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Shared truncation utilities for tool outputs.
|
||||
*
|
||||
* Truncation is based on two independent limits - whichever is hit first wins:
|
||||
* - Line limit (default: 2000 lines)
|
||||
* - Byte limit (default: 50KB)
|
||||
*
|
||||
* Never returns partial lines (except bash tail truncation edge case).
|
||||
*/
|
||||
|
||||
export const DEFAULT_MAX_LINES = 2000;
|
||||
export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB
|
||||
export const GREP_MAX_LINE_LENGTH = 500; // Max chars per grep match line
|
||||
|
||||
export interface TruncationResult {
|
||||
/** The truncated content */
|
||||
content: string;
|
||||
/** Whether truncation occurred */
|
||||
truncated: boolean;
|
||||
/** Which limit was hit: "lines", "bytes", or null if not truncated */
|
||||
truncatedBy: "lines" | "bytes" | null;
|
||||
/** Total number of lines in the original content */
|
||||
totalLines: number;
|
||||
/** Total number of bytes in the original content */
|
||||
totalBytes: number;
|
||||
/** Number of complete lines in the truncated output */
|
||||
outputLines: number;
|
||||
/** Number of bytes in the truncated output */
|
||||
outputBytes: number;
|
||||
/** Whether the last line was partially truncated (only for tail truncation edge case) */
|
||||
lastLinePartial: boolean;
|
||||
/** Whether the first line exceeded the byte limit (for head truncation) */
|
||||
firstLineExceedsLimit: boolean;
|
||||
/** The max lines limit that was applied */
|
||||
maxLines: number;
|
||||
/** The max bytes limit that was applied */
|
||||
maxBytes: number;
|
||||
}
|
||||
|
||||
export interface TruncationOptions {
|
||||
/** Maximum number of lines (default: 2000) */
|
||||
maxLines?: number;
|
||||
/** Maximum number of bytes (default: 50KB) */
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes as human-readable size.
|
||||
*/
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes}B`;
|
||||
} else if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)}KB`;
|
||||
} else {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate content from the head (keep first N lines/bytes).
|
||||
* Suitable for file reads where you want to see the beginning.
|
||||
*
|
||||
* Never returns partial lines. If first line exceeds byte limit,
|
||||
* returns empty content with firstLineExceedsLimit=true.
|
||||
*/
|
||||
export function truncateHead(content: string, options: TruncationOptions = {}): TruncationResult {
|
||||
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
|
||||
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||
|
||||
const totalBytes = Buffer.byteLength(content, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
const totalLines = lines.length;
|
||||
|
||||
// Check if no truncation needed
|
||||
if (totalLines <= maxLines && totalBytes <= maxBytes) {
|
||||
return {
|
||||
content,
|
||||
truncated: false,
|
||||
truncatedBy: null,
|
||||
totalLines,
|
||||
totalBytes,
|
||||
outputLines: totalLines,
|
||||
outputBytes: totalBytes,
|
||||
lastLinePartial: false,
|
||||
firstLineExceedsLimit: false,
|
||||
maxLines,
|
||||
maxBytes,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if first line alone exceeds byte limit
|
||||
const firstLineBytes = Buffer.byteLength(lines[0], "utf-8");
|
||||
if (firstLineBytes > maxBytes) {
|
||||
return {
|
||||
content: "",
|
||||
truncated: true,
|
||||
truncatedBy: "bytes",
|
||||
totalLines,
|
||||
totalBytes,
|
||||
outputLines: 0,
|
||||
outputBytes: 0,
|
||||
lastLinePartial: false,
|
||||
firstLineExceedsLimit: true,
|
||||
maxLines,
|
||||
maxBytes,
|
||||
};
|
||||
}
|
||||
|
||||
// Collect complete lines that fit
|
||||
const outputLinesArr: string[] = [];
|
||||
let outputBytesCount = 0;
|
||||
let truncatedBy: "lines" | "bytes" = "lines";
|
||||
|
||||
for (let i = 0; i < lines.length && i < maxLines; i++) {
|
||||
const line = lines[i];
|
||||
const lineBytes = Buffer.byteLength(line, "utf-8") + (i > 0 ? 1 : 0); // +1 for newline
|
||||
|
||||
if (outputBytesCount + lineBytes > maxBytes) {
|
||||
truncatedBy = "bytes";
|
||||
break;
|
||||
}
|
||||
|
||||
outputLinesArr.push(line);
|
||||
outputBytesCount += lineBytes;
|
||||
}
|
||||
|
||||
// If we exited due to line limit
|
||||
if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
|
||||
truncatedBy = "lines";
|
||||
}
|
||||
|
||||
const outputContent = outputLinesArr.join("\n");
|
||||
const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
|
||||
|
||||
return {
|
||||
content: outputContent,
|
||||
truncated: true,
|
||||
truncatedBy,
|
||||
totalLines,
|
||||
totalBytes,
|
||||
outputLines: outputLinesArr.length,
|
||||
outputBytes: finalOutputBytes,
|
||||
lastLinePartial: false,
|
||||
firstLineExceedsLimit: false,
|
||||
maxLines,
|
||||
maxBytes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate content from the tail (keep last N lines/bytes).
|
||||
* Suitable for bash output where you want to see the end (errors, final results).
|
||||
*
|
||||
* May return partial first line if the last line of original content exceeds byte limit.
|
||||
*/
|
||||
export function truncateTail(content: string, options: TruncationOptions = {}): TruncationResult {
|
||||
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
|
||||
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||
|
||||
const totalBytes = Buffer.byteLength(content, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
const totalLines = lines.length;
|
||||
|
||||
// Check if no truncation needed
|
||||
if (totalLines <= maxLines && totalBytes <= maxBytes) {
|
||||
return {
|
||||
content,
|
||||
truncated: false,
|
||||
truncatedBy: null,
|
||||
totalLines,
|
||||
totalBytes,
|
||||
outputLines: totalLines,
|
||||
outputBytes: totalBytes,
|
||||
lastLinePartial: false,
|
||||
firstLineExceedsLimit: false,
|
||||
maxLines,
|
||||
maxBytes,
|
||||
};
|
||||
}
|
||||
|
||||
// Work backwards from the end
|
||||
const outputLinesArr: string[] = [];
|
||||
let outputBytesCount = 0;
|
||||
let truncatedBy: "lines" | "bytes" = "lines";
|
||||
let lastLinePartial = false;
|
||||
|
||||
for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) {
|
||||
const line = lines[i];
|
||||
const lineBytes = Buffer.byteLength(line, "utf-8") + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline
|
||||
|
||||
if (outputBytesCount + lineBytes > maxBytes) {
|
||||
truncatedBy = "bytes";
|
||||
// Edge case: if we haven't added ANY lines yet and this line exceeds maxBytes,
|
||||
// take the end of the line (partial)
|
||||
if (outputLinesArr.length === 0) {
|
||||
const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes);
|
||||
outputLinesArr.unshift(truncatedLine);
|
||||
outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8");
|
||||
lastLinePartial = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
outputLinesArr.unshift(line);
|
||||
outputBytesCount += lineBytes;
|
||||
}
|
||||
|
||||
// If we exited due to line limit
|
||||
if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
|
||||
truncatedBy = "lines";
|
||||
}
|
||||
|
||||
const outputContent = outputLinesArr.join("\n");
|
||||
const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
|
||||
|
||||
return {
|
||||
content: outputContent,
|
||||
truncated: true,
|
||||
truncatedBy,
|
||||
totalLines,
|
||||
totalBytes,
|
||||
outputLines: outputLinesArr.length,
|
||||
outputBytes: finalOutputBytes,
|
||||
lastLinePartial,
|
||||
firstLineExceedsLimit: false,
|
||||
maxLines,
|
||||
maxBytes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a string to fit within a byte limit (from the end).
|
||||
* Handles multi-byte UTF-8 characters correctly.
|
||||
*/
|
||||
function truncateStringToBytesFromEnd(str: string, maxBytes: number): string {
|
||||
const buf = Buffer.from(str, "utf-8");
|
||||
if (buf.length <= maxBytes) {
|
||||
return str;
|
||||
}
|
||||
|
||||
// Start from the end, skip maxBytes back
|
||||
let start = buf.length - maxBytes;
|
||||
|
||||
// Find a valid UTF-8 boundary (start of a character)
|
||||
while (start < buf.length && (buf[start] & 0xc0) === 0x80) {
|
||||
start++;
|
||||
}
|
||||
|
||||
return buf.slice(start).toString("utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a single line to max characters, adding [truncated] suffix.
|
||||
* Used for grep match lines.
|
||||
*/
|
||||
export function truncateLine(
|
||||
line: string,
|
||||
maxChars: number = GREP_MAX_LINE_LENGTH,
|
||||
): { text: string; wasTruncated: boolean } {
|
||||
if (line.length <= maxChars) {
|
||||
return { text: line, wasTruncated: false };
|
||||
}
|
||||
return { text: `${line.slice(0, maxChars)}... [truncated]`, wasTruncated: true };
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
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)" }),
|
||||
content: Type.String({ description: "Content to write to the file" }),
|
||||
});
|
||||
|
||||
export type WriteToolInput = Static<typeof writeSchema>;
|
||||
|
||||
/**
|
||||
* Pluggable operations for the write tool.
|
||||
* 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 */
|
||||
mkdir: (dir: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const defaultWriteOperations: WriteOperations = {
|
||||
writeFile: (path, content) => fsWriteFile(path, content, "utf-8"),
|
||||
mkdir: (dir) => fsMkdir(dir, { recursive: true }).then(() => {}),
|
||||
};
|
||||
|
||||
export interface WriteToolOptions {
|
||||
/** Custom operations for file writing. Default: local filesystem */
|
||||
operations?: WriteOperations;
|
||||
}
|
||||
|
||||
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,
|
||||
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) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Operation aborted"));
|
||||
return;
|
||||
}
|
||||
let aborted = false;
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
reject(new Error("Operation aborted"));
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
(async () => {
|
||||
try {
|
||||
// Create parent directories if needed.
|
||||
await ops.mkdir(dir);
|
||||
if (aborted) return;
|
||||
// Write the file contents.
|
||||
await ops.writeFile(absolutePath, content);
|
||||
if (aborted) return;
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve({
|
||||
content: [
|
||||
{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` },
|
||||
],
|
||||
details: undefined,
|
||||
});
|
||||
} catch (error: any) {
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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());
|
||||
Reference in new issue
Block a user