feat(coding-agent): added read tool summarize mode with depth limits
- Added DirectoryTree, DirectoryTreeOptions, and buildDirectoryTree exports for configurable tree rendering. - Changed read tool directory output to use buildDirectoryTree with depth and exclusion limits. - Added read.summarize settings and summary-mode parseable read output behavior when no selector is used. - Added tests for truncated root/child listings and hidden or excluded entry filtering.
This commit is contained in:
@@ -1,18 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
-
|
||||
- Removed the top-level `sel` parameter from the `read` tool schema, requiring callers to migrate to `path`-embedded selectors (for example `path:50-100`, `path:raw`, or `https://...:L1-L40`)
|
||||
|
||||
### Added
|
||||
|
||||
- Added `buildDirectoryTree` and `DirectoryTree` exports to generate configurable directory trees with options for depth, entry limits, hidden-file handling, and truncation caps
|
||||
- Added `buildWorkspaceTree` and `WorkspaceTree` exports so callers can precompute and pass a workspace context to prompt generation
|
||||
- Added `workspaceTree` support to `buildSystemPrompt` options to reuse a prebuilt directory snapshot
|
||||
- Added `read.summarize.enabled`, `read.summarize.minBodyLines`, and `read.summarize.minCommentLines` settings to control whether `read` returns structural summaries and how many multiline body/comment lines are collapsed
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed `read` directory rendering to return a two-level recency-sorted directory tree (including nested folders) instead of a flat alphabetical entry list, while still applying configurable truncation
|
||||
- Changed generated system prompts to include a working-directory tree block after directory context, showing recent files/directories (depth ≤ 3) and truncation notices when entries are elided
|
||||
- Changed `read` summary rendering to merge opening- and closing-brace boundaries around elided sections into a single `..` line (including closers like `};` or `})`), reducing those segments to one concise anchored summary line
|
||||
- Changed default `read` output for parseable code files without an explicit selector to return a structural summary instead of full verbatim lines, while still supporting full output for `:raw` and explicit ranges
|
||||
|
||||
@@ -271,7 +271,7 @@ export type { Skill } from "./extensibility/skills";
|
||||
export type { FileSlashCommand } from "./extensibility/slash-commands";
|
||||
export type { MCPManager, MCPServerConfig, MCPServerConnection, MCPToolsLoadResult } from "./mcp";
|
||||
export type { Tool } from "./tools";
|
||||
export { buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
|
||||
export { buildDirectoryTree, buildWorkspaceTree, type DirectoryTree, type WorkspaceTree } from "./workspace-tree";
|
||||
|
||||
export {
|
||||
// Individual tool classes (for custom usage)
|
||||
|
||||
@@ -14,6 +14,7 @@ import { parseInternalUrl } from "../internal-urls/parse";
|
||||
import type { InternalUrl } from "../internal-urls/types";
|
||||
import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
|
||||
import readDescription from "../prompts/tools/read.md" with { type: "text" };
|
||||
import { buildDirectoryTree, type DirectoryTree } from "../workspace-tree";
|
||||
import type { ToolSession } from "../sdk";
|
||||
import {
|
||||
DEFAULT_MAX_BYTES,
|
||||
@@ -41,7 +42,7 @@ import {
|
||||
import { applyListLimit } from "./list-limit";
|
||||
import { formatFullOutputReference, formatStyledTruncationWarning, type OutputMeta } from "./output-meta";
|
||||
import { expandPath, formatPathRelativeToCwd, resolveReadPath, splitPathAndSel } from "./path-utils";
|
||||
import { formatAge, formatBytes, shortenPath, wrapBrackets } from "./render-utils";
|
||||
import { formatBytes, shortenPath, wrapBrackets } from "./render-utils";
|
||||
import {
|
||||
executeReadQuery,
|
||||
getRowByKey,
|
||||
@@ -69,6 +70,21 @@ const MAX_SUMMARY_LINES = 20_000;
|
||||
// Remote mount path prefix (sshfs mounts) - skip fuzzy matching to avoid hangs
|
||||
const REMOTE_MOUNT_PREFIX = getRemoteDir() + path.sep;
|
||||
|
||||
const READ_DIRECTORY_EXCLUDED_DIRS = new Set([
|
||||
"node_modules",
|
||||
".git",
|
||||
".next",
|
||||
"dist",
|
||||
"build",
|
||||
"target",
|
||||
".venv",
|
||||
".cache",
|
||||
".turbo",
|
||||
".parcel-cache",
|
||||
"coverage",
|
||||
]);
|
||||
|
||||
|
||||
function isRemoteMountPath(absolutePath: string): boolean {
|
||||
return absolutePath.startsWith(REMOTE_MOUNT_PREFIX);
|
||||
}
|
||||
@@ -1483,61 +1499,41 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
limit: number | undefined,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AgentToolResult<ReadToolDetails>> {
|
||||
const DEFAULT_LIMIT = 500;
|
||||
const effectiveLimit = limit ?? DEFAULT_LIMIT;
|
||||
const READ_DIRECTORY_MAX_DEPTH = 2;
|
||||
const READ_DIRECTORY_CHILD_LIMIT = 12;
|
||||
|
||||
let entries: string[];
|
||||
throwIfAborted(signal);
|
||||
let tree: DirectoryTree;
|
||||
try {
|
||||
entries = await fs.readdir(absolutePath);
|
||||
tree = await buildDirectoryTree(absolutePath, {
|
||||
maxDepth: READ_DIRECTORY_MAX_DEPTH,
|
||||
directoryEntryLimit: READ_DIRECTORY_CHILD_LIMIT,
|
||||
rootEntryLimit: null,
|
||||
lineCap: limit ?? null,
|
||||
lineCapProtectedDepth: 1,
|
||||
hidden: true,
|
||||
gitignore: false,
|
||||
cache: true,
|
||||
excludedDirectoryNames: READ_DIRECTORY_EXCLUDED_DIRS,
|
||||
rootLabel: ".",
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new ToolError(`Cannot read directory: ${message}`);
|
||||
}
|
||||
throwIfAborted(signal);
|
||||
|
||||
// Sort alphabetically (case-insensitive)
|
||||
entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
|
||||
const listLimit = applyListLimit(entries, { limit: effectiveLimit });
|
||||
const limitedEntries = listLimit.items;
|
||||
const limitMeta = listLimit.meta;
|
||||
|
||||
// Format entries with directory indicators and ages
|
||||
const results: string[] = [];
|
||||
|
||||
for (const entry of limitedEntries) {
|
||||
throwIfAborted(signal);
|
||||
const fullPath = path.join(absolutePath, entry);
|
||||
let suffix = "";
|
||||
let age = "";
|
||||
|
||||
try {
|
||||
const entryStat = await fs.stat(fullPath);
|
||||
suffix = entryStat.isDirectory() ? "/" : "";
|
||||
const ageSeconds = Math.floor((Date.now() - entryStat.mtimeMs) / 1000);
|
||||
age = formatAge(ageSeconds);
|
||||
} catch {
|
||||
// Skip entries we can't stat
|
||||
continue;
|
||||
}
|
||||
|
||||
const line = age ? `${entry}${suffix} (${age})` : entry + suffix;
|
||||
results.push(line);
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return { content: [{ type: "text", text: "(empty directory)" }], details: {} };
|
||||
}
|
||||
|
||||
const output = results.join("\n");
|
||||
const output = tree.totalLines <= 1 ? "(empty directory)" : tree.rendered;
|
||||
const truncation = truncateHead(output, { maxLines: Number.MAX_SAFE_INTEGER });
|
||||
|
||||
const details: ReadToolDetails = {
|
||||
isDirectory: true,
|
||||
resolvedPath: tree.rootPath,
|
||||
};
|
||||
|
||||
const resultBuilder = toolResult(details)
|
||||
.text(truncation.content)
|
||||
.limits({ resultLimit: limitMeta.resultLimit?.reached });
|
||||
const resultBuilder = toolResult(details).text(truncation.content).sourcePath(tree.rootPath);
|
||||
if (tree.truncated) {
|
||||
resultBuilder.limits({ resultLimit: true });
|
||||
}
|
||||
if (truncation.truncated) {
|
||||
resultBuilder.truncation(truncation, { direction: "head" });
|
||||
details.truncation = truncation;
|
||||
|
||||
@@ -2,13 +2,40 @@ import * as path from "node:path";
|
||||
import { glob } from "@oh-my-pi/pi-natives";
|
||||
import { formatAge, formatBytes } from "@oh-my-pi/pi-utils";
|
||||
|
||||
export interface WorkspaceTree {
|
||||
export interface DirectoryTree {
|
||||
rootPath: string;
|
||||
rendered: string;
|
||||
truncated: boolean;
|
||||
totalLines: number;
|
||||
}
|
||||
|
||||
export interface WorkspaceTree extends DirectoryTree {}
|
||||
|
||||
export interface DirectoryTreeOptions {
|
||||
/** Directory depth below the root to include. Root itself is depth 0. */
|
||||
maxDepth?: number;
|
||||
/** Per-directory child cap. Use null to disable per-directory truncation. */
|
||||
directoryEntryLimit?: number | null;
|
||||
/** Optional root child cap. Defaults to directoryEntryLimit; use null to keep all root children. */
|
||||
rootEntryLimit?: number | null;
|
||||
/** Hard rendered line cap. Use null to disable line-cap pruning. */
|
||||
lineCap?: number | null;
|
||||
/** Depth at or above which line-cap pruning is forbidden. Root is 0, root children are 1. */
|
||||
lineCapProtectedDepth?: number;
|
||||
/** Entry names to skip before stat/render. */
|
||||
excludedNames?: ReadonlySet<string> | readonly string[];
|
||||
/** Directory names to skip before traversal. */
|
||||
excludedDirectoryNames?: ReadonlySet<string> | readonly string[];
|
||||
/** Include hidden files and directories. */
|
||||
hidden?: boolean;
|
||||
/** Respect .gitignore while listing children. */
|
||||
gitignore?: boolean;
|
||||
/** Use native glob shared cache. */
|
||||
cache?: boolean;
|
||||
/** Rendered label for the root line. */
|
||||
rootLabel?: string;
|
||||
}
|
||||
|
||||
const WORKSPACE_TREE_MAX_DEPTH = 3;
|
||||
const WORKSPACE_TREE_DIR_LIMIT = 12;
|
||||
const WORKSPACE_TREE_LINE_CAP = 120;
|
||||
@@ -26,19 +53,35 @@ const WORKSPACE_TREE_EXCLUDED_DIRS = new Set([
|
||||
"coverage",
|
||||
]);
|
||||
|
||||
const DIRECTORY_TREE_EXCLUDED_NAMES = new Set([".DS_Store"]);
|
||||
|
||||
const GLOB_SPECIAL_CHARS = new Set(["!", "(", ")", "*", "?", "[", "]", "{", "}", "\\"]);
|
||||
|
||||
interface WorkspaceTreeNode {
|
||||
interface DirectoryTreeNode {
|
||||
name: string;
|
||||
relativePath: string;
|
||||
depth: number;
|
||||
isDirectory: boolean;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
children: WorkspaceTreeNode[];
|
||||
children: DirectoryTreeNode[];
|
||||
droppedChildCount: number;
|
||||
}
|
||||
|
||||
interface ResolvedDirectoryTreeOptions {
|
||||
maxDepth: number;
|
||||
directoryEntryLimit: number | null;
|
||||
rootEntryLimit: number | null;
|
||||
lineCap: number | null;
|
||||
lineCapProtectedDepth: number;
|
||||
excludedDirectoryNames: ReadonlySet<string>;
|
||||
excludedNames: ReadonlySet<string>;
|
||||
hidden: boolean;
|
||||
gitignore: boolean;
|
||||
cache: boolean;
|
||||
rootLabel: string;
|
||||
}
|
||||
|
||||
interface RenderLine {
|
||||
label: string;
|
||||
depth: number;
|
||||
@@ -56,7 +99,32 @@ function emptyWorkspaceTree(rootPath: string): WorkspaceTree {
|
||||
};
|
||||
}
|
||||
|
||||
function compareByRecency(a: WorkspaceTreeNode, b: WorkspaceTreeNode): number {
|
||||
function resolveDirectoryTreeOptions(options: DirectoryTreeOptions): ResolvedDirectoryTreeOptions {
|
||||
const directoryEntryLimit = options.directoryEntryLimit === undefined ? null : options.directoryEntryLimit;
|
||||
const rootEntryLimit = options.rootEntryLimit === undefined ? directoryEntryLimit : options.rootEntryLimit;
|
||||
const excludedDirectoryNames =
|
||||
options.excludedDirectoryNames instanceof Set
|
||||
? options.excludedDirectoryNames
|
||||
: new Set(options.excludedDirectoryNames ?? []);
|
||||
const providedExcludedNames =
|
||||
options.excludedNames instanceof Set ? options.excludedNames : new Set(options.excludedNames ?? []);
|
||||
const excludedNames = new Set([...DIRECTORY_TREE_EXCLUDED_NAMES, ...providedExcludedNames]);
|
||||
return {
|
||||
maxDepth: options.maxDepth ?? 1,
|
||||
directoryEntryLimit,
|
||||
rootEntryLimit,
|
||||
lineCap: options.lineCap === undefined ? null : options.lineCap,
|
||||
lineCapProtectedDepth: options.lineCapProtectedDepth ?? 0,
|
||||
excludedDirectoryNames,
|
||||
excludedNames,
|
||||
hidden: options.hidden ?? true,
|
||||
gitignore: options.gitignore ?? false,
|
||||
cache: options.cache ?? true,
|
||||
rootLabel: options.rootLabel ?? ".",
|
||||
};
|
||||
}
|
||||
|
||||
function compareByRecency(a: DirectoryTreeNode, b: DirectoryTreeNode): number {
|
||||
const mtimeCompare = b.mtimeMs - a.mtimeMs;
|
||||
if (mtimeCompare !== 0) return mtimeCompare;
|
||||
return a.name.localeCompare(b.name);
|
||||
@@ -83,65 +151,83 @@ function matchChildName(parentRelativePath: string, matchPath: string): string |
|
||||
return name.includes("/") ? null : name;
|
||||
}
|
||||
|
||||
async function listWorkspaceTreeChildren(rootPath: string, parent: WorkspaceTreeNode): Promise<WorkspaceTreeNode[]> {
|
||||
async function listDirectoryTreeChildren(
|
||||
rootPath: string,
|
||||
parent: DirectoryTreeNode,
|
||||
options: ResolvedDirectoryTreeOptions,
|
||||
): Promise<DirectoryTreeNode[]> {
|
||||
const result = await glob({
|
||||
pattern: directChildPattern(parent.relativePath),
|
||||
path: rootPath,
|
||||
recursive: false,
|
||||
hidden: false,
|
||||
gitignore: true,
|
||||
cache: true,
|
||||
hidden: options.hidden,
|
||||
gitignore: options.gitignore,
|
||||
cache: options.cache,
|
||||
});
|
||||
|
||||
const children = await Promise.all(
|
||||
result.matches.map(async (match): Promise<WorkspaceTreeNode | null> => {
|
||||
result.matches.map(async (match): Promise<DirectoryTreeNode | null> => {
|
||||
const name = matchChildName(parent.relativePath, match.path);
|
||||
if (!name) return null;
|
||||
if (name.startsWith(".")) return null;
|
||||
const absolutePath = path.join(rootPath, childRelativePath(parent.relativePath, name));
|
||||
if (options.excludedNames.has(name)) return null;
|
||||
if (!options.hidden && name.startsWith(".")) return null;
|
||||
const relativePath = childRelativePath(parent.relativePath, name);
|
||||
const absolutePath = path.join(rootPath, relativePath);
|
||||
try {
|
||||
const stat = await Bun.file(absolutePath).stat();
|
||||
const isDirectory = stat.isDirectory();
|
||||
if (isDirectory && WORKSPACE_TREE_EXCLUDED_DIRS.has(name)) return null;
|
||||
if (isDirectory && options.excludedDirectoryNames.has(name)) return null;
|
||||
return {
|
||||
name,
|
||||
relativePath: childRelativePath(parent.relativePath, name),
|
||||
relativePath,
|
||||
depth: parent.depth + 1,
|
||||
isDirectory,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
size: stat.size,
|
||||
children: [],
|
||||
droppedChildCount: 0,
|
||||
} satisfies WorkspaceTreeNode;
|
||||
} satisfies DirectoryTreeNode;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return children.filter((child): child is WorkspaceTreeNode => child !== null).sort(compareByRecency);
|
||||
return children.filter((child): child is DirectoryTreeNode => child !== null).sort(compareByRecency);
|
||||
}
|
||||
|
||||
function applyDirectoryLimit(children: WorkspaceTreeNode[]): {
|
||||
visibleChildren: WorkspaceTreeNode[];
|
||||
droppedCount: number;
|
||||
} {
|
||||
if (children.length <= WORKSPACE_TREE_DIR_LIMIT) {
|
||||
function entryLimitForNode(node: DirectoryTreeNode, options: ResolvedDirectoryTreeOptions): number | null {
|
||||
return node.depth === 0 ? options.rootEntryLimit : options.directoryEntryLimit;
|
||||
}
|
||||
|
||||
function applyDirectoryLimit(
|
||||
node: DirectoryTreeNode,
|
||||
children: DirectoryTreeNode[],
|
||||
options: ResolvedDirectoryTreeOptions,
|
||||
): { visibleChildren: DirectoryTreeNode[]; droppedCount: number } {
|
||||
const entryLimit = entryLimitForNode(node, options);
|
||||
if (entryLimit === null || children.length <= entryLimit) {
|
||||
return { visibleChildren: children, droppedCount: 0 };
|
||||
}
|
||||
if (entryLimit <= 1) {
|
||||
return { visibleChildren: children.slice(0, Math.max(0, entryLimit)), droppedCount: children.length - entryLimit };
|
||||
}
|
||||
|
||||
const recentChildren = children.slice(0, WORKSPACE_TREE_DIR_LIMIT - 1);
|
||||
const recentChildren = children.slice(0, entryLimit - 1);
|
||||
const oldestChild = children[children.length - 1];
|
||||
return {
|
||||
visibleChildren: oldestChild ? [...recentChildren, oldestChild] : recentChildren,
|
||||
droppedCount: children.length - WORKSPACE_TREE_DIR_LIMIT,
|
||||
droppedCount: children.length - entryLimit,
|
||||
};
|
||||
}
|
||||
|
||||
async function collectWorkspaceTree(rootPath: string): Promise<{ root: WorkspaceTreeNode; truncated: boolean }> {
|
||||
async function collectDirectoryTree(
|
||||
rootPath: string,
|
||||
options: ResolvedDirectoryTreeOptions,
|
||||
): Promise<{ root: DirectoryTreeNode; truncated: boolean }> {
|
||||
const rootStat = await Bun.file(rootPath).stat();
|
||||
const root: WorkspaceTreeNode = {
|
||||
name: ".",
|
||||
const root: DirectoryTreeNode = {
|
||||
name: options.rootLabel,
|
||||
relativePath: "",
|
||||
depth: 0,
|
||||
isDirectory: true,
|
||||
@@ -152,16 +238,16 @@ async function collectWorkspaceTree(rootPath: string): Promise<{ root: Workspace
|
||||
};
|
||||
|
||||
let truncated = false;
|
||||
const queue: WorkspaceTreeNode[] = [root];
|
||||
const queue: DirectoryTreeNode[] = [root];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < queue.length) {
|
||||
const parent = queue[cursor];
|
||||
cursor += 1;
|
||||
if (!parent || parent.depth >= WORKSPACE_TREE_MAX_DEPTH) continue;
|
||||
if (!parent || parent.depth >= options.maxDepth) continue;
|
||||
|
||||
const children = await listWorkspaceTreeChildren(rootPath, parent);
|
||||
const limited = applyDirectoryLimit(children);
|
||||
const children = await listDirectoryTreeChildren(rootPath, parent, options);
|
||||
const limited = applyDirectoryLimit(parent, children, options);
|
||||
parent.children = limited.visibleChildren;
|
||||
parent.droppedChildCount = limited.droppedCount;
|
||||
if (limited.droppedCount > 0) truncated = true;
|
||||
@@ -179,9 +265,9 @@ function formatNodeAge(nowMs: number, mtimeMs: number): string {
|
||||
return formatAge(ageSeconds);
|
||||
}
|
||||
|
||||
function pushNodeLine(lines: RenderLine[], node: WorkspaceTreeNode, nowMs: number): void {
|
||||
function pushNodeLine(lines: RenderLine[], node: DirectoryTreeNode, nowMs: number): void {
|
||||
if (node.depth === 0) {
|
||||
lines.push({ label: ".", depth: 0, isRoot: true });
|
||||
lines.push({ label: node.name, depth: 0, isRoot: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -195,7 +281,7 @@ function pushNodeLine(lines: RenderLine[], node: WorkspaceTreeNode, nowMs: numbe
|
||||
});
|
||||
}
|
||||
|
||||
function pushDroppedChildrenLine(lines: RenderLine[], parent: WorkspaceTreeNode): void {
|
||||
function pushDroppedChildrenLine(lines: RenderLine[], parent: DirectoryTreeNode): void {
|
||||
if (parent.droppedChildCount <= 0) return;
|
||||
const childDepth = parent.depth + 1;
|
||||
const indent = " ".repeat(childDepth);
|
||||
@@ -205,11 +291,11 @@ function pushDroppedChildrenLine(lines: RenderLine[], parent: WorkspaceTreeNode)
|
||||
});
|
||||
}
|
||||
|
||||
function collectRenderLines(node: WorkspaceTreeNode, nowMs: number, lines: RenderLine[]): void {
|
||||
function collectRenderLines(node: DirectoryTreeNode, nowMs: number, lines: RenderLine[]): void {
|
||||
pushNodeLine(lines, node, nowMs);
|
||||
|
||||
if (node.droppedChildCount > 0) {
|
||||
const recentChildren = node.children.slice(0, WORKSPACE_TREE_DIR_LIMIT - 1);
|
||||
const recentChildren = node.children.slice(0, -1);
|
||||
const oldestChild = node.children[node.children.length - 1];
|
||||
for (const child of recentChildren) collectRenderLines(child, nowMs, lines);
|
||||
pushDroppedChildrenLine(lines, node);
|
||||
@@ -220,24 +306,29 @@ function collectRenderLines(node: WorkspaceTreeNode, nowMs: number, lines: Rende
|
||||
for (const child of node.children) collectRenderLines(child, nowMs, lines);
|
||||
}
|
||||
|
||||
function applyLineCap(lines: RenderLine[]): { lines: RenderLine[]; elidedCount: number } {
|
||||
if (lines.length <= WORKSPACE_TREE_LINE_CAP) return { lines, elidedCount: 0 };
|
||||
function applyLineCap(
|
||||
lines: RenderLine[],
|
||||
options: ResolvedDirectoryTreeOptions,
|
||||
): { lines: RenderLine[]; elidedCount: number } {
|
||||
if (options.lineCap === null || lines.length <= options.lineCap) return { lines, elidedCount: 0 };
|
||||
|
||||
const targetLineCount = WORKSPACE_TREE_LINE_CAP - 1;
|
||||
const targetLineCount = Math.max(1, options.lineCap - 1);
|
||||
const removeCount = lines.length - targetLineCount;
|
||||
const removable = lines
|
||||
.map((line, index) => ({ line, index }))
|
||||
.filter(item => !item.line.isRoot)
|
||||
.filter(item => !item.line.isRoot && item.line.depth > options.lineCapProtectedDepth)
|
||||
.sort((a, b) => b.line.depth - a.line.depth || b.index - a.index)
|
||||
.slice(0, removeCount);
|
||||
if (removable.length === 0) return { lines, elidedCount: 0 };
|
||||
|
||||
const removedIndexes = new Set(removable.map(item => item.index));
|
||||
const cappedLines = lines.filter((_, index) => !removedIndexes.has(index));
|
||||
cappedLines.push({
|
||||
label: `… (${removeCount} lines elided beyond depth/cap)`,
|
||||
label: `… (${removable.length} lines elided beyond depth/cap)`,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
return { lines: cappedLines, elidedCount: removeCount };
|
||||
return { lines: cappedLines, elidedCount: removable.length };
|
||||
}
|
||||
|
||||
function renderLines(lines: RenderLine[]): string {
|
||||
@@ -251,20 +342,35 @@ function renderLines(lines: RenderLine[]): string {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export async function buildDirectoryTree(rootPath: string, options: DirectoryTreeOptions = {}): Promise<DirectoryTree> {
|
||||
const resolvedRootPath = path.resolve(rootPath);
|
||||
const resolvedOptions = resolveDirectoryTreeOptions(options);
|
||||
const nowMs = Date.now();
|
||||
const { root, truncated: directoryTruncated } = await collectDirectoryTree(resolvedRootPath, resolvedOptions);
|
||||
const lines: RenderLine[] = [];
|
||||
collectRenderLines(root, nowMs, lines);
|
||||
const { lines: cappedLines, elidedCount } = applyLineCap(lines, resolvedOptions);
|
||||
return {
|
||||
rootPath: resolvedRootPath,
|
||||
rendered: renderLines(cappedLines),
|
||||
truncated: directoryTruncated || elidedCount > 0,
|
||||
totalLines: cappedLines.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildWorkspaceTree(cwd: string): Promise<WorkspaceTree> {
|
||||
const rootPath = path.resolve(cwd);
|
||||
try {
|
||||
const nowMs = Date.now();
|
||||
const { root, truncated: directoryTruncated } = await collectWorkspaceTree(rootPath);
|
||||
const lines: RenderLine[] = [];
|
||||
collectRenderLines(root, nowMs, lines);
|
||||
const { lines: cappedLines, elidedCount } = applyLineCap(lines);
|
||||
return {
|
||||
rootPath,
|
||||
rendered: renderLines(cappedLines),
|
||||
truncated: directoryTruncated || elidedCount > 0,
|
||||
totalLines: cappedLines.length,
|
||||
};
|
||||
return await buildDirectoryTree(rootPath, {
|
||||
maxDepth: WORKSPACE_TREE_MAX_DEPTH,
|
||||
directoryEntryLimit: WORKSPACE_TREE_DIR_LIMIT,
|
||||
lineCap: WORKSPACE_TREE_LINE_CAP,
|
||||
excludedDirectoryNames: WORKSPACE_TREE_EXCLUDED_DIRS,
|
||||
hidden: false,
|
||||
gitignore: true,
|
||||
cache: true,
|
||||
rootLabel: ".",
|
||||
});
|
||||
} catch {
|
||||
return emptyWorkspaceTree(rootPath);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,14 @@ function getTextOutput(result: any): string {
|
||||
);
|
||||
}
|
||||
|
||||
function writeFileWithMtime(filePath: string, content: string, mtimeMs: number): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content);
|
||||
const mtime = new Date(mtimeMs);
|
||||
fs.utimesSync(filePath, mtime, mtime);
|
||||
}
|
||||
|
||||
|
||||
function createFifoOrSkip(fifoPath: string): boolean {
|
||||
if (process.platform === "win32") {
|
||||
return false;
|
||||
@@ -421,6 +429,42 @@ describe("Coding Agent Tools", () => {
|
||||
expect(result.details?.truncation?.outputLines).toBe(defaultLimit);
|
||||
});
|
||||
|
||||
it("should render directories as a two-level tree without capping root entries", async () => {
|
||||
const childDir = path.join(testDir, "child");
|
||||
const base = Date.now() - 60_000;
|
||||
fs.mkdirSync(childDir, { recursive: true });
|
||||
writeFileWithMtime(path.join(testDir, ".hidden-root"), "hidden", base + 20_000);
|
||||
writeFileWithMtime(path.join(testDir, ".DS_Store"), "mac metadata", base + 25_000);
|
||||
writeFileWithMtime(path.join(testDir, "node_modules", "pkg", "index.js"), "ignored", base + 24_000);
|
||||
for (let i = 0; i < 13; i += 1) {
|
||||
const fileName = `root-${String(i).padStart(2, "0")}.txt`;
|
||||
writeFileWithMtime(path.join(testDir, fileName), fileName, base + i);
|
||||
}
|
||||
for (let i = 0; i < 13; i += 1) {
|
||||
const fileName = `child-${String(i).padStart(2, "0")}.txt`;
|
||||
writeFileWithMtime(path.join(childDir, fileName), fileName, base + i);
|
||||
}
|
||||
writeFileWithMtime(path.join(childDir, "nested", "deep.txt"), "deep", base + 30_000);
|
||||
|
||||
const result = await readTool.execute("test-call-directory-tree", { path: testDir });
|
||||
const output = getTextOutput(result);
|
||||
|
||||
expect(result.details?.isDirectory).toBe(true);
|
||||
expect(output).toContain(".");
|
||||
expect(output).toContain(".hidden-root");
|
||||
expect(output).not.toContain(".DS_Store");
|
||||
expect(output).not.toContain("node_modules");
|
||||
expect(output).toContain("root-00.txt");
|
||||
expect(output).toContain("root-01.txt");
|
||||
expect(output).toContain("root-12.txt");
|
||||
expect(output).toContain("child/");
|
||||
expect(output).toContain("nested/");
|
||||
expect(output).toContain("… 2 more");
|
||||
expect(output).not.toContain("child-01.txt");
|
||||
expect(output).toContain("child-00.txt");
|
||||
expect(output).not.toContain("deep.txt");
|
||||
});
|
||||
|
||||
it("should treat .tar archives like directories", async () => {
|
||||
const archivePath = path.join(testDir, "fixture.tar");
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { buildWorkspaceTree } from "@oh-my-pi/pi-coding-agent/workspace-tree";
|
||||
import { buildDirectoryTree, buildWorkspaceTree } from "@oh-my-pi/pi-coding-agent/workspace-tree";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
@@ -122,4 +122,37 @@ describe("buildWorkspaceTree", () => {
|
||||
expect(renderedLines.length).toBeLessThanOrEqual(120);
|
||||
expect(tree.rendered).toContain("lines elided beyond depth/cap");
|
||||
});
|
||||
|
||||
it("can keep root entries uncapped while truncating child directories", async () => {
|
||||
const cwd = await makeTempDir();
|
||||
const childDir = path.join(cwd, "child");
|
||||
const base = Date.now() - 60_000;
|
||||
await touchDirWithMtime(childDir, base + 30_000);
|
||||
await writeFileWithMtime(path.join(cwd, ".DS_Store"), "mac metadata", base + 40_000);
|
||||
for (let i = 0; i < 13; i += 1) {
|
||||
const fileName = `root-${String(i).padStart(2, "0")}.txt`;
|
||||
await writeFileWithMtime(path.join(cwd, fileName), fileName, base + i);
|
||||
}
|
||||
for (let i = 0; i < 13; i += 1) {
|
||||
const fileName = `child-${String(i).padStart(2, "0")}.txt`;
|
||||
await writeFileWithMtime(path.join(childDir, fileName), fileName, base + i);
|
||||
}
|
||||
|
||||
const tree = await buildDirectoryTree(cwd, {
|
||||
maxDepth: 2,
|
||||
directoryEntryLimit: 12,
|
||||
rootEntryLimit: null,
|
||||
hidden: true,
|
||||
gitignore: false,
|
||||
});
|
||||
|
||||
expect(tree.truncated).toBe(true);
|
||||
expect(tree.rendered).not.toContain(".DS_Store");
|
||||
expect(tree.rendered).toContain("root-11.txt");
|
||||
expect(tree.rendered).toContain("root-12.txt");
|
||||
expect(tree.rendered).toContain("child-11.txt");
|
||||
expect(tree.rendered).not.toContain("child-01.txt");
|
||||
expect(tree.rendered).toContain("child-00.txt");
|
||||
expect(tree.rendered).toContain("… 1 more");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user