@agnishc/edb-diff-files 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/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ### Added
6
+ - Initial release: session-scoped file change tracker via write/edit tool events and bash diff snapshots
7
+ - Live widget with flash notification on new files per turn
8
+ - Footer status bar with created/edited/deleted counts
9
+ - `/diff-files` command with full-screen list + inline diff viewer
10
+ - Filter by change type, open in editor support
11
+ - Configurable via env vars and `~/.pi/settings.json`
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Agnish Chakraborty
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # @agnishc/edb-diff-files
2
+
3
+ A Pi CLI extension that tracks every file the agent touches during a session and shows them in a live widget above the editor. Opens a full-screen diff viewer on demand.
4
+
5
+ ## Features
6
+
7
+ - **Live widget** — updates above the editor after every turn showing `+ created`, `~ edited`, `- deleted` files
8
+ - **Flash notification** — briefly highlights newly added files at the end of each turn
9
+ - **Footer status** — compact `D(+N ~N -N)` indicator always visible
10
+ - **Inline diff viewer** — press Enter on any file to see a syntax-coloured unified diff
11
+ - **Filter by type** — cycle through all / created / edited / deleted with `f`
12
+ - **Open in editor** — press `o` to open the file in your configured GUI editor
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pi install npm:@agnishc/edb-diff-files
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ```
23
+ /diff-files
24
+ ```
25
+
26
+ | Key | Action |
27
+ |-----|--------|
28
+ | `j`/`k` or `↑`/`↓` | Navigate file list |
29
+ | `Enter` | Open inline diff for selected file |
30
+ | `o` | Open file in GUI editor |
31
+ | `f` | Cycle filter: all → created → edited → deleted |
32
+ | `Esc` | Close / back to list |
33
+
34
+ ## Configuration
35
+
36
+ Set via environment variables or `~/.pi/settings.json`:
37
+
38
+ | Variable | Default | Description |
39
+ |----------|---------|-------------|
40
+ | `FILES_WIDGET_MAX_LINES` | `8` | Max files shown in widget |
41
+ | `FILES_WIDGET_SHOW_HEADER` | `true` | Show "N files changed" header |
42
+ | `FILES_WIDGET_INCLUDE_DELETED` | `false` | Include deleted files in widget |
43
+
44
+ ## License
45
+
46
+ [MIT](LICENSE) © Agnish Chakraborty
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@agnishc/edb-diff-files",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension: live widget tracking files changed this session with an inline diff viewer",
5
+ "keywords": ["pi-package", "pi-extension", "edb", "git"],
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "author": "Agnish Chakraborty",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/agnishcc/pi-extention-monorepo.git",
12
+ "directory": "packages/edb-diff-files"
13
+ },
14
+ "homepage": "https://github.com/agnishcc/pi-extention-monorepo/tree/main/packages/edb-diff-files#readme",
15
+ "bugs": { "url": "https://github.com/agnishcc/pi-extention-monorepo/issues" },
16
+ "publishConfig": { "access": "public" },
17
+ "scripts": { "test": "vitest run" },
18
+ "files": ["src", "README.md", "LICENSE", "CHANGELOG.md"],
19
+ "pi": {
20
+ "extensions": ["./src/index.ts"]
21
+ },
22
+ "peerDependencies": {
23
+ "@mariozechner/pi-coding-agent": "*",
24
+ "@mariozechner/pi-tui": "*"
25
+ }
26
+ }
package/src/config.ts ADDED
@@ -0,0 +1,82 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import type { Colors, WidgetConfig } from "./types";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Widget config — env → settings.json → defaults
6
+ // ---------------------------------------------------------------------------
7
+
8
+ const DEFAULTS: WidgetConfig = {
9
+ maxLines: 8,
10
+ showHeader: true,
11
+ includeDeleted: false,
12
+ cwd: process.cwd(),
13
+ };
14
+
15
+ function envInt(name: string, fallback: number): number {
16
+ const v = Number.parseInt(process.env[name] ?? "", 10);
17
+ return Number.isFinite(v) && v > 0 ? v : fallback;
18
+ }
19
+
20
+ function envBool(name: string, fallback: boolean): boolean {
21
+ const v = process.env[name]?.toLowerCase();
22
+ if (v === "true" || v === "1") return true;
23
+ if (v === "false" || v === "0") return false;
24
+ return fallback;
25
+ }
26
+
27
+ export function loadConfig(): WidgetConfig {
28
+ let maxLines = envInt("FILES_WIDGET_MAX_LINES", DEFAULTS.maxLines);
29
+ let showHeader = envBool("FILES_WIDGET_SHOW_HEADER", DEFAULTS.showHeader);
30
+ let includeDeleted = envBool("FILES_WIDGET_INCLUDE_DELETED", DEFAULTS.includeDeleted);
31
+
32
+ const paths = [`${process.cwd()}/.pi/settings.json`, `${process.env.HOME ?? ""}/.pi/settings.json`];
33
+ for (const p of paths) {
34
+ try {
35
+ if (existsSync(p)) {
36
+ const raw = JSON.parse(readFileSync(p, "utf-8"));
37
+ if (raw.filesWidgetMaxLines != null) maxLines = raw.filesWidgetMaxLines;
38
+ if (raw.filesWidgetShowHeader != null) showHeader = raw.filesWidgetShowHeader;
39
+ if (raw.filesWidgetIncludeDeleted != null) includeDeleted = raw.filesWidgetIncludeDeleted;
40
+ }
41
+ } catch {
42
+ /* skip invalid */
43
+ }
44
+ }
45
+
46
+ return { maxLines, showHeader, includeDeleted, cwd: process.cwd() };
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Colors — theme → fallbacks
51
+ // ---------------------------------------------------------------------------
52
+
53
+ const FG_GREEN = "\x1b[38;2;100;180;120m";
54
+ const FG_YELLOW = "\x1b[38;2;200;180;80m";
55
+ const FG_RED = "\x1b[38;2;200;100;100m";
56
+ const FG_MUTED = "\x1b[38;2;139;148;158m";
57
+ const FG_DIM = "\x1b[38;2;80;80;80m";
58
+ const RST = "\x1b[0m";
59
+
60
+ export function resolveColors(theme?: any): Colors {
61
+ let fgCreated = FG_GREEN;
62
+ let fgEdited = FG_YELLOW;
63
+ let fgDeleted = FG_RED;
64
+ let fgHeader = FG_MUTED;
65
+ let fgMuted = FG_MUTED;
66
+ let fgDim = FG_DIM;
67
+
68
+ if (theme?.getFgAnsi) {
69
+ try {
70
+ fgCreated = theme.getFgAnsi("toolDiffAdded") || FG_GREEN;
71
+ fgEdited = theme.getFgAnsi("warning") || theme.getFgAnsi("accent") || FG_YELLOW;
72
+ fgDeleted = theme.getFgAnsi("toolDiffRemoved") || FG_RED;
73
+ fgHeader = theme.getFgAnsi("muted") || FG_MUTED;
74
+ fgMuted = theme.getFgAnsi("muted") || FG_MUTED;
75
+ fgDim = theme.getFgAnsi("dim") || FG_DIM;
76
+ } catch {
77
+ /* use fallbacks */
78
+ }
79
+ }
80
+
81
+ return { fgCreated, fgEdited, fgDeleted, fgHeader, fgMuted, fgDim, rst: RST };
82
+ }
package/src/git.ts ADDED
@@ -0,0 +1,120 @@
1
+ import { execSync, spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { ChangeType, type FileEntry, type GitDiffSnapshot } from "./types";
5
+
6
+ /**
7
+ * Check if cwd is inside a git repository.
8
+ */
9
+ export function isGitRepo(cwd: string): boolean {
10
+ try {
11
+ execSync("git rev-parse --is-inside-work-tree", {
12
+ cwd,
13
+ stdio: "pipe",
14
+ timeout: 5000,
15
+ });
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Parse `git diff --name-status HEAD` output into a map.
24
+ *
25
+ * Status codes:
26
+ * A = Added (Created)
27
+ * M = Modified (Edited)
28
+ * D = Deleted
29
+ * R = Renamed (treated as Added for the destination)
30
+ * C = Copied (treated as Added for the destination)
31
+ * T = Type change (treated as Edited)
32
+ */
33
+ export function getGitDiff(cwd: string): GitDiffSnapshot {
34
+ const snapshot: GitDiffSnapshot = new Map();
35
+
36
+ try {
37
+ const output = execSync("git diff --name-status HEAD", {
38
+ cwd,
39
+ stdio: "pipe",
40
+ timeout: 10000,
41
+ encoding: "utf-8",
42
+ });
43
+
44
+ for (const line of output.trim().split("\n")) {
45
+ if (!line.trim()) continue;
46
+
47
+ // Format: "STATUS\tpath" or "R100\told\tnew"
48
+ const parts = line.split("\t");
49
+ const status = parts[0]?.trim();
50
+ const path = parts.length >= 3 ? parts[2] : parts[1]; // rename: R100\told\tnew
51
+
52
+ if (!status || !path) continue;
53
+
54
+ // Resolve absolute path
55
+ const absPath = resolve(cwd, path);
56
+
57
+ let changeType: ChangeType;
58
+ switch (status[0]) {
59
+ case "A":
60
+ case "R": // rename destination = new file
61
+ case "C": // copy destination = new file
62
+ changeType = ChangeType.Created;
63
+ break;
64
+ case "D":
65
+ changeType = ChangeType.Deleted;
66
+ break;
67
+ default:
68
+ changeType = ChangeType.Edited;
69
+ break;
70
+ }
71
+
72
+ snapshot.set(absPath, changeType);
73
+ }
74
+ } catch {
75
+ // git diff failed (e.g., no HEAD yet, or not a git repo)
76
+ // Return empty snapshot — silent no-op
77
+ }
78
+
79
+ return snapshot;
80
+ }
81
+
82
+ /**
83
+ * Get the unified diff for a single tracked file.
84
+ *
85
+ * - Created → git diff --no-index /dev/null <file> (always exits 1 when files differ)
86
+ * - Edited → git diff HEAD -- <file>
87
+ * - Deleted → git diff HEAD -- <file> (shows removal)
88
+ *
89
+ * Returns an array of raw diff lines (caller handles colorisation).
90
+ */
91
+ export function getFileDiff(cwd: string, entry: FileEntry): string[] {
92
+ try {
93
+ if (entry.changeType === ChangeType.Created) {
94
+ // New file — diff against /dev/null for proper unified-diff format.
95
+ // git exits with code 1 when files differ (the normal case), so spawnSync
96
+ // is used to avoid an exception on non-zero exit.
97
+ const r = spawnSync("git", ["diff", "--no-index", "/dev/null", entry.path], {
98
+ cwd,
99
+ encoding: "utf-8",
100
+ timeout: 10_000,
101
+ });
102
+ if (r.stdout?.trim()) return r.stdout.split("\n");
103
+
104
+ // Fallback: render raw file content as additions
105
+ const content = readFileSync(entry.path, "utf-8");
106
+ return [`--- /dev/null`, `+++ b/${entry.relPath}`, ...content.split("\n").map((l) => `+${l}`)];
107
+ }
108
+
109
+ // Edited or Deleted — git diff HEAD shows both staged and unstaged changes
110
+ const r = spawnSync("git", ["diff", "HEAD", "--", entry.path], {
111
+ cwd,
112
+ encoding: "utf-8",
113
+ timeout: 10_000,
114
+ });
115
+ if (r.stdout?.trim()) return r.stdout.split("\n");
116
+ return ["(no changes detected vs HEAD)"];
117
+ } catch {
118
+ return ["(error computing diff)"];
119
+ }
120
+ }
package/src/index.ts ADDED
@@ -0,0 +1,524 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { basename, relative, resolve } from "node:path";
4
+ import { matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
5
+ import { loadConfig, resolveColors } from "./config";
6
+ import { getFileDiff, getGitDiff, isGitRepo } from "./git";
7
+ import { renderWidget } from "./renderer";
8
+ import { FileTracker } from "./tracker";
9
+ import type { FileEntry, GitDiffSnapshot } from "./types";
10
+ import { ChangeType } from "./types";
11
+
12
+ const WIDGET_KEY = "pi-diff-files";
13
+ const STATUS_KEY = "pi-diff-files";
14
+
15
+ // Filter modes cycle: all → created → edited → deleted → all
16
+ type FilterMode = "all" | "created" | "edited" | "deleted";
17
+ const FILTER_CYCLE: FilterMode[] = ["all", "created", "edited", "deleted"];
18
+ const FILTER_LABEL: Record<FilterMode, string> = {
19
+ all: "all",
20
+ created: "+created",
21
+ edited: "~edited",
22
+ deleted: "-deleted",
23
+ };
24
+
25
+ // ─── Open a file in the best available GUI editor ───────────────────────────
26
+
27
+ function openInEditor(filePath: string): string {
28
+ const candidates = ["nvim", "cursor", "code", "subl", "zed", "atom"];
29
+
30
+ // Prefer $VISUAL / $EDITOR if they reference a known GUI editor
31
+ const configured = (process.env.VISUAL ?? process.env.EDITOR ?? "").split(" ")[0];
32
+ if (configured) {
33
+ const base = basename(configured);
34
+ if (candidates.some((e) => base.includes(e))) {
35
+ spawn(configured, [filePath], { detached: true, stdio: "ignore" }).unref();
36
+ return `Opened in ${base}`;
37
+ }
38
+ }
39
+
40
+ for (const editor of candidates) {
41
+ const w = spawnSync("which", [editor], { encoding: "utf-8", stdio: "pipe" });
42
+ if (w.status === 0) {
43
+ spawn(editor, [filePath], { detached: true, stdio: "ignore" }).unref();
44
+ return `Opened in ${editor}`;
45
+ }
46
+ }
47
+
48
+ // Platform fallback
49
+ const opener = process.platform === "darwin" ? "open" : "xdg-open";
50
+ spawn(opener, [filePath], { detached: true, stdio: "ignore" }).unref();
51
+ return "Opened in default app";
52
+ }
53
+
54
+ // ─── Interactive /diff-files viewer ──────────────────────────────────────────
55
+
56
+ class FilesViewComponent {
57
+ private mode: "list" | "diff" = "list";
58
+ private cursor = 0;
59
+ private diffScroll = 0;
60
+ private filterMode: FilterMode = "all";
61
+ private statusMsg = "";
62
+ private cachedWidth?: number;
63
+ private cachedLines?: string[];
64
+
65
+ constructor(
66
+ private readonly entries: ReadonlyArray<FileEntry>,
67
+ private readonly diffs: Map<string, string[]>,
68
+ private readonly theme: any,
69
+ private readonly onClose: () => void,
70
+ ) {}
71
+
72
+ // ── Keyboard handling ──────────────────────────────────────────────────────
73
+
74
+ handleInput(data: string): void {
75
+ if (this.mode === "list") {
76
+ this.handleListInput(data);
77
+ } else {
78
+ this.handleDiffInput(data);
79
+ }
80
+ }
81
+
82
+ private handleListInput(data: string): void {
83
+ const n = this.entries.length;
84
+ if (matchesKey(data, "j") || matchesKey(data, "down")) {
85
+ this.cursor = n > 0 ? Math.min(this.cursor + 1, n - 1) : 0;
86
+ this.invalidate();
87
+ } else if (matchesKey(data, "k") || matchesKey(data, "up")) {
88
+ this.cursor = Math.max(0, this.cursor - 1);
89
+ this.invalidate();
90
+ } else if (matchesKey(data, "return")) {
91
+ if (this.entries[this.cursor]) {
92
+ this.mode = "diff";
93
+ this.diffScroll = 0;
94
+ this.invalidate();
95
+ }
96
+ } else if (matchesKey(data, "o")) {
97
+ const entry = this.entries[this.cursor];
98
+ if (entry) {
99
+ this.statusMsg = openInEditor(entry.path);
100
+ this.invalidate();
101
+ }
102
+ } else if (matchesKey(data, "f")) {
103
+ const idx = FILTER_CYCLE.indexOf(this.filterMode);
104
+ this.filterMode = FILTER_CYCLE[(idx + 1) % FILTER_CYCLE.length]!;
105
+ this.cursor = 0;
106
+ this.invalidate();
107
+ } else if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
108
+ this.onClose();
109
+ }
110
+ }
111
+
112
+ private handleDiffInput(data: string): void {
113
+ const viewH = this.viewportHeight();
114
+ const maxScroll = Math.max(0, this.currentDiffLines().length - viewH);
115
+
116
+ if (matchesKey(data, "j") || matchesKey(data, "down")) {
117
+ this.diffScroll = Math.min(this.diffScroll + 1, maxScroll);
118
+ this.invalidate();
119
+ } else if (matchesKey(data, "k") || matchesKey(data, "up")) {
120
+ this.diffScroll = Math.max(0, this.diffScroll - 1);
121
+ this.invalidate();
122
+ } else if (matchesKey(data, "ctrl+d")) {
123
+ this.diffScroll = Math.min(this.diffScroll + Math.floor(viewH / 2), maxScroll);
124
+ this.invalidate();
125
+ } else if (matchesKey(data, "ctrl+u")) {
126
+ this.diffScroll = Math.max(0, this.diffScroll - Math.floor(viewH / 2));
127
+ this.invalidate();
128
+ } else if (matchesKey(data, "o")) {
129
+ const entry = this.entries[this.cursor];
130
+ if (entry) {
131
+ this.statusMsg = openInEditor(entry.path);
132
+ this.invalidate();
133
+ }
134
+ } else if (matchesKey(data, "escape") || matchesKey(data, "q")) {
135
+ this.mode = "list";
136
+ this.statusMsg = "";
137
+ this.invalidate();
138
+ }
139
+ }
140
+
141
+ private filteredEntries(): ReadonlyArray<FileEntry> {
142
+ if (this.filterMode === "all") return this.entries;
143
+ const target =
144
+ this.filterMode === "created"
145
+ ? ChangeType.Created
146
+ : this.filterMode === "edited"
147
+ ? ChangeType.Edited
148
+ : ChangeType.Deleted;
149
+ return this.entries.filter((e) => e.changeType === target);
150
+ }
151
+
152
+ // ── Rendering ──────────────────────────────────────────────────────────────
153
+
154
+ render(width: number): string[] {
155
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
156
+ const lines = this.mode === "list" ? this.renderList(width) : this.renderDiff(width);
157
+ this.cachedWidth = width;
158
+ this.cachedLines = lines;
159
+ return lines;
160
+ }
161
+
162
+ private renderList(width: number): string[] {
163
+ const { entries, theme: th } = this;
164
+ const visible = this.filteredEntries();
165
+ // Clamp cursor to visible range
166
+ const cursor = Math.min(this.cursor, Math.max(0, visible.length - 1));
167
+ const lines: string[] = [];
168
+
169
+ // ── Header with filter indicator ──
170
+ lines.push("");
171
+ const filterTag = this.filterMode !== "all" ? th.fg("dim", ` ·${FILTER_LABEL[this.filterMode]}·`) : "";
172
+ const countStr = this.filterMode === "all" ? `${entries.length}` : `${visible.length}/${entries.length}`;
173
+ const title = ` Changed Files (${countStr}) `;
174
+ const sideLen = Math.max(
175
+ 0,
176
+ width - title.length - 3 - (this.filterMode !== "all" ? FILTER_LABEL[this.filterMode].length + 4 : 0),
177
+ );
178
+ lines.push(
179
+ truncateToWidth(
180
+ th.fg("borderMuted", "─".repeat(3)) +
181
+ th.fg("accent", title) +
182
+ filterTag +
183
+ th.fg("borderMuted", "─".repeat(Math.max(0, sideLen))),
184
+ width,
185
+ ),
186
+ );
187
+ lines.push("");
188
+
189
+ if (visible.length === 0) {
190
+ const msg = entries.length === 0 ? "No files changed yet." : `No ${this.filterMode} files.`;
191
+ lines.push(truncateToWidth(` ${th.fg("dim", msg)}`, width));
192
+ } else {
193
+ for (let i = 0; i < visible.length; i++) {
194
+ const e = visible[i]!;
195
+ const selected = i === cursor;
196
+ const symColor =
197
+ e.changeType === ChangeType.Created
198
+ ? "toolDiffAdded"
199
+ : e.changeType === ChangeType.Edited
200
+ ? "warning"
201
+ : "toolDiffRemoved";
202
+ const sym = e.changeType === ChangeType.Created ? "+" : e.changeType === ChangeType.Edited ? "~" : "-";
203
+ const arrow = selected ? th.fg("accent", "▶") : " ";
204
+ const nameColor = selected ? "toolTitle" : "muted";
205
+
206
+ lines.push(truncateToWidth(` ${arrow} ${th.fg(symColor, sym)} ${th.fg(nameColor, e.relPath)}`, width));
207
+ }
208
+ }
209
+
210
+ lines.push("");
211
+ lines.push(truncateToWidth(th.fg("borderMuted", "─".repeat(width)), width));
212
+ if (this.statusMsg) {
213
+ lines.push(truncateToWidth(` ${th.fg("success", this.statusMsg)}`, width));
214
+ }
215
+ lines.push(
216
+ truncateToWidth(
217
+ ` ${th.fg("dim", "j/k navigate Enter diff o open f filter Esc close")}`,
218
+ width,
219
+ ),
220
+ );
221
+ lines.push("");
222
+ return lines;
223
+ }
224
+
225
+ private renderDiff(width: number): string[] {
226
+ const { theme: th, cursor, diffScroll } = this;
227
+ const entry = this.entries[cursor];
228
+ const lines: string[] = [];
229
+
230
+ if (!entry) return lines;
231
+
232
+ const rawDiff = this.currentDiffLines();
233
+ const viewH = this.viewportHeight();
234
+ const maxScroll = Math.max(0, rawDiff.length - viewH);
235
+ const scroll = Math.min(diffScroll, maxScroll);
236
+
237
+ // ── Header ──
238
+ lines.push("");
239
+ const symColor =
240
+ entry.changeType === ChangeType.Created
241
+ ? "toolDiffAdded"
242
+ : entry.changeType === ChangeType.Edited
243
+ ? "warning"
244
+ : "toolDiffRemoved";
245
+ const sym = entry.changeType === ChangeType.Created ? "+" : entry.changeType === ChangeType.Edited ? "~" : "-";
246
+ const title = ` ${sym} ${entry.relPath} `;
247
+ const sideLen = Math.max(0, width - title.length - 3);
248
+ lines.push(
249
+ truncateToWidth(
250
+ th.fg("borderMuted", "─".repeat(3)) + th.fg(symColor, title) + th.fg("borderMuted", "─".repeat(sideLen)),
251
+ width,
252
+ ),
253
+ );
254
+ lines.push("");
255
+
256
+ // ── Diff body ──
257
+ if (rawDiff.length === 1 && rawDiff[0]?.startsWith("(")) {
258
+ lines.push(truncateToWidth(` ${th.fg("dim", rawDiff[0])}`, width));
259
+ } else {
260
+ if (scroll > 0) {
261
+ lines.push(truncateToWidth(` ${th.fg("muted", `↑ ${scroll} lines above`)}`, width));
262
+ }
263
+ for (const raw of rawDiff.slice(scroll, scroll + viewH)) {
264
+ lines.push(truncateToWidth(this.colourDiffLine(raw), width));
265
+ }
266
+ const below = rawDiff.length - scroll - viewH;
267
+ if (below > 0) {
268
+ lines.push(truncateToWidth(` ${th.fg("muted", `↓ ${below} lines below`)}`, width));
269
+ }
270
+ }
271
+
272
+ // ── Footer ──
273
+ lines.push("");
274
+ lines.push(truncateToWidth(th.fg("borderMuted", "─".repeat(width)), width));
275
+ if (this.statusMsg) {
276
+ lines.push(truncateToWidth(` ${th.fg("success", this.statusMsg)}`, width));
277
+ }
278
+ lines.push(
279
+ truncateToWidth(
280
+ ` ${th.fg("dim", "j/k scroll Ctrl+D/U half-page o open in editor Esc back to list")}`,
281
+ width,
282
+ ),
283
+ );
284
+ lines.push("");
285
+ return lines;
286
+ }
287
+
288
+ private colourDiffLine(raw: string): string {
289
+ const th = this.theme;
290
+ if (raw.startsWith("+++") || raw.startsWith("---")) return ` ${th.fg("dim", raw)}`;
291
+ if (raw.startsWith("@@")) return ` ${th.fg("accent", raw)}`;
292
+ if (raw.startsWith("+")) return ` ${th.fg("toolDiffAdded", raw)}`;
293
+ if (raw.startsWith("-")) return ` ${th.fg("toolDiffRemoved", raw)}`;
294
+ if (/^(diff |index |new file|deleted|Binary)/.test(raw)) return ` ${th.fg("dim", raw)}`;
295
+ return ` ${th.fg("dim", raw)}`;
296
+ }
297
+
298
+ // ── Helpers ────────────────────────────────────────────────────────────────
299
+
300
+ private currentDiffLines(): string[] {
301
+ const entry = this.entries[this.cursor];
302
+ if (!entry) return [];
303
+ return this.diffs.get(entry.path) ?? ["(no diff available)"];
304
+ }
305
+
306
+ private viewportHeight(): number {
307
+ // Terminal rows minus header (≈5) and footer (≈4) lines
308
+ return Math.max(5, (process.stdout.rows ?? 40) - 9);
309
+ }
310
+
311
+ invalidate(): void {
312
+ this.cachedWidth = undefined;
313
+ this.cachedLines = undefined;
314
+ }
315
+ }
316
+
317
+ // ─── Extension ───────────────────────────────────────────────────────────────
318
+
319
+ export default function diffFilesExtension(pi: any): void {
320
+ const config = loadConfig();
321
+ const tracker = new FileTracker();
322
+
323
+ // Bash snapshots: toolCallId → git diff snapshot taken before bash ran
324
+ const bashSnapshots = new Map<string, GitDiffSnapshot>();
325
+
326
+ const inGit = isGitRepo(config.cwd);
327
+
328
+ function updateWidget(ctx: any, theme?: any): void {
329
+ const colors = resolveColors(theme);
330
+
331
+ // Resolve change types from current git state
332
+ if (inGit) {
333
+ const currentDiff = getGitDiff(config.cwd);
334
+ tracker.resolveFromGitDiff(currentDiff);
335
+ }
336
+
337
+ const lines = renderWidget(tracker.getEntries(), colors, config);
338
+ if (lines.length > 0) {
339
+ ctx.ui.setWidget(WIDGET_KEY, lines, { placement: "aboveEditor" });
340
+ } else {
341
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
342
+ }
343
+
344
+ // ── Footer status ──────────────────────────────────────────────────
345
+ const entries = tracker.getEntries();
346
+ const uiTheme = ctx.ui?.theme;
347
+ if (entries.length === 0 || !uiTheme?.fg) {
348
+ ctx.ui.setStatus(STATUS_KEY, undefined);
349
+ } else {
350
+ const created = entries.filter((e) => e.changeType === ChangeType.Created).length;
351
+ const edited = entries.filter((e) => e.changeType === ChangeType.Edited).length;
352
+ const deleted = entries.filter((e) => e.changeType === ChangeType.Deleted).length;
353
+ const inner: string[] = [];
354
+ if (created > 0) inner.push(uiTheme.fg("toolDiffAdded", `+${created}`));
355
+ if (edited > 0) inner.push(uiTheme.fg("warning", `~${edited}`));
356
+ if (deleted > 0) inner.push(uiTheme.fg("toolDiffRemoved", `-${deleted}`));
357
+ const status = uiTheme.fg("muted", "D(") + inner.join(uiTheme.fg("dim", " ")) + uiTheme.fg("muted", ")");
358
+ ctx.ui.setStatus(STATUS_KEY, status);
359
+ }
360
+ }
361
+
362
+ // ── write/edit tool_call ──────────────────────────────────────────────
363
+ // Record file paths the agent is about to touch
364
+ pi.on("tool_call", async (event: any, _ctx: any) => {
365
+ if (event.toolName === "write" || event.toolName === "edit") {
366
+ const fp = event.input?.path ?? event.input?.file_path ?? "";
367
+ if (!fp) return;
368
+
369
+ const absPath = resolve(config.cwd, fp);
370
+ const relPath = relative(config.cwd, absPath) || absPath;
371
+ let changeType: ChangeType;
372
+
373
+ if (event.toolName === "edit") {
374
+ changeType = ChangeType.Edited;
375
+ } else {
376
+ changeType = existsSync(absPath) ? ChangeType.Edited : ChangeType.Created;
377
+ }
378
+
379
+ tracker.add(absPath, relPath, changeType);
380
+ return;
381
+ }
382
+
383
+ // ── bash tool_call ────────────────────────────────────────────────
384
+ // Snapshot git state before the bash command runs
385
+ if (event.toolName === "bash" && inGit) {
386
+ const snapshot = getGitDiff(config.cwd);
387
+ bashSnapshots.set(event.toolCallId, snapshot);
388
+ }
389
+ });
390
+
391
+ // ── bash tool_result ────────────────────────────────────────────────
392
+ // Diff current state vs snapshot to find new changes
393
+ pi.on("tool_result", async (event: any, _ctx: any) => {
394
+ if (event.toolName !== "bash") return;
395
+ if (event.isError) return;
396
+
397
+ const before = bashSnapshots.get(event.toolCallId);
398
+ bashSnapshots.delete(event.toolCallId);
399
+ if (!before) return;
400
+
401
+ const after = getGitDiff(config.cwd);
402
+
403
+ // Find entries in after that are new or changed compared to before
404
+ const newEntries: GitDiffSnapshot = new Map();
405
+ for (const [path, changeType] of after) {
406
+ if (!before.has(path)) {
407
+ // New entry — file wasn't in the before snapshot
408
+ newEntries.set(path, changeType);
409
+ } else if (before.get(path) !== changeType) {
410
+ // Changed type (e.g., was M, now A because the file was deleted and recreated)
411
+ newEntries.set(path, changeType);
412
+ }
413
+ }
414
+
415
+ if (newEntries.size > 0) {
416
+ tracker.mergeBashDiff(newEntries, config.cwd);
417
+ }
418
+ });
419
+
420
+ // ── Flash state ─────────────────────────────────────────────────────
421
+ let sizeAtTurnStart = 0;
422
+ let flashTimer: ReturnType<typeof setTimeout> | null = null;
423
+
424
+ function renderFlashWidget(ctx: any, theme: any, addedCount: number): void {
425
+ const colors = resolveColors(theme);
426
+ const entries = tracker.getEntries();
427
+ const visible = config.includeDeleted
428
+ ? entries
429
+ : entries.filter((e: FileEntry) => e.changeType !== ChangeType.Deleted);
430
+
431
+ const lines: string[] = [];
432
+ // Flash header — briefly shows how many files changed this turn
433
+ lines.push(
434
+ `${colors.fgCreated}↯ ${addedCount} ${addedCount === 1 ? "file" : "files"} added this turn${colors.rst}`,
435
+ );
436
+ // Entries up to maxLines − 1 (the flash line takes one slot)
437
+ const shown = visible.slice(0, Math.max(1, config.maxLines - 1));
438
+ for (const e of shown) {
439
+ const prefix =
440
+ e.changeType === ChangeType.Created
441
+ ? `${colors.fgCreated}+${colors.rst}`
442
+ : e.changeType === ChangeType.Edited
443
+ ? `${colors.fgEdited}~${colors.rst}`
444
+ : `${colors.fgDeleted}-${colors.rst}`;
445
+ lines.push(` ${prefix} ${colors.fgDim}${e.relPath}${colors.rst}`);
446
+ }
447
+ const remaining = visible.length - shown.length;
448
+ if (remaining > 0) {
449
+ lines.push(`${colors.fgMuted} … ${remaining} more${colors.rst}`);
450
+ }
451
+ ctx.ui.setWidget(WIDGET_KEY, lines, { placement: "aboveEditor" });
452
+ }
453
+
454
+ // ── turn_start ────────────────────────────────────────────────────────
455
+ pi.on("turn_start", async (_event: any, _ctx: any) => {
456
+ sizeAtTurnStart = tracker.size;
457
+ });
458
+
459
+ // ── turn_end ────────────────────────────────────────────────────────
460
+ // Flash widget briefly when new files appear, then settle to normal.
461
+ pi.on("turn_end", async (_event: any, ctx: any) => {
462
+ if (tracker.size === 0) return;
463
+
464
+ if (flashTimer) {
465
+ clearTimeout(flashTimer);
466
+ flashTimer = null;
467
+ }
468
+
469
+ const addedThisTurn = tracker.size - sizeAtTurnStart;
470
+
471
+ if (addedThisTurn > 0) {
472
+ renderFlashWidget(ctx, ctx?.theme, addedThisTurn);
473
+ flashTimer = setTimeout(() => {
474
+ flashTimer = null;
475
+ updateWidget(ctx, ctx?.theme);
476
+ }, 1200);
477
+ } else {
478
+ updateWidget(ctx, ctx?.theme);
479
+ }
480
+ });
481
+
482
+ // ── session_start ───────────────────────────────────────────────────
483
+ // Reset state for new sessions
484
+ pi.on("session_start", async (_event: any, ctx: any) => {
485
+ tracker.clear();
486
+ bashSnapshots.clear();
487
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
488
+ ctx.ui.setStatus(STATUS_KEY, undefined);
489
+ });
490
+
491
+ // ── /diff-files command ─────────────────────────────────────────────────
492
+ pi.registerCommand("diff-files", {
493
+ description: "Show all files changed in this session with inline diff viewer",
494
+ handler: async (_args: any, ctx: any) => {
495
+ const entries = tracker.getEntries();
496
+
497
+ if (!ctx.hasUI) {
498
+ if (entries.length === 0) {
499
+ ctx.ui.notify("No files changed yet.", "info");
500
+ return;
501
+ }
502
+ const lines = entries.map((e: FileEntry) => {
503
+ const prefix =
504
+ e.changeType === ChangeType.Created ? "+" : e.changeType === ChangeType.Edited ? "~" : "-";
505
+ return `${prefix} ${e.relPath}`;
506
+ });
507
+ ctx.ui.notify(lines.join("\n"), "info");
508
+ return;
509
+ }
510
+
511
+ // Pre-compute diffs for every tracked file before opening the panel.
512
+ const diffs = new Map<string, string[]>();
513
+ if (inGit) {
514
+ for (const entry of entries) {
515
+ diffs.set(entry.path, getFileDiff(config.cwd, entry));
516
+ }
517
+ }
518
+
519
+ await ctx.ui.custom((_tui: any, theme: any, _kb: any, done: () => void) => {
520
+ return new FilesViewComponent(entries, diffs, theme, () => done());
521
+ });
522
+ },
523
+ });
524
+ }
@@ -0,0 +1,51 @@
1
+ import { ChangeType, type Colors, type FileEntry, type WidgetConfig } from "./types";
2
+
3
+ /**
4
+ * Render file entries into widget lines.
5
+ *
6
+ * Format:
7
+ * N files changed
8
+ * + src/new-file.ts
9
+ * ~ src/existing.ts
10
+ * - src/removed.ts
11
+ * … 3 more
12
+ */
13
+ export function renderWidget(entries: ReadonlyArray<FileEntry>, colors: Colors, config: WidgetConfig): string[] {
14
+ if (entries.length === 0) return [];
15
+
16
+ // Filter deleted if not included
17
+ const visible = config.includeDeleted ? entries : entries.filter((e) => e.changeType !== ChangeType.Deleted);
18
+
19
+ if (visible.length === 0) return [];
20
+
21
+ const lines: string[] = [];
22
+
23
+ if (config.showHeader) {
24
+ const label = visible.length === 1 ? "1 file changed" : `${visible.length} files changed`;
25
+ lines.push(`${colors.fgHeader}${label}${colors.rst}`);
26
+ }
27
+
28
+ const shown = visible.slice(0, config.maxLines);
29
+ for (const entry of shown) {
30
+ let prefix: string;
31
+ switch (entry.changeType) {
32
+ case ChangeType.Created:
33
+ prefix = `${colors.fgCreated}+${colors.rst}`;
34
+ break;
35
+ case ChangeType.Edited:
36
+ prefix = `${colors.fgEdited}~${colors.rst}`;
37
+ break;
38
+ case ChangeType.Deleted:
39
+ prefix = `${colors.fgDeleted}-${colors.rst}`;
40
+ break;
41
+ }
42
+ lines.push(` ${prefix} ${colors.fgDim}${entry.relPath}${colors.rst}`);
43
+ }
44
+
45
+ const remaining = visible.length - config.maxLines;
46
+ if (remaining > 0) {
47
+ lines.push(`${colors.fgMuted} … ${remaining} more${colors.rst}`);
48
+ }
49
+
50
+ return lines;
51
+ }
package/src/tracker.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { ChangeType, type FileEntry, type GitDiffSnapshot } from "./types";
2
+
3
+ /**
4
+ * Session-scoped file change tracker.
5
+ *
6
+ * Two sources of attribution:
7
+ * 1. write/edit tool events — direct, high confidence
8
+ * 2. bash tool events — via git diff before/after snapshot
9
+ *
10
+ * Change type is resolved via git diff at render time.
11
+ * The tracker stores attribution (which files the agent touched),
12
+ * not the final A/M/D status.
13
+ */
14
+ export class FileTracker {
15
+ /** Tracks files the agent touched — key is absolute path */
16
+ private entries = new Map<string, FileEntry>();
17
+
18
+ /**
19
+ * Record a file that the agent touched via write/edit.
20
+ * If already tracked, keeps existing entry (insertion order preserved).
21
+ * Created wins over Edited (never downgrade).
22
+ */
23
+ add(path: string, relPath: string, changeType: ChangeType): void {
24
+ const existing = this.entries.get(path);
25
+ if (existing) {
26
+ if (existing.changeType === ChangeType.Created) return;
27
+ if (changeType === ChangeType.Edited) return;
28
+ // Upgrade Edited → Created
29
+ existing.changeType = ChangeType.Created;
30
+ return;
31
+ }
32
+
33
+ this.entries.set(path, { path, relPath, changeType });
34
+ }
35
+
36
+ /**
37
+ * Merge bash diff entries into the tracker.
38
+ * Only adds files that aren't already tracked (write/edit are more reliable).
39
+ */
40
+ mergeBashDiff(diff: GitDiffSnapshot, cwd: string): void {
41
+ const { relative } = require("node:path");
42
+ for (const [path, changeType] of diff) {
43
+ if (this.entries.has(path)) continue;
44
+ const relPath = relative(cwd, path) || path;
45
+ this.entries.set(path, { path, relPath, changeType });
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Resolve change types from git diff.
51
+ * Takes the current git diff output and updates tracked entries
52
+ * to match actual A/M/D status.
53
+ */
54
+ resolveFromGitDiff(gitDiff: GitDiffSnapshot): void {
55
+ for (const [path, entry] of this.entries) {
56
+ const gitType = gitDiff.get(path);
57
+ if (gitType !== undefined) {
58
+ entry.changeType = gitType;
59
+ }
60
+ // If file not in git diff, keep existing type
61
+ // (e.g., edited then reverted — still shows as tracked)
62
+ }
63
+ }
64
+
65
+ getEntries(): ReadonlyArray<FileEntry> {
66
+ return Array.from(this.entries.values());
67
+ }
68
+
69
+ clear(): void {
70
+ this.entries.clear();
71
+ }
72
+
73
+ get size(): number {
74
+ return this.entries.size;
75
+ }
76
+ }
package/src/types.ts ADDED
@@ -0,0 +1,31 @@
1
+ export enum ChangeType {
2
+ Created = "created",
3
+ Edited = "edited",
4
+ Deleted = "deleted",
5
+ }
6
+
7
+ export interface FileEntry {
8
+ path: string; // absolute path
9
+ relPath: string; // relative to cwd
10
+ changeType: ChangeType;
11
+ }
12
+
13
+ export interface Colors {
14
+ fgCreated: string;
15
+ fgEdited: string;
16
+ fgDeleted: string;
17
+ fgHeader: string;
18
+ fgMuted: string;
19
+ fgDim: string;
20
+ rst: string;
21
+ }
22
+
23
+ export interface WidgetConfig {
24
+ maxLines: number;
25
+ showHeader: boolean;
26
+ includeDeleted: boolean;
27
+ cwd: string;
28
+ }
29
+
30
+ /** Snapshot of git diff --name-status output */
31
+ export type GitDiffSnapshot = Map<string, ChangeType>;