167 lines
8.1 KiB
JavaScript
167 lines
8.1 KiB
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
|