pi-extensions 0.1.9

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.
Files changed (135) hide show
  1. package/.ralph/import-cc-codex.md +31 -0
  2. package/.ralph/import-cc-codex.state.json +14 -0
  3. package/.ralph/mario-not-impl.md +69 -0
  4. package/.ralph/mario-not-impl.state.json +14 -0
  5. package/.ralph/mario-not-spec.md +163 -0
  6. package/.ralph/mario-not-spec.state.json +14 -0
  7. package/LICENSE +21 -0
  8. package/README.md +65 -0
  9. package/RELEASING.md +34 -0
  10. package/agent-guidance/CHANGELOG.md +4 -0
  11. package/agent-guidance/README.md +102 -0
  12. package/agent-guidance/agent-guidance.ts +147 -0
  13. package/agent-guidance/package.json +22 -0
  14. package/agent-guidance/setup.sh +75 -0
  15. package/agent-guidance/templates/CLAUDE.md +5 -0
  16. package/agent-guidance/templates/CODEX.md +92 -0
  17. package/agent-guidance/templates/GEMINI.md +5 -0
  18. package/arcade/CHANGELOG.md +4 -0
  19. package/arcade/README.md +85 -0
  20. package/arcade/assets/picman.png +0 -0
  21. package/arcade/assets/ping.png +0 -0
  22. package/arcade/assets/spice-invaders.png +0 -0
  23. package/arcade/assets/tetris.png +0 -0
  24. package/arcade/mario-not/README.md +30 -0
  25. package/arcade/mario-not/boss.js +103 -0
  26. package/arcade/mario-not/camera.js +59 -0
  27. package/arcade/mario-not/collision.js +91 -0
  28. package/arcade/mario-not/colors.js +36 -0
  29. package/arcade/mario-not/constants.js +97 -0
  30. package/arcade/mario-not/core.js +39 -0
  31. package/arcade/mario-not/death.js +77 -0
  32. package/arcade/mario-not/effects.js +84 -0
  33. package/arcade/mario-not/enemies.js +31 -0
  34. package/arcade/mario-not/engine.js +171 -0
  35. package/arcade/mario-not/fireballs.js +98 -0
  36. package/arcade/mario-not/items.js +24 -0
  37. package/arcade/mario-not/levels.js +403 -0
  38. package/arcade/mario-not/logic.js +104 -0
  39. package/arcade/mario-not/mario-not.ts +297 -0
  40. package/arcade/mario-not/player.js +244 -0
  41. package/arcade/mario-not/render.js +257 -0
  42. package/arcade/mario-not/spec.md +548 -0
  43. package/arcade/mario-not/state.js +246 -0
  44. package/arcade/mario-not/tests/e2e.test.js +855 -0
  45. package/arcade/mario-not/tests/engine.test.js +888 -0
  46. package/arcade/mario-not/tests/fixtures/story0-frame.txt +4 -0
  47. package/arcade/mario-not/tests/fixtures/story1-camera.txt +4 -0
  48. package/arcade/mario-not/tests/fixtures/story1-glyphs.txt +4 -0
  49. package/arcade/mario-not/tests/fixtures/story10-item.txt +4 -0
  50. package/arcade/mario-not/tests/fixtures/story11-hazards.txt +4 -0
  51. package/arcade/mario-not/tests/fixtures/story12-used-block.txt +4 -0
  52. package/arcade/mario-not/tests/fixtures/story13-pipes.txt +4 -0
  53. package/arcade/mario-not/tests/fixtures/story14-goal.txt +4 -0
  54. package/arcade/mario-not/tests/fixtures/story15-hud-narrow.txt +2 -0
  55. package/arcade/mario-not/tests/fixtures/story16-unknown-tile.txt +4 -0
  56. package/arcade/mario-not/tests/fixtures/story17-mix.txt +4 -0
  57. package/arcade/mario-not/tests/fixtures/story18-hud-score.txt +2 -0
  58. package/arcade/mario-not/tests/fixtures/story19-cue.txt +4 -0
  59. package/arcade/mario-not/tests/fixtures/story2-enemy.txt +4 -0
  60. package/arcade/mario-not/tests/fixtures/story20-camera-offset.txt +4 -0
  61. package/arcade/mario-not/tests/fixtures/story21-hud-zero.txt +2 -0
  62. package/arcade/mario-not/tests/fixtures/story22-big-viewport.txt +4 -0
  63. package/arcade/mario-not/tests/fixtures/story23-camera-negative.txt +4 -0
  64. package/arcade/mario-not/tests/fixtures/story24-camera-width.txt +4 -0
  65. package/arcade/mario-not/tests/fixtures/story25-camera-positive.txt +4 -0
  66. package/arcade/mario-not/tests/fixtures/story26-hud-lives.txt +2 -0
  67. package/arcade/mario-not/tests/fixtures/story27-hud-coins.txt +2 -0
  68. package/arcade/mario-not/tests/fixtures/story28-item-viewport.txt +4 -0
  69. package/arcade/mario-not/tests/fixtures/story29-enemy-viewport.txt +4 -0
  70. package/arcade/mario-not/tests/fixtures/story3-hud.txt +2 -0
  71. package/arcade/mario-not/tests/fixtures/story30-hud-score.txt +2 -0
  72. package/arcade/mario-not/tests/fixtures/story31-particles-viewport.txt +4 -0
  73. package/arcade/mario-not/tests/fixtures/story32-paused-frame.txt +4 -0
  74. package/arcade/mario-not/tests/fixtures/story4-big.txt +4 -0
  75. package/arcade/mario-not/tests/fixtures/story5-resume-hud.txt +2 -0
  76. package/arcade/mario-not/tests/fixtures/story6-particles.txt +4 -0
  77. package/arcade/mario-not/tests/fixtures/story6-paused.txt +4 -0
  78. package/arcade/mario-not/tests/fixtures/story7-powerup.txt +4 -0
  79. package/arcade/mario-not/tests/fixtures/story8-hud-time.txt +2 -0
  80. package/arcade/mario-not/tests/fixtures/story9-hud-level.txt +2 -0
  81. package/arcade/mario-not/tiles.js +79 -0
  82. package/arcade/mario-not/tsconfig.json +14 -0
  83. package/arcade/mario-not/types.js +225 -0
  84. package/arcade/package.json +26 -0
  85. package/arcade/picman.ts +328 -0
  86. package/arcade/ping.ts +594 -0
  87. package/arcade/spice-invaders.ts +1104 -0
  88. package/arcade/tetris.ts +662 -0
  89. package/code-actions/CHANGELOG.md +4 -0
  90. package/code-actions/README.md +65 -0
  91. package/code-actions/actions.ts +107 -0
  92. package/code-actions/index.ts +148 -0
  93. package/code-actions/package.json +22 -0
  94. package/code-actions/search.ts +79 -0
  95. package/code-actions/snippets.ts +179 -0
  96. package/code-actions/ui.ts +120 -0
  97. package/files-widget/CHANGELOG.md +90 -0
  98. package/files-widget/DESIGN.md +452 -0
  99. package/files-widget/README.md +122 -0
  100. package/files-widget/TODO.md +141 -0
  101. package/files-widget/browser.ts +922 -0
  102. package/files-widget/comment.ts +5 -0
  103. package/files-widget/constants.ts +18 -0
  104. package/files-widget/demo.svg +1 -0
  105. package/files-widget/file-tree.ts +224 -0
  106. package/files-widget/file-viewer.ts +93 -0
  107. package/files-widget/git.ts +107 -0
  108. package/files-widget/index.ts +140 -0
  109. package/files-widget/input-utils.ts +3 -0
  110. package/files-widget/package.json +22 -0
  111. package/files-widget/types.ts +28 -0
  112. package/files-widget/utils.ts +26 -0
  113. package/files-widget/viewer.ts +424 -0
  114. package/import-cc-codex/research/import-chats-from-other-agents.md +135 -0
  115. package/import-cc-codex/spec.md +79 -0
  116. package/package.json +29 -0
  117. package/ralph-wiggum/CHANGELOG.md +7 -0
  118. package/ralph-wiggum/README.md +96 -0
  119. package/ralph-wiggum/SKILL.md +73 -0
  120. package/ralph-wiggum/index.ts +792 -0
  121. package/ralph-wiggum/package.json +25 -0
  122. package/raw-paste/CHANGELOG.md +7 -0
  123. package/raw-paste/README.md +52 -0
  124. package/raw-paste/index.ts +112 -0
  125. package/raw-paste/package.json +22 -0
  126. package/tab-status/CHANGELOG.md +4 -0
  127. package/tab-status/README.md +61 -0
  128. package/tab-status/assets/tab-status.png +0 -0
  129. package/tab-status/package.json +22 -0
  130. package/tab-status/tab-status.ts +179 -0
  131. package/usage-extension/CHANGELOG.md +17 -0
  132. package/usage-extension/README.md +120 -0
  133. package/usage-extension/index.ts +628 -0
  134. package/usage-extension/package.json +22 -0
  135. package/usage-extension/screenshot.png +0 -0
@@ -0,0 +1,922 @@
1
+ import type { Theme } from "@mariozechner/pi-coding-agent";
2
+ import { Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
3
+ import { readdir, readFile, stat } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { basename, join, relative, resolve, sep } from "node:path";
6
+
7
+ import {
8
+ DEFAULT_BROWSER_HEIGHT,
9
+ LINE_COUNT_BATCH_DELAY_MS,
10
+ LINE_COUNT_BATCH_SIZE,
11
+ MAX_BROWSER_HEIGHT,
12
+ MAX_LINE_COUNT_BYTES,
13
+ MAX_TREE_DEPTH,
14
+ MIN_PANEL_HEIGHT,
15
+ POLL_INTERVAL_MS,
16
+ SCAN_BATCH_DELAY_MS,
17
+ SCAN_BATCH_SIZE,
18
+ SAFE_MODE_ENTRY_THRESHOLD,
19
+ } from "./constants";
20
+ import { getGitBranch, getGitDiffStats, getGitFileList, getGitStatus, isGitRepo } from "./git";
21
+ import { buildFileTreeFromPaths, flattenTree, getIgnoredNames, sortChildren, updateTreeStats } from "./file-tree";
22
+ import type { DiffStats, FileNode, FlatNode } from "./types";
23
+ import { isIgnoredStatus, isUntrackedStatus } from "./utils";
24
+ import { createViewer, type CommentPayload, type ViewerAction } from "./viewer";
25
+ import { isPrintableChar } from "./input-utils";
26
+
27
+ export interface BrowserController {
28
+ render(width: number): string[];
29
+ handleInput(data: string): void;
30
+ invalidate(): void;
31
+ }
32
+
33
+ interface BrowserStats {
34
+ totalLines?: number;
35
+ additions: number;
36
+ deletions: number;
37
+ }
38
+
39
+ type ScanMode = "full" | "safe" | "none";
40
+
41
+ interface ScanState {
42
+ mode: ScanMode;
43
+ isScanning: boolean;
44
+ isPartial: boolean;
45
+ pending: number;
46
+ spinnerIndex: number;
47
+ }
48
+
49
+ interface BrowserState {
50
+ root: FileNode | null;
51
+ flatList: FlatNode[];
52
+ fullList: FlatNode[];
53
+ stats: BrowserStats;
54
+ nodeByPath: Map<string, FileNode>;
55
+ scanState: ScanState;
56
+ selectedIndex: number;
57
+ searchQuery: string;
58
+ searchMode: boolean;
59
+ showOnlyChanged: boolean;
60
+ browserHeight: number;
61
+ lastPollTime: number;
62
+ }
63
+
64
+ interface ChangedFile {
65
+ file: FileNode;
66
+ ancestors: FileNode[];
67
+ }
68
+
69
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
70
+
71
+ function findNodeByPath(root: FileNode | null, path: string): FileNode | null {
72
+ if (!root) return null;
73
+ if (root.path === path) return root;
74
+ if (!root.children) return null;
75
+
76
+ for (const child of root.children) {
77
+ const found = findNodeByPath(child, path);
78
+ if (found) return found;
79
+ }
80
+
81
+ return null;
82
+ }
83
+
84
+ function indexNodes(root: FileNode | null, map: Map<string, FileNode>): void {
85
+ map.clear();
86
+ if (!root) return;
87
+ const stack: FileNode[] = [root];
88
+ while (stack.length > 0) {
89
+ const node = stack.pop();
90
+ if (!node) continue;
91
+ map.set(node.path, node);
92
+ if (node.children) {
93
+ for (const child of node.children) {
94
+ stack.push(child);
95
+ }
96
+ }
97
+ }
98
+ }
99
+
100
+ function getNodeDepth(node: FileNode, cwd: string): number {
101
+ if (node.path === cwd) return 0;
102
+ const rel = relative(cwd, node.path);
103
+ if (!rel) return 0;
104
+ return rel.split(sep).length;
105
+ }
106
+
107
+ function shouldSafeMode(cwd: string): boolean {
108
+ const resolved = resolve(cwd);
109
+ const home = resolve(homedir());
110
+ const root = resolve(sep);
111
+ return resolved === home || resolved === root;
112
+ }
113
+
114
+ function collectChangedFiles(node: FileNode, ancestors: FileNode[] = []): ChangedFile[] {
115
+ const results: ChangedFile[] = [];
116
+
117
+ if (!node.isDirectory && (node.gitStatus || node.agentModified)) {
118
+ results.push({ file: node, ancestors: [...ancestors] });
119
+ }
120
+
121
+ if (node.children) {
122
+ for (const child of node.children) {
123
+ results.push(...collectChangedFiles(child, [...ancestors, node]));
124
+ }
125
+ }
126
+
127
+ return results;
128
+ }
129
+
130
+ function getTreeStats(root: FileNode | null): BrowserStats {
131
+ if (!root) {
132
+ return { totalLines: undefined, additions: 0, deletions: 0 };
133
+ }
134
+
135
+ return {
136
+ totalLines: root.lineCountComplete ? root.totalLines ?? 0 : undefined,
137
+ additions: root.totalAdditions ?? 0,
138
+ deletions: root.totalDeletions ?? 0,
139
+ };
140
+ }
141
+
142
+ function formatNodeStatus(node: FileNode, theme: Theme): string {
143
+ if (isIgnoredStatus(node.gitStatus)) return "";
144
+ if (node.agentModified) return theme.fg("accent", " 🤖");
145
+ if (node.gitStatus === "M" || node.gitStatus === "MM") return theme.fg("warning", " M");
146
+ if (isUntrackedStatus(node.gitStatus)) return theme.fg("dim", " ?");
147
+ if (node.gitStatus === "A") return theme.fg("success", " A");
148
+ if (node.gitStatus === "D") return theme.fg("error", " D");
149
+ return "";
150
+ }
151
+
152
+ function formatNodeMeta(node: FileNode, theme: Theme): string {
153
+ if (isIgnoredStatus(node.gitStatus)) return "";
154
+
155
+ const parts: string[] = [];
156
+
157
+ if (node.isDirectory && !node.expanded) {
158
+ if (node.totalAdditions && node.totalAdditions > 0) {
159
+ parts.push(theme.fg("success", `+${node.totalAdditions}`));
160
+ }
161
+ if (node.totalDeletions && node.totalDeletions > 0) {
162
+ parts.push(theme.fg("error", `-${node.totalDeletions}`));
163
+ }
164
+ if (node.totalLines && node.lineCountComplete !== false) {
165
+ parts.push(theme.fg("dim", `${node.totalLines}L`));
166
+ }
167
+ } else if (!node.isDirectory) {
168
+ if (node.diffStats) {
169
+ if (node.diffStats.additions > 0) {
170
+ parts.push(theme.fg("success", `+${node.diffStats.additions}`));
171
+ }
172
+ if (node.diffStats.deletions > 0) {
173
+ parts.push(theme.fg("error", `-${node.diffStats.deletions}`));
174
+ }
175
+ } else if (isUntrackedStatus(node.gitStatus) && node.lineCount !== undefined) {
176
+ parts.push(theme.fg("success", `+${node.lineCount}`));
177
+ }
178
+ if (node.lineCount !== undefined) {
179
+ parts.push(theme.fg("dim", `${node.lineCount}L`));
180
+ }
181
+ }
182
+
183
+ return parts.length > 0 ? ` ${parts.join(" ")}` : "";
184
+ }
185
+
186
+ function formatNodeName(node: FileNode, theme: Theme): string {
187
+ if (isIgnoredStatus(node.gitStatus)) return theme.fg("dim", node.name);
188
+ if (node.isDirectory) {
189
+ const label = node.hasChangedChildren ? theme.fg("warning", node.name) : theme.fg("accent", node.name);
190
+ return node.loading ? `${label}${theme.fg("dim", " ⏳")}` : label;
191
+ }
192
+ if (node.gitStatus) return theme.fg("warning", node.name);
193
+ return node.name;
194
+ }
195
+
196
+ function collapseAllExcept(node: FileNode, keep: Set<FileNode>): void {
197
+ if (node.isDirectory) {
198
+ node.expanded = keep.has(node);
199
+ if (node.children) {
200
+ for (const child of node.children) {
201
+ collapseAllExcept(child, keep);
202
+ }
203
+ }
204
+ }
205
+ }
206
+
207
+ export function createFileBrowser(
208
+ cwd: string,
209
+ agentModifiedFiles: Set<string>,
210
+ theme: Theme,
211
+ onClose: () => void,
212
+ requestComment: (payload: CommentPayload, comment: string) => void,
213
+ requestRender: () => void
214
+ ): BrowserController {
215
+ const ignored = getIgnoredNames();
216
+ const repo = isGitRepo(cwd);
217
+ let gitStatus = repo ? getGitStatus(cwd) : new Map<string, string>();
218
+ let diffStats = repo ? getGitDiffStats(cwd) : new Map<string, DiffStats>();
219
+ const gitBranch = repo ? getGitBranch(cwd) : "";
220
+
221
+ const viewer = createViewer(cwd, theme, requestComment);
222
+
223
+ const root = repo
224
+ ? buildFileTreeFromPaths(cwd, getGitFileList(cwd), gitStatus, diffStats, ignored, agentModifiedFiles)
225
+ : {
226
+ name: ".",
227
+ path: cwd,
228
+ isDirectory: true,
229
+ children: undefined,
230
+ expanded: true,
231
+ hasChangedChildren: false,
232
+ };
233
+
234
+ const safeMode = !repo && shouldSafeMode(cwd);
235
+ const scanState: ScanState = {
236
+ mode: repo ? "none" : safeMode ? "safe" : "full",
237
+ isScanning: false,
238
+ isPartial: safeMode,
239
+ pending: 0,
240
+ spinnerIndex: 0,
241
+ };
242
+
243
+ const browser: BrowserState = {
244
+ root,
245
+ flatList: [],
246
+ fullList: [],
247
+ stats: getTreeStats(root),
248
+ nodeByPath: new Map<string, FileNode>(),
249
+ scanState,
250
+ selectedIndex: 0,
251
+ searchQuery: "",
252
+ searchMode: false,
253
+ showOnlyChanged: false,
254
+ browserHeight: DEFAULT_BROWSER_HEIGHT,
255
+ lastPollTime: Date.now(),
256
+ };
257
+
258
+ indexNodes(browser.root, browser.nodeByPath);
259
+ browser.flatList = browser.root ? flattenTree(browser.root) : [];
260
+ browser.fullList = browser.root ? flattenTree(browser.root, 0, true, true) : [];
261
+
262
+ const lineCountCache = new Map<string, { size: number; mtimeMs: number; count: number }>();
263
+ const lineCountQueue: FileNode[] = [];
264
+ const lineCountPending = new Set<string>();
265
+ let lineCountTimer: ReturnType<typeof setTimeout> | null = null;
266
+
267
+ const scanQueue: Array<{ node: FileNode; depth: number }> = [];
268
+ const scanQueued = new Set<string>();
269
+ let scanTimer: ReturnType<typeof setTimeout> | null = null;
270
+
271
+ const normalizeGitPath = (path: string): string => path.split(sep).join("/");
272
+
273
+ function refreshLists(): void {
274
+ browser.flatList = browser.root ? flattenTree(browser.root) : [];
275
+ browser.fullList = browser.root ? flattenTree(browser.root, 0, true, true) : [];
276
+ }
277
+
278
+ function queueLineCount(node: FileNode, force = false): void {
279
+ if (node.isDirectory) return;
280
+ if (!force && node.lineCount !== undefined) return;
281
+ if (lineCountPending.has(node.path)) return;
282
+ lineCountPending.add(node.path);
283
+ lineCountQueue.push(node);
284
+ if (!lineCountTimer) {
285
+ lineCountTimer = setTimeout(processLineCountBatch, LINE_COUNT_BATCH_DELAY_MS);
286
+ }
287
+ }
288
+
289
+ function queueLineCountsForTree(rootNode: FileNode | null): void {
290
+ if (!rootNode) return;
291
+ const stack: FileNode[] = [rootNode];
292
+ while (stack.length > 0) {
293
+ const node = stack.pop();
294
+ if (!node) continue;
295
+ if (node.isDirectory) {
296
+ if (node.children) {
297
+ for (const child of node.children) {
298
+ stack.push(child);
299
+ }
300
+ }
301
+ } else {
302
+ queueLineCount(node);
303
+ }
304
+ }
305
+ }
306
+
307
+ async function updateLineCount(node: FileNode): Promise<void> {
308
+ try {
309
+ const fileStat = await stat(node.path);
310
+ if (fileStat.size > MAX_LINE_COUNT_BYTES) {
311
+ node.lineCount = undefined;
312
+ return;
313
+ }
314
+ const cached = lineCountCache.get(node.path);
315
+ if (cached && cached.size === fileStat.size && cached.mtimeMs === fileStat.mtimeMs) {
316
+ node.lineCount = cached.count;
317
+ return;
318
+ }
319
+ const content = await readFile(node.path, "utf-8");
320
+ const count = content.split("\n").length;
321
+ node.lineCount = count;
322
+ lineCountCache.set(node.path, { size: fileStat.size, mtimeMs: fileStat.mtimeMs, count });
323
+ } catch {
324
+ node.lineCount = undefined;
325
+ }
326
+ }
327
+
328
+ async function processLineCountBatch(): Promise<void> {
329
+ lineCountTimer = null;
330
+ if (!browser.root) return;
331
+ const batch = lineCountQueue.splice(0, LINE_COUNT_BATCH_SIZE);
332
+ if (batch.length === 0) return;
333
+
334
+ await Promise.all(
335
+ batch.map(async node => {
336
+ await updateLineCount(node);
337
+ lineCountPending.delete(node.path);
338
+ })
339
+ );
340
+
341
+ updateTreeStats(browser.root);
342
+ browser.stats = getTreeStats(browser.root);
343
+ refreshLists();
344
+ requestRender();
345
+
346
+ if (lineCountQueue.length > 0) {
347
+ lineCountTimer = setTimeout(processLineCountBatch, LINE_COUNT_BATCH_DELAY_MS);
348
+ }
349
+ }
350
+
351
+ function shouldAutoScan(depth: number): boolean {
352
+ if (browser.scanState.mode === "safe") {
353
+ return depth <= 0;
354
+ }
355
+ return depth <= MAX_TREE_DEPTH;
356
+ }
357
+
358
+ function getScanBatchSize(): number {
359
+ return browser.scanState.mode === "safe" ? 1 : SCAN_BATCH_SIZE;
360
+ }
361
+
362
+ function getScanDelay(): number {
363
+ return browser.scanState.mode === "safe" ? SCAN_BATCH_DELAY_MS * 4 : SCAN_BATCH_DELAY_MS;
364
+ }
365
+
366
+ function enqueueScan(node: FileNode, depth: number, force = false): void {
367
+ if (depth > MAX_TREE_DEPTH) return;
368
+ if (!force && browser.scanState.mode === "safe" && depth > 0) return;
369
+ if (node.children !== undefined || node.loading) return;
370
+ if (scanQueued.has(node.path)) return;
371
+
372
+ node.loading = true;
373
+ scanQueued.add(node.path);
374
+ scanQueue.push({ node, depth });
375
+ browser.scanState.pending = scanQueue.length;
376
+ browser.scanState.isScanning = true;
377
+
378
+ if (!scanTimer) {
379
+ scanTimer = setTimeout(processScanBatch, getScanDelay());
380
+ }
381
+ }
382
+
383
+ async function scanDirectory(node: FileNode, depth: number): Promise<void> {
384
+ try {
385
+ const entries = await readdir(node.path, { withFileTypes: true });
386
+ const sorted = [...entries].sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
387
+
388
+ if (node.path === cwd && browser.scanState.mode === "full" && sorted.length >= SAFE_MODE_ENTRY_THRESHOLD) {
389
+ browser.scanState.mode = "safe";
390
+ browser.scanState.isPartial = true;
391
+ scanQueue.length = 0;
392
+ scanQueued.clear();
393
+ }
394
+
395
+ const dirs: FileNode[] = [];
396
+ const files: FileNode[] = [];
397
+
398
+ for (const entry of sorted) {
399
+ if (ignored.has(entry.name) || entry.name.startsWith(".")) continue;
400
+ const fullPath = join(node.path, entry.name);
401
+ if (entry.isDirectory()) {
402
+ const dirNode: FileNode = {
403
+ name: entry.name,
404
+ path: fullPath,
405
+ isDirectory: true,
406
+ children: undefined,
407
+ expanded: depth + 1 < 1,
408
+ hasChangedChildren: false,
409
+ };
410
+ dirs.push(dirNode);
411
+ browser.nodeByPath.set(fullPath, dirNode);
412
+ if (shouldAutoScan(depth + 1)) {
413
+ enqueueScan(dirNode, depth + 1);
414
+ }
415
+ } else {
416
+ const fileNode: FileNode = {
417
+ name: entry.name,
418
+ path: fullPath,
419
+ isDirectory: false,
420
+ agentModified: agentModifiedFiles.has(fullPath),
421
+ };
422
+ files.push(fileNode);
423
+ browser.nodeByPath.set(fullPath, fileNode);
424
+ queueLineCount(fileNode);
425
+ }
426
+ }
427
+
428
+ node.children = [...dirs, ...files];
429
+ } catch {
430
+ node.children = [];
431
+ } finally {
432
+ node.loading = false;
433
+ scanQueued.delete(node.path);
434
+ }
435
+ }
436
+
437
+ async function processScanBatch(): Promise<void> {
438
+ scanTimer = null;
439
+ if (!browser.root) return;
440
+ const batch = scanQueue.splice(0, getScanBatchSize());
441
+ if (batch.length === 0) {
442
+ browser.scanState.isScanning = false;
443
+ browser.scanState.pending = 0;
444
+ return;
445
+ }
446
+
447
+ for (const item of batch) {
448
+ await scanDirectory(item.node, item.depth);
449
+ }
450
+
451
+ browser.scanState.pending = scanQueue.length;
452
+ browser.scanState.isScanning = scanQueue.length > 0;
453
+
454
+ updateTreeStats(browser.root);
455
+ browser.stats = getTreeStats(browser.root);
456
+ refreshLists();
457
+ requestRender();
458
+
459
+ if (scanQueue.length > 0) {
460
+ scanTimer = setTimeout(processScanBatch, getScanDelay());
461
+ }
462
+ }
463
+
464
+ function stopBackgroundTasks(): void {
465
+ if (lineCountTimer) {
466
+ clearTimeout(lineCountTimer);
467
+ lineCountTimer = null;
468
+ }
469
+ if (scanTimer) {
470
+ clearTimeout(scanTimer);
471
+ scanTimer = null;
472
+ }
473
+ }
474
+
475
+ function applyAgentModified(): void {
476
+ for (const node of browser.nodeByPath.values()) {
477
+ if (!node.isDirectory) {
478
+ node.agentModified = agentModifiedFiles.has(node.path);
479
+ }
480
+ }
481
+ }
482
+
483
+ function applyGitUpdates(): void {
484
+ for (const node of browser.nodeByPath.values()) {
485
+ if (node.isDirectory) continue;
486
+ const relPath = normalizeGitPath(relative(cwd, node.path));
487
+ node.gitStatus = gitStatus.get(relPath);
488
+ node.diffStats = diffStats.get(relPath);
489
+ }
490
+ }
491
+
492
+ function ensureFileNode(relPath: string): FileNode | null {
493
+ if (!browser.root) return null;
494
+ let normalized = relPath.trim();
495
+ if (!normalized) return null;
496
+ if (normalized.startsWith("./")) {
497
+ normalized = normalized.slice(2);
498
+ }
499
+ normalized = normalizeGitPath(normalized);
500
+ const parts = normalized.split("/").filter(Boolean);
501
+ if (parts.length === 0) return null;
502
+ if (parts.length - 1 > MAX_TREE_DEPTH) return null;
503
+
504
+ let current = browser.root;
505
+ let currentRel = "";
506
+
507
+ for (let i = 0; i < parts.length - 1; i++) {
508
+ const part = parts[i];
509
+ if (ignored.has(part) || part.startsWith(".")) return null;
510
+ currentRel = currentRel ? `${currentRel}/${part}` : part;
511
+ const dirPath = join(cwd, currentRel);
512
+ let dirNode = browser.nodeByPath.get(dirPath);
513
+ if (!dirNode) {
514
+ const depth = i + 1;
515
+ dirNode = {
516
+ name: part,
517
+ path: dirPath,
518
+ isDirectory: true,
519
+ children: [],
520
+ expanded: depth < 1,
521
+ hasChangedChildren: false,
522
+ };
523
+ current.children ??= [];
524
+ current.children.push(dirNode);
525
+ sortChildren(current);
526
+ browser.nodeByPath.set(dirPath, dirNode);
527
+ }
528
+ current = dirNode;
529
+ }
530
+
531
+ const fileName = parts[parts.length - 1];
532
+ if (ignored.has(fileName) || fileName.startsWith(".")) return null;
533
+
534
+ const filePath = join(cwd, normalized);
535
+ const existing = browser.nodeByPath.get(filePath);
536
+ if (existing) return existing;
537
+
538
+ const fileNode: FileNode = {
539
+ name: fileName,
540
+ path: filePath,
541
+ isDirectory: false,
542
+ gitStatus: gitStatus.get(normalized),
543
+ agentModified: agentModifiedFiles.has(filePath),
544
+ diffStats: diffStats.get(normalized),
545
+ };
546
+
547
+ current.children ??= [];
548
+ current.children.push(fileNode);
549
+ sortChildren(current);
550
+ browser.nodeByPath.set(filePath, fileNode);
551
+ return fileNode;
552
+ }
553
+
554
+ function addUntrackedNodes(): void {
555
+ for (const [relPath, status] of gitStatus.entries()) {
556
+ if (!isUntrackedStatus(status)) continue;
557
+ const node = ensureFileNode(relPath);
558
+ if (node) {
559
+ node.gitStatus = status;
560
+ node.diffStats = diffStats.get(relPath);
561
+ queueLineCount(node, true);
562
+ }
563
+ }
564
+ }
565
+
566
+ function refreshMetadata(): void {
567
+ if (!browser.root) return;
568
+ const previousDisplayList = getDisplayList();
569
+ const currentPath = previousDisplayList[browser.selectedIndex]?.node.path;
570
+ const viewingFile = viewer.getFile();
571
+ const viewingFilePath = viewingFile?.path;
572
+
573
+ if (repo) {
574
+ gitStatus = getGitStatus(cwd);
575
+ diffStats = getGitDiffStats(cwd);
576
+ applyGitUpdates();
577
+ addUntrackedNodes();
578
+ }
579
+
580
+ applyAgentModified();
581
+ updateTreeStats(browser.root);
582
+ browser.stats = getTreeStats(browser.root);
583
+ refreshLists();
584
+
585
+ const updatedDisplayList = getDisplayList();
586
+ if (currentPath) {
587
+ const newIdx = updatedDisplayList.findIndex(f => f.node.path === currentPath);
588
+ if (newIdx !== -1) {
589
+ browser.selectedIndex = newIdx;
590
+ }
591
+ }
592
+
593
+ browser.selectedIndex = Math.min(browser.selectedIndex, Math.max(0, updatedDisplayList.length - 1));
594
+
595
+ if (viewingFilePath && browser.root) {
596
+ const newNode = browser.nodeByPath.get(viewingFilePath) ?? findNodeByPath(browser.root, viewingFilePath);
597
+ if (newNode) {
598
+ if (newNode.lineCount === undefined && viewingFile?.lineCount !== undefined) {
599
+ newNode.lineCount = viewingFile.lineCount;
600
+ }
601
+ viewer.updateFileRef(newNode);
602
+ }
603
+ }
604
+ }
605
+
606
+ if (repo) {
607
+ queueLineCountsForTree(browser.root);
608
+ } else if (browser.root) {
609
+ enqueueScan(browser.root, 0, true);
610
+ }
611
+
612
+ function getDisplayList(): FlatNode[] {
613
+ let list = browser.searchQuery ? browser.fullList : browser.flatList;
614
+
615
+ if (browser.showOnlyChanged) {
616
+ list = list.filter(f =>
617
+ f.node.gitStatus ||
618
+ f.node.agentModified ||
619
+ (f.node.isDirectory && f.node.hasChangedChildren)
620
+ );
621
+ }
622
+
623
+ if (browser.searchQuery) {
624
+ const q = browser.searchQuery.toLowerCase();
625
+ list = list.filter(f => f.node.name.toLowerCase().includes(q));
626
+ }
627
+
628
+ return list;
629
+ }
630
+
631
+ function navigateToChange(direction: 1 | -1): void {
632
+ if (!browser.root) return;
633
+
634
+ const changedFiles = collectChangedFiles(browser.root);
635
+ if (changedFiles.length === 0) return;
636
+
637
+ const displayList = getDisplayList();
638
+ const currentNode = displayList[browser.selectedIndex]?.node;
639
+
640
+ let currentIdx = -1;
641
+ if (currentNode && !currentNode.isDirectory) {
642
+ currentIdx = changedFiles.findIndex(c => c.file.path === currentNode.path);
643
+ }
644
+
645
+ let nextIdx: number;
646
+ if (currentIdx === -1) {
647
+ nextIdx = direction === 1 ? 0 : changedFiles.length - 1;
648
+ } else {
649
+ nextIdx = currentIdx + direction;
650
+ if (nextIdx < 0) nextIdx = changedFiles.length - 1;
651
+ if (nextIdx >= changedFiles.length) nextIdx = 0;
652
+ }
653
+
654
+ const target = changedFiles[nextIdx];
655
+
656
+ const ancestorSet = new Set(target.ancestors);
657
+ collapseAllExcept(browser.root, ancestorSet);
658
+
659
+ for (const ancestor of target.ancestors) {
660
+ ancestor.expanded = true;
661
+ }
662
+
663
+ browser.flatList = flattenTree(browser.root);
664
+
665
+ const newDisplayList = getDisplayList();
666
+ const targetIdx = newDisplayList.findIndex(f => f.node.path === target.file.path);
667
+ if (targetIdx !== -1) {
668
+ browser.selectedIndex = targetIdx;
669
+ }
670
+ }
671
+
672
+ function toggleDir(node: FileNode): void {
673
+ if (node.isDirectory) {
674
+ node.expanded = !node.expanded;
675
+ if (!repo && node.expanded && node.children === undefined) {
676
+ enqueueScan(node, getNodeDepth(node, cwd), true);
677
+ }
678
+ refreshLists();
679
+ }
680
+ }
681
+
682
+ function openFile(node: FileNode): void {
683
+ viewer.setFile(node);
684
+ }
685
+
686
+ function renderBrowser(width: number): string[] {
687
+ const lines: string[] = [];
688
+ const pathDisplay = basename(cwd);
689
+ const branchDisplay = gitBranch ? theme.fg("accent", ` (${gitBranch})`) : "";
690
+ const stats = browser.stats;
691
+
692
+ let statsDisplay = "";
693
+ if (stats.totalLines !== undefined) {
694
+ statsDisplay += theme.fg("dim", ` ${stats.totalLines}L`);
695
+ }
696
+ if (stats.additions > 0) statsDisplay += theme.fg("success", ` +${stats.additions}`);
697
+ if (stats.deletions > 0) statsDisplay += theme.fg("error", ` -${stats.deletions}`);
698
+
699
+ const hasActivity = browser.scanState.isScanning || lineCountPending.size > 0;
700
+ if (hasActivity) {
701
+ browser.scanState.spinnerIndex = (browser.scanState.spinnerIndex + 1) % SPINNER_FRAMES.length;
702
+ }
703
+ const spinner = SPINNER_FRAMES[browser.scanState.spinnerIndex];
704
+ const activityParts: string[] = [];
705
+ if (browser.scanState.isScanning) activityParts.push(`${spinner} scanning`);
706
+ if (lineCountPending.size > 0) activityParts.push(`${spinner} counts`);
707
+ const activityIndicator = activityParts.length > 0 ? theme.fg("dim", ` ${activityParts.join(" ")}`) : "";
708
+ const partialIndicator = browser.scanState.isPartial ? theme.fg("warning", " [partial]") : "";
709
+
710
+ const searchIndicator = browser.searchMode
711
+ ? theme.fg("accent", ` /${browser.searchQuery}█`)
712
+ : "";
713
+
714
+ lines.push(
715
+ truncateToWidth(theme.bold(pathDisplay) + branchDisplay + statsDisplay + activityIndicator + partialIndicator + searchIndicator, width)
716
+ );
717
+ lines.push(theme.fg("borderMuted", "─".repeat(width)));
718
+
719
+ const displayList = getDisplayList();
720
+ if (displayList.length === 0) {
721
+ const emptyLabel = browser.scanState.isScanning
722
+ ? " (loading...)"
723
+ : " (no files" + (browser.searchQuery ? " matching '" + browser.searchQuery + "'" : "") + ")";
724
+ lines.push(theme.fg("dim", emptyLabel));
725
+ for (let i = 1; i < browser.browserHeight; i++) {
726
+ lines.push("");
727
+ }
728
+ } else {
729
+ const start = Math.max(
730
+ 0,
731
+ Math.min(browser.selectedIndex - Math.floor(browser.browserHeight / 2), displayList.length - browser.browserHeight)
732
+ );
733
+ const end = Math.min(displayList.length, start + browser.browserHeight);
734
+
735
+ for (let i = start; i < end; i++) {
736
+ const { node, depth } = displayList[i];
737
+ const isSelected = i === browser.selectedIndex;
738
+ const indent = " ".repeat(depth);
739
+ const icon = node.isDirectory
740
+ ? (node.expanded ? "▼ " : "▶ ")
741
+ : " ";
742
+
743
+ const status = formatNodeStatus(node, theme);
744
+ const meta = formatNodeMeta(node, theme);
745
+ const name = formatNodeName(node, theme);
746
+
747
+ let line = `${indent}${icon}${name}${status}${meta}`;
748
+ line = truncateToWidth(line, width);
749
+
750
+ if (isSelected) {
751
+ line = theme.bg("selectedBg", line);
752
+ }
753
+
754
+ lines.push(line);
755
+ }
756
+
757
+ const renderedCount = end - start;
758
+ for (let i = renderedCount; i < browser.browserHeight; i++) {
759
+ lines.push("");
760
+ }
761
+
762
+ const pct = displayList.length > 1
763
+ ? Math.round((browser.selectedIndex / (displayList.length - 1)) * 100)
764
+ : 100;
765
+ lines.push(theme.fg("dim", ` ${browser.selectedIndex + 1}/${displayList.length} (${pct}%)`));
766
+ }
767
+
768
+ lines.push(theme.fg("borderMuted", "─".repeat(width)));
769
+ const changedIndicator = browser.showOnlyChanged ? theme.fg("warning", " [changed only]") : "";
770
+ const help = browser.searchMode
771
+ ? theme.fg("dim", "Type to search ↑↓: nav Enter: confirm Esc: cancel")
772
+ : theme.fg("dim", "j/k: nav []: next/prev change c: toggle changed /: search q: close") + changedIndicator;
773
+ lines.push(truncateToWidth(help, width));
774
+
775
+ return lines;
776
+ }
777
+
778
+ function handleViewerInput(data: string): void {
779
+ const action: ViewerAction = viewer.handleInput(data);
780
+ if (action.type === "close") {
781
+ viewer.close();
782
+ return;
783
+ }
784
+ if (action.type === "navigate") {
785
+ viewer.close();
786
+ navigateToChange(action.direction);
787
+ const displayList = getDisplayList();
788
+ const item = displayList[browser.selectedIndex];
789
+ if (item && !item.node.isDirectory) {
790
+ openFile(item.node);
791
+ }
792
+ }
793
+ }
794
+
795
+ function handleBrowserInput(data: string): void {
796
+ const displayList = getDisplayList();
797
+
798
+ if (matchesKey(data, "q") && !browser.searchMode) {
799
+ stopBackgroundTasks();
800
+ onClose();
801
+ return;
802
+ }
803
+ if (matchesKey(data, Key.escape)) {
804
+ if (browser.searchMode) {
805
+ browser.searchMode = false;
806
+ browser.searchQuery = "";
807
+ } else {
808
+ stopBackgroundTasks();
809
+ onClose();
810
+ }
811
+ return;
812
+ }
813
+ if (matchesKey(data, "/") && !browser.searchMode) {
814
+ browser.searchMode = true;
815
+ browser.searchQuery = "";
816
+ return;
817
+ }
818
+ if (matchesKey(data, "j") || matchesKey(data, Key.down)) {
819
+ browser.selectedIndex = Math.min(displayList.length - 1, browser.selectedIndex + 1);
820
+ return;
821
+ }
822
+ if (matchesKey(data, "k") || matchesKey(data, Key.up)) {
823
+ browser.selectedIndex = Math.max(0, browser.selectedIndex - 1);
824
+ return;
825
+ }
826
+ if (browser.searchMode) {
827
+ if (matchesKey(data, Key.enter)) {
828
+ browser.searchMode = false;
829
+ browser.selectedIndex = 0;
830
+ } else if (matchesKey(data, Key.backspace)) {
831
+ browser.searchQuery = browser.searchQuery.slice(0, -1);
832
+ browser.selectedIndex = 0;
833
+ } else if (isPrintableChar(data)) {
834
+ browser.searchQuery += data;
835
+ browser.selectedIndex = 0;
836
+ }
837
+ return;
838
+ }
839
+ if (matchesKey(data, Key.enter)) {
840
+ const item = displayList[browser.selectedIndex];
841
+ if (item) {
842
+ if (item.node.isDirectory) {
843
+ toggleDir(item.node);
844
+ } else {
845
+ openFile(item.node);
846
+ }
847
+ }
848
+ return;
849
+ }
850
+ if (matchesKey(data, "l") || matchesKey(data, Key.right)) {
851
+ const item = displayList[browser.selectedIndex];
852
+ if (item?.node.isDirectory && !item.node.expanded) {
853
+ toggleDir(item.node);
854
+ } else if (item && !item.node.isDirectory) {
855
+ openFile(item.node);
856
+ }
857
+ return;
858
+ }
859
+ if (matchesKey(data, "h") || matchesKey(data, Key.left)) {
860
+ const item = displayList[browser.selectedIndex];
861
+ if (item?.node.isDirectory && item.node.expanded) {
862
+ toggleDir(item.node);
863
+ }
864
+ return;
865
+ }
866
+ if (matchesKey(data, Key.pageDown)) {
867
+ browser.selectedIndex = Math.min(displayList.length - 1, browser.selectedIndex + browser.browserHeight);
868
+ return;
869
+ }
870
+ if (matchesKey(data, Key.pageUp)) {
871
+ browser.selectedIndex = Math.max(0, browser.selectedIndex - browser.browserHeight);
872
+ return;
873
+ }
874
+ if (matchesKey(data, "+") || matchesKey(data, "=")) {
875
+ browser.browserHeight = Math.min(MAX_BROWSER_HEIGHT, browser.browserHeight + 5);
876
+ return;
877
+ }
878
+ if (matchesKey(data, "-") || matchesKey(data, "_")) {
879
+ browser.browserHeight = Math.max(MIN_PANEL_HEIGHT, browser.browserHeight - 5);
880
+ return;
881
+ }
882
+ if (matchesKey(data, "c")) {
883
+ browser.showOnlyChanged = !browser.showOnlyChanged;
884
+ browser.selectedIndex = 0;
885
+ return;
886
+ }
887
+ if (matchesKey(data, "]")) {
888
+ navigateToChange(1);
889
+ return;
890
+ }
891
+ if (matchesKey(data, "[")) {
892
+ navigateToChange(-1);
893
+ return;
894
+ }
895
+ }
896
+
897
+ return {
898
+ render(width: number): string[] {
899
+ const now = Date.now();
900
+ if (repo && now - browser.lastPollTime > POLL_INTERVAL_MS) {
901
+ browser.lastPollTime = now;
902
+ refreshMetadata();
903
+ }
904
+
905
+ if (viewer.isOpen()) {
906
+ return viewer.render(width);
907
+ }
908
+
909
+ return renderBrowser(width);
910
+ },
911
+
912
+ handleInput(data: string): void {
913
+ if (viewer.isOpen()) {
914
+ handleViewerInput(data);
915
+ } else {
916
+ handleBrowserInput(data);
917
+ }
918
+ },
919
+
920
+ invalidate(): void {},
921
+ };
922
+ }