@earendil-works/pi-coding-agent
Version:
Coding agent CLI with read, bash, edit, write tools and session management
167 lines • 8.27 kB
JavaScript
import { readdir as fsReaddir, stat as fsStat } from "node:fs/promises";
import { Text } from "@earendil-works/pi-tui";
import nodePath from "path";
import { Type } from "typebox";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
import { pathExists, resolveToCwd } from "./path-utils.js";
import { getTextOutput, renderToolPath, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
import { DEFAULT_MAX_BYTES, formatSize, 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)" })),
});
const DEFAULT_LIMIT = 500;
const defaultLsOperations = {
exists: pathExists,
stat: fsStat,
readdir: fsReaddir,
};
function formatLsCall(args, theme, cwd) {
const limit = args?.limit;
const pathDisplay = renderToolPath(str(args?.path), theme, cwd, { emptyFallback: "." });
let text = `${theme.fg("toolTitle", theme.bold("ls"))} ${pathDisplay}`;
if (limit !== undefined) {
text += theme.fg("toolOutput", ` (limit ${limit})`);
}
return text;
}
function formatLsResult(result, options, theme, showImages) {
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")}${theme.fg("muted", ")")}`;
}
}
const entryLimit = result.details?.entryLimitReached;
const truncation = result.details?.truncation;
if (entryLimit || truncation?.truncated) {
const warnings = [];
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, options) {
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 }, signal, _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;
try {
entries = await ops.readdir(dirPath);
}
catch (e) {
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 = [];
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 = {};
// Build actionable notices for truncation and entry limits.
const notices = [];
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) {
signal?.removeEventListener("abort", onAbort);
reject(e);
}
})();
});
},
renderCall(args, theme, context) {
const text = context.lastComponent ?? new Text("", 0, 0);
text.setText(formatLsCall(args, theme, context.cwd));
return text;
},
renderResult(result, options, theme, context) {
const text = context.lastComponent ?? new Text("", 0, 0);
text.setText(formatLsResult(result, options, theme, context.showImages));
return text;
},
};
}
export function createLsTool(cwd, options) {
return wrapToolDefinition(createLsToolDefinition(cwd, options));
}
//# sourceMappingURL=ls.js.map