From 45209e233c9e72115ea6f145a3e5a2f2e6613f5e Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 4 May 2026 05:10:43 +0200 Subject: [PATCH] feat(coding-agent): added workspace-tree context APIs for system prompts - Added `WorkspaceTree` and `buildWorkspaceTree` APIs for working-directory tree rendering with limits. - Extended `buildSystemPrompt` and `createAgentSession` to resolve and pass workspace tree context for system prompts. - Updated the system prompt template to include a `` section with truncation notices before append output. - Added workspace-tree and system-prompt tests covering sorting, truncation, exclusions, and prompt ordering. --- packages/coding-agent/CHANGELOG.md | 4 +- .../src/prompts/system/system-prompt.md | 11 + packages/coding-agent/src/sdk.ts | 6 + packages/coding-agent/src/system-prompt.ts | 19 ++ packages/coding-agent/src/workspace-tree.ts | 271 ++++++++++++++++++ .../test/system-prompt-templates.test.ts | 29 ++ .../coding-agent/test/workspace-tree.test.ts | 125 ++++++++ 7 files changed, 464 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/src/workspace-tree.ts create mode 100644 packages/coding-agent/test/workspace-tree.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8e5649ee5..4a139da07 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,17 +1,19 @@ # 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 `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 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 - Changed truncation/pagination hints in read, archive, and SQLite outputs to use colon syntax (`Use :`) when continuing reads diff --git a/packages/coding-agent/src/prompts/system/system-prompt.md b/packages/coding-agent/src/prompts/system/system-prompt.md index 3deb2076c..851ee8fee 100644 --- a/packages/coding-agent/src/prompts/system/system-prompt.md +++ b/packages/coding-agent/src/prompts/system/system-prompt.md @@ -34,6 +34,17 @@ Some directories may have their own rules. Deeper rules override higher ones. {{/if}} +{{#if workspaceTree.rendered}} + +Working directory layout (sorted by mtime, recent first; depth ≤ 3): +{{workspaceTree.rendered}} +{{#if workspaceTree.truncated}} +(some entries elided to keep the tree short — use `find`/`read` to drill in) +{{/if}} + + +{{/if}} + {{#if appendPrompt}} {{appendPrompt}} {{/if}} diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 67784737a..5417c81b1 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -140,6 +140,7 @@ import { wrapToolWithMetaNotice } from "./tools/output-meta"; import { queueResolveHandler } from "./tools/resolve"; import { EventBus } from "./utils/event-bus"; import { buildNamedToolChoice } from "./utils/tool-choice"; +import { buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree"; // Types export interface CreateAgentSessionOptions { @@ -270,6 +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 { // Individual tool classes (for custom usage) @@ -680,6 +682,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // (~200ms on large repos) and only needs `cwd`, so it can overlap with everything that follows. const agentsMdSearchPromise: Promise = logger.time("buildAgentsMdSearch", buildAgentsMdSearch, cwd); agentsMdSearchPromise.catch(() => {}); + const workspaceTreePromise: Promise = logger.time("buildWorkspaceTree", buildWorkspaceTree, cwd); + workspaceTreePromise.catch(() => {}); // Independent discoveries that depend only on cwd/agentDir — kicked off in parallel and awaited // at their respective consumer sites. Their work can overlap with model resolution, secret loading, @@ -1380,6 +1384,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} eagerTasks, secretsEnabled, agentsMdSearch: agentsMdSearchPromise, + workspaceTree: workspaceTreePromise, }); if (options.systemPrompt === undefined) { @@ -1404,6 +1409,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} eagerTasks, secretsEnabled, agentsMdSearch: agentsMdSearchPromise, + workspaceTree: workspaceTreePromise, }); } return options.systemPrompt(defaultPrompt); diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index 55017a68f..42d125df3 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -14,6 +14,7 @@ import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile import { loadSkills, type Skill } from "./extensibility/skills"; import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" }; import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" }; +import { buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree"; interface AlwaysApplyRule { name: string; @@ -409,6 +410,8 @@ export interface BuildSystemPromptOptions { secretsEnabled?: boolean; /** Pre-loaded AGENTS.md search (skips discovery if provided). May be a Promise to allow early kick-off. */ agentsMdSearch?: AgentsMdSearch | Promise; + /** Pre-loaded workspace tree (skips discovery if provided). May be a Promise to allow early kick-off. */ + workspaceTree?: WorkspaceTree | Promise; } /** Build the system prompt with tools, guidelines, and context */ @@ -435,6 +438,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): eagerTasks = false, secretsEnabled = false, agentsMdSearch: providedAgentsMdSearch, + workspaceTree: providedWorkspaceTree, } = options; const resolvedCwd = cwd ?? getProjectDir(); @@ -449,6 +453,10 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): providedAgentsMdSearch !== undefined ? Promise.resolve(providedAgentsMdSearch) : logger.time("buildAgentsMdSearch", buildAgentsMdSearch, resolvedCwd); + const workspaceTreePromise = + providedWorkspaceTree !== undefined + ? Promise.resolve(providedWorkspaceTree) + : logger.time("buildWorkspaceTree", buildWorkspaceTree, resolvedCwd); const skillsPromise: Promise = providedSkills !== undefined ? Promise.resolve(providedSkills) @@ -463,6 +471,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): contextFilesPromise, agentsMdSearchPromise, skillsPromise, + workspaceTreePromise, ]).then( ([ resolvedCustomPrompt, @@ -471,6 +480,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): contextFiles, agentsMdSearch, skills, + workspaceTree, ]) => ({ resolvedCustomPrompt, resolvedAppendPrompt, @@ -478,6 +488,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): contextFiles, agentsMdSearch, skills, + workspaceTree, }), ); })(); @@ -501,6 +512,12 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): pattern: `AGENTS.md depth ${AGENTS_MD_MIN_DEPTH}-${AGENTS_MD_MAX_DEPTH}`, files: [], }; + let workspaceTree: WorkspaceTree = { + rootPath: resolvedCwd, + rendered: "", + truncated: false, + totalLines: 0, + }; let skills: Skill[] = providedSkills ?? []; if (prepResult.type === "timeout") { @@ -524,6 +541,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): contextFiles = dedupeExactContextFiles(prepResult.value.contextFiles); agentsMdSearch = prepResult.value.agentsMdSearch; skills = prepResult.value.skills; + workspaceTree = prepResult.value.workspaceTree; } const date = new Date().toISOString().slice(0, 10); @@ -578,6 +596,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): environment, contextFiles, agentsMdSearch, + workspaceTree, skills: filteredSkills, rules: rules ?? [], alwaysApplyRules: injectedAlwaysApplyRules, diff --git a/packages/coding-agent/src/workspace-tree.ts b/packages/coding-agent/src/workspace-tree.ts new file mode 100644 index 000000000..0d2d39c56 --- /dev/null +++ b/packages/coding-agent/src/workspace-tree.ts @@ -0,0 +1,271 @@ +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 { + rootPath: string; + rendered: string; + truncated: boolean; + totalLines: number; +} + +const WORKSPACE_TREE_MAX_DEPTH = 3; +const WORKSPACE_TREE_DIR_LIMIT = 12; +const WORKSPACE_TREE_LINE_CAP = 120; +const WORKSPACE_TREE_EXCLUDED_DIRS = new Set([ + "node_modules", + ".git", + ".next", + "dist", + "build", + "target", + ".venv", + ".cache", + ".turbo", + ".parcel-cache", + "coverage", +]); + +const GLOB_SPECIAL_CHARS = new Set(["!", "(", ")", "*", "?", "[", "]", "{", "}", "\\"]); + +interface WorkspaceTreeNode { + name: string; + relativePath: string; + depth: number; + isDirectory: boolean; + mtimeMs: number; + size: number; + children: WorkspaceTreeNode[]; + droppedChildCount: number; +} + +interface RenderLine { + label: string; + depth: number; + size?: string; + age?: string; + isRoot?: boolean; +} + +function emptyWorkspaceTree(rootPath: string): WorkspaceTree { + return { + rootPath, + rendered: "", + truncated: false, + totalLines: 0, + }; +} + +function compareByRecency(a: WorkspaceTreeNode, b: WorkspaceTreeNode): number { + const mtimeCompare = b.mtimeMs - a.mtimeMs; + if (mtimeCompare !== 0) return mtimeCompare; + return a.name.localeCompare(b.name); +} + +function childRelativePath(parentRelativePath: string, name: string): string { + return parentRelativePath ? `${parentRelativePath}/${name}` : name; +} + +function escapeGlobSegment(segment: string): string { + return Array.from(segment, char => (GLOB_SPECIAL_CHARS.has(char) ? `\\${char}` : char)).join(""); +} + +function directChildPattern(parentRelativePath: string): string { + if (!parentRelativePath) return "*"; + return `${parentRelativePath.split("/").map(escapeGlobSegment).join("/")}/*`; +} + +function matchChildName(parentRelativePath: string, matchPath: string): string | null { + if (!parentRelativePath) return matchPath.includes("/") ? null : matchPath; + const prefix = `${parentRelativePath}/`; + if (!matchPath.startsWith(prefix)) return null; + const name = matchPath.slice(prefix.length); + return name.includes("/") ? null : name; +} + +async function listWorkspaceTreeChildren(rootPath: string, parent: WorkspaceTreeNode): Promise { + const result = await glob({ + pattern: directChildPattern(parent.relativePath), + path: rootPath, + recursive: false, + hidden: false, + gitignore: true, + cache: true, + }); + + const children = await Promise.all( + 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)); + try { + const stat = await Bun.file(absolutePath).stat(); + const isDirectory = stat.isDirectory(); + if (isDirectory && WORKSPACE_TREE_EXCLUDED_DIRS.has(name)) return null; + return { + name, + relativePath: childRelativePath(parent.relativePath, name), + depth: parent.depth + 1, + isDirectory, + mtimeMs: stat.mtimeMs, + size: stat.size, + children: [], + droppedChildCount: 0, + } satisfies WorkspaceTreeNode; + } catch { + return null; + } + }), + ); + + return children.filter((child): child is WorkspaceTreeNode => child !== null).sort(compareByRecency); +} + +function applyDirectoryLimit(children: WorkspaceTreeNode[]): { + visibleChildren: WorkspaceTreeNode[]; + droppedCount: number; +} { + if (children.length <= WORKSPACE_TREE_DIR_LIMIT) { + return { visibleChildren: children, droppedCount: 0 }; + } + + const recentChildren = children.slice(0, WORKSPACE_TREE_DIR_LIMIT - 1); + const oldestChild = children[children.length - 1]; + return { + visibleChildren: oldestChild ? [...recentChildren, oldestChild] : recentChildren, + droppedCount: children.length - WORKSPACE_TREE_DIR_LIMIT, + }; +} + +async function collectWorkspaceTree(rootPath: string): Promise<{ root: WorkspaceTreeNode; truncated: boolean }> { + const rootStat = await Bun.file(rootPath).stat(); + const root: WorkspaceTreeNode = { + name: ".", + relativePath: "", + depth: 0, + isDirectory: true, + mtimeMs: rootStat.mtimeMs, + size: rootStat.size, + children: [], + droppedChildCount: 0, + }; + + let truncated = false; + const queue: WorkspaceTreeNode[] = [root]; + let cursor = 0; + + while (cursor < queue.length) { + const parent = queue[cursor]; + cursor += 1; + if (!parent || parent.depth >= WORKSPACE_TREE_MAX_DEPTH) continue; + + const children = await listWorkspaceTreeChildren(rootPath, parent); + const limited = applyDirectoryLimit(children); + parent.children = limited.visibleChildren; + parent.droppedChildCount = limited.droppedCount; + if (limited.droppedCount > 0) truncated = true; + + for (const child of parent.children) { + if (child.isDirectory) queue.push(child); + } + } + + return { root, truncated }; +} + +function formatNodeAge(nowMs: number, mtimeMs: number): string { + const ageSeconds = Math.max(0, Math.floor((nowMs - mtimeMs) / 1000)); + return formatAge(ageSeconds); +} + +function pushNodeLine(lines: RenderLine[], node: WorkspaceTreeNode, nowMs: number): void { + if (node.depth === 0) { + lines.push({ label: ".", depth: 0, isRoot: true }); + return; + } + + const indent = " ".repeat(node.depth); + const suffix = node.isDirectory ? "/" : ""; + lines.push({ + label: `${indent}- ${node.name}${suffix}`, + depth: node.depth, + size: node.isDirectory ? undefined : formatBytes(node.size), + age: formatNodeAge(nowMs, node.mtimeMs), + }); +} + +function pushDroppedChildrenLine(lines: RenderLine[], parent: WorkspaceTreeNode): void { + if (parent.droppedChildCount <= 0) return; + const childDepth = parent.depth + 1; + const indent = " ".repeat(childDepth); + lines.push({ + label: `${indent}- … ${parent.droppedChildCount} more`, + depth: childDepth, + }); +} + +function collectRenderLines(node: WorkspaceTreeNode, 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 oldestChild = node.children[node.children.length - 1]; + for (const child of recentChildren) collectRenderLines(child, nowMs, lines); + pushDroppedChildrenLine(lines, node); + if (oldestChild && !recentChildren.includes(oldestChild)) collectRenderLines(oldestChild, nowMs, lines); + return; + } + + 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 }; + + const targetLineCount = WORKSPACE_TREE_LINE_CAP - 1; + const removeCount = lines.length - targetLineCount; + const removable = lines + .map((line, index) => ({ line, index })) + .filter(item => !item.line.isRoot) + .sort((a, b) => b.line.depth - a.line.depth || b.index - a.index) + .slice(0, removeCount); + 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)`, + depth: 0, + }); + + return { lines: cappedLines, elidedCount: removeCount }; +} + +function renderLines(lines: RenderLine[]): string { + const maxLabelLength = lines.reduce((max, line) => Math.max(max, line.label.length), 0); + return lines + .map(line => { + if (!line.age) return line.label; + const sizeColumn = (line.size ?? "").padEnd(8); + return `${line.label.padEnd(maxLabelLength + 2)}${sizeColumn} ${line.age.padEnd(4)}`.trimEnd(); + }) + .join("\n"); +} + +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, + }; + } catch { + return emptyWorkspaceTree(rootPath); + } +} diff --git a/packages/coding-agent/test/system-prompt-templates.test.ts b/packages/coding-agent/test/system-prompt-templates.test.ts index 957a06250..7b16988f8 100644 --- a/packages/coding-agent/test/system-prompt-templates.test.ts +++ b/packages/coding-agent/test/system-prompt-templates.test.ts @@ -203,6 +203,35 @@ describe("system Handlebars prompt templates", () => { expect(rendered).toContain("call `search_tool_bm25` before concluding no such tool exists"); }); + test("buildSystemPrompt renders workspace tree after directory context", async () => { + await withTempDir(async dir => { + const systemPrompt = await buildSystemPrompt({ + cwd: dir, + contextFiles: [], + skills: [], + rules: [], + toolNames: ["read"], + agentsMdSearch: { + scopePath: ".", + limit: 200, + pattern: "AGENTS.md depth 1-4", + files: ["packages/coding-agent/AGENTS.md"], + }, + workspaceTree: { + rootPath: dir, + rendered: ".\n - src/ 1m", + truncated: true, + totalLines: 2, + }, + }); + + expect(systemPrompt).toContain(""); + expect(systemPrompt).toContain("Working directory layout (sorted by mtime, recent first; depth ≤ 3):"); + expect(systemPrompt).toContain("(some entries elided to keep the tree short"); + expect(systemPrompt.indexOf("")).toBeLessThan(systemPrompt.indexOf("")); + }); + }); + test("buildSystemPrompt deduplicates always-apply rules already present in SYSTEM.md", async () => { const duplicateRule = ["Use static imports.", "", "Do not use dynamic loading."].join("\n"); const distinctRule = "Validate inputs at boundaries."; diff --git a/packages/coding-agent/test/workspace-tree.test.ts b/packages/coding-agent/test/workspace-tree.test.ts new file mode 100644 index 000000000..7734d97b1 --- /dev/null +++ b/packages/coding-agent/test/workspace-tree.test.ts @@ -0,0 +1,125 @@ +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"; + +const tempDirs: string[] = []; + +async function makeTempDir(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-workspace-tree-")); + tempDirs.push(dir); + return dir; +} + +async function writeFileWithMtime(filePath: string, content: string, mtimeMs: number): Promise { + await Bun.write(filePath, content); + const mtime = new Date(mtimeMs); + await fs.utimes(filePath, mtime, mtime); +} + +async function touchDirWithMtime(dirPath: string, mtimeMs: number): Promise { + await fs.mkdir(dirPath, { recursive: true }); + const mtime = new Date(mtimeMs); + await fs.utimes(dirPath, mtime, mtime); +} + +function lineIndex(rendered: string, needle: string): number { + return rendered.split("\n").findIndex(line => line.includes(needle)); +} + +describe("buildWorkspaceTree", () => { + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true }))); + }); + + it("sorts files and directories together by modification time", async () => { + const cwd = await makeTempDir(); + const base = Date.now() - 60_000; + const recentDir = path.join(cwd, "recent-dir"); + const staleDir = path.join(cwd, "stale-dir"); + await touchDirWithMtime(recentDir, base + 3_000); + await touchDirWithMtime(staleDir, base); + await writeFileWithMtime(path.join(cwd, "newest.txt"), "newest", base + 4_000); + await writeFileWithMtime(path.join(cwd, "old.txt"), "old", base + 2_000); + await touchDirWithMtime(recentDir, base + 3_000); + await touchDirWithMtime(staleDir, base); + + const tree = await buildWorkspaceTree(cwd); + + expect(lineIndex(tree.rendered, "newest.txt")).toBeGreaterThan(-1); + expect(lineIndex(tree.rendered, "recent-dir/")).toBeGreaterThan(lineIndex(tree.rendered, "newest.txt")); + expect(lineIndex(tree.rendered, "old.txt")).toBeGreaterThan(lineIndex(tree.rendered, "recent-dir/")); + expect(lineIndex(tree.rendered, "stale-dir/")).toBeGreaterThan(lineIndex(tree.rendered, "old.txt")); + }); + + it("keeps the newest entries, a truncation marker, and the oldest entry per directory", async () => { + const cwd = await makeTempDir(); + const base = Date.now() - 60_000; + for (let i = 0; i < 13; i += 1) { + const ageRank = String(i).padStart(2, "0"); + await writeFileWithMtime(path.join(cwd, `file-${ageRank}.txt`), ageRank, base + (13 - i) * 1_000); + } + + const tree = await buildWorkspaceTree(cwd); + + expect(tree.truncated).toBe(true); + expect(tree.rendered).toContain("… 1 more"); + expect(tree.rendered).toContain("file-00.txt"); + expect(tree.rendered).toContain("file-10.txt"); + expect(tree.rendered).not.toContain("file-11.txt"); + expect(tree.rendered).toContain("file-12.txt"); + expect(lineIndex(tree.rendered, "… 1 more")).toBeLessThan(lineIndex(tree.rendered, "file-12.txt")); + }); + + it("enforces the depth cap and skips hidden, excluded, and gitignored paths", async () => { + const cwd = await makeTempDir(); + await Bun.write(path.join(cwd, ".gitignore"), "ignored.txt\nignored-dir/\na/b/ignored-nested.txt\n"); + await writeFileWithMtime(path.join(cwd, "kept.txt"), "kept", Date.now() - 1_000); + await writeFileWithMtime(path.join(cwd, "ignored.txt"), "ignored", Date.now() - 1_000); + await touchDirWithMtime(path.join(cwd, "ignored-dir"), Date.now() - 1_000); + await writeFileWithMtime(path.join(cwd, "ignored-dir", "child.txt"), "ignored", Date.now() - 1_000); + await touchDirWithMtime(path.join(cwd, "node_modules"), Date.now() - 1_000); + await writeFileWithMtime(path.join(cwd, "node_modules", "pkg.js"), "ignored", Date.now() - 1_000); + await touchDirWithMtime(path.join(cwd, ".git"), Date.now() - 1_000); + await writeFileWithMtime(path.join(cwd, ".git", "config"), "ignored", Date.now() - 1_000); + await touchDirWithMtime(path.join(cwd, "a", "b", "c", "d"), Date.now() - 1_000); + await writeFileWithMtime(path.join(cwd, "a", "b", "c", "d", "deep.txt"), "deep", Date.now() - 1_000); + await writeFileWithMtime(path.join(cwd, "a", "b", "ignored-nested.txt"), "ignored", Date.now() - 1_000); + + const tree = await buildWorkspaceTree(cwd); + + expect(tree.rendered).toContain("kept.txt"); + expect(tree.rendered).toContain("c/"); + expect(tree.rendered).not.toContain("ignored.txt"); + expect(tree.rendered).not.toContain("ignored-dir"); + expect(tree.rendered).not.toContain("ignored-nested.txt"); + expect(tree.rendered).not.toContain("node_modules"); + expect(tree.rendered).not.toContain(".git"); + expect(tree.rendered).not.toContain("d/"); + expect(tree.rendered).not.toContain("deep.txt"); + }); + + it("caps the rendered tree at the hard line limit", async () => { + const cwd = await makeTempDir(); + const base = Date.now() - 60_000; + for (let dirIndex = 0; dirIndex < 12; dirIndex += 1) { + const dirName = `dir-${String(dirIndex).padStart(2, "0")}`; + const dirPath = path.join(cwd, dirName); + await fs.mkdir(dirPath, { recursive: true }); + for (let fileIndex = 0; fileIndex < 12; fileIndex += 1) { + const fileName = `file-${String(fileIndex).padStart(2, "0")}.txt`; + await writeFileWithMtime(path.join(dirPath, fileName), fileName, base + fileIndex); + } + await touchDirWithMtime(dirPath, base + dirIndex); + } + + const tree = await buildWorkspaceTree(cwd); + const renderedLines = tree.rendered.split("\n"); + + expect(tree.truncated).toBe(true); + expect(tree.totalLines).toBeLessThanOrEqual(120); + expect(renderedLines.length).toBeLessThanOrEqual(120); + expect(tree.rendered).toContain("lines elided beyond depth/cap"); + }); +});