From ebd17597f4ebefdeb3fcbd47d741c89e3fea1681 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 4 May 2026 05:15:26 +0200 Subject: [PATCH] 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. --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/src/sdk.ts | 2 +- packages/coding-agent/src/tools/read.ts | 84 ++++--- packages/coding-agent/src/workspace-tree.ts | 208 +++++++++++++----- packages/coding-agent/test/tools.test.ts | 44 ++++ .../coding-agent/test/workspace-tree.test.ts | 35 ++- 6 files changed, 280 insertions(+), 97 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4a139da07..917e7ec7b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 5417c81b1..e1c742875 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -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) diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 0656ccdc5..ea9c5cae6 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -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 { limit: number | undefined, signal?: AbortSignal, ): Promise> { - 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; diff --git a/packages/coding-agent/src/workspace-tree.ts b/packages/coding-agent/src/workspace-tree.ts index 0d2d39c56..95ae4d0ef 100644 --- a/packages/coding-agent/src/workspace-tree.ts +++ b/packages/coding-agent/src/workspace-tree.ts @@ -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 | readonly string[]; + /** Directory names to skip before traversal. */ + excludedDirectoryNames?: ReadonlySet | 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; + excludedNames: ReadonlySet; + 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 { +async function listDirectoryTreeChildren( + rootPath: string, + parent: DirectoryTreeNode, + options: ResolvedDirectoryTreeOptions, +): Promise { 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 => { + result.matches.map(async (match): Promise => { 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 { + 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 { 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); } diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index c05033791..fb28e5f7b 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -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( diff --git a/packages/coding-agent/test/workspace-tree.test.ts b/packages/coding-agent/test/workspace-tree.test.ts index 7734d97b1..645df8145 100644 --- a/packages/coding-agent/test/workspace-tree.test.ts +++ b/packages/coding-agent/test/workspace-tree.test.ts @@ -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"); + }); });