@nredd/pi-session-kit 0.0.0-stage → 0.4.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.
Files changed (52) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +191 -2
  3. package/dist/pi-stats-export.mjs +657 -0
  4. package/docs/stats-snapshots.md +122 -0
  5. package/package.json +55 -4
  6. package/src/child.ts +79 -0
  7. package/src/clock.ts +25 -0
  8. package/src/config.ts +64 -0
  9. package/src/diff/component.ts +214 -0
  10. package/src/diff/git.ts +289 -0
  11. package/src/diff/index.ts +65 -0
  12. package/src/diff/parse.ts +180 -0
  13. package/src/diff/view.ts +98 -0
  14. package/src/goal/index.ts +343 -0
  15. package/src/goal/state.ts +270 -0
  16. package/src/goal/subagents.ts +77 -0
  17. package/src/index.ts +52 -0
  18. package/src/loop/index.ts +151 -0
  19. package/src/loop/state.ts +107 -0
  20. package/src/monitor/README.md +12 -0
  21. package/src/monitor/coalescer.ts +76 -0
  22. package/src/monitor/index.ts +220 -0
  23. package/src/monitor/manager.ts +336 -0
  24. package/src/monitor/matcher.ts +113 -0
  25. package/src/monitor/messages.ts +93 -0
  26. package/src/monitor/output.ts +41 -0
  27. package/src/monitor/pidfiles.ts +415 -0
  28. package/src/monitor/process.ts +125 -0
  29. package/src/monitor/ring.ts +52 -0
  30. package/src/monitor/splitter.ts +57 -0
  31. package/src/monitor/types.ts +35 -0
  32. package/src/monitor/validate.ts +59 -0
  33. package/src/monitor/widget.ts +75 -0
  34. package/src/output.ts +25 -0
  35. package/src/rules/index.ts +94 -0
  36. package/src/rules/logic.ts +96 -0
  37. package/src/shared.ts +177 -0
  38. package/src/stats/aggregate.ts +287 -0
  39. package/src/stats/chart.ts +247 -0
  40. package/src/stats/cli.ts +50 -0
  41. package/src/stats/component.ts +496 -0
  42. package/src/stats/days.ts +46 -0
  43. package/src/stats/format.ts +49 -0
  44. package/src/stats/heatmap.ts +98 -0
  45. package/src/stats/index.ts +88 -0
  46. package/src/stats/library.ts +4 -0
  47. package/src/stats/load.ts +512 -0
  48. package/src/stats/palette.ts +22 -0
  49. package/src/stats/snapshot.ts +497 -0
  50. package/src/titles/aliases.ts +20 -0
  51. package/src/titles/index.ts +87 -0
  52. package/src/titles/logic.ts +160 -0
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Read-only git access for `/diff`. Every call is an argv spawn (no shell) with `--no-pager
3
+ * --literal-pathspecs`, `GIT_OPTIONAL_LOCKS=0` so a diff never takes the index lock another pi
4
+ * session may be holding, a hard output cap and a timeout.
5
+ */
6
+
7
+ import { spawn } from "node:child_process";
8
+ import { lstat, open, readlink } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import { type DiffFile, PER_FILE_BYTES, parseUnifiedDiff } from "./parse.ts";
11
+
12
+ export const TOTAL_BYTES = 2 * 1024 * 1024;
13
+ export const MAX_UNTRACKED_FILES = 500;
14
+ const GIT_TIMEOUT_MS = 30_000;
15
+ const KILL_GRACE_MS = 2_000;
16
+ /** After SIGKILL, how long to wait for `close` before resolving with what we have. */
17
+ const CLOSE_GRACE_MS = 1_000;
18
+ const SNIFF_BYTES = 8000;
19
+
20
+ export interface GitOutput {
21
+ stdout: Buffer;
22
+ stderr: string;
23
+ code: number | null;
24
+ /** Output hit `maxBytes`; git was stopped. */
25
+ truncated: boolean;
26
+ spawnError?: string;
27
+ }
28
+
29
+ /** Signal only the detached group; a reaped leader's bare pid may already have been reused. */
30
+ function killTree(pid: number, sig: NodeJS.Signals): void {
31
+ try {
32
+ process.kill(-pid, sig);
33
+ } catch {
34
+ // already gone
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Run git in its own process group so a timeout or the output cap stops the whole tree (hooks,
40
+ * filter drivers, ssh). SIGTERM first, SIGKILL after `KILL_GRACE_MS`, then a hard resolve
41
+ * `CLOSE_GRACE_MS` later so a grandchild holding the pipes cannot hang the caller.
42
+ */
43
+ export function runGit(
44
+ args: readonly string[],
45
+ opts: { cwd: string; maxBytes?: number; timeoutMs?: number; cmd?: string },
46
+ ): Promise<GitOutput> {
47
+ const maxBytes = opts.maxBytes ?? TOTAL_BYTES;
48
+ return new Promise((resolve) => {
49
+ const child = spawn(
50
+ opts.cmd ?? "git",
51
+ ["--no-pager", "--literal-pathspecs", "-c", "core.quotepath=false", "-c", "color.ui=false", ...args],
52
+ {
53
+ cwd: opts.cwd,
54
+ stdio: ["ignore", "pipe", "pipe"],
55
+ detached: true,
56
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0", LC_ALL: "C" },
57
+ },
58
+ );
59
+ const chunks: Buffer[] = [];
60
+ let size = 0;
61
+ let truncated = false;
62
+ let stderr = "";
63
+ let done = false;
64
+ let stopping = false;
65
+ let escalated = false;
66
+ let closed = false;
67
+ const timers: NodeJS.Timeout[] = [];
68
+ const finish = (code: number | null, spawnError?: string) => {
69
+ if (done || (stopping && !escalated)) return;
70
+ done = true;
71
+ for (const t of timers) clearTimeout(t);
72
+ child.stdout.destroy();
73
+ child.stderr.destroy();
74
+ resolve({ stdout: Buffer.concat(chunks), stderr, code, truncated, spawnError });
75
+ };
76
+ const stop = () => {
77
+ if (stopping || done || child.pid === undefined) return;
78
+ stopping = true;
79
+ const pid = child.pid;
80
+ killTree(pid, "SIGTERM");
81
+ timers.push(
82
+ setTimeout(() => {
83
+ if (done) return;
84
+ killTree(pid, "SIGKILL");
85
+ escalated = true;
86
+ if (closed) finish(child.exitCode);
87
+ else timers.push(setTimeout(() => finish(child.exitCode), CLOSE_GRACE_MS));
88
+ }, KILL_GRACE_MS),
89
+ );
90
+ };
91
+ timers.push(setTimeout(stop, opts.timeoutMs ?? GIT_TIMEOUT_MS));
92
+ child.stdout.on("data", (chunk: Buffer) => {
93
+ if (truncated) return;
94
+ const room = maxBytes - size;
95
+ if (chunk.length > room) {
96
+ chunks.push(chunk.subarray(0, room));
97
+ size = maxBytes;
98
+ truncated = true;
99
+ stop();
100
+ } else {
101
+ chunks.push(chunk);
102
+ size += chunk.length;
103
+ }
104
+ });
105
+ child.stderr.on("data", (chunk: Buffer) => {
106
+ if (stderr.length < 4096) stderr += chunk.toString("utf8");
107
+ });
108
+ child.once("error", (e) => finish(null, e.message));
109
+ child.once("close", (code) => {
110
+ closed = true;
111
+ // Closed pipes do not prove the process group is gone. Await escalation when stopping.
112
+ finish(code);
113
+ });
114
+ });
115
+ }
116
+
117
+ export type LoadResult =
118
+ | { ok: true; files: DiffFile[]; label: string; notices: string[]; root: string }
119
+ | { ok: false; error: string };
120
+
121
+ const BAD_REF = /^(-|$)|[\s\0]/;
122
+
123
+ function firstLine(text: string): string {
124
+ return text.trim().split("\n")[0] ?? "";
125
+ }
126
+
127
+ /** Resolve the repo root, or explain why this is not a usable work tree. */
128
+ export async function repoRoot(cwd: string): Promise<{ root: string } | { error: string }> {
129
+ const r = await runGit(["rev-parse", "--show-toplevel"], { cwd, maxBytes: 4096 });
130
+ if (r.spawnError) return { error: `cannot run git: ${r.spawnError}` };
131
+ if (r.code !== 0) return { error: "not a git repository (or no work tree here)" };
132
+ return { root: r.stdout.toString("utf8").trim() };
133
+ }
134
+
135
+ /** The ref to diff the work tree against: `ref`, else HEAD, else the empty tree in a repo with no commits. */
136
+ async function resolveBase(
137
+ root: string,
138
+ ref: string | undefined,
139
+ ): Promise<{ base: string; label: string } | { error: string }> {
140
+ if (ref !== undefined) {
141
+ if (BAD_REF.test(ref)) return { error: `invalid ref ${JSON.stringify(ref)}` };
142
+ return { base: ref, label: ref };
143
+ }
144
+ const head = await runGit(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], { cwd: root, maxBytes: 4096 });
145
+ if (head.code === 0) return { base: "HEAD", label: "HEAD" };
146
+ const empty = await runGit(["hash-object", "-t", "tree", "/dev/null"], { cwd: root, maxBytes: 4096 });
147
+ const hash = empty.stdout.toString("utf8").trim();
148
+ if (empty.code !== 0 || !hash) return { error: "cannot resolve HEAD or the empty tree" };
149
+ return { base: hash, label: "empty tree (no commits yet)" };
150
+ }
151
+
152
+ /** Tracked changes (work tree + index vs base) and untracked files, parsed and capped. */
153
+ export async function loadDiff(cwd: string, ref?: string): Promise<LoadResult> {
154
+ const repo = await repoRoot(cwd);
155
+ if ("error" in repo) return { ok: false, error: repo.error };
156
+ const root = repo.root;
157
+ const resolved = await resolveBase(root, ref);
158
+ if ("error" in resolved) return { ok: false, error: resolved.error };
159
+
160
+ const diff = await runGit(
161
+ ["diff", "--no-color", "--no-ext-diff", "--no-textconv", "--src-prefix=a/", "--dst-prefix=b/", resolved.base, "--"],
162
+ { cwd: root },
163
+ );
164
+ if (diff.spawnError) return { ok: false, error: `cannot run git: ${diff.spawnError}` };
165
+ if (diff.code !== 0 && !diff.truncated) {
166
+ const why = firstLine(diff.stderr) || `git diff exited with ${diff.code}`;
167
+ return { ok: false, error: ref ? `bad ref ${JSON.stringify(ref)}: ${why}` : why };
168
+ }
169
+ const notices: string[] = [];
170
+ const files = parseUnifiedDiff(diff.stdout.toString("utf8"));
171
+ if (diff.truncated) {
172
+ notices.push(`diff output cut at ${TOTAL_BYTES / 1024 / 1024}MB; later files are missing`);
173
+ const last = files.at(-1);
174
+ if (last && !last.notice) last.notice = "patch cut by the total output cap";
175
+ }
176
+
177
+ // Untracked files only make sense against the work tree, not for a `a..b` commit range.
178
+ if (ref === undefined || !ref.includes("..")) {
179
+ const budget = Math.max(0, TOTAL_BYTES - diff.stdout.length);
180
+ files.push(...(await untrackedFiles(root, budget, notices)));
181
+ }
182
+ return { ok: true, files, label: resolved.label, notices, root };
183
+ }
184
+
185
+ async function untrackedFiles(root: string, budget: number, notices: string[]): Promise<DiffFile[]> {
186
+ const ls = await runGit(["ls-files", "--others", "--exclude-standard", "-z"], { cwd: root });
187
+ if (ls.code !== 0) {
188
+ notices.push(`could not list untracked files: ${firstLine(ls.stderr)}`);
189
+ return [];
190
+ }
191
+ const paths = ls.stdout.toString("utf8").split("\0").filter(Boolean).sort();
192
+ const out: DiffFile[] = [];
193
+ let remaining = budget;
194
+ for (const path of paths.slice(0, MAX_UNTRACKED_FILES)) {
195
+ const file = await untrackedPatch(root, path, Math.min(PER_FILE_BYTES, remaining));
196
+ remaining -= file.lines.reduce((n, l) => n + l.length + 1, 0);
197
+ out.push(file);
198
+ }
199
+ if (paths.length > MAX_UNTRACKED_FILES) {
200
+ notices.push(`${paths.length - MAX_UNTRACKED_FILES} more untracked files not listed`);
201
+ }
202
+ return out;
203
+ }
204
+
205
+ /** A synthetic "new file" patch for an untracked path, size-capped, binary and symlink aware. */
206
+ export async function untrackedPatch(root: string, path: string, capBytes: number): Promise<DiffFile> {
207
+ const header = [`diff --git a/${path} b/${path}`, "new file (untracked)", "--- /dev/null", `+++ b/${path}`];
208
+ const base = { path, status: "untracked" as const, hunkStart: header.length, additions: 0, deletions: 0 };
209
+ const note = (lines: string[], notice?: string): DiffFile => ({ ...base, lines: [...header, ...lines], notice });
210
+ const full = join(root, path);
211
+ try {
212
+ const st = await lstat(full);
213
+ if (st.isSymbolicLink()) {
214
+ const target = await readlink(full);
215
+ return { ...base, lines: [...header, "@@ -0,0 +1 @@", `+-> ${target}`], additions: 1 };
216
+ }
217
+ if (!st.isFile())
218
+ return { ...base, status: "untracked", hunkStart: header.length, lines: [...header, "\\ not a regular file"] };
219
+ if (capBytes <= 0) return note(["\\ content omitted: output budget exhausted"]);
220
+ const handle = await open(full, "r");
221
+ let buf: Buffer;
222
+ try {
223
+ buf = Buffer.alloc(Math.min(st.size, capBytes + 1));
224
+ const { bytesRead } = await handle.read(buf, 0, buf.length, 0);
225
+ buf = buf.subarray(0, bytesRead);
226
+ } finally {
227
+ await handle.close();
228
+ }
229
+ if (buf.subarray(0, SNIFF_BYTES).includes(0)) {
230
+ return { ...base, status: "untracked", lines: [...header, `\\ binary file (${st.size} bytes)`] };
231
+ }
232
+ if (st.size === 0) return note(["\\ empty file"]);
233
+ let cut = buf.length > capBytes;
234
+ const text = buf.subarray(0, Math.min(buf.length, capBytes)).toString("utf8");
235
+ const lines = text.split("\n");
236
+ if (lines.at(-1) === "") lines.pop();
237
+ // The `+` prefix and newline add bytes, so the cap is applied to the patch lines themselves.
238
+ const body: string[] = [];
239
+ let bytes = 0;
240
+ for (const l of lines) {
241
+ bytes += l.length + 2;
242
+ if (bytes > capBytes) {
243
+ cut = true;
244
+ break;
245
+ }
246
+ body.push(`+${l}`);
247
+ }
248
+ const shown = body.length;
249
+ const missingNewline = !cut && !text.endsWith("\n");
250
+ const hunk = `@@ -0,0 +1,${shown} @@`;
251
+ return {
252
+ ...base,
253
+ lines: [...header, hunk, ...body, ...(missingNewline ? ["\"] : [])],
254
+ additions: shown,
255
+ notice: cut ? `file truncated at ${Math.round(capBytes / 1024)}KB (${st.size} bytes total)` : undefined,
256
+ };
257
+ } catch (error) {
258
+ return note([`\\ unreadable: ${(error as Error).message}`]);
259
+ }
260
+ }
261
+
262
+ /** `git diff --stat` for the tracked changes plus `?? path` lines, for non-TUI output. */
263
+ export async function loadStat(
264
+ cwd: string,
265
+ ref?: string,
266
+ ): Promise<{ ok: true; text: string } | { ok: false; error: string }> {
267
+ const repo = await repoRoot(cwd);
268
+ if ("error" in repo) return { ok: false, error: repo.error };
269
+ const resolved = await resolveBase(repo.root, ref);
270
+ if ("error" in resolved) return { ok: false, error: resolved.error };
271
+ const stat = await runGit(["diff", "--no-color", "--no-ext-diff", "--stat", resolved.base, "--"], {
272
+ cwd: repo.root,
273
+ maxBytes: 256 * 1024,
274
+ });
275
+ if (stat.code !== 0 && !stat.truncated) {
276
+ const why = firstLine(stat.stderr) || `git diff exited with ${stat.code}`;
277
+ return { ok: false, error: ref ? `bad ref ${JSON.stringify(ref)}: ${why}` : why };
278
+ }
279
+ let text = stat.stdout.toString("utf8").trimEnd();
280
+ if (ref === undefined || !ref.includes("..")) {
281
+ const ls = await runGit(["ls-files", "--others", "--exclude-standard", "-z"], {
282
+ cwd: repo.root,
283
+ maxBytes: 256 * 1024,
284
+ });
285
+ const untracked = ls.stdout.toString("utf8").split("\0").filter(Boolean).sort();
286
+ if (untracked.length > 0) text += `${text ? "\n" : ""}${untracked.map((p) => `?? ${p}`).join("\n")}`;
287
+ }
288
+ return { ok: true, text };
289
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `/diff [ref]`: read-only overlay over `git diff` (work tree + index vs HEAD, or vs `ref`) plus
3
+ * untracked files. A command, not a tool, so Plan mode allows it. Outside the TUI it prints the
4
+ * `--stat` instead.
5
+ */
6
+
7
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
8
+ import type { KitConfig } from "../config.ts";
9
+ import { say } from "../output.ts";
10
+ import { DiffView } from "./component.ts";
11
+ import { loadDiff, loadStat } from "./git.ts";
12
+
13
+ const STATUS_KEY = "session-kit:diff";
14
+ export const MESSAGE_TYPE = "session-kit:diff";
15
+
16
+ export function register(pi: ExtensionAPI, _config: KitConfig): void {
17
+ pi.registerCommand("diff", {
18
+ description: "Browse the git diff (work tree + staged vs HEAD, or vs <ref>) and untracked files",
19
+ async handler(args: string, ctx: ExtensionCommandContext) {
20
+ const words = args.trim().split(/\s+/).filter(Boolean);
21
+ if (words.length > 1) {
22
+ ctx.ui.notify("usage: /diff [ref]", "warning");
23
+ return;
24
+ }
25
+ const ref = words[0];
26
+
27
+ if (ctx.mode !== "tui") {
28
+ const stat = await loadStat(ctx.cwd, ref);
29
+ if (!stat.ok) say(pi, ctx, MESSAGE_TYPE, `/diff: ${stat.error}`, "error");
30
+ else say(pi, ctx, MESSAGE_TYPE, stat.text || "/diff: no changes", "info");
31
+ return;
32
+ }
33
+
34
+ ctx.ui.setStatus(STATUS_KEY, "diff: loading...");
35
+ let loaded: Awaited<ReturnType<typeof loadDiff>>;
36
+ try {
37
+ loaded = await loadDiff(ctx.cwd, ref);
38
+ } finally {
39
+ ctx.ui.setStatus(STATUS_KEY, undefined);
40
+ }
41
+ if (!loaded.ok) {
42
+ ctx.ui.notify(`/diff: ${loaded.error}`, "error");
43
+ return;
44
+ }
45
+ if (loaded.files.length === 0) {
46
+ ctx.ui.notify(`/diff: no changes vs ${loaded.label}`, "info");
47
+ return;
48
+ }
49
+ const { files, label, notices } = loaded;
50
+ await ctx.ui.custom<void>(
51
+ (tui, theme, _kb, done) =>
52
+ new DiffView({
53
+ files,
54
+ label,
55
+ notices,
56
+ theme,
57
+ rows: () => tui.terminal.rows,
58
+ requestRender: () => tui.requestRender(),
59
+ onClose: () => done(),
60
+ }),
61
+ { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 } },
62
+ );
63
+ },
64
+ });
65
+ }
@@ -0,0 +1,180 @@
1
+ /** Unified diff text -> per-file sections with status, counts and per-line kinds. */
2
+
3
+ export type FileStatus = "modified" | "added" | "deleted" | "renamed" | "copied" | "binary" | "mode" | "untracked";
4
+ export type LineKind = "meta" | "hunk" | "added" | "removed" | "context";
5
+
6
+ export interface DiffFile {
7
+ /** Path to show (the new path for renames). */
8
+ path: string;
9
+ oldPath?: string;
10
+ status: FileStatus;
11
+ /** Raw patch lines, header first, without line terminators. */
12
+ lines: string[];
13
+ /** Index into `lines` of the first hunk header, or `lines.length` when there is none. */
14
+ hunkStart: number;
15
+ additions: number;
16
+ deletions: number;
17
+ /** Set when `lines` was cut at the per-file cap; additions/deletions still count everything. */
18
+ notice?: string;
19
+ }
20
+
21
+ export const PER_FILE_BYTES = 200 * 1024;
22
+
23
+ /** Decode a git C-style quoted path (`"a\tb"`, octal escapes are UTF-8 bytes). */
24
+ export function unquoteC(s: string): string {
25
+ if (!(s.length >= 2 && s.startsWith('"') && s.endsWith('"'))) return s;
26
+ const bytes: number[] = [];
27
+ const body = s.slice(1, -1);
28
+ const simple: Record<string, number> = { n: 10, t: 9, r: 13, a: 7, b: 8, f: 12, v: 11, '"': 34, "\\": 92 };
29
+ for (let i = 0; i < body.length; i++) {
30
+ const ch = body[i] as string;
31
+ if (ch !== "\\") {
32
+ bytes.push(...Buffer.from(ch, "utf8"));
33
+ continue;
34
+ }
35
+ const next = body[i + 1] ?? "";
36
+ const octal = /^[0-7]{1,3}/.exec(body.slice(i + 1, i + 4));
37
+ if (octal) {
38
+ bytes.push(Number.parseInt(octal[0], 8));
39
+ i += octal[0].length;
40
+ } else if (next in simple) {
41
+ bytes.push(simple[next] as number);
42
+ i += 1;
43
+ } else {
44
+ bytes.push(92);
45
+ }
46
+ }
47
+ return Buffer.from(bytes).toString("utf8");
48
+ }
49
+
50
+ function stripPrefix(p: string): string {
51
+ const bare = unquoteC(p.replace(/\t.*$/, ""));
52
+ return bare.replace(/^[ab]\//, "");
53
+ }
54
+
55
+ /** Paths from the `diff --git a/x b/y` line alone (used when no ---/+++ lines exist). */
56
+ function headerPaths(line: string): { oldPath: string; newPath: string } {
57
+ const rest = line.slice("diff --git ".length);
58
+ const quoted = /^("(?:[^"\\]|\\.)*") ("(?:[^"\\]|\\.)*")$/.exec(rest);
59
+ if (quoted) return { oldPath: stripPrefix(quoted[1] as string), newPath: stripPrefix(quoted[2] as string) };
60
+ const n = (rest.length - 5) / 2;
61
+ if (Number.isInteger(n) && n > 0 && rest.slice(2, 2 + n) === rest.slice(rest.length - n)) {
62
+ const p = rest.slice(rest.length - n);
63
+ return { oldPath: p, newPath: p };
64
+ }
65
+ const m = /^a\/(.*) b\/(.*)$/.exec(rest);
66
+ return m ? { oldPath: m[1] as string, newPath: m[2] as string } : { oldPath: rest, newPath: rest };
67
+ }
68
+
69
+ function finishFile(lines: string[], overflow: { lines: number } | undefined, capBytes: number): DiffFile {
70
+ const first = lines[0] ?? "";
71
+ const hdr = headerPaths(first);
72
+ let status: FileStatus = "modified";
73
+ let oldPath = hdr.oldPath;
74
+ let newPath = hdr.newPath;
75
+ let hunkStart = lines.length;
76
+ let additions = 0;
77
+ let deletions = 0;
78
+ let sawMode = false;
79
+ let sawContent = false;
80
+ for (let i = 1; i < lines.length; i++) {
81
+ const l = lines[i] as string;
82
+ if (hunkStart === lines.length) {
83
+ if (l.startsWith("@@")) {
84
+ hunkStart = i;
85
+ sawContent = true;
86
+ continue;
87
+ }
88
+ if (l.startsWith("new file mode")) status = "added";
89
+ else if (l.startsWith("deleted file mode")) status = "deleted";
90
+ else if (l.startsWith("rename from ")) {
91
+ status = "renamed";
92
+ oldPath = unquoteC(l.slice("rename from ".length));
93
+ } else if (l.startsWith("rename to ")) newPath = unquoteC(l.slice("rename to ".length));
94
+ else if (l.startsWith("copy from ")) {
95
+ status = "copied";
96
+ oldPath = unquoteC(l.slice("copy from ".length));
97
+ } else if (l.startsWith("copy to ")) newPath = unquoteC(l.slice("copy to ".length));
98
+ else if (l.startsWith("Binary files ") || l.startsWith("GIT binary patch")) {
99
+ status = "binary";
100
+ sawContent = true;
101
+ } else if (l.startsWith("old mode") || l.startsWith("new mode")) sawMode = true;
102
+ else if (l.startsWith("--- ") && l !== "--- /dev/null") oldPath = stripPrefix(l.slice(4));
103
+ else if (l.startsWith("+++ ") && l !== "+++ /dev/null") newPath = stripPrefix(l.slice(4));
104
+ } else if (l.startsWith("+")) additions++;
105
+ else if (l.startsWith("-")) deletions++;
106
+ }
107
+ if (status === "modified" && sawMode && !sawContent) status = "mode";
108
+ if (status === "deleted") newPath = oldPath;
109
+ const file: DiffFile = {
110
+ path: newPath,
111
+ status,
112
+ lines,
113
+ hunkStart,
114
+ additions,
115
+ deletions,
116
+ };
117
+ if (oldPath !== newPath) file.oldPath = oldPath;
118
+ if (overflow) {
119
+ file.notice = `patch truncated: ${overflow.lines} more lines not shown (${Math.round(capBytes / 1024)}KB per-file cap)`;
120
+ // Counts of the lines we dropped are unknown to the loop above; recount happens in parseUnifiedDiff.
121
+ }
122
+ return file;
123
+ }
124
+
125
+ /** Parse `git diff` output. `perFileBytes` bounds the lines kept per file; counts stay exact. */
126
+ export function parseUnifiedDiff(text: string, perFileBytes = PER_FILE_BYTES): DiffFile[] {
127
+ const files: DiffFile[] = [];
128
+ if (!text) return files;
129
+ const all = text.split("\n");
130
+ if (all.at(-1) === "") all.pop();
131
+ let current: string[] | undefined;
132
+ let bytes = 0;
133
+ let dropped = 0;
134
+ let extra = { additions: 0, deletions: 0 };
135
+ let inHunk = false;
136
+
137
+ const flush = () => {
138
+ if (!current) return;
139
+ const file = finishFile(current, dropped > 0 ? { lines: dropped } : undefined, perFileBytes);
140
+ file.additions += extra.additions;
141
+ file.deletions += extra.deletions;
142
+ files.push(file);
143
+ };
144
+
145
+ for (const line of all) {
146
+ if (line.startsWith("diff --git ")) {
147
+ flush();
148
+ current = [line];
149
+ bytes = line.length + 1;
150
+ dropped = 0;
151
+ extra = { additions: 0, deletions: 0 };
152
+ inHunk = false;
153
+ continue;
154
+ }
155
+ if (!current) continue;
156
+ if (line.startsWith("@@")) inHunk = true;
157
+ if (bytes + line.length + 1 > perFileBytes) {
158
+ dropped++;
159
+ if (inHunk && line.startsWith("+")) extra.additions++;
160
+ else if (inHunk && line.startsWith("-")) extra.deletions++;
161
+ continue;
162
+ }
163
+ bytes += line.length + 1;
164
+ current.push(line);
165
+ }
166
+ flush();
167
+ return files;
168
+ }
169
+
170
+ /** Kind of every line of a file section, for colouring. */
171
+ export function classifyLines(file: DiffFile): LineKind[] {
172
+ return file.lines.map((line, i) => {
173
+ if (i < file.hunkStart) return "meta";
174
+ if (line.startsWith("@@")) return "hunk";
175
+ if (line.startsWith("+")) return "added";
176
+ if (line.startsWith("-")) return "removed";
177
+ if (line.startsWith("\\")) return "meta";
178
+ return "context";
179
+ });
180
+ }
@@ -0,0 +1,98 @@
1
+ /** Pure helpers for the `/diff` overlay: sanitising, width fitting, scroll math, layout. */
2
+
3
+ import { sliceByColumn, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
+
5
+ export const TAB_WIDTH = 4;
6
+ export const ELLIPSIS = "…";
7
+
8
+ /** Make arbitrary file text safe for a terminal: C0/C1 controls become `^X`, tabs expand. */
9
+ export function sanitize(text: string, tabWidth = TAB_WIDTH): string {
10
+ let out = "";
11
+ let col = 0;
12
+ for (const ch of text) {
13
+ const code = ch.codePointAt(0) ?? 0;
14
+ if (ch === "\t") {
15
+ const pad = tabWidth - (col % tabWidth);
16
+ out += " ".repeat(pad);
17
+ col += pad;
18
+ } else if (code < 0x20) {
19
+ out += `^${String.fromCharCode(code + 64)}`;
20
+ col += 2;
21
+ } else if (code === 0x7f) {
22
+ out += "^?";
23
+ col += 2;
24
+ } else if (code >= 0x80 && code < 0xa0) {
25
+ out += "?";
26
+ col += 1;
27
+ } else if (code === 0x2028 || code === 0x2029) {
28
+ out += "?";
29
+ col += 1;
30
+ } else {
31
+ out += ch;
32
+ col += visibleWidth(ch);
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+
38
+ /** Exactly `width` columns: truncated with an ellipsis when too long, space padded when short. */
39
+ export function fitCols(text: string, width: number): string {
40
+ if (width <= 0) return "";
41
+ return truncateToWidth(text, width, ELLIPSIS, true);
42
+ }
43
+
44
+ /** Columns `[from, from + width)` of plain text, padded to `width`. Never splits a wide char. */
45
+ export function window(text: string, from: number, width: number): string {
46
+ if (width <= 0) return "";
47
+ const slice = from > 0 ? sliceByColumn(text, from, width, true) : text;
48
+ return fitCols(slice, width);
49
+ }
50
+
51
+ /** Shorten a path from the left so the file name stays visible: `…/dir/file.ts`. */
52
+ export function fitPathLeft(path: string, width: number): string {
53
+ if (width <= 0) return "";
54
+ const w = visibleWidth(path);
55
+ if (w <= width) return fitCols(path, width);
56
+ if (width === 1) return ELLIPSIS;
57
+ return fitCols(`${ELLIPSIS}${sliceByColumn(path, w - (width - 1), width - 1, true)}`, width);
58
+ }
59
+
60
+ export function clamp(n: number, lo: number, hi: number): number {
61
+ return Math.max(lo, Math.min(hi, n));
62
+ }
63
+
64
+ /** Largest valid scroll offset for `total` lines in a `viewport`-line window. */
65
+ export function maxScroll(total: number, viewport: number): number {
66
+ return Math.max(0, total - Math.max(1, viewport));
67
+ }
68
+
69
+ export function clampScroll(offset: number, total: number, viewport: number): number {
70
+ return clamp(offset, 0, maxScroll(total, viewport));
71
+ }
72
+
73
+ /** Lines moved by PgUp/PgDn: a window minus one line of overlap. */
74
+ export function pageSize(viewport: number): number {
75
+ return Math.max(1, viewport - 1);
76
+ }
77
+
78
+ /** First visible list row so that `selected` is visible in `viewport` rows, keeping `top` when possible. */
79
+ export function listTop(top: number, selected: number, total: number, viewport: number): number {
80
+ const h = Math.max(1, viewport);
81
+ let t = top;
82
+ if (selected < t) t = selected;
83
+ else if (selected >= t + h) t = selected - h + 1;
84
+ return clamp(t, 0, Math.max(0, total - h));
85
+ }
86
+
87
+ export interface Layout {
88
+ left: number;
89
+ sep: number;
90
+ right: number;
91
+ }
92
+
93
+ /** Split the width between file list, separator and patch. Always sums to `width`. */
94
+ export function layout(width: number): Layout {
95
+ if (width < 24) return { left: 0, sep: 0, right: Math.max(0, width) };
96
+ const left = clamp(Math.floor(width * 0.28), 14, 44);
97
+ return { left, sep: 1, right: width - left - 1 };
98
+ }