@tmustier/pi-files-widget 0.1.21 → 0.2.1

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/CHANGELOG.md CHANGED
@@ -2,7 +2,21 @@
2
2
 
3
3
  All notable changes to this extension will be documented in this file.
4
4
 
5
- ## [Unreleased]
5
+ ## [0.2.1] - 2026-09-22
6
+
7
+ ### Changed
8
+ - Reduce Pi startup work by checking required commands directly on `PATH` instead of spawning `which`/`where`, and load the file-browser implementation only when `/readfiles` is invoked.
9
+ - Update session lifecycle, key handling, notifications, and file viewer results for Pi 0.87.
10
+
11
+ ## [0.2.0] - 2026-07-04
12
+
13
+ ### Added
14
+ - `/readfiles` now supports browsing outside the current working directory. Press `u` to re-root to the parent, `.` to jump back to where you started, or pass an explicit starting path (`/readfiles <path>` or `/readfiles ~/somewhere`). The browser header shows the current root so you always know where you are, and comments on files outside the project use absolute paths so the agent can still find them.
15
+
16
+ ### Fixed
17
+ - Git status, diff stats, and untracked-file discovery now work when the browser root is a subdirectory of the git repository (e.g. after `u`, `.`, or `/readfiles <subdir>`, or when pi runs from a repo subdirectory). Previously repo-root-relative git paths were mixed with root-relative node keys, producing phantom tree entries, missing statuses, and unopenable nested paths.
18
+ - Re-rooting the browser while a background directory scan or line-count batch is in flight no longer lets the stale batch mutate the new root's tree, node index, or scan state.
19
+ - Use `where` instead of `which` on Windows to detect `bat`, `delta`, and `glow`, so the dependency check works when running from PowerShell or cmd.exe.
6
20
 
7
21
  ## [0.1.21] - 2026-05-07
8
22
 
package/README.md CHANGED
@@ -81,7 +81,8 @@ The `/readfiles` browser requires these tools and will refuse to open until they
81
81
 
82
82
  ## Commands
83
83
 
84
- - `/readfiles` - open the file browser and viewer
84
+ - `/readfiles` - open the file browser in the current directory
85
+ - `/readfiles <path>` - open the file browser rooted at `<path>` (absolute, relative, or `~`-prefixed)
85
86
 
86
87
  Diff viewing is built into the file viewer: open a changed tracked file and press `d` to toggle the git diff view.
87
88
 
@@ -94,6 +95,8 @@ Diff viewing is built into the file viewer: open a changed tracked file and pres
94
95
  - `c`: toggle changed-only view
95
96
  - `]` / `[`: next/prev changed file
96
97
  - `/`: search (type to filter, `Esc` to exit)
98
+ - `u`: go up one directory (re-root to parent)
99
+ - `.`: jump back to the starting directory
97
100
  - `+` / `-`: increase/decrease browser height
98
101
  - `q`: close
99
102
 
@@ -118,6 +121,7 @@ Diff viewing is built into the file viewer: open a changed tracked file and pres
118
121
 
119
122
  - Untracked files show as `[UNTRACKED]` and open in normal view.
120
123
  - Searching in rendered Markdown switches to raw mode first, and selecting from rendered Markdown first switches you back to raw so line-based matches and comments stay aligned with the source file.
124
+ - When you browse outside the current project directory, inline comments on those files use absolute paths so the agent can still locate them. Files inside the project continue to use project-relative paths.
121
125
  - Folder LOCs are shown only when the folder is collapsed (expanded folders would duplicate counts).
122
126
  - Line counts load asynchronously; the header shows activity while counts are computed.
123
127
  - Large non-git folders load progressively and may show `[partial]` while loading in safe mode.
package/TODO.md CHANGED
@@ -17,6 +17,7 @@
17
17
  - [x] Collapse/expand directories
18
18
  - [x] Navigation with `j/k` and arrow keys
19
19
  - [x] Enter to expand dir or open file
20
+ - [x] Browse outside the current working directory (`u` to go up, `.` to reset, `/readfiles <path>` to start elsewhere)
20
21
 
21
22
  ### Git Integration
22
23
  - [x] Parse `git status --porcelain` output
package/browser.ts CHANGED
@@ -3,7 +3,7 @@ import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
3
  import { lstatSync, realpathSync, statSync } from "node:fs";
4
4
  import { readdir, readFile, realpath, stat } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
- import { basename, join, relative, resolve, sep } from "node:path";
6
+ import { join, relative, resolve, sep } from "node:path";
7
7
 
8
8
  import {
9
9
  DEFAULT_BROWSER_HEIGHT,
@@ -98,13 +98,21 @@ function indexNodes(root: FileNode | null, map: Map<string, FileNode>): void {
98
98
  }
99
99
  }
100
100
 
101
- function getNodeDepth(node: FileNode, cwd: string): number {
102
- if (node.path === cwd) return 0;
103
- const rel = relative(cwd, node.path);
101
+ function getNodeDepth(node: FileNode, root: string): number {
102
+ if (node.path === root) return 0;
103
+ const rel = relative(root, node.path);
104
104
  if (!rel) return 0;
105
105
  return rel.split(sep).length;
106
106
  }
107
107
 
108
+ function formatRootPath(path: string): string {
109
+ const home = homedir();
110
+ if (!home) return path;
111
+ if (path === home) return "~";
112
+ if (path.startsWith(home + sep)) return "~" + path.slice(home.length);
113
+ return path;
114
+ }
115
+
108
116
  function safeRealPathSync(path: string): string {
109
117
  try {
110
118
  return realpathSync(path);
@@ -150,8 +158,8 @@ function hasAncestorRealPath(node: FileNode | undefined, realPath: string): bool
150
158
  return false;
151
159
  }
152
160
 
153
- function shouldSafeMode(cwd: string): boolean {
154
- const resolved = resolve(cwd);
161
+ function shouldSafeMode(path: string): boolean {
162
+ const resolved = resolve(path);
155
163
  const home = resolve(homedir());
156
164
  const root = resolve(sep);
157
165
  return resolved === home || resolved === root;
@@ -256,48 +264,39 @@ function collapseAllExcept(node: FileNode, keep: Set<FileNode>): void {
256
264
  }
257
265
 
258
266
  export function createFileBrowser(
259
- cwd: string,
267
+ initialPath: string,
260
268
  agentModifiedFiles: Set<string>,
261
269
  theme: Theme,
262
270
  onClose: () => void,
263
271
  requestComment: (payload: CommentPayload, comment: string) => void,
264
- requestRender: () => void
272
+ requestRender: () => void,
273
+ projectCwd: string = initialPath
265
274
  ): BrowserController {
266
275
  const ignored = getIgnoredNames();
267
- const repo = isGitRepo(cwd);
268
- let gitStatus = repo ? getGitStatus(cwd) : new Map<string, string>();
269
- let diffStats = repo ? getGitDiffStats(cwd) : new Map<string, DiffStats>();
270
- const gitBranch = repo ? getGitBranch(cwd) : "";
271
276
 
272
- const viewer = createViewer(cwd, theme, requestComment);
273
- const textInput = createTextInputBuffer();
277
+ let rootPath = resolve(initialPath);
278
+ const initialRoot = rootPath;
279
+ let repo = false;
280
+ let gitStatus = new Map<string, string>();
281
+ let diffStats = new Map<string, DiffStats>();
282
+ let gitBranch = "";
274
283
 
275
- const root = repo
276
- ? buildFileTreeFromPaths(cwd, getGitFileList(cwd), gitStatus, diffStats, ignored, agentModifiedFiles)
277
- : {
278
- name: ".",
279
- path: cwd,
280
- isDirectory: true,
281
- realPath: safeRealPathSync(cwd),
282
- children: undefined,
283
- expanded: true,
284
- hasChangedChildren: false,
285
- };
284
+ const viewer = createViewer({ getRoot: () => rootPath, projectCwd }, theme, requestComment);
285
+ const textInput = createTextInputBuffer();
286
286
 
287
- const safeMode = !repo && shouldSafeMode(cwd);
288
287
  const scanState: ScanState = {
289
- mode: repo ? "none" : safeMode ? "safe" : "full",
288
+ mode: "none",
290
289
  isScanning: false,
291
- isPartial: safeMode,
290
+ isPartial: false,
292
291
  pending: 0,
293
292
  spinnerIndex: 0,
294
293
  };
295
294
 
296
295
  const browser: BrowserState = {
297
- root,
296
+ root: null,
298
297
  flatList: [],
299
298
  fullList: [],
300
- stats: getTreeStats(root),
299
+ stats: { totalLines: undefined, additions: 0, deletions: 0 },
301
300
  nodeByPath: new Map<string, FileNode>(),
302
301
  scanState,
303
302
  selectedIndex: 0,
@@ -308,10 +307,6 @@ export function createFileBrowser(
308
307
  lastPollTime: Date.now(),
309
308
  };
310
309
 
311
- indexNodes(browser.root, browser.nodeByPath);
312
- browser.flatList = browser.root ? flattenTree(browser.root) : [];
313
- browser.fullList = browser.root ? flattenTree(browser.root, 0, true, true) : [];
314
-
315
310
  const lineCountCache = new Map<string, { size: number; mtimeMs: number; count: number }>();
316
311
  const lineCountQueue: FileNode[] = [];
317
312
  const lineCountPending = new Set<string>();
@@ -320,6 +315,10 @@ export function createFileBrowser(
320
315
  const scanQueue: Array<{ node: FileNode; depth: number }> = [];
321
316
  const scanQueued = new Set<string>();
322
317
  let scanTimer: ReturnType<typeof setTimeout> | null = null;
318
+ // Incremented on every (re-)root. In-flight async scan/line-count batches
319
+ // capture the value when they start and bail after each await if it changed,
320
+ // so work belonging to an old root can never mutate state for the new one.
321
+ let rootGeneration = 0;
323
322
 
324
323
  const normalizeGitPath = (path: string): string => path.split(sep).join("/");
325
324
 
@@ -381,15 +380,19 @@ export function createFileBrowser(
381
380
  async function processLineCountBatch(): Promise<void> {
382
381
  lineCountTimer = null;
383
382
  if (!browser.root) return;
383
+ const generation = rootGeneration;
384
384
  const batch = lineCountQueue.splice(0, LINE_COUNT_BATCH_SIZE);
385
385
  if (batch.length === 0) return;
386
386
 
387
387
  await Promise.all(
388
388
  batch.map(async node => {
389
389
  await updateLineCount(node);
390
- lineCountPending.delete(node.path);
390
+ if (generation === rootGeneration) {
391
+ lineCountPending.delete(node.path);
392
+ }
391
393
  })
392
394
  );
395
+ if (generation !== rootGeneration) return;
393
396
 
394
397
  updateTreeStats(browser.root);
395
398
  browser.stats = getTreeStats(browser.root);
@@ -441,12 +444,13 @@ export function createFileBrowser(
441
444
  }
442
445
  }
443
446
 
444
- async function scanDirectory(node: FileNode, depth: number): Promise<void> {
447
+ async function scanDirectory(node: FileNode, depth: number, generation: number): Promise<void> {
445
448
  try {
446
449
  const entries = await readdir(node.path, { withFileTypes: true });
450
+ if (generation !== rootGeneration) return;
447
451
  const sorted = [...entries].sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
448
452
 
449
- if (node.path === cwd && browser.scanState.mode === "full" && sorted.length >= SAFE_MODE_ENTRY_THRESHOLD) {
453
+ if (node.path === rootPath && browser.scanState.mode === "full" && sorted.length >= SAFE_MODE_ENTRY_THRESHOLD) {
450
454
  browser.scanState.mode = "safe";
451
455
  browser.scanState.isPartial = true;
452
456
  scanQueue.length = 0;
@@ -462,11 +466,13 @@ export function createFileBrowser(
462
466
  const childDepth = depth + 1;
463
467
 
464
468
  if (entry.isDirectory()) {
469
+ const dirRealPath = await realpath(fullPath).catch(() => resolve(fullPath));
470
+ if (generation !== rootGeneration) return;
465
471
  const dirNode: FileNode = {
466
472
  name: entry.name,
467
473
  path: fullPath,
468
474
  isDirectory: true,
469
- realPath: await realpath(fullPath).catch(() => resolve(fullPath)),
475
+ realPath: dirRealPath,
470
476
  parent: node,
471
477
  children: undefined,
472
478
  expanded: childDepth < 1,
@@ -482,6 +488,7 @@ export function createFileBrowser(
482
488
 
483
489
  if (entry.isSymbolicLink()) {
484
490
  const pathInfo = await getPathInfo(fullPath, true);
491
+ if (generation !== rootGeneration) return;
485
492
  if (pathInfo.isDirectory) {
486
493
  const isCycle = pathInfo.realPath ? hasAncestorRealPath(node, pathInfo.realPath) : false;
487
494
  const dirNode: FileNode = {
@@ -531,16 +538,21 @@ export function createFileBrowser(
531
538
 
532
539
  node.children = [...dirs, ...files];
533
540
  } catch {
534
- node.children = [];
541
+ if (generation === rootGeneration) {
542
+ node.children = [];
543
+ }
535
544
  } finally {
536
- node.loading = false;
537
- scanQueued.delete(node.path);
545
+ if (generation === rootGeneration) {
546
+ node.loading = false;
547
+ scanQueued.delete(node.path);
548
+ }
538
549
  }
539
550
  }
540
551
 
541
552
  async function processScanBatch(): Promise<void> {
542
553
  scanTimer = null;
543
554
  if (!browser.root) return;
555
+ const generation = rootGeneration;
544
556
  const batch = scanQueue.splice(0, getScanBatchSize());
545
557
  if (batch.length === 0) {
546
558
  browser.scanState.isScanning = false;
@@ -549,7 +561,8 @@ export function createFileBrowser(
549
561
  }
550
562
 
551
563
  for (const item of batch) {
552
- await scanDirectory(item.node, item.depth);
564
+ await scanDirectory(item.node, item.depth, generation);
565
+ if (generation !== rootGeneration) return;
553
566
  }
554
567
 
555
568
  browser.scanState.pending = scanQueue.length;
@@ -586,7 +599,7 @@ export function createFileBrowser(
586
599
 
587
600
  function applyGitUpdates(): void {
588
601
  for (const node of browser.nodeByPath.values()) {
589
- const relPath = normalizeGitPath(relative(cwd, node.path));
602
+ const relPath = normalizeGitPath(relative(rootPath, node.path));
590
603
  node.gitStatus = gitStatus.get(relPath);
591
604
  node.diffStats = diffStats.get(relPath);
592
605
  }
@@ -611,7 +624,7 @@ export function createFileBrowser(
611
624
  const part = parts[i];
612
625
  if (ignored.has(part) || part.startsWith(".")) return null;
613
626
  currentRel = currentRel ? `${currentRel}/${part}` : part;
614
- const dirPath = join(cwd, currentRel);
627
+ const dirPath = join(rootPath, currentRel);
615
628
  let dirNode = browser.nodeByPath.get(dirPath);
616
629
  if (!dirNode) {
617
630
  const depth = i + 1;
@@ -636,7 +649,7 @@ export function createFileBrowser(
636
649
  const fileName = parts[parts.length - 1];
637
650
  if (ignored.has(fileName) || fileName.startsWith(".")) return null;
638
651
 
639
- const filePath = join(cwd, normalized);
652
+ const filePath = join(rootPath, normalized);
640
653
  const existing = browser.nodeByPath.get(filePath);
641
654
  if (existing) return existing;
642
655
 
@@ -704,8 +717,8 @@ export function createFileBrowser(
704
717
  const viewingFilePath = viewingFile?.path;
705
718
 
706
719
  if (repo) {
707
- gitStatus = getGitStatus(cwd);
708
- diffStats = getGitDiffStats(cwd);
720
+ gitStatus = getGitStatus(rootPath);
721
+ diffStats = getGitDiffStats(rootPath);
709
722
  applyGitUpdates();
710
723
  addUntrackedNodes();
711
724
  }
@@ -736,12 +749,67 @@ export function createFileBrowser(
736
749
  }
737
750
  }
738
751
 
739
- if (repo) {
740
- queueLineCountsForTree(browser.root);
741
- } else if (browser.root) {
742
- enqueueScan(browser.root, 0, true);
752
+ function loadRoot(newRoot: string): void {
753
+ rootGeneration += 1;
754
+ rootPath = resolve(newRoot);
755
+
756
+ repo = isGitRepo(rootPath);
757
+ gitStatus = repo ? getGitStatus(rootPath) : new Map<string, string>();
758
+ diffStats = repo ? getGitDiffStats(rootPath) : new Map<string, DiffStats>();
759
+ gitBranch = repo ? getGitBranch(rootPath) : "";
760
+
761
+ const newRootNode: FileNode = repo
762
+ ? buildFileTreeFromPaths(rootPath, getGitFileList(rootPath), gitStatus, diffStats, ignored, agentModifiedFiles)
763
+ : {
764
+ name: ".",
765
+ path: rootPath,
766
+ isDirectory: true,
767
+ realPath: safeRealPathSync(rootPath),
768
+ children: undefined,
769
+ expanded: true,
770
+ hasChangedChildren: false,
771
+ };
772
+
773
+ browser.root = newRootNode;
774
+
775
+ const safeMode = !repo && shouldSafeMode(rootPath);
776
+ browser.scanState.mode = repo ? "none" : safeMode ? "safe" : "full";
777
+ browser.scanState.isScanning = false;
778
+ browser.scanState.isPartial = safeMode;
779
+ browser.scanState.pending = 0;
780
+
781
+ indexNodes(browser.root, browser.nodeByPath);
782
+ refreshLists();
783
+ browser.stats = getTreeStats(browser.root);
784
+
785
+ browser.selectedIndex = 0;
786
+ browser.searchQuery = "";
787
+ browser.searchMode = false;
788
+ textInput.reset();
789
+ browser.lastPollTime = Date.now();
790
+
791
+ if (repo) {
792
+ queueLineCountsForTree(browser.root);
793
+ } else if (browser.root) {
794
+ enqueueScan(browser.root, 0, true);
795
+ }
743
796
  }
744
797
 
798
+ function setRoot(newRoot: string): void {
799
+ if (viewer.isOpen()) {
800
+ viewer.close();
801
+ }
802
+ stopBackgroundTasks();
803
+ scanQueue.length = 0;
804
+ scanQueued.clear();
805
+ lineCountQueue.length = 0;
806
+ lineCountPending.clear();
807
+ loadRoot(newRoot);
808
+ requestRender();
809
+ }
810
+
811
+ loadRoot(initialRoot);
812
+
745
813
  function getDisplayList(): FlatNode[] {
746
814
  let list = browser.searchQuery ? browser.fullList : browser.flatList;
747
815
 
@@ -806,7 +874,7 @@ export function createFileBrowser(
806
874
  if (node.isDirectory) {
807
875
  node.expanded = !node.expanded;
808
876
  if (node.expanded && node.children === undefined) {
809
- enqueueScan(node, getNodeDepth(node, cwd), true);
877
+ enqueueScan(node, getNodeDepth(node, rootPath), true);
810
878
  }
811
879
  refreshLists();
812
880
  }
@@ -818,7 +886,7 @@ export function createFileBrowser(
818
886
 
819
887
  function renderBrowser(width: number): string[] {
820
888
  const lines: string[] = [];
821
- const pathDisplay = basename(cwd);
889
+ const pathDisplay = formatRootPath(rootPath);
822
890
  const branchDisplay = gitBranch ? theme.fg("accent", ` (${gitBranch})`) : "";
823
891
  const stats = browser.stats;
824
892
 
@@ -902,7 +970,7 @@ export function createFileBrowser(
902
970
  const changedIndicator = browser.showOnlyChanged ? theme.fg("warning", " [changed only]") : "";
903
971
  const help = browser.searchMode
904
972
  ? theme.fg("dim", "Type to search ↑↓: nav Enter: confirm Esc: cancel")
905
- : theme.fg("dim", "j/k: nav []: next/prev change c: toggle changed /: search q: close") + changedIndicator;
973
+ : theme.fg("dim", "j/k: nav u: up .: home []: next/prev change c: toggle changed /: search q: close") + changedIndicator;
906
974
  lines.push(truncateToWidth(help, width));
907
975
 
908
976
  return lines;
@@ -974,6 +1042,19 @@ export function createFileBrowser(
974
1042
  }
975
1043
  return;
976
1044
  }
1045
+ if (matchesKey(data, "u")) {
1046
+ const parent = resolve(rootPath, "..");
1047
+ if (parent !== rootPath) {
1048
+ setRoot(parent);
1049
+ }
1050
+ return;
1051
+ }
1052
+ if (matchesKey(data, ".")) {
1053
+ if (rootPath !== initialRoot) {
1054
+ setRoot(initialRoot);
1055
+ }
1056
+ return;
1057
+ }
977
1058
  if (matchesKey(data, "j") || matchesKey(data, Key.down)) {
978
1059
  browser.selectedIndex = Math.min(maxIndex, browser.selectedIndex + 1);
979
1060
  return;
package/file-viewer.ts CHANGED
@@ -171,7 +171,10 @@ export function loadFileContent(
171
171
  try {
172
172
  try {
173
173
  if (statSync(filePath).isDirectory()) {
174
- return ["Directory selected - expand it in the file tree instead of opening it."];
174
+ return {
175
+ lines: ["Directory selected - expand it in the file tree instead of opening it."],
176
+ renderedMarkdown: false,
177
+ };
175
178
  }
176
179
  } catch {
177
180
  // Ignore stat errors and fall through to normal handling
package/git.ts CHANGED
@@ -11,6 +11,27 @@ export function isGitRepo(cwd: string): boolean {
11
11
  }
12
12
  }
13
13
 
14
+ /**
15
+ * Path of `cwd` relative to the repository top-level (e.g. "app/"), or "" when
16
+ * `cwd` is the top-level itself. `git status --porcelain` reports paths relative
17
+ * to the repository root, while `git ls-files` (and this widget's node keys) are
18
+ * relative to `cwd` — this prefix lets us translate between the two.
19
+ */
20
+ function getGitPathPrefix(cwd: string): string {
21
+ try {
22
+ return execSync("git rev-parse --show-prefix", { cwd, encoding: "utf-8", timeout: 2000, stdio: "pipe" }).trim();
23
+ } catch {
24
+ return "";
25
+ }
26
+ }
27
+
28
+ /** Convert a repo-root-relative path to a cwd-relative one; null if outside cwd. */
29
+ function stripPathPrefix(filePath: string, prefix: string): string | null {
30
+ if (!prefix) return filePath;
31
+ if (filePath.startsWith(prefix)) return filePath.slice(prefix.length);
32
+ return null;
33
+ }
34
+
14
35
  export function getGitStatus(cwd: string, options: { includeIgnored?: boolean } = {}): Map<string, string> {
15
36
  const status = new Map<string, string>();
16
37
  try {
@@ -18,11 +39,13 @@ export function getGitStatus(cwd: string, options: { includeIgnored?: boolean }
18
39
  if (options.includeIgnored !== false) {
19
40
  flags.push("--ignored");
20
41
  }
42
+ const prefix = getGitPathPrefix(cwd);
21
43
  const output = execSync(`git status ${flags.join(" ")}`, { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe" });
22
44
  for (const line of output.split("\n")) {
23
45
  if (line.length < 3) continue;
24
46
  const statusCode = line.slice(0, 2).trim() || "?";
25
- const filePath = line.slice(3);
47
+ const filePath = stripPathPrefix(line.slice(3), prefix);
48
+ if (filePath === null || !filePath) continue;
26
49
  status.set(filePath, statusCode);
27
50
  }
28
51
  } catch {}
@@ -39,6 +62,7 @@ export function getGitFileList(cwd: string): string[] {
39
62
  } catch {}
40
63
 
41
64
  try {
65
+ const prefix = getGitPathPrefix(cwd);
42
66
  const statusOutput = execSync("git status --porcelain -uall -z", { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe" });
43
67
  const entries = statusOutput.split("\0");
44
68
  for (let i = 0; i < entries.length; i++) {
@@ -52,8 +76,9 @@ export function getGitFileList(cwd: string): string[] {
52
76
  } else if (filePath.includes(" -> ")) {
53
77
  filePath = filePath.split(" -> ").pop() || filePath;
54
78
  }
55
- if (filePath) {
56
- files.add(filePath);
79
+ const relPath = filePath ? stripPathPrefix(filePath, prefix) : null;
80
+ if (relPath) {
81
+ files.add(relPath);
57
82
  }
58
83
  }
59
84
  } catch {}
@@ -72,8 +97,10 @@ export function getGitBranch(cwd: string): string {
72
97
  export function getGitDiffStats(cwd: string): Map<string, DiffStats> {
73
98
  const stats = new Map<string, DiffStats>();
74
99
  try {
75
- // Get diff stats for modified files
76
- const output = execSync("git diff --numstat HEAD", { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe" });
100
+ // Get diff stats for modified files. --relative keeps paths relative to cwd
101
+ // (and scoped to it) so they match the widget's cwd-relative node keys even
102
+ // when cwd is a subdirectory of the repository.
103
+ const output = execSync("git diff --relative --numstat HEAD", { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe" });
77
104
  for (const line of output.split("\n")) {
78
105
  const parts = line.split("\t");
79
106
  if (parts.length >= 3) {
@@ -84,7 +111,7 @@ export function getGitDiffStats(cwd: string): Map<string, DiffStats> {
84
111
  }
85
112
  }
86
113
  // Also get stats for staged files
87
- const stagedOutput = execSync("git diff --numstat --cached", { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe" });
114
+ const stagedOutput = execSync("git diff --relative --numstat --cached", { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe" });
88
115
  for (const line of stagedOutput.split("\n")) {
89
116
  const parts = line.split("\t");
90
117
  if (parts.length >= 3) {
package/index.ts CHANGED
@@ -6,13 +6,35 @@
6
6
  */
7
7
 
8
8
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
- import { join } from "node:path";
9
+ import { statSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { isAbsolute, join, resolve } from "node:path";
10
12
 
11
- import { createFileBrowser } from "./browser";
12
13
  import { POLL_INTERVAL_MS } from "./constants";
13
14
  import { formatCommentMessage } from "./comment";
14
15
  import { hasCommand } from "./utils";
15
16
 
17
+ function resolveInitialPath(arg: string | undefined, cwd: string): { path: string; error?: string } {
18
+ if (!arg) return { path: cwd };
19
+ let candidate = arg.trim();
20
+ if (!candidate) return { path: cwd };
21
+ const home = homedir();
22
+ if (candidate === "~") {
23
+ candidate = home;
24
+ } else if (candidate.startsWith("~/")) {
25
+ candidate = join(home, candidate.slice(2));
26
+ }
27
+ const absolute = isAbsolute(candidate) ? candidate : resolve(cwd, candidate);
28
+ try {
29
+ if (!statSync(absolute).isDirectory()) {
30
+ return { path: cwd, error: `${absolute} is not a directory` };
31
+ }
32
+ } catch {
33
+ return { path: cwd, error: `${absolute} is not accessible` };
34
+ }
35
+ return { path: absolute };
36
+ }
37
+
16
38
  export default function editorExtension(pi: ExtensionAPI): void {
17
39
  const cwd = process.cwd();
18
40
  const agentModifiedFiles = new Set<string>();
@@ -20,14 +42,22 @@ export default function editorExtension(pi: ExtensionAPI): void {
20
42
  const getMissingDeps = () => requiredDeps.filter((dep) => !hasCommand(dep));
21
43
 
22
44
  pi.registerCommand("readfiles", {
23
- description: "Open file browser",
24
- handler: async (_args, ctx) => {
45
+ description: "Open file browser (optional: /readfiles <path> to start outside the current directory)",
46
+ handler: async (args, ctx) => {
25
47
  const missing = getMissingDeps();
26
48
  if (missing.length > 0) {
27
49
  ctx.ui.notify(`files-widget requires ${missing.join(", ")}. Install: brew install bat git-delta glow`, "error");
28
50
  return;
29
51
  }
30
52
 
53
+ const resolved = resolveInitialPath(args, cwd);
54
+ if (resolved.error) {
55
+ ctx.ui.notify(resolved.error, "error");
56
+ return;
57
+ }
58
+ const initialPath = resolved.path;
59
+ const { createFileBrowser } = await import("./browser");
60
+
31
61
  await ctx.ui.custom<void>((tui, theme, _kb, done) => {
32
62
  let pollInterval: ReturnType<typeof setInterval> | null = null;
33
63
 
@@ -43,7 +73,7 @@ export default function editorExtension(pi: ExtensionAPI): void {
43
73
  const message = formatCommentMessage(payload, comment);
44
74
  if (ctx.isIdle()) {
45
75
  pi.sendUserMessage(message);
46
- ctx.ui.notify(`Comment sent to agent for ${payload.relPath} (${payload.lineRange})`, "success");
76
+ ctx.ui.notify(`Comment sent to agent for ${payload.relPath} (${payload.lineRange})`, "info");
47
77
  } else {
48
78
  pi.sendUserMessage(message, { deliverAs: "followUp" });
49
79
  ctx.ui.notify(`Comment queued for agent for ${payload.relPath} (${payload.lineRange})`, "info");
@@ -51,7 +81,15 @@ export default function editorExtension(pi: ExtensionAPI): void {
51
81
  };
52
82
 
53
83
  const requestRender = () => tui.requestRender();
54
- const browser = createFileBrowser(cwd, agentModifiedFiles, theme, cleanup, requestComment, requestRender);
84
+ const browser = createFileBrowser(
85
+ initialPath,
86
+ agentModifiedFiles,
87
+ theme,
88
+ cleanup,
89
+ requestComment,
90
+ requestRender,
91
+ cwd
92
+ );
55
93
 
56
94
  pollInterval = setInterval(() => {
57
95
  requestRender();
@@ -87,7 +125,4 @@ export default function editorExtension(pi: ExtensionAPI): void {
87
125
  agentModifiedFiles.clear();
88
126
  });
89
127
 
90
- pi.on("session_switch", async () => {
91
- agentModifiedFiles.clear();
92
- });
93
128
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-files-widget",
3
- "version": "0.1.21",
3
+ "version": "0.2.1",
4
4
  "description": "In-terminal file browser and viewer for Pi.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",
@@ -28,7 +28,7 @@
28
28
  "video": "https://raw.githubusercontent.com/tmustier/pi-extensions/main/files-widget/demo.mp4"
29
29
  },
30
30
  "devDependencies": {
31
- "@earendil-works/pi-coding-agent": "^0.74.0",
32
- "@earendil-works/pi-tui": "^0.74.0"
31
+ "@earendil-works/pi-coding-agent": "0.87.0",
32
+ "@earendil-works/pi-tui": "0.87.0"
33
33
  }
34
34
  }
package/utils.ts CHANGED
@@ -1,12 +1,41 @@
1
- import { execSync } from "node:child_process";
2
-
3
- export function hasCommand(cmd: string): boolean {
4
- try {
5
- execSync(`which ${cmd}`, { stdio: "ignore" });
6
- return true;
7
- } catch {
8
- return false;
1
+ import { accessSync, constants, statSync } from "node:fs";
2
+ import { extname, join } from "node:path";
3
+
4
+ export interface CommandLookupOptions {
5
+ platform?: NodeJS.Platform;
6
+ path?: string;
7
+ pathExt?: string;
8
+ cwd?: string;
9
+ }
10
+
11
+ export function hasCommand(cmd: string, options: CommandLookupOptions = {}): boolean {
12
+ const platform = options.platform ?? process.platform;
13
+ const pathValue = options.path ?? process.env.PATH ?? "";
14
+ const pathEntries = pathValue.split(platform === "win32" ? ";" : ":");
15
+ const searchDirectories = platform === "win32"
16
+ ? [options.cwd ?? process.cwd(), ...pathEntries]
17
+ : pathEntries;
18
+ const extensions = platform === "win32" && !extname(cmd)
19
+ ? (options.pathExt ?? process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
20
+ : [""];
21
+ const accessMode = platform === "win32" ? constants.F_OK : constants.X_OK;
22
+
23
+ for (const rawDirectory of searchDirectories) {
24
+ const directory = platform === "win32" && rawDirectory.startsWith('"') && rawDirectory.endsWith('"')
25
+ ? rawDirectory.slice(1, -1)
26
+ : rawDirectory;
27
+ for (const extension of extensions) {
28
+ try {
29
+ const candidate = join(directory || ".", `${cmd}${extension}`);
30
+ accessSync(candidate, accessMode);
31
+ if (statSync(candidate).isFile()) return true;
32
+ } catch {
33
+ // Continue searching PATH.
34
+ }
35
+ }
9
36
  }
37
+
38
+ return false;
10
39
  }
11
40
 
12
41
  export function isUntrackedStatus(status?: string): boolean {
package/viewer.ts CHANGED
@@ -59,11 +59,17 @@ export interface ViewerController {
59
59
  handleInput(data: string): ViewerAction;
60
60
  }
61
61
 
62
+ export interface ViewerConfig {
63
+ getRoot: () => string;
64
+ projectCwd: string;
65
+ }
66
+
62
67
  export function createViewer(
63
- cwd: string,
68
+ config: ViewerConfig,
64
69
  theme: Theme,
65
70
  requestComment: (payload: CommentPayload, comment: string) => void
66
71
  ): ViewerController {
72
+ const { getRoot, projectCwd } = config;
67
73
  const searchInput = createTextInputBuffer();
68
74
  const commentInput = createTextInputBuffer({ preserveNewlines: true });
69
75
 
@@ -177,7 +183,7 @@ export function createViewer(
177
183
  if (!state.file) return;
178
184
  refreshRawContent();
179
185
  const hasChanges = !!state.file.gitStatus;
180
- const result = loadFileContent(state.file.path, cwd, state.diffMode, hasChanges, width, state.renderMarkdown);
186
+ const result = loadFileContent(state.file.path, getRoot(), state.diffMode, hasChanges, width, state.renderMarkdown);
181
187
  state.content = result.lines;
182
188
  state.renderMarkdown = result.renderedMarkdown;
183
189
  state.lastRenderWidth = width;
@@ -242,7 +248,8 @@ export function createViewer(
242
248
 
243
249
  const rawLines = state.rawContent.split("\n");
244
250
  const selectedText = rawLines.slice(state.selectStart, state.selectEnd + 1).join("\n");
245
- const relPath = relative(cwd, state.file.path);
251
+ const rel = relative(projectCwd, state.file.path);
252
+ const relPath = !rel || rel.startsWith("..") ? state.file.path : rel;
246
253
  const lineRange = state.selectStart === state.selectEnd
247
254
  ? `line ${state.selectStart + 1}`
248
255
  : `lines ${state.selectStart + 1}-${state.selectEnd + 1}`;
@@ -494,7 +501,7 @@ export function createViewer(
494
501
  jumpToNextMatch(1);
495
502
  return { type: "none" };
496
503
  }
497
- if (matchesKey(data, "N") && state.mode !== "select" && state.searchMatches.length > 0) {
504
+ if (matchesKey(data, "shift+n") && state.mode !== "select" && state.searchMatches.length > 0) {
498
505
  jumpToNextMatch(-1);
499
506
  return { type: "none" };
500
507
  }
@@ -546,7 +553,7 @@ export function createViewer(
546
553
  state.scroll = 0;
547
554
  return { type: "none" };
548
555
  }
549
- if (matchesKey(data, "m") && state.mode !== "select" && state.mode !== "comment") {
556
+ if (matchesKey(data, "m") && state.mode !== "select") {
550
557
  toggleMarkdownMode();
551
558
  return { type: "none" };
552
559
  }