@tmustier/pi-files-widget 0.1.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/file-tree.ts ADDED
@@ -0,0 +1,156 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { basename, join, relative } from "node:path";
3
+
4
+ import { MAX_TREE_DEPTH } from "./constants";
5
+ import type { DiffStats, FileNode, FlatNode } from "./types";
6
+
7
+ export function getFileLineCount(filePath: string): number {
8
+ try {
9
+ const content = readFileSync(filePath, "utf-8");
10
+ return content.split("\n").length;
11
+ } catch {
12
+ return 0;
13
+ }
14
+ }
15
+
16
+ export function getIgnoredNames(): Set<string> {
17
+ return new Set([
18
+ "node_modules",
19
+ ".git",
20
+ ".DS_Store",
21
+ "__pycache__",
22
+ ".pytest_cache",
23
+ ".mypy_cache",
24
+ ".next",
25
+ ".nuxt",
26
+ "dist",
27
+ "build",
28
+ ".venv",
29
+ "venv",
30
+ ".env",
31
+ "coverage",
32
+ ".nyc_output",
33
+ ".turbo",
34
+ ".cache",
35
+ ]);
36
+ }
37
+
38
+ export function buildFileTree(
39
+ dir: string,
40
+ cwd: string,
41
+ gitStatus: Map<string, string>,
42
+ diffStats: Map<string, DiffStats>,
43
+ ignored: Set<string>,
44
+ agentModified: Set<string>,
45
+ depth = 0
46
+ ): FileNode | null {
47
+ if (depth > MAX_TREE_DEPTH) return null;
48
+
49
+ const name = basename(dir) || dir;
50
+ if (ignored.has(name)) return null;
51
+
52
+ const node: FileNode = {
53
+ name: depth === 0 ? "." : name,
54
+ path: dir,
55
+ isDirectory: true,
56
+ children: [],
57
+ expanded: depth < 1,
58
+ hasChangedChildren: false,
59
+ };
60
+
61
+ try {
62
+ const entries = readdirSync(dir);
63
+ const dirs: FileNode[] = [];
64
+ const files: FileNode[] = [];
65
+
66
+ for (const entry of entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))) {
67
+ if (ignored.has(entry) || entry.startsWith(".")) continue;
68
+
69
+ const fullPath = join(dir, entry);
70
+ let stat;
71
+ try {
72
+ stat = statSync(fullPath);
73
+ } catch {
74
+ continue;
75
+ }
76
+
77
+ if (stat.isDirectory()) {
78
+ const child = buildFileTree(fullPath, cwd, gitStatus, diffStats, ignored, agentModified, depth + 1);
79
+ if (child) {
80
+ dirs.push(child);
81
+ // Propagate hasChangedChildren up
82
+ if (child.hasChangedChildren || child.gitStatus) {
83
+ node.hasChangedChildren = true;
84
+ }
85
+ }
86
+ } else {
87
+ const relPath = relative(cwd, fullPath);
88
+ const fileGitStatus = gitStatus.get(relPath);
89
+ const fileDiffStats = diffStats.get(relPath);
90
+
91
+ if (fileGitStatus) {
92
+ node.hasChangedChildren = true;
93
+ }
94
+
95
+ files.push({
96
+ name: entry,
97
+ path: fullPath,
98
+ isDirectory: false,
99
+ gitStatus: fileGitStatus,
100
+ agentModified: agentModified.has(fullPath),
101
+ lineCount: getFileLineCount(fullPath),
102
+ diffStats: fileDiffStats,
103
+ });
104
+ }
105
+ }
106
+
107
+ node.children = [...dirs, ...files];
108
+
109
+ // Calculate aggregated stats for this directory
110
+ let totalLines = 0;
111
+ let totalAdditions = 0;
112
+ let totalDeletions = 0;
113
+
114
+ for (const child of node.children) {
115
+ if (child.isDirectory) {
116
+ totalLines += child.totalLines || 0;
117
+ totalAdditions += child.totalAdditions || 0;
118
+ totalDeletions += child.totalDeletions || 0;
119
+ } else {
120
+ totalLines += child.lineCount || 0;
121
+ if (child.diffStats) {
122
+ totalAdditions += child.diffStats.additions;
123
+ totalDeletions += child.diffStats.deletions;
124
+ }
125
+ }
126
+ }
127
+
128
+ node.totalLines = totalLines;
129
+ node.totalAdditions = totalAdditions;
130
+ node.totalDeletions = totalDeletions;
131
+ } catch {}
132
+
133
+ return node;
134
+ }
135
+
136
+ export function flattenTree(node: FileNode, depth = 0, isRoot = true): FlatNode[] {
137
+ const result: FlatNode[] = [];
138
+
139
+ // Skip the root "." node itself, just process its children
140
+ if (isRoot && node.name === ".") {
141
+ for (const child of node.children || []) {
142
+ result.push(...flattenTree(child, 0, false));
143
+ }
144
+ return result;
145
+ }
146
+
147
+ result.push({ node, depth });
148
+
149
+ if (node.isDirectory && node.expanded && node.children) {
150
+ for (const child of node.children) {
151
+ result.push(...flattenTree(child, depth + 1, false));
152
+ }
153
+ }
154
+
155
+ return result;
156
+ }
package/file-viewer.ts ADDED
@@ -0,0 +1,93 @@
1
+ import { execSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ import { isGitRepo } from "./git";
5
+ import { hasCommand, stripLeadingEmptyLines } from "./utils";
6
+
7
+ export function loadFileContent(
8
+ filePath: string,
9
+ cwd: string,
10
+ diffMode: boolean,
11
+ hasChanges: boolean,
12
+ width?: number
13
+ ): string[] {
14
+ const isMarkdown = filePath.endsWith(".md");
15
+ const termWidth = width || process.stdout.columns || 80;
16
+
17
+ try {
18
+ if (diffMode && hasChanges && isGitRepo(cwd)) {
19
+ try {
20
+ // Try different diff strategies
21
+ let diffOutput = "";
22
+
23
+ // First try: unstaged changes
24
+ const unstaged = execSync(`git diff -- "${filePath}"`, { cwd, encoding: "utf-8", timeout: 10000, stdio: "pipe" });
25
+ if (unstaged.trim()) {
26
+ diffOutput = unstaged;
27
+ } else {
28
+ // Second try: staged changes
29
+ const staged = execSync(`git diff --cached -- "${filePath}"`, { cwd, encoding: "utf-8", timeout: 10000, stdio: "pipe" });
30
+ if (staged.trim()) {
31
+ diffOutput = staged;
32
+ } else {
33
+ // 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" });
35
+ if (headDiff.trim()) {
36
+ diffOutput = headDiff;
37
+ }
38
+ }
39
+ }
40
+
41
+ if (!diffOutput.trim()) {
42
+ return ["No diff available - file may be untracked or unchanged"];
43
+ }
44
+
45
+ if (hasCommand("delta")) {
46
+ // Pipe through delta with line numbers for better readability
47
+ try {
48
+ const deltaOutput = execSync(
49
+ `delta --no-gitconfig --width=${termWidth} --line-numbers`,
50
+ {
51
+ cwd,
52
+ encoding: "utf-8",
53
+ timeout: 10000,
54
+ input: diffOutput,
55
+ stdio: ["pipe", "pipe", "pipe"],
56
+ }
57
+ );
58
+ return stripLeadingEmptyLines(deltaOutput.split("\n"));
59
+ } catch {
60
+ // Fall back to raw diff
61
+ }
62
+ }
63
+
64
+ return diffOutput.split("\n");
65
+ } catch (e: any) {
66
+ return [`Diff error: ${e.message}`];
67
+ }
68
+ }
69
+
70
+ if (isMarkdown && hasCommand("glow")) {
71
+ try {
72
+ const output = execSync(`glow -s dark -w ${termWidth} "${filePath}"`, { encoding: "utf-8", timeout: 10000 });
73
+ if (output.trim()) {
74
+ return stripLeadingEmptyLines(output.split("\n"));
75
+ }
76
+ } catch {
77
+ // Fall through to bat
78
+ }
79
+ }
80
+
81
+ 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");
86
+ }
87
+
88
+ const raw = readFileSync(filePath, "utf-8");
89
+ return raw.split("\n").map((line, i) => `${String(i + 1).padStart(4)} │ ${line}`);
90
+ } catch (e: any) {
91
+ return [`Error loading file: ${e.message}`];
92
+ }
93
+ }
package/git.ts ADDED
@@ -0,0 +1,72 @@
1
+ import { execSync } from "node:child_process";
2
+
3
+ import type { DiffStats } from "./types";
4
+
5
+ export function isGitRepo(cwd: string): boolean {
6
+ try {
7
+ execSync("git rev-parse --is-inside-work-tree", { cwd, encoding: "utf-8", timeout: 2000, stdio: "pipe" });
8
+ return true;
9
+ } catch {
10
+ return false;
11
+ }
12
+ }
13
+
14
+ export function getGitStatus(cwd: string): Map<string, string> {
15
+ const status = new Map<string, string>();
16
+ try {
17
+ // Include ignored files with --ignored flag
18
+ const output = execSync("git status --porcelain --ignored", { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe" });
19
+ for (const line of output.split("\n")) {
20
+ if (line.length < 3) continue;
21
+ const statusCode = line.slice(0, 2).trim() || "?";
22
+ const filePath = line.slice(3);
23
+ status.set(filePath, statusCode);
24
+ }
25
+ } catch {}
26
+ return status;
27
+ }
28
+
29
+ export function getGitBranch(cwd: string): string {
30
+ try {
31
+ return execSync("git branch --show-current", { cwd, encoding: "utf-8", timeout: 2000, stdio: "pipe" }).trim();
32
+ } catch {
33
+ return "";
34
+ }
35
+ }
36
+
37
+ export function getGitDiffStats(cwd: string): Map<string, DiffStats> {
38
+ const stats = new Map<string, DiffStats>();
39
+ try {
40
+ // Get diff stats for modified files
41
+ const output = execSync("git diff --numstat HEAD", { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe" });
42
+ for (const line of output.split("\n")) {
43
+ const parts = line.split("\t");
44
+ if (parts.length >= 3) {
45
+ const additions = parseInt(parts[0], 10) || 0;
46
+ const deletions = parseInt(parts[1], 10) || 0;
47
+ const filePath = parts[2];
48
+ stats.set(filePath, { additions, deletions });
49
+ }
50
+ }
51
+ // Also get stats for staged files
52
+ const stagedOutput = execSync("git diff --numstat --cached", { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe" });
53
+ for (const line of stagedOutput.split("\n")) {
54
+ const parts = line.split("\t");
55
+ if (parts.length >= 3) {
56
+ const additions = parseInt(parts[0], 10) || 0;
57
+ const deletions = parseInt(parts[1], 10) || 0;
58
+ const filePath = parts[2];
59
+ const existing = stats.get(filePath);
60
+ if (existing) {
61
+ stats.set(filePath, {
62
+ additions: existing.additions + additions,
63
+ deletions: existing.deletions + deletions,
64
+ });
65
+ } else {
66
+ stats.set(filePath, { additions, deletions });
67
+ }
68
+ }
69
+ }
70
+ } catch {}
71
+ return stats;
72
+ }
package/index.ts ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Pi Editor Extension
3
+ *
4
+ * Provides an in-terminal file browser, viewer, and review workflow.
5
+ * Use /files to open the file browser, navigate with j/k, Enter to view.
6
+ */
7
+
8
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
+ import { execSync, spawnSync } from "node:child_process";
10
+ import { join } from "node:path";
11
+
12
+ import { createFileBrowser } from "./browser";
13
+ import { POLL_INTERVAL_MS } from "./constants";
14
+ import { formatCommentMessage } from "./comment";
15
+ import { hasCommand } from "./utils";
16
+
17
+ export default function editorExtension(pi: ExtensionAPI): void {
18
+ const cwd = process.cwd();
19
+ const agentModifiedFiles = new Set<string>();
20
+
21
+ pi.registerCommand("files", {
22
+ description: "Open file browser",
23
+ handler: async (_args, ctx) => {
24
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
25
+ let pollInterval: ReturnType<typeof setInterval> | null = null;
26
+
27
+ const cleanup = () => {
28
+ if (pollInterval) {
29
+ clearInterval(pollInterval);
30
+ pollInterval = null;
31
+ }
32
+ done();
33
+ };
34
+
35
+ const requestComment = (payload: { relPath: string; lineRange: string; ext: string; selectedText: string }, comment: string) => {
36
+ pi.sendUserMessage(formatCommentMessage(payload, comment), {
37
+ deliverAs: "followUp",
38
+ streamingBehavior: "followUp" as any,
39
+ } as any);
40
+ };
41
+
42
+ const browser = createFileBrowser(cwd, agentModifiedFiles, theme, cleanup, requestComment);
43
+
44
+ pollInterval = setInterval(() => {
45
+ tui.requestRender();
46
+ }, POLL_INTERVAL_MS);
47
+
48
+ return {
49
+ render: (w) => browser.render(w),
50
+ handleInput: (data) => {
51
+ browser.handleInput(data);
52
+ tui.requestRender();
53
+ },
54
+ invalidate: () => browser.invalidate(),
55
+ };
56
+ });
57
+ },
58
+ });
59
+
60
+ pi.registerCommand("review", {
61
+ description: "Open tuicr to review changes and send feedback to agent",
62
+ handler: async (_args, ctx) => {
63
+ if (!hasCommand("tuicr")) {
64
+ ctx.ui.notify("Install tuicr: brew install agavra/tap/tuicr", "error");
65
+ return;
66
+ }
67
+
68
+ ctx.ui.notify("Opening tuicr... Press :wq or y to copy review", "info");
69
+
70
+ try {
71
+ spawnSync("tuicr", [], { cwd, stdio: "inherit" });
72
+
73
+ try {
74
+ const clipboard = execSync(
75
+ process.platform === "darwin" ? "pbpaste" : "xclip -selection clipboard -o",
76
+ { encoding: "utf-8", timeout: 5000 }
77
+ );
78
+
79
+ if (
80
+ clipboard.includes("## Review") ||
81
+ clipboard.includes("```") ||
82
+ clipboard.includes("[Issue]") ||
83
+ clipboard.includes("[Suggestion]")
84
+ ) {
85
+ pi.sendUserMessage(clipboard, { deliverAs: "steer" });
86
+ ctx.ui.notify("Review sent to agent", "success");
87
+ }
88
+ } catch {}
89
+ } catch (e: any) {
90
+ ctx.ui.notify(`tuicr error: ${e.message}`, "error");
91
+ }
92
+ },
93
+ });
94
+
95
+ pi.registerCommand("diff", {
96
+ description: "Open critique to view diffs",
97
+ handler: async (args, ctx) => {
98
+ if (!hasCommand("bun")) {
99
+ ctx.ui.notify("critique requires Bun: brew install oven-sh/bun/bun", "error");
100
+ return;
101
+ }
102
+
103
+ const critiqueArgs = args ? args.split(" ") : [];
104
+ ctx.ui.notify("Opening critique...", "info");
105
+
106
+ try {
107
+ spawnSync("bunx", ["critique", ...critiqueArgs], { cwd, stdio: "inherit" });
108
+ } catch (e: any) {
109
+ ctx.ui.notify(`critique error: ${e.message}`, "error");
110
+ }
111
+ },
112
+ });
113
+
114
+ pi.on("tool_result", async (event) => {
115
+ if (event.toolName === "write" || event.toolName === "edit") {
116
+ const filePath = event.input?.path as string | undefined;
117
+ if (filePath) {
118
+ agentModifiedFiles.add(join(cwd, filePath));
119
+ }
120
+ }
121
+ });
122
+
123
+ pi.on("session_start", async (_event, ctx) => {
124
+ const missing: string[] = [];
125
+ if (!hasCommand("bat")) missing.push("bat");
126
+ if (!hasCommand("delta")) missing.push("delta");
127
+ if (!hasCommand("glow")) missing.push("glow");
128
+
129
+ if (missing.length > 0) {
130
+ ctx.ui.notify(`Editor: install ${missing.join(", ")} for better experience`, "info");
131
+ }
132
+
133
+ agentModifiedFiles.clear();
134
+ });
135
+
136
+ pi.on("session_switch", async () => {
137
+ agentModifiedFiles.clear();
138
+ });
139
+ }
package/input-utils.ts ADDED
@@ -0,0 +1,3 @@
1
+ export function isPrintableChar(data: string): boolean {
2
+ return data.length === 1 && data.charCodeAt(0) >= 32 && data.charCodeAt(0) < 127;
3
+ }
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@tmustier/pi-files-widget",
3
+ "version": "0.1.0",
4
+ "description": "In-terminal file browser and viewer for Pi.",
5
+ "license": "MIT",
6
+ "author": "Thomas Mustier",
7
+ "keywords": [
8
+ "pi-package"
9
+ ],
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/tmustier/pi-extensions.git",
13
+ "directory": "files-widget"
14
+ },
15
+ "bugs": "https://github.com/tmustier/pi-extensions/issues",
16
+ "homepage": "https://github.com/tmustier/pi-extensions/tree/main/files-widget",
17
+ "pi": {
18
+ "extensions": [
19
+ "index.ts"
20
+ ]
21
+ }
22
+ }
package/types.ts ADDED
@@ -0,0 +1,26 @@
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
+ children?: FileNode[];
11
+ expanded?: boolean;
12
+ gitStatus?: string;
13
+ agentModified?: boolean;
14
+ lineCount?: number;
15
+ diffStats?: DiffStats;
16
+ hasChangedChildren?: boolean; // For directories
17
+ // Aggregated stats for directories
18
+ totalLines?: number;
19
+ totalAdditions?: number;
20
+ totalDeletions?: number;
21
+ }
22
+
23
+ export interface FlatNode {
24
+ node: FileNode;
25
+ depth: number;
26
+ }
package/utils.ts ADDED
@@ -0,0 +1,26 @@
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;
9
+ }
10
+ }
11
+
12
+ export function isUntrackedStatus(status?: string): boolean {
13
+ return status === "?" || status === "??";
14
+ }
15
+
16
+ export function isIgnoredStatus(status?: string): boolean {
17
+ return status === "!" || status === "!!";
18
+ }
19
+
20
+ export function stripLeadingEmptyLines(lines: string[]): string[] {
21
+ let startIdx = 0;
22
+ while (startIdx < lines.length && !lines[startIdx].trim()) {
23
+ startIdx++;
24
+ }
25
+ return lines.slice(startIdx);
26
+ }