@oh-my-pi/pi-coding-agent 14.9.1 → 14.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,19 @@
1
- import * as fs from "node:fs/promises";
2
1
  import * as path from "node:path";
3
- import { glob } from "@oh-my-pi/pi-natives";
4
- import { $which, formatAge, formatBytes, logger } from "@oh-my-pi/pi-utils";
2
+ import { FileType, type GlobMatch, listWorkspace } from "@oh-my-pi/pi-natives";
3
+ import { formatAge, formatBytes } from "@oh-my-pi/pi-utils";
4
+
5
+ /** Defaults for the workspace tree shown in the system prompt. */
6
+ const WORKSPACE_DEFAULTS = {
7
+ maxDepth: 3,
8
+ perDirLimit: 12,
9
+ lineCap: 120,
10
+ } as const;
11
+
12
+ /**
13
+ * Hard cap on AGENTS.md files surfaced by `buildWorkspaceTree`. Mirrors the
14
+ * native cap so the system-prompt builder does not need a second pass.
15
+ */
16
+ export const AGENTS_MD_LIMIT = 200;
5
17
 
6
18
  export interface DirectoryTree {
7
19
  rootPath: string;
@@ -10,360 +22,250 @@ export interface DirectoryTree {
10
22
  totalLines: number;
11
23
  }
12
24
 
13
- export interface WorkspaceTree extends DirectoryTree {}
25
+ export interface WorkspaceTree extends DirectoryTree {
26
+ /** AGENTS.md files beneath the root whose rules may apply to subdirectories. */
27
+ agentsMdFiles: string[];
28
+ }
14
29
 
15
- export interface DirectoryTreeOptions {
16
- /** Directory depth below the root to include. Root itself is depth 0. */
30
+ export interface BuildDirectoryTreeOptions {
31
+ /** Directory depth below the root to include. Root itself is depth 0. Default: 1. */
17
32
  maxDepth?: number;
18
- /** Per-directory child cap. Use null to disable per-directory truncation. */
19
- directoryEntryLimit?: number | null;
20
- /** Optional root child cap. Defaults to directoryEntryLimit; use null to keep all root children. */
21
- rootEntryLimit?: number | null;
22
- /** Hard rendered line cap. Use null to disable line-cap pruning. */
33
+ /** Per-directory child cap. `null` disables the cap. Default: `null`. */
34
+ perDirLimit?: number | null;
35
+ /** Optional override for the root level. Defaults to `perDirLimit`. */
36
+ rootLimit?: number | null;
37
+ /** Hard rendered line cap. `null` disables. Default: `null`. */
23
38
  lineCap?: number | null;
24
- /** Depth at or above which line-cap pruning is forbidden. Root is 0, root children are 1. */
25
- lineCapProtectedDepth?: number;
26
- /** Entry names to skip before stat/render. */
27
- excludedNames?: ReadonlySet<string> | readonly string[];
28
- /** Directory names to skip before traversal. */
29
- excludedDirectoryNames?: ReadonlySet<string> | readonly string[];
30
- /** Include hidden files and directories. */
31
- hidden?: boolean;
32
- /** Respect .gitignore while listing children. */
33
- gitignore?: boolean;
34
- /** Use native glob shared cache. */
35
- cache?: boolean;
36
- /** Rendered label for the root line. */
37
- rootLabel?: string;
38
- /**
39
- * Pre-built map of `parentRelativePath` → child name set used in place of
40
- * native directory listing. When provided, the tree builder consults this
41
- * map for child enumeration instead of `glob` / `readdir`. Stat calls per
42
- * displayed node are still performed for mtime/size/dir-ness.
43
- */
44
- childIndex?: ReadonlyMap<string, ReadonlySet<string>>;
45
- }
46
-
47
- const WORKSPACE_TREE_MAX_DEPTH = 3;
48
- const WORKSPACE_TREE_DIR_LIMIT = 12;
49
- const WORKSPACE_TREE_LINE_CAP = 120;
50
- const WORKSPACE_TREE_EXCLUDED_DIRS = new Set([
51
- "node_modules",
52
- ".git",
53
- ".next",
54
- "dist",
55
- "build",
56
- "target",
57
- ".venv",
58
- ".cache",
59
- ".turbo",
60
- ".parcel-cache",
61
- "coverage",
62
- ]);
63
-
64
- const DIRECTORY_TREE_EXCLUDED_NAMES = new Set([".DS_Store"]);
65
-
66
- const GLOB_SPECIAL_CHARS = new Set(["!", "(", ")", "*", "?", "[", "]", "{", "}", "\\"]);
67
-
68
- interface DirectoryTreeNode {
69
- name: string;
70
- relativePath: string;
71
- depth: number;
72
- isDirectory: boolean;
73
- mtimeMs: number;
74
- size: number;
75
- children: DirectoryTreeNode[];
76
- droppedChildCount: number;
77
39
  }
78
40
 
79
- interface ResolvedDirectoryTreeOptions {
80
- maxDepth: number;
81
- directoryEntryLimit: number | null;
82
- rootEntryLimit: number | null;
83
- lineCap: number | null;
84
- lineCapProtectedDepth: number;
85
- excludedDirectoryNames: ReadonlySet<string>;
86
- excludedNames: ReadonlySet<string>;
87
- hidden: boolean;
88
- gitignore: boolean;
89
- cache: boolean;
90
- rootLabel: string;
91
- childIndex: ReadonlyMap<string, ReadonlySet<string>> | null;
41
+ export interface BuildWorkspaceTreeOptions {
42
+ /** Abort the native workspace scan after this many milliseconds. */
43
+ timeoutMs?: number;
92
44
  }
93
45
 
94
- interface RenderLine {
95
- label: string;
96
- depth: number;
97
- size?: string;
98
- age?: string;
99
- isRoot?: boolean;
100
- }
46
+ /**
47
+ * Build a generic directory tree using a single native scan. Hidden files are
48
+ * shown, .gitignore is not consulted, and the standard non-source directories
49
+ * (`node_modules`, `.git`, build outputs, caches…) are pruned by the native
50
+ * walker. Used by the read tool's directory-listing path.
51
+ */
52
+ export async function buildDirectoryTree(cwd: string, options: BuildDirectoryTreeOptions = {}): Promise<DirectoryTree> {
53
+ const rootPath = path.resolve(cwd);
54
+ const maxDepth = options.maxDepth ?? 1;
55
+ const perDirLimit = options.perDirLimit === undefined ? null : options.perDirLimit;
56
+ const rootLimit = options.rootLimit === undefined ? perDirLimit : options.rootLimit;
101
57
 
102
- function emptyWorkspaceTree(rootPath: string): WorkspaceTree {
103
- return {
104
- rootPath,
105
- rendered: "",
106
- truncated: false,
107
- totalLines: 0,
108
- };
109
- }
58
+ let entries: readonly GlobMatch[];
59
+ let nativeTruncated: boolean;
60
+ try {
61
+ const result = await listWorkspace({
62
+ path: rootPath,
63
+ maxDepth,
64
+ hidden: true,
65
+ gitignore: false,
66
+ });
67
+ entries = result.entries;
68
+ nativeTruncated = result.truncated;
69
+ } catch {
70
+ return emptyTree(rootPath);
71
+ }
110
72
 
111
- function resolveDirectoryTreeOptions(options: DirectoryTreeOptions): ResolvedDirectoryTreeOptions {
112
- const directoryEntryLimit = options.directoryEntryLimit === undefined ? null : options.directoryEntryLimit;
113
- const rootEntryLimit = options.rootEntryLimit === undefined ? directoryEntryLimit : options.rootEntryLimit;
114
- const excludedDirectoryNames =
115
- options.excludedDirectoryNames instanceof Set
116
- ? options.excludedDirectoryNames
117
- : new Set(options.excludedDirectoryNames ?? []);
118
- const providedExcludedNames =
119
- options.excludedNames instanceof Set ? options.excludedNames : new Set(options.excludedNames ?? []);
120
- const excludedNames = new Set([...DIRECTORY_TREE_EXCLUDED_NAMES, ...providedExcludedNames]);
121
- return {
122
- maxDepth: options.maxDepth ?? 1,
123
- directoryEntryLimit,
124
- rootEntryLimit,
73
+ return assembleTree(rootPath, entries, {
74
+ perDirLimit,
75
+ rootLimit,
125
76
  lineCap: options.lineCap === undefined ? null : options.lineCap,
126
- lineCapProtectedDepth: options.lineCapProtectedDepth ?? 0,
127
- excludedDirectoryNames,
128
- excludedNames,
129
- hidden: options.hidden ?? true,
130
- gitignore: options.gitignore ?? false,
131
- cache: options.cache ?? true,
132
- rootLabel: options.rootLabel ?? ".",
133
- childIndex: options.childIndex ?? null,
134
- };
135
- }
136
-
137
- function compareByRecency(a: DirectoryTreeNode, b: DirectoryTreeNode): number {
138
- const mtimeCompare = b.mtimeMs - a.mtimeMs;
139
- if (mtimeCompare !== 0) return mtimeCompare;
140
- return a.name.localeCompare(b.name);
141
- }
142
-
143
- function childRelativePath(parentRelativePath: string, name: string): string {
144
- return parentRelativePath ? `${parentRelativePath}/${name}` : name;
145
- }
146
-
147
- function escapeGlobSegment(segment: string): string {
148
- return Array.from(segment, char => (GLOB_SPECIAL_CHARS.has(char) ? `\\${char}` : char)).join("");
149
- }
150
-
151
- function directChildPattern(parentRelativePath: string): string {
152
- if (!parentRelativePath) return "*";
153
- return `${parentRelativePath.split("/").map(escapeGlobSegment).join("/")}/*`;
154
- }
155
-
156
- function matchChildName(parentRelativePath: string, matchPath: string): string | null {
157
- if (!parentRelativePath) return matchPath.includes("/") ? null : matchPath;
158
- const prefix = `${parentRelativePath}/`;
159
- if (!matchPath.startsWith(prefix)) return null;
160
- const name = matchPath.slice(prefix.length);
161
- return name.includes("/") ? null : name;
77
+ nativeTruncated,
78
+ });
162
79
  }
163
80
 
164
- async function listDirectChildNames(
165
- rootPath: string,
166
- parent: DirectoryTreeNode,
167
- options: ResolvedDirectoryTreeOptions,
168
- ): Promise<string[]> {
169
- if (options.childIndex) {
170
- const names = options.childIndex.get(parent.relativePath);
171
- return names ? Array.from(names) : [];
172
- }
173
- if (!options.gitignore) {
174
- const directoryPath = parent.relativePath ? path.join(rootPath, parent.relativePath) : rootPath;
175
- return await fs.readdir(directoryPath);
81
+ /**
82
+ * Build the workspace tree shown in the system prompt. Returns the rendered
83
+ * tree plus the AGENTS.md files surfaced by the same native walk so callers
84
+ * never need to do a second filesystem scan.
85
+ */
86
+ export async function buildWorkspaceTree(cwd: string, options: BuildWorkspaceTreeOptions = {}): Promise<WorkspaceTree> {
87
+ const rootPath = path.resolve(cwd);
88
+ try {
89
+ const result = await listWorkspace({
90
+ path: rootPath,
91
+ maxDepth: WORKSPACE_DEFAULTS.maxDepth,
92
+ hidden: false,
93
+ gitignore: true,
94
+ collectAgentsMd: true,
95
+ timeoutMs: options.timeoutMs,
96
+ });
97
+ const tree = assembleTree(rootPath, result.entries, {
98
+ perDirLimit: WORKSPACE_DEFAULTS.perDirLimit,
99
+ rootLimit: WORKSPACE_DEFAULTS.perDirLimit,
100
+ lineCap: WORKSPACE_DEFAULTS.lineCap,
101
+ nativeTruncated: result.truncated,
102
+ });
103
+ return { ...tree, agentsMdFiles: result.agentsMdFiles };
104
+ } catch {
105
+ return { ...emptyTree(rootPath), agentsMdFiles: [] };
176
106
  }
177
-
178
- const result = await glob({
179
- pattern: directChildPattern(parent.relativePath),
180
- path: rootPath,
181
- recursive: false,
182
- hidden: options.hidden,
183
- gitignore: true,
184
- cache: options.cache,
185
- });
186
-
187
- return result.matches
188
- .map(match => matchChildName(parent.relativePath, match.path))
189
- .filter((name): name is string => name !== null);
190
107
  }
191
108
 
192
- async function listDirectoryTreeChildren(
193
- rootPath: string,
194
- parent: DirectoryTreeNode,
195
- options: ResolvedDirectoryTreeOptions,
196
- ): Promise<DirectoryTreeNode[]> {
197
- const childNames = await listDirectChildNames(rootPath, parent, options);
109
+ // ─── internals ──────────────────────────────────────────────────────────────
198
110
 
199
- const children = await Promise.all(
200
- childNames.map(async (name): Promise<DirectoryTreeNode | null> => {
201
- if (options.excludedNames.has(name)) return null;
202
- if (!options.hidden && name.startsWith(".")) return null;
203
- const relativePath = childRelativePath(parent.relativePath, name);
204
- const absolutePath = path.join(rootPath, relativePath);
205
- try {
206
- const stat = await Bun.file(absolutePath).stat();
207
- const isDirectory = stat.isDirectory();
208
- if (isDirectory && options.excludedDirectoryNames.has(name)) return null;
209
- return {
210
- name,
211
- relativePath,
212
- depth: parent.depth + 1,
213
- isDirectory,
214
- mtimeMs: stat.mtimeMs,
215
- size: stat.size,
216
- children: [],
217
- droppedChildCount: 0,
218
- } satisfies DirectoryTreeNode;
219
- } catch {
220
- return null;
221
- }
222
- }),
223
- );
111
+ interface Node {
112
+ name: string;
113
+ isDir: boolean;
114
+ mtimeMs: number;
115
+ size: number;
116
+ depth: number;
117
+ children: Node[];
118
+ /** When > 0, `children` is laid out as `[recent…, oldest]`. */
119
+ droppedCount: number;
120
+ }
224
121
 
225
- return children.filter((child): child is DirectoryTreeNode => child !== null).sort(compareByRecency);
122
+ interface RenderedLine {
123
+ label: string;
124
+ depth: number;
125
+ isRoot: boolean;
126
+ size?: string;
127
+ age?: string;
226
128
  }
227
129
 
228
- function entryLimitForNode(node: DirectoryTreeNode, options: ResolvedDirectoryTreeOptions): number | null {
229
- return node.depth === 0 ? options.rootEntryLimit : options.directoryEntryLimit;
130
+ interface AssembleOptions {
131
+ perDirLimit: number | null;
132
+ rootLimit: number | null;
133
+ lineCap: number | null;
134
+ nativeTruncated: boolean;
230
135
  }
231
136
 
232
- function applyDirectoryLimit(
233
- node: DirectoryTreeNode,
234
- children: DirectoryTreeNode[],
235
- options: ResolvedDirectoryTreeOptions,
236
- ): { visibleChildren: DirectoryTreeNode[]; droppedCount: number } {
237
- const entryLimit = entryLimitForNode(node, options);
238
- if (entryLimit === null || children.length <= entryLimit) {
239
- return { visibleChildren: children, droppedCount: 0 };
240
- }
241
- if (entryLimit <= 1) {
242
- return {
243
- visibleChildren: children.slice(0, Math.max(0, entryLimit)),
244
- droppedCount: children.length - entryLimit,
137
+ function assembleTree(rootPath: string, entries: readonly GlobMatch[], opts: AssembleOptions): DirectoryTree {
138
+ // Bucket entries by parent path. The native walker may yield siblings in
139
+ // any order across worker threads, so we group by string key and sort once
140
+ // per directory below.
141
+ const byParent = new Map<string, Node[]>();
142
+ for (const entry of entries) {
143
+ const slash = entry.path.lastIndexOf("/");
144
+ const name = slash === -1 ? entry.path : entry.path.slice(slash + 1);
145
+ const parentPath = slash === -1 ? "" : entry.path.slice(0, slash);
146
+ const node: Node = {
147
+ name,
148
+ isDir: entry.fileType === FileType.Dir,
149
+ mtimeMs: entry.mtime ?? 0,
150
+ size: entry.size ?? 0,
151
+ depth: parentPath ? parentPath.split("/").length + 1 : 1,
152
+ children: [],
153
+ droppedCount: 0,
245
154
  };
155
+ const bucket = byParent.get(parentPath);
156
+ if (bucket) bucket.push(node);
157
+ else byParent.set(parentPath, [node]);
246
158
  }
247
159
 
248
- const recentChildren = children.slice(0, entryLimit - 1);
249
- const oldestChild = children[children.length - 1];
250
- return {
251
- visibleChildren: oldestChild ? [...recentChildren, oldestChild] : recentChildren,
252
- droppedCount: children.length - entryLimit,
253
- };
254
- }
255
-
256
- async function collectDirectoryTree(
257
- rootPath: string,
258
- options: ResolvedDirectoryTreeOptions,
259
- ): Promise<{ root: DirectoryTreeNode; truncated: boolean }> {
260
- const rootStat = await Bun.file(rootPath).stat();
261
- const root: DirectoryTreeNode = {
262
- name: options.rootLabel,
263
- relativePath: "",
160
+ const root: Node = {
161
+ name: ".",
162
+ isDir: true,
163
+ mtimeMs: 0,
164
+ size: 0,
264
165
  depth: 0,
265
- isDirectory: true,
266
- mtimeMs: rootStat.mtimeMs,
267
- size: rootStat.size,
268
166
  children: [],
269
- droppedChildCount: 0,
167
+ droppedCount: 0,
270
168
  };
271
169
 
272
- let truncated = false;
273
- const queue: DirectoryTreeNode[] = [root];
274
- let cursor = 0;
275
-
276
- while (cursor < queue.length) {
277
- const parent = queue[cursor];
278
- cursor += 1;
279
- if (!parent || parent.depth >= options.maxDepth) continue;
280
-
281
- const children = await listDirectoryTreeChildren(rootPath, parent, options);
282
- const limited = applyDirectoryLimit(parent, children, options);
283
- parent.children = limited.visibleChildren;
284
- parent.droppedChildCount = limited.droppedCount;
285
- if (limited.droppedCount > 0) truncated = true;
286
-
287
- for (const child of parent.children) {
288
- if (child.isDirectory) queue.push(child);
170
+ let truncated = opts.nativeTruncated;
171
+ const stack: Array<{ node: Node; relPath: string }> = [{ node: root, relPath: "" }];
172
+ while (stack.length > 0) {
173
+ const { node, relPath } = stack.pop()!;
174
+ const all = (byParent.get(relPath) ?? []).slice().sort(byRecency);
175
+ const limit = node.depth === 0 ? opts.rootLimit : opts.perDirLimit;
176
+ if (limit !== null && all.length > limit) {
177
+ node.children = limit <= 1 ? all.slice(0, Math.max(0, limit)) : [...all.slice(0, limit - 1), all.at(-1)!];
178
+ node.droppedCount = all.length - limit;
179
+ truncated = true;
180
+ } else {
181
+ node.children = all;
182
+ }
183
+ for (const child of node.children) {
184
+ if (!child.isDir) continue;
185
+ stack.push({ node: child, relPath: relPath ? `${relPath}/${child.name}` : child.name });
289
186
  }
290
187
  }
291
188
 
292
- return { root, truncated };
189
+ const rawLines: RenderedLine[] = [];
190
+ renderNode(root, Date.now(), rawLines);
191
+ const { lines, elidedCount } = applyLineCap(rawLines, opts.lineCap);
192
+
193
+ return {
194
+ rootPath,
195
+ rendered: formatLines(lines),
196
+ truncated: truncated || elidedCount > 0,
197
+ totalLines: lines.length,
198
+ };
293
199
  }
294
200
 
295
- function formatNodeAge(nowMs: number, mtimeMs: number): string {
296
- const ageSeconds = Math.max(0, Math.floor((nowMs - mtimeMs) / 1000));
297
- return formatAge(ageSeconds);
201
+ function byRecency(a: Node, b: Node): number {
202
+ return b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name);
298
203
  }
299
204
 
300
- function pushNodeLine(lines: RenderLine[], node: DirectoryTreeNode, nowMs: number): void {
205
+ function renderNode(node: Node, nowMs: number, out: RenderedLine[]): void {
301
206
  if (node.depth === 0) {
302
- lines.push({ label: node.name, depth: 0, isRoot: true });
303
- return;
207
+ out.push({ label: node.name, depth: 0, isRoot: true });
208
+ } else {
209
+ const indent = " ".repeat(node.depth);
210
+ const suffix = node.isDir ? "/" : "";
211
+ out.push({
212
+ label: `${indent}- ${node.name}${suffix}`,
213
+ depth: node.depth,
214
+ isRoot: false,
215
+ size: node.isDir ? undefined : formatBytes(node.size),
216
+ age: formatAge(Math.max(0, Math.floor((nowMs - node.mtimeMs) / 1000))),
217
+ });
304
218
  }
305
219
 
306
- const indent = " ".repeat(node.depth);
307
- const suffix = node.isDirectory ? "/" : "";
308
- lines.push({
309
- label: `${indent}- ${node.name}${suffix}`,
310
- depth: node.depth,
311
- size: node.isDirectory ? undefined : formatBytes(node.size),
312
- age: formatNodeAge(nowMs, node.mtimeMs),
313
- });
314
- }
315
-
316
- function pushDroppedChildrenLine(lines: RenderLine[], parent: DirectoryTreeNode): void {
317
- if (parent.droppedChildCount <= 0) return;
318
- const childDepth = parent.depth + 1;
319
- const indent = " ".repeat(childDepth);
320
- lines.push({
321
- label: `${indent}- … ${parent.droppedChildCount} more`,
322
- depth: childDepth,
323
- });
324
- }
325
-
326
- function collectRenderLines(node: DirectoryTreeNode, nowMs: number, lines: RenderLine[]): void {
327
- pushNodeLine(lines, node, nowMs);
328
-
329
- if (node.droppedChildCount > 0) {
330
- const recentChildren = node.children.slice(0, -1);
331
- const oldestChild = node.children[node.children.length - 1];
332
- for (const child of recentChildren) collectRenderLines(child, nowMs, lines);
333
- pushDroppedChildrenLine(lines, node);
334
- if (oldestChild && !recentChildren.includes(oldestChild)) collectRenderLines(oldestChild, nowMs, lines);
220
+ if (node.droppedCount === 0) {
221
+ for (const child of node.children) renderNode(child, nowMs, out);
335
222
  return;
336
223
  }
337
224
 
338
- for (const child of node.children) collectRenderLines(child, nowMs, lines);
225
+ // Layout: recent children, then "… N more" marker, then the oldest child.
226
+ const recent = node.children.slice(0, -1);
227
+ const oldest = node.children.at(-1);
228
+ for (const child of recent) renderNode(child, nowMs, out);
229
+ const childDepth = node.depth + 1;
230
+ out.push({
231
+ label: `${" ".repeat(childDepth)}- … ${node.droppedCount} more`,
232
+ depth: childDepth,
233
+ isRoot: false,
234
+ });
235
+ if (oldest) renderNode(oldest, nowMs, out);
339
236
  }
340
237
 
238
+ /**
239
+ * Cap the rendered tree at `lineCap` lines by removing the deepest trailing
240
+ * entries first. Root and root children (depth ≤ 1) are always preserved so
241
+ * the structural overview stays intact.
242
+ */
341
243
  function applyLineCap(
342
- lines: RenderLine[],
343
- options: ResolvedDirectoryTreeOptions,
344
- ): { lines: RenderLine[]; elidedCount: number } {
345
- if (options.lineCap === null || lines.length <= options.lineCap) return { lines, elidedCount: 0 };
244
+ lines: readonly RenderedLine[],
245
+ lineCap: number | null,
246
+ ): { lines: RenderedLine[]; elidedCount: number } {
247
+ if (lineCap === null || lines.length <= lineCap) return { lines: [...lines], elidedCount: 0 };
346
248
 
347
- const targetLineCount = Math.max(1, options.lineCap - 1);
348
- const removeCount = lines.length - targetLineCount;
249
+ const PROTECTED_DEPTH = 1;
250
+ const target = Math.max(1, lineCap - 1);
349
251
  const removable = lines
350
252
  .map((line, index) => ({ line, index }))
351
- .filter(item => !item.line.isRoot && item.line.depth > options.lineCapProtectedDepth)
253
+ .filter(({ line }) => !line.isRoot && line.depth > PROTECTED_DEPTH)
352
254
  .sort((a, b) => b.line.depth - a.line.depth || b.index - a.index)
353
- .slice(0, removeCount);
354
- if (removable.length === 0) return { lines, elidedCount: 0 };
255
+ .slice(0, lines.length - target);
256
+ if (removable.length === 0) return { lines: [...lines], elidedCount: 0 };
355
257
 
356
- const removedIndexes = new Set(removable.map(item => item.index));
357
- const cappedLines = lines.filter((_, index) => !removedIndexes.has(index));
358
- cappedLines.push({
258
+ const removed = new Set(removable.map(item => item.index));
259
+ const kept = lines.filter((_, index) => !removed.has(index));
260
+ kept.push({
359
261
  label: `… (${removable.length} lines elided beyond depth/cap)`,
360
262
  depth: 0,
263
+ isRoot: false,
361
264
  });
362
-
363
- return { lines: cappedLines, elidedCount: removable.length };
265
+ return { lines: kept, elidedCount: removable.length };
364
266
  }
365
267
 
366
- function renderLines(lines: RenderLine[]): string {
268
+ function formatLines(lines: readonly RenderedLine[]): string {
367
269
  const maxLabelLength = lines.reduce((max, line) => Math.max(max, line.label.length), 0);
368
270
  return lines
369
271
  .map(line => {
@@ -374,113 +276,11 @@ function renderLines(lines: RenderLine[]): string {
374
276
  .join("\n");
375
277
  }
376
278
 
377
- export async function buildDirectoryTree(rootPath: string, options: DirectoryTreeOptions = {}): Promise<DirectoryTree> {
378
- const resolvedRootPath = path.resolve(rootPath);
379
- const resolvedOptions = resolveDirectoryTreeOptions(options);
380
- const nowMs = Date.now();
381
- const { root, truncated: directoryTruncated } = await collectDirectoryTree(resolvedRootPath, resolvedOptions);
382
- const lines: RenderLine[] = [];
383
- collectRenderLines(root, nowMs, lines);
384
- const { lines: cappedLines, elidedCount } = applyLineCap(lines, resolvedOptions);
279
+ function emptyTree(rootPath: string): DirectoryTree {
385
280
  return {
386
- rootPath: resolvedRootPath,
387
- rendered: renderLines(cappedLines),
388
- truncated: directoryTruncated || elidedCount > 0,
389
- totalLines: cappedLines.length,
390
- };
391
- }
392
-
393
- /**
394
- * Build a `parentRelativePath` → child name index from a flat list of POSIX
395
- * paths. Intermediate directory components are inferred from path segments;
396
- * the index covers every ancestor directory implied by the input.
397
- */
398
- function buildChildIndexFromPaths(paths: readonly string[]): Map<string, Set<string>> {
399
- const index = new Map<string, Set<string>>();
400
- const ensure = (parent: string): Set<string> => {
401
- let bucket = index.get(parent);
402
- if (!bucket) {
403
- bucket = new Set<string>();
404
- index.set(parent, bucket);
405
- }
406
- return bucket;
281
+ rootPath,
282
+ rendered: "",
283
+ truncated: false,
284
+ totalLines: 0,
407
285
  };
408
- for (const raw of paths) {
409
- if (!raw) continue;
410
- const normalized = raw.replace(/\\/g, "/");
411
- const parts = normalized.split("/").filter(segment => segment.length > 0);
412
- if (parts.length === 0) continue;
413
- for (let i = 0; i < parts.length; i += 1) {
414
- const parent = parts.slice(0, i).join("/");
415
- const segment = parts[i];
416
- if (segment !== undefined) ensure(parent).add(segment);
417
- }
418
- }
419
- return index;
420
- }
421
-
422
- const GIT_LS_FILES_TIMEOUT_MS = 3000;
423
-
424
- /**
425
- * List tracked + untracked-not-ignored files at `rootPath` via `git ls-files`.
426
- * Returns `null` when git is unavailable, the directory is not inside a
427
- * worktree, or the call fails / times out — caller falls back to native
428
- * directory listing.
429
- */
430
- async function tryListGitFiles(rootPath: string): Promise<string[] | null> {
431
- const gitPath = $which("git");
432
- if (!gitPath) return null;
433
- const signal = AbortSignal.timeout(GIT_LS_FILES_TIMEOUT_MS);
434
- try {
435
- const child = Bun.spawn([gitPath, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], {
436
- cwd: rootPath,
437
- stdout: "pipe",
438
- stderr: "pipe",
439
- stdin: "ignore",
440
- signal,
441
- });
442
- const [stdout, exitCode] = await Promise.all([
443
- new Response(child.stdout as ReadableStream<Uint8Array>).text(),
444
- child.exited,
445
- ]);
446
- if (exitCode !== 0) return null;
447
- if (!stdout) return [];
448
- // `-z` separates entries with NUL; trailing NUL after final entry.
449
- return stdout.split("\0").filter(entry => entry.length > 0);
450
- } catch (error) {
451
- logger.debug("git ls-files failed; falling back to native directory listing", {
452
- rootPath,
453
- error: error instanceof Error ? error.message : String(error),
454
- });
455
- return null;
456
- }
457
- }
458
-
459
- export async function buildWorkspaceTree(cwd: string): Promise<WorkspaceTree> {
460
- const rootPath = path.resolve(cwd);
461
- const baseOptions = {
462
- maxDepth: WORKSPACE_TREE_MAX_DEPTH,
463
- directoryEntryLimit: WORKSPACE_TREE_DIR_LIMIT,
464
- lineCap: WORKSPACE_TREE_LINE_CAP,
465
- excludedDirectoryNames: WORKSPACE_TREE_EXCLUDED_DIRS,
466
- hidden: false,
467
- cache: true,
468
- rootLabel: ".",
469
- } satisfies DirectoryTreeOptions;
470
-
471
- try {
472
- const gitFiles = await tryListGitFiles(rootPath);
473
- if (gitFiles !== null) {
474
- // Git already applied gitignore + tracking semantics: bypass native
475
- // recursive scan and feed the index directly to the tree builder.
476
- return await buildDirectoryTree(rootPath, {
477
- ...baseOptions,
478
- gitignore: false,
479
- childIndex: buildChildIndexFromPaths(gitFiles),
480
- });
481
- }
482
- return await buildDirectoryTree(rootPath, { ...baseOptions, gitignore: true });
483
- } catch {
484
- return emptyWorkspaceTree(rootPath);
485
- }
486
286
  }