@guneriu/pi-files 0.2.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Uğur Güneri (guneriu)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # @guneriu/pi-files
2
+
3
+ Shows the files the agent edited this session in a compact widget above the
4
+ input bar, and opens an interactive, gitignore-aware project tree on demand.
5
+
6
+ ## Features
7
+
8
+ - **Compact widget** above the editor: `+` new / `M` modified, capped rows with
9
+ `… +N more` overflow so a big change set never swamps the terminal.
10
+ - **Idle hint** when nothing is edited yet (toggle off in settings).
11
+ - **`/pi-files`** — full-screen tree overlay with keyboard navigation;
12
+ auto-expands to your edited files on open.
13
+ - **`Enter`** opens the selected file in your OS default app.
14
+ - **`Space`** opens a scrollable, syntax-highlighted in-TUI peek (Quick Look style).
15
+ - **Type anything** to filter all project files instantly — no prefix needed.
16
+ - **`/pi-files-settings`** — interactive settings menu to toggle the widget,
17
+ collapse it for the session, adjust the row cap, set the peek size limit, and more.
18
+ - Respects `.gitignore` via `git ls-files`; falls back to a filesystem walk
19
+ outside git repos.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pi install npm:@guneriu/pi-files
25
+ # or the whole mono:
26
+ pi install git:github.com/guneriu/pi-extension-mono
27
+ ```
28
+
29
+ ## Commands & shortcuts
30
+
31
+ ### Slash commands
32
+
33
+ | Command | Description |
34
+ |---|---|
35
+ | `/pi-files` | Open the interactive project tree overlay |
36
+ | `/pi-files-settings` | Open the settings menu |
37
+
38
+ ### Inside the project tree (`/pi-files`)
39
+
40
+ | Key | Action |
41
+ |---|---|
42
+ | `↑` / `↓` | Move selection |
43
+ | `Enter` | **Open file in OS default app** — expands a directory |
44
+ | `Space` | **Peek** — in-TUI syntax-highlighted preview (toggle: same key closes) |
45
+ | `→` | Expand a directory |
46
+ | `←` | Collapse a directory — or jump to its parent if already collapsed |
47
+ | `Esc` | Close the overlay (clears filter first if one is active) |
48
+ | *any printable char* | **Filter** — type to search all project files instantly |
49
+ | `Backspace` | Remove last filter character; empty filter returns to tree view |
50
+
51
+ ### Type-to-filter search
52
+
53
+ Start typing anywhere in the tree — no prefix key needed. The view switches to
54
+ a flat filtered list of **all** project files (including inside collapsed
55
+ directories) whose path contains your query (case-insensitive).
56
+
57
+ - The header shows `/ query▌ N results esc clear` while filtering.
58
+ - Matched portion of each path is highlighted.
59
+ - `↑/↓`, `Enter`, and `Space` (peek) all work on filter results.
60
+ - `Esc` clears the filter and returns to the tree. `Esc` again closes the overlay.
61
+ - `Backspace` removes one character at a time; empties = back to tree.
62
+ - `→` / `←` expand/collapse are inactive while filtering.
63
+
64
+ ### Opening files
65
+
66
+ - **`Enter`** launches the file with your OS default application (`open` on
67
+ macOS, `xdg-open` on Linux, `start` on Windows). On a headless/SSH box with
68
+ no GUI handler you'll get a notification instead.
69
+ - **`Space`** opens a scrollable, syntax-highlighted preview *inside* pi — no
70
+ need to leave the terminal. Press `Space` again (or `Esc`/`q`) to close.
71
+ - Scroll with `↑/↓`, page with `PgUp/PgDn`, jump to top/bottom with `g`/`G`.
72
+ - Markdown files get structural highlighting (headings, code blocks, lists,
73
+ links) via a built-in pure ANSI renderer — no extra tools required.
74
+ - Files larger than **Max peek size** (default 512 KB) are refused with a
75
+ hint to open them externally instead.
76
+ - Binary files are detected and refused (open them externally).
77
+
78
+ ### Inside the settings menu (`/pi-files-settings`)
79
+
80
+ | Key | Action |
81
+ |---|---|
82
+ | `↑` / `↓` | Move between items |
83
+ | `Space` or `Enter` | Toggle a boolean setting |
84
+ | `←` / `→` | Decrease / increase a number setting |
85
+ | `Esc` or `q` | Close the menu |
86
+
87
+ ## Settings
88
+
89
+ Settings can be changed interactively via `/pi-files-settings` or by editing
90
+ `<agent-dir>/extensions/pi-files/settings.json` directly.
91
+
92
+ | Key | Default | Meaning | Persists? |
93
+ |---|---|---|---|
94
+ | `enabled` | `true` | Master on/off for the widget | ✅ yes |
95
+ | `maxWidgetRows` | `6` | Max file rows shown in the compact widget | ✅ yes |
96
+ | `showIdleHint` | `true` | Show a one-line hint when no files have been edited yet | ✅ yes |
97
+ | `maxPeekBytes` | `524288` | Largest file (bytes) the in-TUI peek will render | ✅ yes |
98
+
99
+ In the settings menu, **Max widget rows** is adjusted with `←/→` (range 1–20)
100
+ and **Max peek size (KB)** in 64 KB steps (range 64 KB–8 MB).
101
+
102
+ ### Session collapse
103
+
104
+ The settings menu also offers **Collapse this session** — a temporary hide that
105
+ lasts only until you close pi. Unlike `enabled`, it is never written to disk, so
106
+ the widget always comes back on the next session start without any manual action.
107
+
108
+ Use it when you want to free up screen space mid-session without permanently
109
+ disabling the feature.
@@ -0,0 +1,754 @@
1
+ /**
2
+ * pi-files (@guneriu/pi-files)
3
+ *
4
+ * Compact widget above the input bar listing files the agent edited this
5
+ * session, plus an on-demand interactive project tree (/pi-files, /files).
6
+ */
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+ import { getAgentDir, isToolCallEventType } from "@earendil-works/pi-coding-agent";
9
+ import { matchesKey, Key, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
10
+ import { spawn } from "node:child_process";
11
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
12
+ import { basename, relative, resolve } from "node:path";
13
+ import {
14
+ ancestorsOf,
15
+ applyInlineMarkdown,
16
+ buildOpenCommand,
17
+ buildTree,
18
+ buildWidgetLines,
19
+ classifyEdit,
20
+ detectLanguageFromPath,
21
+ extractEditsFromBranch,
22
+ filterFiles,
23
+ flattenVisible,
24
+ highlightMarkdown,
25
+ isPreviewable,
26
+ listProjectFiles,
27
+ looksBinary,
28
+ statusGlyph,
29
+ type EditStatus,
30
+ type EditedFile,
31
+ } from "../src/core";
32
+
33
+ // ─── Settings ───────────────────────────────────────────────────────────────
34
+ interface Settings {
35
+ enabled: boolean;
36
+ maxWidgetRows: number;
37
+ showIdleHint: boolean;
38
+ maxPeekBytes: number;
39
+ }
40
+ const DEFAULTS: Settings = {
41
+ enabled: true,
42
+ maxWidgetRows: 6,
43
+ showIdleHint: true,
44
+ maxPeekBytes: 524288, // 512 KB
45
+ };
46
+
47
+ function getSettingsFile(): string {
48
+ const dir = `${getAgentDir()}/extensions/pi-files`;
49
+ mkdirSync(dir, { recursive: true });
50
+ return `${dir}/settings.json`;
51
+ }
52
+ function loadSettings(): Settings {
53
+ try {
54
+ return { ...DEFAULTS, ...JSON.parse(readFileSync(getSettingsFile(), "utf-8")) };
55
+ } catch {
56
+ return { ...DEFAULTS };
57
+ }
58
+ }
59
+
60
+ function saveSettings(s: Settings): void {
61
+ try {
62
+ writeFileSync(getSettingsFile(), JSON.stringify(s, null, 2), "utf-8");
63
+ } catch {
64
+ // non-fatal
65
+ }
66
+ }
67
+
68
+ const WIDGET_ID = "pi-files";
69
+
70
+ export default function (pi: ExtensionAPI) {
71
+ // absPath -> status, insertion-ordered (oldest first; rendered newest-first).
72
+ const edited = new Map<string, EditStatus>();
73
+ // toolCallId -> pre-execution context, committed on success (S1).
74
+ const pending = new Map<
75
+ string,
76
+ { abs: string; kind: "write" | "edit"; existsBefore: boolean }
77
+ >();
78
+ // Loaded once per session, not on every tool call (S2).
79
+ let settings: Settings = loadSettings();
80
+ // Session-only collapse flag — never written to disk, resets on session_start.
81
+ let collapsed = false;
82
+
83
+ function updateSettings(fn: (s: Settings) => void, ctx: any): void {
84
+ fn(settings);
85
+ saveSettings(settings);
86
+ renderWidget(ctx);
87
+ }
88
+
89
+ function toEditedFiles(cwd: string): EditedFile[] {
90
+ // Newest-first so the compact widget shows the most recent edits (C1).
91
+ return [...edited.entries()].reverse().map(([abs, status]) => ({
92
+ relPath: relative(cwd, abs) || abs,
93
+ status,
94
+ }));
95
+ }
96
+
97
+ function renderWidget(ctx: any) {
98
+ if (ctx.mode !== "tui") return;
99
+ if (!settings.enabled || collapsed) {
100
+ ctx.ui.setWidget(WIDGET_ID, undefined);
101
+ return;
102
+ }
103
+ const cwd = ctx.sessionManager.getCwd();
104
+ const files = toEditedFiles(cwd);
105
+
106
+ if (files.length === 0) {
107
+ if (settings.showIdleHint) {
108
+ ctx.ui.setWidget(WIDGET_ID, (_tui: any, theme: any) => ({
109
+ render: () => [theme.fg("dim", "📁 /files — file tree")],
110
+ invalidate: () => {},
111
+ }));
112
+ } else {
113
+ ctx.ui.setWidget(WIDGET_ID, undefined);
114
+ }
115
+ return;
116
+ }
117
+
118
+ const w = buildWidgetLines(files, settings.maxWidgetRows);
119
+ const shown = files.slice(0, settings.maxWidgetRows);
120
+ ctx.ui.setWidget(WIDGET_ID, (_tui: any, theme: any) => ({
121
+ render: () => {
122
+ const lines: string[] = [];
123
+ if (w.header) {
124
+ lines.push(theme.fg("accent", w.header) + theme.fg("dim", " · /files"));
125
+ }
126
+ for (const f of shown) {
127
+ const color = f.status === "new" ? "success" : "warning";
128
+ lines.push(theme.fg(color, statusGlyph(f.status) + " ") + theme.fg("muted", f.relPath));
129
+ }
130
+ if (w.overflow) lines.push(theme.fg("dim", w.overflow));
131
+ return lines;
132
+ },
133
+ invalidate: () => {},
134
+ }));
135
+ }
136
+
137
+ function rebuildFromHistory(ctx: any) {
138
+ edited.clear();
139
+ const branch = ctx.sessionManager.getBranch();
140
+ for (const e of extractEditsFromBranch(branch)) {
141
+ const abs = resolve(ctx.sessionManager.getCwd(), e.path);
142
+ // Reconstruction cannot know the pre-write filesystem state, so we treat
143
+ // history-derived edits as "modified" (existsBefore = true). Live
144
+ // tool_call tracking provides accurate new/modified during the session.
145
+ const status = classifyEdit(e.kind, true, edited.get(abs));
146
+ edited.set(abs, status);
147
+ }
148
+ }
149
+
150
+ pi.on("session_start", async (_event, ctx) => {
151
+ if (ctx.mode !== "tui") return;
152
+ settings = loadSettings();
153
+ collapsed = false; // session collapse always resets on fresh session
154
+ rebuildFromHistory(ctx);
155
+ renderWidget(ctx);
156
+ });
157
+
158
+ pi.on("session_shutdown", async (_event, ctx) => {
159
+ edited.clear();
160
+ pending.clear();
161
+ if (ctx?.mode === "tui") ctx.ui.setWidget(WIDGET_ID, undefined); // N1
162
+ });
163
+
164
+ // Capture pre-execution state on tool_call (fires before the tool runs), so
165
+ // existsSync reflects the pre-write filesystem (new vs modified).
166
+ pi.on("tool_call", async (event, ctx) => {
167
+ if (ctx.mode !== "tui") return;
168
+ let kind: "write" | "edit" | undefined;
169
+ if (isToolCallEventType("write", event)) kind = "write";
170
+ else if (isToolCallEventType("edit", event)) kind = "edit";
171
+ if (!kind) return;
172
+ const rawPath = (event.input as { path?: string }).path;
173
+ if (!rawPath) return;
174
+ const abs = resolve(ctx.sessionManager.getCwd(), rawPath);
175
+ pending.set(event.toolCallId, { abs, kind, existsBefore: existsSync(abs) });
176
+ });
177
+
178
+ // Commit only on success (S1): a failed write/edit must not appear as edited.
179
+ pi.on("tool_execution_end", async (event, ctx) => {
180
+ if (ctx.mode !== "tui") return;
181
+ const p = pending.get(event.toolCallId);
182
+ if (!p) return;
183
+ pending.delete(event.toolCallId);
184
+ if (event.isError) return;
185
+ const prev = edited.get(p.abs); // read BEFORE delete so sticky-new survives
186
+ edited.delete(p.abs); // re-insert so the newest edit sorts last
187
+ edited.set(p.abs, classifyEdit(p.kind, p.existsBefore, prev));
188
+ renderWidget(ctx);
189
+ });
190
+
191
+ registerTreeCommands(pi, edited, () => settings);
192
+ registerSettingsCommand(
193
+ pi,
194
+ () => settings,
195
+ (fn, ctx) => updateSettings(fn, ctx),
196
+ () => collapsed,
197
+ (v, ctx) => { collapsed = v; renderWidget(ctx); },
198
+ );
199
+ }
200
+
201
+ // ─── Settings menu ──────────────────────────────────────────────────────────
202
+
203
+ function registerSettingsCommand(
204
+ pi: ExtensionAPI,
205
+ getSettings: () => Settings,
206
+ updateSettings: (fn: (s: Settings) => void, ctx: any) => void,
207
+ getCollapsed: () => boolean,
208
+ setCollapsed: (v: boolean, ctx: any) => void,
209
+ ) {
210
+ pi.registerCommand("pi-files-settings", {
211
+ description: "Open pi-files settings menu",
212
+ handler: async (_args, ctx) => {
213
+ if (ctx.mode !== "tui") {
214
+ ctx.ui.notify("/pi-files-settings requires TUI mode", "error");
215
+ return;
216
+ }
217
+
218
+ let selected = 0;
219
+
220
+ type ToggleItem = {
221
+ kind: "toggle";
222
+ label: string;
223
+ hint?: string;
224
+ get: () => boolean;
225
+ toggle: () => void;
226
+ };
227
+ type NumberItem = {
228
+ kind: "number";
229
+ label: string;
230
+ get: () => number;
231
+ inc: () => void;
232
+ dec: () => void;
233
+ min: number;
234
+ max: number;
235
+ };
236
+ type MenuItem = ToggleItem | NumberItem;
237
+
238
+ const items: MenuItem[] = [
239
+ {
240
+ kind: "toggle",
241
+ label: "Widget enabled",
242
+ hint: "persists across sessions",
243
+ get: () => getSettings().enabled,
244
+ toggle: () => updateSettings((s) => { s.enabled = !s.enabled; }, ctx),
245
+ },
246
+ {
247
+ kind: "toggle",
248
+ label: "Collapse this session",
249
+ hint: "resets when you restart pi",
250
+ get: () => getCollapsed(),
251
+ toggle: () => setCollapsed(!getCollapsed(), ctx),
252
+ },
253
+ {
254
+ kind: "number",
255
+ label: "Max widget rows",
256
+ get: () => getSettings().maxWidgetRows,
257
+ inc: () => updateSettings((s) => { s.maxWidgetRows = Math.min(20, s.maxWidgetRows + 1); }, ctx),
258
+ dec: () => updateSettings((s) => { s.maxWidgetRows = Math.max(1, s.maxWidgetRows - 1); }, ctx),
259
+ min: 1,
260
+ max: 20,
261
+ },
262
+ {
263
+ kind: "toggle",
264
+ label: "Show idle hint",
265
+ hint: "persists across sessions",
266
+ get: () => getSettings().showIdleHint,
267
+ toggle: () => updateSettings((s) => { s.showIdleHint = !s.showIdleHint; }, ctx),
268
+ },
269
+ {
270
+ kind: "number",
271
+ label: "Max peek size (KB)",
272
+ get: () => Math.round(getSettings().maxPeekBytes / 1024),
273
+ inc: () => updateSettings((s) => {
274
+ s.maxPeekBytes = Math.min(8192 * 1024, s.maxPeekBytes + 64 * 1024);
275
+ }, ctx),
276
+ dec: () => updateSettings((s) => {
277
+ s.maxPeekBytes = Math.max(64 * 1024, s.maxPeekBytes - 64 * 1024);
278
+ }, ctx),
279
+ min: 64,
280
+ max: 8192,
281
+ },
282
+ ];
283
+
284
+ await ctx.ui.custom(
285
+ (tui: any, theme: any, _kb: any, done: (v: null) => void) => {
286
+ const B = (s: string) => theme.fg("border", s);
287
+
288
+ const buildRow = (item: MenuItem, i: number, innerW: number): string => {
289
+ const isSelected = i === selected;
290
+ const gutter = isSelected ? theme.fg("accent", "›") : " ";
291
+
292
+ let rowContent: string;
293
+ if (item.kind === "toggle") {
294
+ const on = item.get();
295
+ // Dim the "Collapse" option when widget is fully disabled — it's a no-op.
296
+ const dimmed = item.label === "Collapse this session" && !getSettings().enabled;
297
+ const box = dimmed
298
+ ? theme.fg("dim", "[ ]")
299
+ : on
300
+ ? theme.fg("success", "[●]")
301
+ : theme.fg("dim", "[ ]");
302
+ const labelColor = dimmed ? "dim" : isSelected ? "accent" : "muted";
303
+ const label = theme.fg(labelColor, item.label);
304
+ const hintStr = item.hint ? theme.fg("dim", ` ${item.hint}`) : "";
305
+ rowContent = ` ${box} ${label}${hintStr}`;
306
+ } else {
307
+ const val = item.get();
308
+ const atMin = val <= item.min;
309
+ const atMax = val >= item.max;
310
+ const labelColor = isSelected ? "accent" : "muted";
311
+ const left = atMin ? theme.fg("dim", "‹") : theme.fg("accent", "‹");
312
+ const right = atMax ? theme.fg("dim", "›") : theme.fg("accent", "›");
313
+ rowContent = ` ${theme.fg(labelColor, item.label)}: ${left} ${theme.fg("success", String(val))} ${right}`;
314
+ }
315
+
316
+ const full = gutter + rowContent;
317
+ const cell = truncateToWidth(full, innerW);
318
+ return B("│") + cell + " ".repeat(Math.max(0, innerW - visibleWidth(cell))) + B("│");
319
+ };
320
+
321
+ const build = (width: number): string[] => {
322
+ const innerW = width - 2;
323
+ const H = "─";
324
+ const lines: string[] = [];
325
+
326
+ lines.push(B("╭" + H.repeat(innerW) + "╮"));
327
+ const title = " ⚙ Agent Files Settings";
328
+ const hint = "↑↓ move spc/↵ toggle ←→ adjust esc close ";
329
+ const gap = Math.max(1, innerW - visibleWidth(title) - visibleWidth(hint));
330
+ lines.push(
331
+ B("│") + theme.fg("accent", title) + " ".repeat(gap) + theme.fg("dim", hint) + B("│"),
332
+ );
333
+ lines.push(B("├" + H.repeat(innerW) + "┤"));
334
+
335
+ for (let i = 0; i < items.length; i++) {
336
+ lines.push(buildRow(items[i], i, innerW));
337
+ }
338
+
339
+ lines.push(B("╰" + H.repeat(innerW) + "╯"));
340
+ return lines;
341
+ };
342
+
343
+ return {
344
+ render: (w: number) => build(w),
345
+ invalidate: () => {},
346
+ handleInput: (data: string) => {
347
+ if (matchesKey(data, Key.escape) || data === "q") return done(null);
348
+ if (matchesKey(data, Key.up)) { selected = Math.max(0, selected - 1); tui.requestRender(); return; }
349
+ if (matchesKey(data, Key.down)) { selected = Math.min(items.length - 1, selected + 1); tui.requestRender(); return; }
350
+
351
+ const item = items[selected];
352
+ if (!item) return;
353
+
354
+ if (item.kind === "toggle" && (data === " " || data === "\r")) {
355
+ item.toggle();
356
+ tui.requestRender();
357
+ return;
358
+ }
359
+ if (item.kind === "number") {
360
+ if (matchesKey(data, Key.right)) { item.inc(); tui.requestRender(); return; }
361
+ if (matchesKey(data, Key.left)) { item.dec(); tui.requestRender(); return; }
362
+ }
363
+ },
364
+ };
365
+ },
366
+ {
367
+ overlay: true,
368
+ overlayOptions: { width: "60%", maxWidth: 72, minWidth: 52, maxHeight: "50%", anchor: "center" },
369
+ },
370
+ );
371
+ },
372
+ });
373
+ }
374
+
375
+ // ─── Project tree ─────────────────────────────────────────────────────────────
376
+
377
+ function registerTreeCommands(
378
+ pi: ExtensionAPI,
379
+ edited: Map<string, EditStatus>,
380
+ getSettings: () => Settings,
381
+ ) {
382
+ const openExternally = (ctx: any, absPath: string) => {
383
+ const { cmd, args } = buildOpenCommand(process.platform, absPath);
384
+ try {
385
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
386
+ child.on("error", () => {
387
+ ctx.ui.notify(`Could not open ${absPath}`, "error");
388
+ });
389
+ child.unref();
390
+ } catch {
391
+ ctx.ui.notify(`Could not open ${absPath}`, "error");
392
+ }
393
+ };
394
+
395
+ const peek = async (ctx: any, absPath: string) => {
396
+ const max = getSettings().maxPeekBytes;
397
+ let size = 0;
398
+ try {
399
+ size = statSync(absPath).size;
400
+ } catch {
401
+ ctx.ui.notify(`Cannot read ${basename(absPath)}`, "error");
402
+ return;
403
+ }
404
+ if (!isPreviewable(size, max)) {
405
+ const kb = (size / 1024).toFixed(0);
406
+ ctx.ui.notify(
407
+ `${basename(absPath)} too large to preview (${kb} KB) — press Enter to open externally`,
408
+ "warning",
409
+ );
410
+ return;
411
+ }
412
+
413
+ let raw: Buffer;
414
+ try {
415
+ raw = readFileSync(absPath);
416
+ } catch {
417
+ ctx.ui.notify(`Cannot read ${basename(absPath)}`, "error");
418
+ return;
419
+ }
420
+ if (looksBinary(raw.subarray(0, 4096))) {
421
+ ctx.ui.notify(
422
+ `${basename(absPath)} looks binary — press Enter to open externally`,
423
+ "warning",
424
+ );
425
+ return;
426
+ }
427
+
428
+ const text = raw.toString("utf-8");
429
+ const lang = detectLanguageFromPath(absPath);
430
+ let rendered: string;
431
+ if (lang === "markdown") {
432
+ // Built-in pure highlighter — zero deps, 16-color ANSI, works on every OS.
433
+ rendered = highlightMarkdown(text);
434
+ } else {
435
+ try {
436
+ // S4: force color so cli-highlight (chalk) emits ANSI under pi's managed,
437
+ // non-TTY stdout. Without this, peek shows uncolored plain text.
438
+ process.env.FORCE_COLOR ||= "3";
439
+ // B2: lazy + graceful — if cli-highlight is missing, fall back to plain
440
+ // text instead of crashing the whole extension at module load.
441
+ const mod = await import("cli-highlight").catch(() => undefined);
442
+ rendered = mod?.highlight
443
+ ? mod.highlight(text, { language: lang, ignoreIllegals: true })
444
+ : text;
445
+ } catch {
446
+ rendered = text; // never crash the peek on a highlight failure
447
+ }
448
+ }
449
+ // Tab-expand so widths are predictable; split into display lines.
450
+ const allLines = rendered.replace(/\t/g, " ").split("\n");
451
+
452
+ let peekScroll = 0;
453
+ await ctx.ui.custom(
454
+ (tui: any, theme: any, _kb: any, done: (v: null) => void) => {
455
+ const B = (s: string) => theme.fg("border", s);
456
+ const bodyH = (): number => Math.max(1, Math.floor(tui.terminal.rows * 0.8) - 4);
457
+
458
+ const buildPeek = (width: number): string[] => {
459
+ const innerW = width - 2;
460
+ const h = bodyH();
461
+ const maxScroll = Math.max(0, allLines.length - h);
462
+ if (peekScroll > maxScroll) peekScroll = maxScroll;
463
+ if (peekScroll < 0) peekScroll = 0;
464
+ const H = "─";
465
+ const lines: string[] = [];
466
+ lines.push(B("╭" + H.repeat(innerW) + "╮"));
467
+ const title = ` 👁 ${basename(absPath)}`;
468
+ const pos = `${peekScroll + 1}-${Math.min(peekScroll + h, allLines.length)}/${allLines.length} `;
469
+ const hint = `↑↓ scroll g/G ends spc/esc close ${pos}`;
470
+ const gap = Math.max(1, innerW - visibleWidth(title) - visibleWidth(hint));
471
+ lines.push(B("│") + theme.fg("accent", title) + " ".repeat(gap) +
472
+ theme.fg("dim", hint) + B("│"));
473
+ lines.push(B("├" + H.repeat(innerW) + "┤"));
474
+ const view = allLines.slice(peekScroll, peekScroll + h);
475
+ const rowsOut = view.length ? view : [theme.fg("dim", " (empty file)")];
476
+ for (const row of rowsOut) {
477
+ const cell = truncateToWidth(row, innerW);
478
+ // S5: append a hard reset so a multi-line highlight token (block
479
+ // comment, template literal) never bleeds color into the padding
480
+ // or right border of this or the next row.
481
+ const padded = cell + "\x1b[0m" + " ".repeat(Math.max(0, innerW - visibleWidth(cell)));
482
+ lines.push(B("│") + padded + B("│"));
483
+ }
484
+ lines.push(B("╰" + H.repeat(innerW) + "╯"));
485
+ return lines;
486
+ };
487
+
488
+ return {
489
+ render: (w: number) => buildPeek(w),
490
+ invalidate: () => {},
491
+ handleInput: (data: string) => {
492
+ const h = bodyH();
493
+ const maxScroll = Math.max(0, allLines.length - h);
494
+ if (matchesKey(data, Key.escape) || data === "q" || data === " ") return done(null);
495
+ if (matchesKey(data, Key.up)) { peekScroll = Math.max(0, peekScroll - 1); tui.requestRender(); return; }
496
+ if (matchesKey(data, Key.down)) { peekScroll = Math.min(maxScroll, peekScroll + 1); tui.requestRender(); return; }
497
+ if (matchesKey(data, Key.pageUp)) { peekScroll = Math.max(0, peekScroll - h); tui.requestRender(); return; }
498
+ if (matchesKey(data, Key.pageDown)) { peekScroll = Math.min(maxScroll, peekScroll + h); tui.requestRender(); return; }
499
+ if (data === "g") { peekScroll = 0; tui.requestRender(); return; }
500
+ if (data === "G") { peekScroll = maxScroll; tui.requestRender(); return; }
501
+ },
502
+ };
503
+ },
504
+ {
505
+ overlay: true,
506
+ overlayOptions: { width: "85%", maxWidth: 120, minWidth: 50, maxHeight: "80%", anchor: "center" },
507
+ },
508
+ );
509
+ };
510
+
511
+ const open = async (ctx: any) => {
512
+ if (ctx.mode !== "tui") {
513
+ ctx.ui.notify("/pi-files requires TUI mode", "error");
514
+ return;
515
+ }
516
+ const cwd = ctx.sessionManager.getCwd();
517
+ const allFiles = listProjectFiles(cwd);
518
+ const root = buildTree(allFiles);
519
+
520
+ // Edited files as cwd-relative posix paths for highlight + auto-expand.
521
+ const toRel = (abs: string) => relative(cwd, abs).split("\\").join("/");
522
+ const editedStatus = new Map<string, EditStatus>();
523
+ for (const [abs, status] of edited.entries()) editedStatus.set(toRel(abs), status);
524
+
525
+ const expanded = new Set<string>();
526
+ for (const rel of editedStatus.keys()) {
527
+ for (const dir of ancestorsOf(rel)) expanded.add(dir);
528
+ }
529
+
530
+ let selected = 0;
531
+ let scroll = 0;
532
+ let searchQuery = "";
533
+ let treeSelected = 0; // tree cursor saved when search starts, restored on clear
534
+ let treeScroll = 0;
535
+
536
+ await ctx.ui.custom(
537
+ (tui: any, theme: any, _kb: any, done: (v: null) => void) => {
538
+ const B = (s: string) => theme.fg("border", s);
539
+
540
+ // Highlight the matched portion of a path in accent color.
541
+ const highlightMatch = (path: string, query: string): string => {
542
+ if (!query) return theme.fg("muted", path);
543
+ const lo = path.toLowerCase().indexOf(query.toLowerCase());
544
+ if (lo < 0) return theme.fg("muted", path);
545
+ return (
546
+ theme.fg("muted", path.slice(0, lo)) +
547
+ theme.fg("accent", path.slice(lo, lo + query.length)) +
548
+ theme.fg("muted", path.slice(lo + query.length))
549
+ );
550
+ };
551
+
552
+ const visibleBody = (): number => {
553
+ const max = Math.max(1, Math.floor(tui.terminal.rows * 0.8) - 4);
554
+ const total = searchQuery
555
+ ? Math.max(1, filterFiles(allFiles, searchQuery).length)
556
+ : Math.max(1, flattenVisible(root, expanded).length);
557
+ return Math.min(max, total);
558
+ };
559
+
560
+ const buildSearchBody = (innerW: number, bodyH: number): string[] => {
561
+ const results = filterFiles(allFiles, searchQuery);
562
+ if (selected >= results.length) selected = Math.max(0, results.length - 1);
563
+ if (selected < 0) selected = 0;
564
+ if (selected < scroll) scroll = selected;
565
+ if (selected >= scroll + bodyH) scroll = selected - bodyH + 1;
566
+ if (scroll < 0) scroll = 0;
567
+
568
+ return results.slice(scroll, scroll + bodyH).map((path, i) => {
569
+ const idx = scroll + i;
570
+ const isSelected = idx === selected;
571
+ const status = editedStatus.get(path);
572
+ const statusPart = status ? statusGlyph(status) + " " : "";
573
+ const gutter = isSelected ? theme.fg("accent", "›") : " ";
574
+ const prefix = status
575
+ ? theme.fg(status === "new" ? "success" : "warning", statusGlyph(status) + " ")
576
+ : "";
577
+ const pathStyled = highlightMatch(path, searchQuery);
578
+ // Use plain-text width for padding calculation
579
+ const pad = " ".repeat(Math.max(0, innerW - 1 - visibleWidth(` ${statusPart}${path}`)));
580
+ return gutter + " " + prefix + pathStyled + pad;
581
+ });
582
+ };
583
+
584
+ const buildBody = (innerW: number, bodyH: number): string[] => {
585
+ const rows = flattenVisible(root, expanded);
586
+ if (selected >= rows.length) selected = rows.length - 1;
587
+ if (selected < 0) selected = 0;
588
+ if (selected < scroll) scroll = selected;
589
+ if (selected >= scroll + bodyH) scroll = selected - bodyH + 1;
590
+ if (scroll < 0) scroll = 0;
591
+
592
+ return rows.slice(scroll, scroll + bodyH).map((n, i) => {
593
+ const idx = scroll + i;
594
+ const indent = " ".repeat(n.depth);
595
+ const caret = n.isDir ? (expanded.has(n.path) ? "▾ " : "▸ ") : " ";
596
+ const status = !n.isDir ? editedStatus.get(n.path) : undefined;
597
+
598
+ // S4: raw + styled share identical glyph prefixes so widths match.
599
+ const namePlain = status ? `${statusGlyph(status)} ${n.name}` : n.name;
600
+ const nameStyled = status
601
+ ? theme.fg(status === "new" ? "success" : "warning", namePlain)
602
+ : n.isDir
603
+ ? theme.fg("accent", n.name)
604
+ : theme.fg("muted", n.name);
605
+
606
+ // S3: reserve a 1-col cursor gutter; row content starts at column 2,
607
+ // so the selection marker never overwrites the caret/glyph.
608
+ const gutter = idx === selected ? theme.fg("accent", "›") : " ";
609
+ const contentPlain = ` ${indent}${caret}${namePlain}`;
610
+ const contentStyled = ` ${indent}${caret}${nameStyled}`;
611
+ const pad = " ".repeat(Math.max(0, innerW - 1 - visibleWidth(contentPlain)));
612
+ return gutter + contentStyled + pad;
613
+ });
614
+ };
615
+
616
+ const build = (width: number): string[] => {
617
+ const innerW = width - 2;
618
+ const bodyH = visibleBody();
619
+ const H = "─";
620
+ const lines: string[] = [];
621
+ lines.push(B("╭" + H.repeat(innerW) + "╮"));
622
+ const title = " 📁 Project files";
623
+ if (searchQuery) {
624
+ const count = filterFiles(allFiles, searchQuery).length;
625
+ const prompt = theme.fg("success", `/ ${searchQuery}▌`);
626
+ const info = theme.fg("dim", ` ${count} result${count !== 1 ? "s" : ""} esc clear `);
627
+ const promptPlain = `/ ${searchQuery}▌`;
628
+ const infoPlain = ` ${count} result${count !== 1 ? "s" : ""} esc clear `;
629
+ const gap = Math.max(1, innerW - visibleWidth(title) - visibleWidth(promptPlain) - visibleWidth(infoPlain));
630
+ lines.push(B("│") + theme.fg("accent", title) + " ".repeat(gap) + prompt + info + B("│"));
631
+ } else {
632
+ const hint = "↑↓ move ↵ open → expand ← collapse spc peek type to filter esc close ";
633
+ const gap = Math.max(1, innerW - visibleWidth(title) - visibleWidth(hint));
634
+ lines.push(B("│") + theme.fg("accent", title) + " ".repeat(gap) +
635
+ theme.fg("dim", hint) + B("│"));
636
+ }
637
+ lines.push(B("├" + H.repeat(innerW) + "┤"));
638
+ const body = searchQuery ? buildSearchBody(innerW, bodyH) : buildBody(innerW, bodyH);
639
+ const empty = searchQuery ? " (no matches)" : " (no files)";
640
+ const rowsOut = body.length ? body : [theme.fg("dim", empty)];
641
+ for (const row of rowsOut) {
642
+ const cell = truncateToWidth(row, innerW);
643
+ lines.push(B("│") + cell + " ".repeat(Math.max(0, innerW - visibleWidth(cell))) + B("│"));
644
+ }
645
+ lines.push(B("╰" + H.repeat(innerW) + "╯"));
646
+ return lines;
647
+ };
648
+
649
+ return {
650
+ render: (w: number) => build(w),
651
+ invalidate: () => {},
652
+ handleInput: (data: string) => {
653
+ // Esc: clear filter and restore tree cursor, or close when already clear
654
+ if (matchesKey(data, Key.escape)) {
655
+ if (searchQuery) { searchQuery = ""; selected = treeSelected; scroll = treeScroll; tui.requestRender(); }
656
+ else done(null);
657
+ return;
658
+ }
659
+
660
+ // Backspace: remove last filter char; restore tree cursor when emptied
661
+ if (data === "\x7f" || data === "\b") {
662
+ if (searchQuery.length > 0) {
663
+ searchQuery = searchQuery.slice(0, -1);
664
+ if (searchQuery.length === 0) { selected = treeSelected; scroll = treeScroll; }
665
+ else { selected = 0; scroll = 0; }
666
+ tui.requestRender();
667
+ }
668
+ return;
669
+ }
670
+
671
+ // Space: peek selected file (Quick Look — works in both modes)
672
+ if (data === " ") {
673
+ if (searchQuery) {
674
+ const path = filterFiles(allFiles, searchQuery)[selected];
675
+ if (path) void peek(ctx, resolve(cwd, path));
676
+ } else {
677
+ const rows = flattenVisible(root, expanded);
678
+ const node = rows[selected];
679
+ if (node && !node.isDir) void peek(ctx, resolve(cwd, node.path));
680
+ else if (node?.isDir) ctx.ui.notify("Space peeks files — press Enter or → to expand directories", "warning");
681
+ }
682
+ return;
683
+ }
684
+
685
+ // Up / Down: navigate
686
+ if (matchesKey(data, Key.up)) {
687
+ selected = Math.max(0, selected - 1); tui.requestRender(); return;
688
+ }
689
+ // Down: also uses pre-computed results if available to avoid extra call
690
+ if (matchesKey(data, Key.down)) {
691
+ const count = searchQuery
692
+ ? filterFiles(allFiles, searchQuery).length
693
+ : flattenVisible(root, expanded).length;
694
+ selected = Math.min(Math.max(0, count - 1), selected + 1);
695
+ tui.requestRender(); return;
696
+ }
697
+
698
+ // Enter: open selected
699
+ if (data === "\r") {
700
+ if (searchQuery) {
701
+ const path = filterFiles(allFiles, searchQuery)[selected];
702
+ if (path) openExternally(ctx, resolve(cwd, path));
703
+ } else {
704
+ const rows = flattenVisible(root, expanded);
705
+ const node = rows[selected];
706
+ if (!node) return;
707
+ if (node.isDir) { expanded.add(node.path); tui.requestRender(); }
708
+ else openExternally(ctx, resolve(cwd, node.path));
709
+ }
710
+ return;
711
+ }
712
+
713
+ // Tree-only keys: expand / collapse dirs (inactive while filtering)
714
+ if (!searchQuery) {
715
+ const rows = flattenVisible(root, expanded);
716
+ const node = rows[selected];
717
+ if (!node) return;
718
+ if (matchesKey(data, Key.right)) {
719
+ if (node.isDir) { expanded.add(node.path); tui.requestRender(); }
720
+ return;
721
+ }
722
+ if (matchesKey(data, Key.left)) {
723
+ if (node.isDir && expanded.has(node.path)) {
724
+ expanded.delete(node.path);
725
+ } else {
726
+ const parents = ancestorsOf(node.path);
727
+ const parent = parents[parents.length - 1];
728
+ if (parent) {
729
+ const pIdx = flattenVisible(root, expanded).findIndex((n) => n.path === parent);
730
+ if (pIdx >= 0) selected = pIdx;
731
+ }
732
+ }
733
+ tui.requestRender(); return;
734
+ }
735
+ }
736
+
737
+ // Any printable char (excl. space): append to filter.
738
+ // Save tree cursor on first character so Esc/backspace can restore it.
739
+ if (data.length === 1 && data >= "!") {
740
+ if (!searchQuery) { treeSelected = selected; treeScroll = scroll; }
741
+ searchQuery += data; selected = 0; scroll = 0; tui.requestRender();
742
+ }
743
+ },
744
+ };
745
+ },
746
+ {
747
+ overlay: true,
748
+ overlayOptions: { width: "80%", maxWidth: 100, minWidth: 50, maxHeight: "80%", anchor: "center" },
749
+ },
750
+ );
751
+ };
752
+
753
+ pi.registerCommand("pi-files", { description: "Browse the project file tree (agent edits highlighted)", handler: (_a, ctx) => open(ctx) });
754
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@guneriu/pi-files",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "description": "Shows agent-edited files in a compact widget above the input bar, plus an on-demand interactive project tree (gitignore-aware)",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "files",
10
+ "tree",
11
+ "explorer",
12
+ "session"
13
+ ],
14
+ "author": "Uğur Güneri (guneriu)",
15
+ "license": "MIT",
16
+ "files": [
17
+ "extensions/",
18
+ "src/",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/guneriu/pi-extension-mono",
25
+ "directory": "packages/pi-files"
26
+ },
27
+ "pi": {
28
+ "extensions": [
29
+ "./extensions"
30
+ ],
31
+ "image": "https://raw.githubusercontent.com/guneriu/pi-extension-mono/main/assets/pi-files-preview.png"
32
+ },
33
+ "dependencies": {
34
+ "cli-highlight": "^2.1.11"
35
+ },
36
+ "peerDependencies": {
37
+ "@earendil-works/pi-coding-agent": "*",
38
+ "@earendil-works/pi-tui": "*"
39
+ }
40
+ }
package/src/core.ts ADDED
@@ -0,0 +1,373 @@
1
+ export type EditStatus = "new" | "modified";
2
+ export type EditKind = "write" | "edit";
3
+
4
+ /**
5
+ * Decide the status glyph for a write/edit.
6
+ * - "new" is sticky once set (the agent created the file this session).
7
+ * - write to a path that does not currently exist => "new".
8
+ * - everything else => "modified".
9
+ */
10
+ export function classifyEdit(
11
+ kind: EditKind,
12
+ existsBefore: boolean,
13
+ previous: EditStatus | undefined,
14
+ ): EditStatus {
15
+ if (previous === "new") return "new";
16
+ if (kind === "write" && !existsBefore) return "new";
17
+ return "modified";
18
+ }
19
+
20
+ export interface EditedFile {
21
+ relPath: string;
22
+ status: EditStatus;
23
+ }
24
+
25
+ export interface WidgetLines {
26
+ header: string | undefined;
27
+ rows: string[];
28
+ overflow: string | undefined;
29
+ }
30
+
31
+ export function statusGlyph(status: EditStatus): string {
32
+ return status === "new" ? "+" : "M";
33
+ }
34
+
35
+ /**
36
+ * Build plain (unstyled) widget content; the extension applies theme colors.
37
+ * `files` MUST already be in display order (newest edit first) — the extension
38
+ * is responsible for ordering. We keep the first `maxRows` so the newest edits
39
+ * are shown and older ones fold into the overflow line.
40
+ */
41
+ export function buildWidgetLines(files: EditedFile[], maxRows: number): WidgetLines {
42
+ if (files.length === 0) {
43
+ return { header: undefined, rows: [], overflow: undefined };
44
+ }
45
+ const shown = files.slice(0, maxRows);
46
+ const rows = shown.map((f) => `${statusGlyph(f.status)} ${f.relPath}`);
47
+ const hidden = files.length - shown.length;
48
+ return {
49
+ header: `Edited files (${files.length})`,
50
+ rows,
51
+ overflow: hidden > 0 ? `… +${hidden} more` : undefined,
52
+ };
53
+ }
54
+
55
+ export interface TreeNode {
56
+ name: string; // basename
57
+ path: string; // posix-style relative path from cwd
58
+ isDir: boolean;
59
+ depth: number; // 0 for top-level entries
60
+ children: TreeNode[];
61
+ }
62
+
63
+ /** Parse `git ls-files` style newline output into clean relative paths. */
64
+ export function parseGitFileList(out: string): string[] {
65
+ return out
66
+ .split("\n")
67
+ .map((l) => l.trim())
68
+ .filter((l) => l.length > 0);
69
+ }
70
+
71
+ /** All ancestor directory paths of a file, root-first. "docs/plans/x" -> ["docs","docs/plans"]. */
72
+ export function ancestorsOf(relPath: string): string[] {
73
+ const parts = relPath.split("/");
74
+ const dirs: string[] = [];
75
+ for (let i = 0; i < parts.length - 1; i++) {
76
+ dirs.push(parts.slice(0, i + 1).join("/"));
77
+ }
78
+ return dirs;
79
+ }
80
+
81
+ /** Build a synthetic root node whose children are the top-level entries. */
82
+ export function buildTree(relPaths: string[]): TreeNode {
83
+ const root: TreeNode = { name: "", path: "", isDir: true, depth: -1, children: [] };
84
+ for (const rel of relPaths) {
85
+ const parts = rel.split("/");
86
+ let node = root;
87
+ for (let i = 0; i < parts.length; i++) {
88
+ const name = parts[i];
89
+ const isDir = i < parts.length - 1;
90
+ const path = parts.slice(0, i + 1).join("/");
91
+ let child = node.children.find((c) => c.name === name && c.isDir === isDir);
92
+ if (!child) {
93
+ child = { name, path, isDir, depth: i, children: [] };
94
+ node.children.push(child);
95
+ }
96
+ node = child;
97
+ }
98
+ }
99
+ sortTree(root);
100
+ return root;
101
+ }
102
+
103
+ function sortTree(node: TreeNode): void {
104
+ node.children.sort((a, b) => {
105
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; // dirs first
106
+ return a.name.localeCompare(b.name);
107
+ });
108
+ for (const c of node.children) sortTree(c);
109
+ }
110
+
111
+ /** DFS over the tree, descending into a dir only when its path is in `expanded`. */
112
+ export function flattenVisible(root: TreeNode, expanded: Set<string>): TreeNode[] {
113
+ const out: TreeNode[] = [];
114
+ const walk = (n: TreeNode) => {
115
+ for (const child of n.children) {
116
+ out.push(child);
117
+ if (child.isDir && expanded.has(child.path)) walk(child);
118
+ }
119
+ };
120
+ walk(root);
121
+ return out;
122
+ }
123
+
124
+ export interface BranchEdit {
125
+ path: string;
126
+ kind: EditKind;
127
+ }
128
+
129
+ /**
130
+ * Scan a session branch (ctx.sessionManager.getBranch()) for write/edit tool
131
+ * calls and return their target paths in order. Mirrors the toolCall shape used
132
+ * across pi sessions: assistant message -> content[] -> { type:"toolCall", name, arguments }.
133
+ */
134
+ export function extractEditsFromBranch(branch: any[]): BranchEdit[] {
135
+ const edits: BranchEdit[] = [];
136
+ for (const entry of branch) {
137
+ if (entry?.type !== "message") continue;
138
+ if (entry.message?.role !== "assistant") continue;
139
+ for (const block of entry.message?.content ?? []) {
140
+ if (block?.type !== "toolCall") continue;
141
+ const kind = block.name;
142
+ if (kind !== "write" && kind !== "edit") continue;
143
+ const path = block.arguments?.path;
144
+ if (typeof path !== "string" || path.length === 0) continue;
145
+ edits.push({ path, kind });
146
+ }
147
+ }
148
+ return edits;
149
+ }
150
+
151
+ import { readdirSync, existsSync } from "node:fs";
152
+ import { join } from "node:path";
153
+ import { execFileSync } from "node:child_process";
154
+
155
+ const ALWAYS_EXCLUDE = new Set([".git", "node_modules"]);
156
+
157
+ /** Recursive readdir fallback returning posix-relative paths, excluding noise dirs. */
158
+ export function walkDirRelative(cwd: string): string[] {
159
+ const out: string[] = [];
160
+ const walk = (absDir: string, relPrefix: string) => {
161
+ let entries: import("node:fs").Dirent[];
162
+ try {
163
+ entries = readdirSync(absDir, { withFileTypes: true });
164
+ } catch {
165
+ return;
166
+ }
167
+ for (const e of entries) {
168
+ if (e.isDirectory() && ALWAYS_EXCLUDE.has(e.name)) continue;
169
+ const rel = relPrefix ? `${relPrefix}/${e.name}` : e.name;
170
+ if (e.isDirectory()) {
171
+ walk(`${absDir}/${e.name}`, rel);
172
+ } else if (e.isFile()) {
173
+ out.push(rel);
174
+ }
175
+ }
176
+ };
177
+ walk(cwd, "");
178
+ return out;
179
+ }
180
+
181
+ /**
182
+ * Preferred source: git ls-files (respects .gitignore exactly). Falls back to a
183
+ * filesystem walk when not a git repo or git is unavailable.
184
+ *
185
+ * Note: `git ls-files --cached` can report staged-but-deleted paths, so we drop
186
+ * entries that no longer exist on disk before building the tree.
187
+ */
188
+ export function listProjectFiles(cwd: string): string[] {
189
+ try {
190
+ const out = execFileSync(
191
+ "git",
192
+ ["ls-files", "--cached", "--others", "--exclude-standard"],
193
+ { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] },
194
+ );
195
+ const files = parseGitFileList(out).filter((rel) => existsSync(join(cwd, rel)));
196
+ return files; // trust git result even when empty — avoids surfacing gitignored files via fallback
197
+ } catch {
198
+ return walkDirRelative(cwd);
199
+ }
200
+ }
201
+
202
+ // ─── Open & Peek helpers ────────────────────────────────────────────────────
203
+
204
+ export interface OpenCommand {
205
+ cmd: string;
206
+ args: string[];
207
+ }
208
+
209
+ /**
210
+ * Map a platform + absolute path to a spawnable OS "open with default app"
211
+ * command. Pure so it can be unit-tested without spawning anything.
212
+ * - darwin -> `open <path>`
213
+ * - win32 -> `cmd /c start "" <path>` (empty "" is the start window title)
214
+ * - other -> `xdg-open <path>` (linux, *bsd, incl. WSL)
215
+ */
216
+ export function buildOpenCommand(
217
+ platform: NodeJS.Platform,
218
+ absPath: string,
219
+ ): OpenCommand {
220
+ if (platform === "darwin") return { cmd: "open", args: [absPath] };
221
+ if (platform === "win32") return { cmd: "cmd", args: ["/c", "start", "", absPath] };
222
+ return { cmd: "xdg-open", args: [absPath] };
223
+ }
224
+
225
+ /** Minimal extension -> cli-highlight language id map. Undefined => auto-detect. */
226
+ const EXT_LANG: Record<string, string> = {
227
+ ts: "typescript",
228
+ tsx: "typescript",
229
+ js: "javascript",
230
+ jsx: "javascript",
231
+ mjs: "javascript",
232
+ cjs: "javascript",
233
+ json: "json",
234
+ md: "markdown",
235
+ markdown: "markdown",
236
+ css: "css",
237
+ scss: "scss",
238
+ html: "html",
239
+ xml: "xml",
240
+ yml: "yaml",
241
+ yaml: "yaml",
242
+ sh: "bash",
243
+ bash: "bash",
244
+ py: "python",
245
+ rs: "rust",
246
+ go: "go",
247
+ c: "c",
248
+ h: "c",
249
+ cpp: "cpp",
250
+ java: "java",
251
+ rb: "ruby",
252
+ toml: "ini",
253
+ sql: "sql",
254
+ };
255
+
256
+ /** Language id for cli-highlight from a file path, or undefined to auto-detect. */
257
+ export function detectLanguageFromPath(path: string): string | undefined {
258
+ const base = path.split("/").pop() ?? path;
259
+ const dot = base.lastIndexOf(".");
260
+ if (dot <= 0) return undefined; // no ext, or dotfile like ".env"
261
+ const ext = base.slice(dot + 1).toLowerCase();
262
+ return EXT_LANG[ext];
263
+ }
264
+
265
+ /** Heuristic: a NUL byte in the sampled bytes means "binary". */
266
+ export function looksBinary(buf: Buffer | Uint8Array): boolean {
267
+ for (let i = 0; i < buf.length; i++) {
268
+ if (buf[i] === 0) return true;
269
+ }
270
+ return false;
271
+ }
272
+
273
+ /** True when a file of `sizeBytes` is within the peek cap `maxBytes`. */
274
+ export function isPreviewable(sizeBytes: number, maxBytes: number): boolean {
275
+ return sizeBytes <= maxBytes;
276
+ }
277
+
278
+ /**
279
+ * Case-insensitive substring filter over a flat file list.
280
+ * Empty query returns the full list unchanged.
281
+ */
282
+ export function filterFiles(files: string[], query: string): string[] {
283
+ if (!query) return files;
284
+ const q = query.toLowerCase();
285
+ return files.filter((f) => f.toLowerCase().includes(q));
286
+ }
287
+
288
+ // ─── Markdown highlighter ───────────────────────────────────────────────────
289
+ // Pure, zero-dependency, 16-color ANSI — works on macOS, Linux, Windows.
290
+ // Uses only the base 16-color range so there is no truecolor/256-color
291
+ // requirement; every terminal and Windows Terminal supports these codes.
292
+
293
+ const _R = "\x1b[0m"; // reset
294
+ const _B = "\x1b[1m"; // bold
295
+ const _DIM = "\x1b[2m"; // dim
296
+ const _IT = "\x1b[3m"; // italic
297
+ const _CYN = "\x1b[36m"; // cyan — headings
298
+ const _YLW = "\x1b[33m"; // yellow — code
299
+ const _GRN = "\x1b[32m"; // green — list markers
300
+ const _MGT = "\x1b[35m"; // magenta — links
301
+ const _BLU = "\x1b[94m"; // bright blue — blockquotes
302
+
303
+ /**
304
+ * Apply inline markdown spans to a single string: inline code, bold, italic,
305
+ * links. Exported so it can be unit-tested independently.
306
+ */
307
+ export function applyInlineMarkdown(text: string): string {
308
+ // Inline code first — everything inside backticks is literal.
309
+ text = text.replace(/`([^`\n]+)`/g, `${_YLW}\`$1\`${_R}`);
310
+ // Bold: **text** or __text__ — exclude spans that contain backticks (already code-styled)
311
+ text = text.replace(/\*\*([^*\n`]+)\*\*/g, `${_B}**$1**${_R}`);
312
+ text = text.replace(/__([^_\n`]+)__/g, `${_B}__$1__${_R}`);
313
+ // Italic: *text* or _text_ — lookbehind/lookahead on backtick prevents matching
314
+ // inside code spans whose replacement output still contains the ` char.
315
+ text = text.replace(/(?<![*_`])\*([^*\n`]+)\*(?![*`])/g, `${_IT}*$1*${_R}`);
316
+ text = text.replace(/(?<![_`])_([^_\n`]+)_(?![_`])/g, `${_IT}_$1_${_R}`);
317
+ // Links / images: [text](url)
318
+ text = text.replace(/!?\[([^\]]*)\]\(([^)]*)\)/g, `${_MGT}[$1]${_R}${_DIM}($2)${_R}`);
319
+ return text;
320
+ }
321
+
322
+ /**
323
+ * Syntax-highlight a full markdown document for terminal display.
324
+ * Processes line-by-line with stateful fenced-code-block tracking.
325
+ * Emits standard 16-color ANSI codes only — compatible with every terminal.
326
+ */
327
+ export function highlightMarkdown(text: string): string {
328
+ let inFence = false;
329
+ return text
330
+ .split("\n")
331
+ .map((line) => {
332
+ // Fenced code block delimiter (``` or ~~~)
333
+ if (/^(`{3,}|~{3,})/.test(line)) {
334
+ inFence = !inFence;
335
+ return `${_DIM}${_YLW}${line}${_R}`;
336
+ }
337
+ // Inside a fenced code block — dim yellow, no further processing
338
+ if (inFence) return `${_YLW}${_DIM}${line}${_R}`;
339
+
340
+ // ATX headings: # … ######
341
+ const hm = line.match(/^(#{1,6})\s(.+)$/);
342
+ if (hm) {
343
+ const marks = hm[1];
344
+ const body = applyInlineMarkdown(hm[2]);
345
+ if (marks.length === 1) return `${_B}${_CYN}${marks} ${_R}${_B}${body}${_R}`;
346
+ if (marks.length === 2) return `${_B}${_CYN}${marks} ${_R}${body}`;
347
+ return `${_DIM}${_CYN}${marks} ${_R}${body}`;
348
+ }
349
+
350
+ // Blockquote
351
+ if (/^>/.test(line)) return `${_BLU}${_DIM}${line}${_R}`;
352
+
353
+ // Horizontal rule (must come before setext-underline check)
354
+ if (/^(\*{3,}|-{3,}|_{3,})$/.test(line)) return `${_DIM}${line}${_R}`;
355
+
356
+ // Unordered list item: - / * / + (with optional indentation)
357
+ const ulm = line.match(/^(\s*)([-*+]) (.*)$/);
358
+ if (ulm) return `${ulm[1]}${_GRN}${ulm[2]}${_R} ${applyInlineMarkdown(ulm[3])}`;
359
+
360
+ // Ordered list item: 1. / 12. (with optional indentation)
361
+ const olm = line.match(/^(\s*)(\d+\.) (.*)$/);
362
+ if (olm) return `${olm[1]}${_GRN}${olm[2]}${_R} ${applyInlineMarkdown(olm[3])}`;
363
+
364
+ // Table separator row |---|---|
365
+ if (/^\|[-: |]+\|$/.test(line)) return `${_DIM}${line}${_R}`;
366
+ // Table data row — dim the pipe characters
367
+ if (/^\|/.test(line)) return line.replace(/\|/g, `${_DIM}|${_R}`);
368
+
369
+ // Plain paragraph text — apply inline spans only
370
+ return applyInlineMarkdown(line);
371
+ })
372
+ .join("\n");
373
+ }