@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 +15 -0
- package/README.md +13 -7
- package/file-tree.ts +41 -2
- package/file-viewer.ts +178 -11
- package/index.ts +10 -6
- package/package.json +8 -1
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
|

|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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(`
|
|
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.
|
|
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"
|