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:
can1357
2026-05-04 05:15:26 +02:00
parent 45209e233c
commit ebd17597f4
6 changed files with 280 additions and 97 deletions
+4
View File
@@ -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
+1 -1
View File
@@ -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)
+40 -44
View File
@@ -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;
+157 -51
View File
@@ -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);
}
+44
View File
@@ -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");
});
});