pi-files-widget-overlay 0.3.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/CHANGELOG.md +198 -0
- package/LICENSE +22 -0
- package/README.md +102 -0
- package/demo.png +0 -0
- package/package.json +38 -0
- package/publish-pi-files-widget-overlay.sh +228 -0
- package/src/activity.ts +10 -0
- package/src/browser.ts +1244 -0
- package/src/comment.ts +6 -0
- package/src/constants.ts +31 -0
- package/src/file-tree.ts +321 -0
- package/src/file-viewer.ts +181 -0
- package/src/git.ts +129 -0
- package/src/index.ts +147 -0
- package/src/input-utils.ts +96 -0
- package/src/types.ts +31 -0
- package/src/utils.ts +59 -0
- package/src/viewer.ts +729 -0
- package/tsconfig.json +13 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi Editor Extension
|
|
3
|
+
*
|
|
4
|
+
* Provides an in-terminal file browser and viewer.
|
|
5
|
+
* Use /readfiles to open the file browser, navigate with j/k, Enter to view.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
10
|
+
import { statSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
13
|
+
|
|
14
|
+
import { OVERLAY_MAX_HEIGHT, POLL_INTERVAL_MS } from "./constants";
|
|
15
|
+
import { formatCommentMessage } from "./comment";
|
|
16
|
+
import { getObservedToolActivityPath } from "./activity";
|
|
17
|
+
|
|
18
|
+
function resolveInitialPath(arg: string | undefined, cwd: string): { path: string; error?: string } {
|
|
19
|
+
if (!arg) return { path: cwd };
|
|
20
|
+
let candidate = arg.trim();
|
|
21
|
+
if (!candidate) return { path: cwd };
|
|
22
|
+
const home = homedir();
|
|
23
|
+
if (candidate === "~") {
|
|
24
|
+
candidate = home;
|
|
25
|
+
} else if (candidate.startsWith("~/")) {
|
|
26
|
+
candidate = join(home, candidate.slice(2));
|
|
27
|
+
}
|
|
28
|
+
const absolute = isAbsolute(candidate) ? candidate : resolve(cwd, candidate);
|
|
29
|
+
try {
|
|
30
|
+
if (!statSync(absolute).isDirectory()) {
|
|
31
|
+
return { path: cwd, error: `${absolute} is not a directory` };
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
return { path: cwd, error: `${absolute} is not accessible` };
|
|
35
|
+
}
|
|
36
|
+
return { path: absolute };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default function editorExtension(pi: ExtensionAPI): void {
|
|
40
|
+
const cwd = process.cwd();
|
|
41
|
+
const agentModifiedFiles = new Set<string>();
|
|
42
|
+
|
|
43
|
+
pi.registerCommand("readfiles", {
|
|
44
|
+
description: "Open file browser as a floating overlay (optional: /readfiles <path> to start outside the current directory)",
|
|
45
|
+
handler: async (args, ctx) => {
|
|
46
|
+
|
|
47
|
+
const resolved = resolveInitialPath(args, cwd);
|
|
48
|
+
if (resolved.error) {
|
|
49
|
+
ctx.ui.notify(resolved.error, "error");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const initialPath = resolved.path;
|
|
53
|
+
const { createFileBrowser } = await import("./browser");
|
|
54
|
+
|
|
55
|
+
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
|
56
|
+
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
|
57
|
+
|
|
58
|
+
const cleanup = () => {
|
|
59
|
+
if (pollInterval) {
|
|
60
|
+
clearInterval(pollInterval);
|
|
61
|
+
pollInterval = null;
|
|
62
|
+
}
|
|
63
|
+
done();
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const requestComment = (payload: { relPath: string; lineRange: string; ext: string; selectedText: string }, comment: string) => {
|
|
67
|
+
const message = formatCommentMessage(payload, comment);
|
|
68
|
+
if (ctx.isIdle()) {
|
|
69
|
+
pi.sendUserMessage(message);
|
|
70
|
+
ctx.ui.notify(`Comment sent to agent for ${payload.relPath} (${payload.lineRange})`, "info");
|
|
71
|
+
} else {
|
|
72
|
+
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
73
|
+
ctx.ui.notify(`Comment queued for agent for ${payload.relPath} (${payload.lineRange})`, "info");
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const requestRender = () => tui.requestRender();
|
|
78
|
+
const browser = createFileBrowser(
|
|
79
|
+
initialPath,
|
|
80
|
+
agentModifiedFiles,
|
|
81
|
+
theme,
|
|
82
|
+
cleanup,
|
|
83
|
+
requestComment,
|
|
84
|
+
requestRender,
|
|
85
|
+
cwd
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
pollInterval = setInterval(() => {
|
|
89
|
+
requestRender();
|
|
90
|
+
}, POLL_INTERVAL_MS);
|
|
91
|
+
|
|
92
|
+
const renderOverlay = (width: number): string[] => {
|
|
93
|
+
// Keep the browser usable on narrow terminals: a percentage width is
|
|
94
|
+
// clamped by Pi to the viewport, while the component truncates safely.
|
|
95
|
+
if (width < 3) return browser.render(width);
|
|
96
|
+
|
|
97
|
+
const innerWidth = width - 2;
|
|
98
|
+
const padLine = (line: string) => {
|
|
99
|
+
const truncated = truncateToWidth(line, innerWidth, "", true);
|
|
100
|
+
return truncated + " ".repeat(Math.max(0, innerWidth - visibleWidth(truncated)));
|
|
101
|
+
};
|
|
102
|
+
const border = (character: string) => theme.fg("border", character);
|
|
103
|
+
const header = padLine(theme.fg("accent", theme.bold(" Files ")));
|
|
104
|
+
|
|
105
|
+
return [
|
|
106
|
+
border(`┌${"─".repeat(innerWidth)}┐`),
|
|
107
|
+
border("│") + header + border("│"),
|
|
108
|
+
border(`├${"─".repeat(innerWidth)}┤`),
|
|
109
|
+
...browser.render(innerWidth).map(line => border("│") + padLine(line) + border("│")),
|
|
110
|
+
border(`└${"─".repeat(innerWidth)}┘`),
|
|
111
|
+
];
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
render: renderOverlay,
|
|
116
|
+
handleInput: (data) => {
|
|
117
|
+
browser.handleInput(data);
|
|
118
|
+
requestRender();
|
|
119
|
+
},
|
|
120
|
+
invalidate: () => browser.invalidate(),
|
|
121
|
+
};
|
|
122
|
+
}, {
|
|
123
|
+
overlay: true,
|
|
124
|
+
overlayOptions: {
|
|
125
|
+
anchor: "center",
|
|
126
|
+
width: "95%",
|
|
127
|
+
maxHeight: OVERLAY_MAX_HEIGHT,
|
|
128
|
+
margin: 1,
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
pi.on("tool_result", async (event) => {
|
|
135
|
+
const filePath = getObservedToolActivityPath(event.toolName, event.input, cwd);
|
|
136
|
+
if (filePath) agentModifiedFiles.add(filePath);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
pi.on("session_start", async () => {
|
|
140
|
+
|
|
141
|
+
agentModifiedFiles.clear();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
pi.on("session_before_switch", () => {
|
|
145
|
+
agentModifiedFiles.clear();
|
|
146
|
+
});
|
|
147
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { decodeKittyPrintable } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
const CONTROL_CHARS = /[\u0000-\u0008\u000B-\u001F\u007F]/g;
|
|
4
|
+
const BRACKETED_PASTE_START = "\u001b[200~";
|
|
5
|
+
const BRACKETED_PASTE_END = "\u001b[201~";
|
|
6
|
+
|
|
7
|
+
interface SanitizeTextInputOptions {
|
|
8
|
+
preserveNewlines?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function sanitizeTextInput(data: string, options: SanitizeTextInputOptions = {}): string {
|
|
12
|
+
const normalized = decodeKittyPrintable(data) ?? data;
|
|
13
|
+
if (!normalized || normalized.includes("\u001b")) {
|
|
14
|
+
return "";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const withNewlines = normalized.replace(/\r\n?/g, "\n");
|
|
18
|
+
const withoutTabs = options.preserveNewlines
|
|
19
|
+
? withNewlines.replace(/\t/g, " ")
|
|
20
|
+
: withNewlines.replace(/\n/g, " ").replace(/\t/g, " ");
|
|
21
|
+
|
|
22
|
+
return withoutTabs.replace(CONTROL_CHARS, "");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getPendingStartSuffix(data: string): string {
|
|
26
|
+
const maxLength = BRACKETED_PASTE_START.length - 1;
|
|
27
|
+
for (let length = Math.min(data.length, maxLength); length > 0; length--) {
|
|
28
|
+
const suffix = data.slice(-length);
|
|
29
|
+
if (BRACKETED_PASTE_START.startsWith(suffix)) {
|
|
30
|
+
return suffix;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TextInputBuffer {
|
|
37
|
+
push(data: string): string;
|
|
38
|
+
reset(): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface TextInputBufferOptions {
|
|
42
|
+
preserveNewlines?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createTextInputBuffer(options: TextInputBufferOptions = {}): TextInputBuffer {
|
|
46
|
+
let isInPaste = false;
|
|
47
|
+
let pasteBuffer = "";
|
|
48
|
+
let pendingStart = "";
|
|
49
|
+
|
|
50
|
+
const push = (data: string): string => {
|
|
51
|
+
if (!data) {
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const combined = pendingStart + data;
|
|
56
|
+
pendingStart = "";
|
|
57
|
+
|
|
58
|
+
if (!isInPaste) {
|
|
59
|
+
const startIndex = combined.indexOf(BRACKETED_PASTE_START);
|
|
60
|
+
if (startIndex === -1) {
|
|
61
|
+
const pendingSuffix = getPendingStartSuffix(combined);
|
|
62
|
+
const completeText = pendingSuffix ? combined.slice(0, combined.length - pendingSuffix.length) : combined;
|
|
63
|
+
pendingStart = pendingSuffix;
|
|
64
|
+
return sanitizeTextInput(completeText, options);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const beforePaste = combined.slice(0, startIndex);
|
|
68
|
+
const afterStart = combined.slice(startIndex + BRACKETED_PASTE_START.length);
|
|
69
|
+
isInPaste = true;
|
|
70
|
+
pasteBuffer = "";
|
|
71
|
+
return sanitizeTextInput(beforePaste, options) + push(afterStart);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
pasteBuffer += combined;
|
|
75
|
+
const endIndex = pasteBuffer.indexOf(BRACKETED_PASTE_END);
|
|
76
|
+
if (endIndex === -1) {
|
|
77
|
+
return "";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const pastedText = pasteBuffer.slice(0, endIndex);
|
|
81
|
+
const remaining = pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length);
|
|
82
|
+
isInPaste = false;
|
|
83
|
+
pasteBuffer = "";
|
|
84
|
+
|
|
85
|
+
return sanitizeTextInput(pastedText, options) + push(remaining);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
push,
|
|
90
|
+
reset(): void {
|
|
91
|
+
isInPaste = false;
|
|
92
|
+
pasteBuffer = "";
|
|
93
|
+
pendingStart = "";
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface DiffStats {
|
|
2
|
+
additions: number;
|
|
3
|
+
deletions: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface FileNode {
|
|
7
|
+
name: string;
|
|
8
|
+
path: string;
|
|
9
|
+
isDirectory: boolean;
|
|
10
|
+
isSymlink?: boolean;
|
|
11
|
+
realPath?: string;
|
|
12
|
+
parent?: FileNode;
|
|
13
|
+
children?: FileNode[];
|
|
14
|
+
expanded?: boolean;
|
|
15
|
+
gitStatus?: string;
|
|
16
|
+
agentModified?: boolean;
|
|
17
|
+
lineCount?: number;
|
|
18
|
+
diffStats?: DiffStats;
|
|
19
|
+
hasChangedChildren?: boolean; // For directories
|
|
20
|
+
// Aggregated stats for directories
|
|
21
|
+
totalLines?: number;
|
|
22
|
+
totalAdditions?: number;
|
|
23
|
+
totalDeletions?: number;
|
|
24
|
+
lineCountComplete?: boolean;
|
|
25
|
+
loading?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface FlatNode {
|
|
29
|
+
node: FileNode;
|
|
30
|
+
depth: number;
|
|
31
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
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
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isUntrackedStatus(status?: string): boolean {
|
|
42
|
+
return status === "?" || status === "??";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isIgnoredStatus(status?: string): boolean {
|
|
46
|
+
return status === "!" || status === "!!";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isMarkdownPath(path: string): boolean {
|
|
50
|
+
return path.toLowerCase().endsWith(".md");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function stripLeadingEmptyLines(lines: string[]): string[] {
|
|
54
|
+
let startIdx = 0;
|
|
55
|
+
while (startIdx < lines.length && !lines[startIdx].trim()) {
|
|
56
|
+
startIdx++;
|
|
57
|
+
}
|
|
58
|
+
return lines.slice(startIdx);
|
|
59
|
+
}
|