@tmustier/pi-files-widget 0.1.9 → 0.1.11

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,6 +2,21 @@
2
2
 
3
3
  All notable changes to this extension will be documented in this file.
4
4
 
5
+ ## [0.1.11] - 2026-01-26
6
+
7
+ ### Changed
8
+ - Require `bat`, `delta`, and `glow` before opening `/files`
9
+ - Add a postinstall reminder for required system tools
10
+ - Document install commands next to the Pi install steps
11
+
12
+ ## [0.1.10] - 2026-01-26
13
+
14
+ ### Fixed
15
+ - Treat git-reported directory entries as directories to avoid viewer errors
16
+ - Guard the viewer against opening directories directly
17
+ - Wrap delta diff output without breaking gutters and avoid truncation
18
+ - Add a safe fallback when `bat` fails to render with wrapping
19
+
5
20
  ## [0.1.9] - 2026-01-26
6
21
 
7
22
  ### Changed
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # files-widget
2
2
 
3
- In-terminal file browser and viewer for Pi. Navigate files, view diffs, select code, and send comments to the agent without leaving the terminal.
3
+ In-terminal file browser and diff viewer widget for Pi. Navigate files, view diffs, select code, and send comments to the agent without leaving the terminal and without interrupting your agent.
4
4
 
5
5
  ![Demo](demo.svg)
6
6
 
@@ -12,6 +12,16 @@ In-terminal file browser and viewer for Pi. Navigate files, view diffs, select c
12
12
  pi install npm:@tmustier/pi-files-widget
13
13
  ```
14
14
 
15
+ Required deps (needed for /files):
16
+
17
+ ```bash
18
+ # macOS (Homebrew)
19
+ brew install bat git-delta glow
20
+
21
+ # Ubuntu/Debian
22
+ sudo apt-get install -y bat git-delta glow
23
+ ```
24
+
15
25
  ```bash
16
26
  pi install git:github.com/tmustier/pi-extensions
17
27
  ```
@@ -57,17 +67,13 @@ Then reference it in your settings:
57
67
  }
58
68
  ```
59
69
 
60
- ## Dependencies (recommended)
61
-
62
- ```bash
63
- brew install bat git-delta glow
64
- ```
70
+ ## Dependencies (required)
65
71
 
66
72
  - `bat`: syntax highlighting
67
73
  - `delta`: formatted diffs
68
74
  - `glow`: markdown rendering
69
75
 
70
- If any are missing, the extension will notify you at session start and gracefully fall back to plain rendering.
76
+ The `/files` browser requires these tools and will refuse to open until they are installed.
71
77
 
72
78
  ## Commands
73
79
 
package/file-tree.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { statSync } from "node:fs";
1
2
  import { join } from "node:path";
2
3
 
3
4
  import { MAX_TREE_DEPTH } from "./constants";
@@ -5,6 +6,14 @@ import type { DiffStats, FileNode, FlatNode } from "./types";
5
6
 
6
7
  const collator = new Intl.Collator(undefined, { sensitivity: "base" });
7
8
 
9
+ function isDirectoryPath(path: string): boolean {
10
+ try {
11
+ return statSync(path).isDirectory();
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+
8
17
  function compareNodes(a: FileNode, b: FileNode): number {
9
18
  if (a.isDirectory !== b.isDirectory) {
10
19
  return a.isDirectory ? -1 : 1;
@@ -153,11 +162,41 @@ export function buildFileTreeFromPaths(
153
162
 
154
163
  const fileRelPath = parts.join("/");
155
164
  if (seenFiles.has(fileRelPath)) continue;
156
- seenFiles.add(fileRelPath);
157
165
 
158
166
  const filePath = join(cwd, fileRelPath);
159
- const fileGitStatus = gitStatus.get(fileRelPath);
167
+ const fileGitStatus = gitStatus.get(fileRelPath) ?? gitStatus.get(`${fileRelPath}/`);
160
168
  const fileDiffStats = diffStats.get(fileRelPath);
169
+ const existingDir = directoryMap.get(fileRelPath);
170
+
171
+ if (existingDir) {
172
+ if (fileGitStatus) {
173
+ existingDir.gitStatus = fileGitStatus;
174
+ }
175
+ if (fileDiffStats) {
176
+ existingDir.diffStats = fileDiffStats;
177
+ }
178
+ continue;
179
+ }
180
+
181
+ const isDirEntry = normalized.endsWith("/") || isDirectoryPath(filePath);
182
+ if (isDirEntry) {
183
+ const depth = parts.length;
184
+ const dirNode: FileNode = {
185
+ name: fileName,
186
+ path: filePath,
187
+ isDirectory: true,
188
+ children: [],
189
+ expanded: depth < 1,
190
+ hasChangedChildren: false,
191
+ gitStatus: fileGitStatus,
192
+ diffStats: fileDiffStats,
193
+ };
194
+ directoryMap.set(fileRelPath, dirNode);
195
+ current.children?.push(dirNode);
196
+ continue;
197
+ }
198
+
199
+ seenFiles.add(fileRelPath);
161
200
 
162
201
  current.children?.push({
163
202
  name: fileName,
package/file-viewer.ts CHANGED
@@ -1,9 +1,157 @@
1
+ import { visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
1
2
  import { execSync } from "node:child_process";
2
- import { readFileSync } from "node:fs";
3
+ import { readFileSync, statSync } from "node:fs";
3
4
 
4
5
  import { isGitRepo } from "./git";
5
6
  import { hasCommand, stripLeadingEmptyLines } from "./utils";
6
7
 
8
+ const DIFF_CONTENT_PREFIXES = new Set(["+", "-", " "]);
9
+
10
+ function isDiffContentLine(line: string): boolean {
11
+ if (!line) return false;
12
+ if (!DIFF_CONTENT_PREFIXES.has(line[0])) return false;
13
+ if (line.startsWith("+++ ") || line.startsWith("--- ")) return false;
14
+ return true;
15
+ }
16
+
17
+ function wrapLine(line: string, width: number): string[] {
18
+ if (width <= 0 || line.length <= width) {
19
+ return [line];
20
+ }
21
+ const wrapped: string[] = [];
22
+ for (let i = 0; i < line.length; i += width) {
23
+ wrapped.push(line.slice(i, i + width));
24
+ }
25
+ return wrapped;
26
+ }
27
+
28
+ function wrapDiffLines(lines: string[], width: number): string[] {
29
+ if (width <= 0) return lines;
30
+ const wrapped: string[] = [];
31
+ for (const line of lines) {
32
+ if (line.length <= width) {
33
+ wrapped.push(line);
34
+ continue;
35
+ }
36
+ if (isDiffContentLine(line)) {
37
+ const prefix = line[0];
38
+ const content = line.slice(1);
39
+ const contentWidth = Math.max(width - 1, 1);
40
+ for (const chunk of wrapLine(content, contentWidth)) {
41
+ wrapped.push(prefix + chunk);
42
+ }
43
+ } else {
44
+ wrapped.push(...wrapLine(line, width));
45
+ }
46
+ }
47
+ return wrapped;
48
+ }
49
+
50
+ function extractAnsiCode(str: string, pos: number): { length: number } | null {
51
+ if (pos >= str.length || str[pos] !== "\x1b") return null;
52
+ const next = str[pos + 1];
53
+ if (next === "[") {
54
+ let j = pos + 2;
55
+ while (j < str.length && !/[mGKHJ]/.test(str[j])) j++;
56
+ if (j < str.length) return { length: j + 1 - pos };
57
+ return null;
58
+ }
59
+ if (next === "]") {
60
+ let j = pos + 2;
61
+ while (j < str.length) {
62
+ if (str[j] === "\x07") return { length: j + 1 - pos };
63
+ if (str[j] === "\x1b" && str[j + 1] === "\\") return { length: j + 2 - pos };
64
+ j++;
65
+ }
66
+ return null;
67
+ }
68
+ if (next === "_") {
69
+ let j = pos + 2;
70
+ while (j < str.length) {
71
+ if (str[j] === "\x07") return { length: j + 1 - pos };
72
+ if (str[j] === "\x1b" && str[j + 1] === "\\") return { length: j + 2 - pos };
73
+ j++;
74
+ }
75
+ return null;
76
+ }
77
+ return null;
78
+ }
79
+
80
+ function stripAnsiCodes(line: string): string {
81
+ let result = "";
82
+ for (let i = 0; i < line.length;) {
83
+ const ansi = extractAnsiCode(line, i);
84
+ if (ansi) {
85
+ i += ansi.length;
86
+ continue;
87
+ }
88
+ result += line[i];
89
+ i += 1;
90
+ }
91
+ return result;
92
+ }
93
+
94
+ function splitByVisibleWidth(line: string, width: number): { prefix: string; rest: string } {
95
+ if (width <= 0) return { prefix: "", rest: line };
96
+ let visible = 0;
97
+ let i = 0;
98
+ while (i < line.length) {
99
+ const ansi = extractAnsiCode(line, i);
100
+ if (ansi) {
101
+ i += ansi.length;
102
+ continue;
103
+ }
104
+ const charWidth = visibleWidth(line[i]);
105
+ if (visible + charWidth > width) break;
106
+ visible += charWidth;
107
+ i += 1;
108
+ }
109
+ return { prefix: line.slice(0, i), rest: line.slice(i) };
110
+ }
111
+
112
+ function maskDigits(line: string): string {
113
+ let result = "";
114
+ for (let i = 0; i < line.length;) {
115
+ const ansi = extractAnsiCode(line, i);
116
+ if (ansi) {
117
+ result += line.slice(i, i + ansi.length);
118
+ i += ansi.length;
119
+ continue;
120
+ }
121
+ const char = line[i];
122
+ result += char >= "0" && char <= "9" ? " " : char;
123
+ i += 1;
124
+ }
125
+ return result;
126
+ }
127
+
128
+ function wrapDeltaLine(line: string, width: number): string[] {
129
+ const clean = stripAnsiCodes(line);
130
+ let separatorIndex = clean.indexOf("│");
131
+ if (separatorIndex === -1) separatorIndex = clean.indexOf("|");
132
+ if (separatorIndex === -1) return wrapTextWithAnsi(line, width);
133
+
134
+ let prefixWidth = visibleWidth(clean.slice(0, separatorIndex + 1));
135
+ if (clean[separatorIndex + 1] === " ") prefixWidth += 1;
136
+ if (prefixWidth >= width) return wrapTextWithAnsi(line, width);
137
+
138
+ const { prefix, rest } = splitByVisibleWidth(line, prefixWidth);
139
+ const contentWidth = Math.max(width - visibleWidth(prefix), 1);
140
+ const continuationPrefix = maskDigits(prefix);
141
+ const wrappedContent = wrapTextWithAnsi(rest, contentWidth);
142
+
143
+ return wrappedContent.map((chunk, index) => (index === 0 ? prefix : continuationPrefix) + chunk);
144
+ }
145
+
146
+ function wrapDeltaLines(lines: string[], width: number): string[] {
147
+ if (width <= 0) return lines;
148
+ const wrapped: string[] = [];
149
+ for (const line of lines) {
150
+ wrapped.push(...wrapDeltaLine(line, width));
151
+ }
152
+ return wrapped;
153
+ }
154
+
7
155
  export function loadFileContent(
8
156
  filePath: string,
9
157
  cwd: string,
@@ -15,23 +163,31 @@ export function loadFileContent(
15
163
  const termWidth = width || process.stdout.columns || 80;
16
164
 
17
165
  try {
166
+ try {
167
+ if (statSync(filePath).isDirectory()) {
168
+ return ["Directory selected - expand it in the file tree instead of opening it."];
169
+ }
170
+ } catch {
171
+ // Ignore stat errors and fall through to normal handling
172
+ }
173
+
18
174
  if (diffMode && hasChanges && isGitRepo(cwd)) {
19
175
  try {
20
176
  // Try different diff strategies
21
177
  let diffOutput = "";
22
178
 
23
179
  // First try: unstaged changes
24
- const unstaged = execSync(`git diff -- "${filePath}"`, { cwd, encoding: "utf-8", timeout: 10000, stdio: "pipe" });
180
+ const unstaged = execSync(`git diff --no-color -- "${filePath}"`, { cwd, encoding: "utf-8", timeout: 10000, stdio: "pipe" });
25
181
  if (unstaged.trim()) {
26
182
  diffOutput = unstaged;
27
183
  } else {
28
184
  // Second try: staged changes
29
- const staged = execSync(`git diff --cached -- "${filePath}"`, { cwd, encoding: "utf-8", timeout: 10000, stdio: "pipe" });
185
+ const staged = execSync(`git diff --no-color --cached -- "${filePath}"`, { cwd, encoding: "utf-8", timeout: 10000, stdio: "pipe" });
30
186
  if (staged.trim()) {
31
187
  diffOutput = staged;
32
188
  } else {
33
189
  // Third try: diff against HEAD (for new files that are staged)
34
- const headDiff = execSync(`git diff HEAD -- "${filePath}"`, { cwd, encoding: "utf-8", timeout: 10000, stdio: "pipe" });
190
+ const headDiff = execSync(`git diff --no-color HEAD -- "${filePath}"`, { cwd, encoding: "utf-8", timeout: 10000, stdio: "pipe" });
35
191
  if (headDiff.trim()) {
36
192
  diffOutput = headDiff;
37
193
  }
@@ -46,7 +202,7 @@ export function loadFileContent(
46
202
  // Pipe through delta with line numbers for better readability
47
203
  try {
48
204
  const deltaOutput = execSync(
49
- `delta --no-gitconfig --width=${termWidth} --line-numbers`,
205
+ `delta --no-gitconfig --width=${termWidth} --line-numbers --wrap-max-lines=unlimited --max-line-length=0`,
50
206
  {
51
207
  cwd,
52
208
  encoding: "utf-8",
@@ -55,13 +211,13 @@ export function loadFileContent(
55
211
  stdio: ["pipe", "pipe", "pipe"],
56
212
  }
57
213
  );
58
- return stripLeadingEmptyLines(deltaOutput.split("\n"));
214
+ return wrapDeltaLines(stripLeadingEmptyLines(deltaOutput.split("\n")), termWidth);
59
215
  } catch {
60
216
  // Fall back to raw diff
61
217
  }
62
218
  }
63
219
 
64
- return diffOutput.split("\n");
220
+ return wrapDiffLines(stripLeadingEmptyLines(diffOutput.split("\n")), termWidth);
65
221
  } catch (e: any) {
66
222
  return [`Diff error: ${e.message}`];
67
223
  }
@@ -79,10 +235,21 @@ export function loadFileContent(
79
235
  }
80
236
 
81
237
  if (hasCommand("bat")) {
82
- return execSync(
83
- `bat --style=numbers --color=always --paging=never --wrap=auto --terminal-width=${termWidth} "${filePath}"`,
84
- { encoding: "utf-8", timeout: 10000 }
85
- ).split("\n");
238
+ try {
239
+ return execSync(
240
+ `bat --style=numbers --color=always --paging=never --wrap=auto --terminal-width=${termWidth} "${filePath}"`,
241
+ { encoding: "utf-8", timeout: 10000 }
242
+ ).split("\n");
243
+ } catch {
244
+ try {
245
+ return execSync(
246
+ `bat --style=numbers --color=always --paging=never --terminal-width=${termWidth} "${filePath}"`,
247
+ { encoding: "utf-8", timeout: 10000 }
248
+ ).split("\n");
249
+ } catch {
250
+ // Fall through to raw file read
251
+ }
252
+ }
86
253
  }
87
254
 
88
255
  const raw = readFileSync(filePath, "utf-8");
package/index.ts CHANGED
@@ -17,10 +17,18 @@ import { hasCommand } from "./utils";
17
17
  export default function editorExtension(pi: ExtensionAPI): void {
18
18
  const cwd = process.cwd();
19
19
  const agentModifiedFiles = new Set<string>();
20
+ const requiredDeps = ["bat", "delta", "glow"] as const;
21
+ const getMissingDeps = () => requiredDeps.filter((dep) => !hasCommand(dep));
20
22
 
21
23
  pi.registerCommand("files", {
22
24
  description: "Open file browser",
23
25
  handler: async (_args, ctx) => {
26
+ const missing = getMissingDeps();
27
+ if (missing.length > 0) {
28
+ ctx.ui.notify(`files-widget requires ${missing.join(", ")}. Install: brew install bat git-delta glow`, "error");
29
+ return;
30
+ }
31
+
24
32
  await ctx.ui.custom<void>((tui, theme, _kb, done) => {
25
33
  let pollInterval: ReturnType<typeof setInterval> | null = null;
26
34
 
@@ -122,13 +130,9 @@ export default function editorExtension(pi: ExtensionAPI): void {
122
130
  });
123
131
 
124
132
  pi.on("session_start", async (_event, ctx) => {
125
- const missing: string[] = [];
126
- if (!hasCommand("bat")) missing.push("bat");
127
- if (!hasCommand("delta")) missing.push("delta");
128
- if (!hasCommand("glow")) missing.push("glow");
129
-
133
+ const missing = getMissingDeps();
130
134
  if (missing.length > 0) {
131
- ctx.ui.notify(`Editor: install ${missing.join(", ")} for better experience`, "info");
135
+ ctx.ui.notify(`files-widget requires ${missing.join(", ")}. Install: brew install bat git-delta glow`, "error");
132
136
  }
133
137
 
134
138
  agentModifiedFiles.clear();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-files-widget",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "In-terminal file browser and viewer for Pi.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",
@@ -14,6 +14,13 @@
14
14
  },
15
15
  "bugs": "https://github.com/tmustier/pi-extensions/issues",
16
16
  "homepage": "https://github.com/tmustier/pi-extensions/tree/main/files-widget",
17
+ "peerDependencies": {
18
+ "@mariozechner/pi-coding-agent": "^0.50.0",
19
+ "@mariozechner/pi-tui": "^0.50.0"
20
+ },
21
+ "scripts": {
22
+ "postinstall": "node -e \"console.log('pi-files-widget: required deps: bat, delta, glow. Install with: brew install bat git-delta glow')\""
23
+ },
17
24
  "pi": {
18
25
  "extensions": [
19
26
  "index.ts"