tmux-ide 2.7.0 → 2.8.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 (101) hide show
  1. package/README.md +22 -5
  2. package/bin/cli.js +3532 -1090
  3. package/bin/cli.ts +368 -71
  4. package/package.json +2 -1
  5. package/packages/contracts/src/__tests__/control.test.ts +154 -0
  6. package/packages/contracts/src/control.ts +217 -0
  7. package/packages/contracts/src/index.ts +1 -0
  8. package/packages/daemon/dist/control/client.d.ts +23 -0
  9. package/packages/daemon/dist/control/client.js +105 -0
  10. package/packages/daemon/dist/control/dispatch.d.ts +34 -0
  11. package/packages/daemon/dist/control/dispatch.js +83 -0
  12. package/packages/daemon/dist/control/fanout.d.ts +19 -0
  13. package/packages/daemon/dist/control/fanout.js +37 -0
  14. package/packages/daemon/dist/control/frames.d.ts +23 -0
  15. package/packages/daemon/dist/control/frames.js +37 -0
  16. package/packages/daemon/dist/control/lifecycle.d.ts +45 -0
  17. package/packages/daemon/dist/control/lifecycle.js +114 -0
  18. package/packages/daemon/dist/control/server.d.ts +16 -0
  19. package/packages/daemon/dist/control/server.js +214 -0
  20. package/packages/daemon/dist/control/verbs.d.ts +11 -0
  21. package/packages/daemon/dist/control/verbs.js +91 -0
  22. package/packages/daemon/dist/doctor.d.ts +18 -0
  23. package/packages/daemon/dist/doctor.js +105 -15
  24. package/packages/daemon/dist/lib/agent-discovery.d.ts +27 -2
  25. package/packages/daemon/dist/lib/agent-discovery.js +29 -14
  26. package/packages/daemon/dist/lib/app-config.d.ts +106 -0
  27. package/packages/daemon/dist/lib/app-config.js +104 -5
  28. package/packages/daemon/dist/lib/manifest-pack.d.ts +79 -0
  29. package/packages/daemon/dist/lib/manifest-pack.js +232 -0
  30. package/packages/daemon/dist/lib/state-home.d.ts +2 -0
  31. package/packages/daemon/dist/lib/state-home.js +12 -0
  32. package/packages/daemon/dist/lib/update-check.js +5 -0
  33. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +34 -0
  34. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  35. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/PkgInfo +1 -0
  36. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
  37. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  38. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +139 -0
  39. package/packages/daemon/dist/restore.d.ts +35 -8
  40. package/packages/daemon/dist/restore.js +52 -15
  41. package/packages/daemon/dist/send.d.ts +33 -1
  42. package/packages/daemon/dist/send.js +32 -19
  43. package/packages/daemon/src/control/client.ts +128 -0
  44. package/packages/daemon/src/control/dispatch.ts +107 -0
  45. package/packages/daemon/src/control/fanout.ts +44 -0
  46. package/packages/daemon/src/control/frames.ts +40 -0
  47. package/packages/daemon/src/control/lifecycle.ts +151 -0
  48. package/packages/daemon/src/control/server.ts +237 -0
  49. package/packages/daemon/src/control/verbs.ts +118 -0
  50. package/packages/daemon/src/doctor.ts +113 -28
  51. package/packages/daemon/src/lib/agent-discovery.ts +53 -13
  52. package/packages/daemon/src/lib/app-config.ts +103 -5
  53. package/packages/daemon/src/lib/manifest-pack.ts +255 -0
  54. package/packages/daemon/src/lib/state-home.ts +13 -0
  55. package/packages/daemon/src/lib/update-check.ts +5 -0
  56. package/packages/daemon/src/restore.ts +53 -15
  57. package/packages/daemon/src/send.ts +55 -21
  58. package/packages/daemon/src/tui/chrome/events.ts +4 -4
  59. package/packages/daemon/src/tui/chrome/front-door.ts +39 -0
  60. package/packages/daemon/src/tui/chrome/notify-prefs.ts +58 -0
  61. package/packages/daemon/src/tui/chrome/notify-state.ts +76 -0
  62. package/packages/daemon/src/tui/chrome/notify.ts +582 -84
  63. package/packages/daemon/src/tui/chrome/updater.ts +268 -62
  64. package/packages/daemon/src/tui/detect/classify.ts +34 -0
  65. package/packages/daemon/src/tui/detect/manifest-loader.ts +54 -5
  66. package/packages/daemon/src/tui/detect/manifest.ts +24 -3
  67. package/packages/daemon/src/tui/detect/manifests.ts +240 -6
  68. package/packages/daemon/src/tui/detect/process-tree.ts +13 -3
  69. package/packages/daemon/src/tui/detect/session-id.ts +503 -0
  70. package/packages/daemon/src/tui/integrations/opencode.ts +121 -0
  71. package/packages/daemon/src/tui/mirror/agent-chip.ts +40 -11
  72. package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
  73. package/packages/daemon/src/tui/mirror/agent-rows.ts +27 -5
  74. package/packages/daemon/src/tui/mirror/app-state.ts +171 -8
  75. package/packages/daemon/src/tui/mirror/app.tsx +2182 -399
  76. package/packages/daemon/src/tui/mirror/attention.ts +110 -0
  77. package/packages/daemon/src/tui/mirror/dialog-stack.ts +17 -4
  78. package/packages/daemon/src/tui/mirror/diff-model.ts +279 -4
  79. package/packages/daemon/src/tui/mirror/file-tree.ts +231 -6
  80. package/packages/daemon/src/tui/mirror/host-terminal.ts +49 -0
  81. package/packages/daemon/src/tui/mirror/hosted.ts +205 -0
  82. package/packages/daemon/src/tui/mirror/layout-parse.ts +154 -0
  83. package/packages/daemon/src/tui/mirror/menu-model.ts +27 -4
  84. package/packages/daemon/src/tui/mirror/palette.ts +299 -9
  85. package/packages/daemon/src/tui/mirror/pane-mirror.ts +82 -4
  86. package/packages/daemon/src/tui/mirror/pane-surface.tsx +18 -11
  87. package/packages/daemon/src/tui/mirror/perf-tap.ts +29 -3
  88. package/packages/daemon/src/tui/mirror/selection.ts +122 -8
  89. package/packages/daemon/src/tui/mirror/session-mirror.ts +349 -68
  90. package/packages/daemon/src/tui/mirror/settings-model.ts +96 -16
  91. package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
  92. package/packages/daemon/src/tui/mirror/size-truth.ts +53 -0
  93. package/packages/daemon/src/tui/mirror/theme.ts +45 -0
  94. package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
  95. package/packages/daemon/src/tui/team/sessions.ts +85 -7
  96. package/packages/daemon/src/tui/team/wait.ts +144 -0
  97. package/scripts/build-macos-notifier.mjs +160 -0
  98. package/scripts/postinstall.js +8 -1
  99. package/scripts/prepublish-check.mjs +37 -1
  100. package/scripts/publish-tap.sh +55 -0
  101. package/skill/SKILL.md +88 -2
@@ -0,0 +1,110 @@
1
+ /**
2
+ * In-app attention surfacing (M25.1) — the PURE half of "an agent you can't
3
+ * see needs you". The app's 3s fleet poll diffs the previous per-pane agent
4
+ * states against the fresh payload; blocked/done transitions for agents NOT on
5
+ * the current screen (another workspace, another window, or a non-Terminal
6
+ * tab) become a status-strip note plus a brief sidebar flash. The diff, the
7
+ * visibility test, and the note text live here so they unit-test without
8
+ * OpenTUI; app.tsx supplies the signals and timers.
9
+ *
10
+ * First sight is graced exactly like the chrome updater's notification path
11
+ * (`from: null` never notes) — an app boot must not re-announce every agent
12
+ * that was already blocked.
13
+ */
14
+ import type { AgentStatus } from "../detect/classify.ts";
15
+
16
+ /** The per-agent slice of the fleet payload the diff needs. */
17
+ export interface AttentionAgent {
18
+ paneId: string;
19
+ session: string;
20
+ kind: string;
21
+ state: AgentStatus;
22
+ }
23
+
24
+ /** One agent's state change between two fleet polls. */
25
+ export interface AttentionTransition {
26
+ paneId: string;
27
+ session: string;
28
+ kind: string;
29
+ from: AgentStatus | null;
30
+ to: AgentStatus;
31
+ }
32
+
33
+ /** What the app is currently showing — the visibility side of the test. */
34
+ export interface AttentionView {
35
+ /** The active surface tab (only `terminal` shows panes at all). */
36
+ tab: string;
37
+ /** Pane ids on screen right now (the mirrored window's panes). */
38
+ visiblePaneIds: readonly string[];
39
+ }
40
+
41
+ /** The two states worth interrupting the user over (mirrors the chrome
42
+ * updater's NOTIFY_STATES). */
43
+ const NOTE_STATES: ReadonlySet<AgentStatus> = new Set<AgentStatus>(["blocked", "done"]);
44
+
45
+ /**
46
+ * PURE — diff the previous per-pane states against this poll's agents.
47
+ * Returns the transitions plus the fresh state map to thread into the next
48
+ * poll (the input map is NOT mutated). A pane that vanished emits nothing and
49
+ * drops out of the state.
50
+ */
51
+ export function diffAttention(
52
+ prev: ReadonlyMap<string, AgentStatus>,
53
+ agents: readonly AttentionAgent[],
54
+ ): { transitions: AttentionTransition[]; next: Map<string, AgentStatus> } {
55
+ const next = new Map<string, AgentStatus>();
56
+ const transitions: AttentionTransition[] = [];
57
+ for (const a of agents) {
58
+ if (next.has(a.paneId)) continue; // deduped: a session can surface under two projects
59
+ const before = prev.has(a.paneId) ? prev.get(a.paneId)! : null;
60
+ next.set(a.paneId, a.state);
61
+ if (before === a.state) continue;
62
+ transitions.push({
63
+ paneId: a.paneId,
64
+ session: a.session,
65
+ kind: a.kind,
66
+ from: before,
67
+ to: a.state,
68
+ });
69
+ }
70
+ return { transitions, next };
71
+ }
72
+
73
+ /** PURE — is this pane on the app's screen right now? Only the Terminal tab
74
+ * shows panes; there, exactly the mirrored window's panes are visible. */
75
+ export function isPaneVisible(paneId: string, view: AttentionView): boolean {
76
+ return view.tab === "terminal" && view.visiblePaneIds.includes(paneId);
77
+ }
78
+
79
+ /**
80
+ * PURE — the transitions worth a note: blocked/done, not first-sight, and not
81
+ * currently visible (the user can see a visible pane flip themselves).
82
+ */
83
+ export function noteworthyTransitions(
84
+ transitions: readonly AttentionTransition[],
85
+ view: AttentionView,
86
+ ): AttentionTransition[] {
87
+ return transitions.filter(
88
+ (t) => t.from !== null && NOTE_STATES.has(t.to) && !isPaneVisible(t.paneId, view),
89
+ );
90
+ }
91
+
92
+ /** PURE — the status-strip note for one transition, e.g.
93
+ * `● claude blocked · zz-api — click agents`. Done reads `✓ … — click agents`. */
94
+ export function attentionNote(t: AttentionTransition): string {
95
+ const glyph = t.to === "blocked" ? "●" : "✓";
96
+ return `${glyph} ${t.kind} ${t.to} · ${t.session} — click agents`;
97
+ }
98
+
99
+ /** PURE — the note line for a whole poll's noteworthy set: the first (most
100
+ * recent diff order) transition's note, with a `(+N)` tail when several agents
101
+ * flipped in the same poll. Null for an empty set. */
102
+ export function attentionNoteLine(worthy: readonly AttentionTransition[]): string | null {
103
+ const first = worthy[0];
104
+ if (!first) return null;
105
+ const extra = worthy.length > 1 ? ` (+${worthy.length - 1})` : "";
106
+ return `${attentionNote(first)}${extra}`;
107
+ }
108
+
109
+ /** How long the sidebar attention flash lasts. */
110
+ export const ATTENTION_FLASH_MS = 2500;
@@ -279,6 +279,17 @@ export interface DialogKeyEvent {
279
279
  shift: boolean;
280
280
  }
281
281
 
282
+ /** The typed character a key event contributes to a filter/prompt, or null.
283
+ * OpenTUI names the space key `"space"` (not a 1-char name), so the bare
284
+ * length-1 check silently DROPPED every space — a custom command with flags
285
+ * could never be typed (measured live, M24.1). */
286
+ function typedChar(evt: DialogKeyEvent): string | null {
287
+ if (evt.ctrl || evt.meta) return null;
288
+ if (evt.name === "space") return " ";
289
+ if (evt.name.length === 1) return evt.shift ? evt.name.toUpperCase() : evt.name;
290
+ return null;
291
+ }
292
+
282
293
  /**
283
294
  * Feed one key to the stack's top dialog. Centralized ESCAPE lives here: it
284
295
  * pops exactly one level. The caller must treat EVERY key as consumed while
@@ -313,8 +324,9 @@ export function dialogKey(stack: DialogStack, evt: DialogKeyEvent): void {
313
324
  state.armed = null;
314
325
  return stack.touch();
315
326
  }
316
- if (evt.name.length === 1 && !evt.ctrl && !evt.meta) {
317
- state.query += evt.shift ? evt.name.toUpperCase() : evt.name;
327
+ const ch = typedChar(evt);
328
+ if (ch !== null) {
329
+ state.query += ch;
318
330
  state.sel = 0;
319
331
  state.top = 0;
320
332
  state.armed = null;
@@ -338,8 +350,9 @@ export function dialogKey(stack: DialogStack, evt: DialogKeyEvent): void {
338
350
  state.error = null;
339
351
  return stack.touch();
340
352
  }
341
- if (evt.name.length === 1 && !evt.ctrl && !evt.meta) {
342
- state.input += evt.shift ? evt.name.toUpperCase() : evt.name;
353
+ const ch = typedChar(evt);
354
+ if (ch !== null) {
355
+ state.input += ch;
343
356
  state.error = null;
344
357
  return stack.touch();
345
358
  }
@@ -1,14 +1,22 @@
1
1
  /**
2
- * Pure, io-free helpers for the built-in git diff panel (M18.3).
2
+ * Pure, io-free helpers for the built-in git diff panel (M18.3; grouped +
3
+ * stage-aware M24.5).
3
4
  *
4
5
  * Like {@link ./editor-buffer.ts}, this imports NOTHING from @opentui/core: the
5
6
  * app runs the git subprocesses (async execFile) and reads untracked files, but
6
7
  * the PARSING and coordinate math live here so they unit-test as tables under
7
8
  * the Node/vitest runner while app.tsx keeps the OpenTUI wiring.
8
9
  *
9
- * Three concerns: (1) parse `git status --porcelain` into a flat, display-ready
10
- * file list; (2) classify each `git diff` line so the renderer can color it; and
11
- * (3) turn an untracked file's contents into an all-additions pseudo-diff (git
10
+ * Concerns: (1) parse `git status --porcelain` into display-ready entries —
11
+ * flat ({@link parseStatusPorcelain}, legacy) or split into Staged / Unstaged /
12
+ * Untracked components ({@link parseStatusGroups}: an `MM` file yields BOTH a
13
+ * staged and an unstaged row, each diffing its own side of the index); (2) lay
14
+ * the grouped entries out as list ROWS (section headers + files) whose math is
15
+ * shared by the render and the mouse router ({@link buildDiffRows}); (3) merge
16
+ * `git diff --numstat` ± counts into the entries; (4) classify each `git diff`
17
+ * line so the renderer can color it; (5) hunk coordinate math — `]`/`[` jump
18
+ * targets and the `^e` open-editor-at-line target parsed from `@@ -a,b +c,d`;
19
+ * (6) turn an untracked file's contents into an all-additions pseudo-diff (git
12
20
  * diff prints nothing for untracked paths).
13
21
  */
14
22
 
@@ -110,3 +118,270 @@ export function clampSel(sel: number, count: number): number {
110
118
  if (count <= 0) return 0;
111
119
  return Math.max(0, Math.min(sel, count - 1));
112
120
  }
121
+
122
+ // ── Grouped list (M24.5) ─────────────────────────────────────────────────────
123
+
124
+ /** Which section of the grouped list an entry belongs to. */
125
+ export type DiffGroup = "staged" | "unstaged" | "untracked";
126
+
127
+ /** One stage-aware changed-file entry: a porcelain XY line is SPLIT into its
128
+ * index and worktree components, so an `MM` file yields two entries — the
129
+ * staged one diffs `--cached`, the unstaged one diffs the worktree. */
130
+ export interface DiffEntry {
131
+ group: DiffGroup;
132
+ /** Display letter for THIS component: the X (index) letter for staged rows,
133
+ * the Y (worktree) letter for unstaged rows, `?` for untracked. */
134
+ status: string;
135
+ /** Repo-relative path (the NEW path for renames). */
136
+ path: string;
137
+ /** ± line counts from `git diff --numstat` (untracked: the file's line
138
+ * count). `null` until loaded, and for binary/unreadable files. */
139
+ additions: number | null;
140
+ deletions: number | null;
141
+ }
142
+
143
+ /**
144
+ * PURE — parse porcelain v1 output into grouped, stage-aware entries. Each
145
+ * `XY PATH` line contributes: a STAGED entry when X is a real index state, an
146
+ * UNSTAGED entry when Y is a real worktree state, or one UNTRACKED entry for
147
+ * `??`. Ignored (`!!`) and malformed lines are skipped. Counts start `null`
148
+ * ({@link applyCounts} fills them).
149
+ */
150
+ export function parseStatusGroups(out: string): DiffEntry[] {
151
+ const entries: DiffEntry[] = [];
152
+ for (const line of out.split("\n")) {
153
+ if (line.length < 4) continue;
154
+ const index = line[0]!;
155
+ const work = line[1]!;
156
+ let rest = line.slice(3);
157
+ const arrow = rest.indexOf(" -> ");
158
+ if (arrow !== -1) rest = rest.slice(arrow + 4);
159
+ if (index === "!" && work === "!") continue;
160
+ if (index === "?" && work === "?") {
161
+ entries.push({
162
+ group: "untracked",
163
+ status: "?",
164
+ path: rest,
165
+ additions: null,
166
+ deletions: null,
167
+ });
168
+ continue;
169
+ }
170
+ if (index !== " " && index !== "?")
171
+ entries.push({
172
+ group: "staged",
173
+ status: index,
174
+ path: rest,
175
+ additions: null,
176
+ deletions: null,
177
+ });
178
+ if (work !== " " && work !== "?")
179
+ entries.push({
180
+ group: "unstaged",
181
+ status: work,
182
+ path: rest,
183
+ additions: null,
184
+ deletions: null,
185
+ });
186
+ }
187
+ return entries;
188
+ }
189
+
190
+ /** PURE — narrow entries to paths containing `query` (case-insensitive
191
+ * substring). An empty/blank query returns the input unchanged. */
192
+ export function filterEntries(entries: DiffEntry[], query: string): DiffEntry[] {
193
+ const q = query.trim().toLowerCase();
194
+ if (!q) return entries;
195
+ return entries.filter((e) => e.path.toLowerCase().includes(q));
196
+ }
197
+
198
+ /** One row of the grouped file list: a non-selectable section header or a file
199
+ * entry carrying its index into the flat selectable-file order. */
200
+ export type DiffRow =
201
+ | { kind: "header"; group: DiffGroup; label: string }
202
+ | { kind: "file"; entry: DiffEntry; fileIndex: number };
203
+
204
+ const GROUP_ORDER: { group: DiffGroup; label: string }[] = [
205
+ { group: "staged", label: "Staged" },
206
+ { group: "unstaged", label: "Unstaged" },
207
+ { group: "untracked", label: "Untracked" },
208
+ ];
209
+
210
+ /**
211
+ * PURE — lay grouped entries out as list rows: per-group `Label (n)` headers
212
+ * (only for non-empty groups, in Staged → Unstaged → Untracked order) followed
213
+ * by that group's files. `files` is the flat selectable order — `fileIndex` on
214
+ * each file row indexes it, so the selection model stays a plain integer while
215
+ * the render/hit math walk ROWS. Shared render↔router, one source of truth.
216
+ */
217
+ export function buildDiffRows(entries: DiffEntry[]): { rows: DiffRow[]; files: DiffEntry[] } {
218
+ const rows: DiffRow[] = [];
219
+ const files: DiffEntry[] = [];
220
+ for (const { group, label } of GROUP_ORDER) {
221
+ const members = entries.filter((e) => e.group === group);
222
+ if (members.length === 0) continue;
223
+ rows.push({ kind: "header", group, label: `${label} (${members.length})` });
224
+ for (const entry of members) {
225
+ rows.push({ kind: "file", entry, fileIndex: files.length });
226
+ files.push(entry);
227
+ }
228
+ }
229
+ return { rows, files };
230
+ }
231
+
232
+ /** PURE — the row index of selectable file `fileIndex` (for keeping the
233
+ * selection in view when the list scrolls over ROWS). -1 when absent. */
234
+ export function rowIndexOfFile(rows: DiffRow[], fileIndex: number): number {
235
+ return rows.findIndex((r) => r.kind === "file" && r.fileIndex === fileIndex);
236
+ }
237
+
238
+ // ── numstat (M24.5) ──────────────────────────────────────────────────────────
239
+
240
+ /** ± line counts for one file. */
241
+ export interface Numstat {
242
+ additions: number;
243
+ deletions: number;
244
+ }
245
+
246
+ /** Resolve a numstat path cell to the NEW path: renames print either
247
+ * `pre{old => new}post` (common-prefix brace form) or a bare `old => new`. */
248
+ function numstatPath(raw: string): string {
249
+ const brace = raw.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
250
+ if (brace) return `${brace[1]}${brace[3]}${brace[4]}`;
251
+ const arrow = raw.indexOf(" => ");
252
+ if (arrow !== -1) return raw.slice(arrow + 4);
253
+ return raw;
254
+ }
255
+
256
+ /**
257
+ * PURE — parse `git diff --numstat` output (`added<TAB>deleted<TAB>path`) into
258
+ * a path → counts map. Binary files print `- - path` and are SKIPPED (their
259
+ * entries keep `null` counts); rename cells resolve to the new path.
260
+ */
261
+ export function parseNumstat(out: string): Map<string, Numstat> {
262
+ const counts = new Map<string, Numstat>();
263
+ for (const line of out.split("\n")) {
264
+ if (!line) continue;
265
+ const parts = line.split("\t");
266
+ if (parts.length < 3) continue;
267
+ const additions = parseInt(parts[0]!, 10);
268
+ const deletions = parseInt(parts[1]!, 10);
269
+ if (Number.isNaN(additions) || Number.isNaN(deletions)) continue; // binary: "-"
270
+ counts.set(numstatPath(parts.slice(2).join("\t")), { additions, deletions });
271
+ }
272
+ return counts;
273
+ }
274
+
275
+ /** PURE — the line count an untracked file shows as its `+` count: content
276
+ * lines, with the single trailing newline's empty not counted (mirrors
277
+ * {@link untrackedDiffText}); empty content counts 0. */
278
+ export function untrackedLineCount(content: string): number {
279
+ if (content === "") return 0;
280
+ const lines = content.split("\n");
281
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
282
+ return lines.length;
283
+ }
284
+
285
+ /**
286
+ * PURE — merge per-group counts into entries (staged entries read the
287
+ * `--cached` numstat, unstaged the worktree numstat, untracked the line-count
288
+ * map). Entries without a match keep `null` counts (binary/unreadable).
289
+ */
290
+ export function applyCounts(
291
+ entries: DiffEntry[],
292
+ staged: Map<string, Numstat>,
293
+ unstaged: Map<string, Numstat>,
294
+ untrackedLines: Map<string, number>,
295
+ ): DiffEntry[] {
296
+ return entries.map((e) => {
297
+ if (e.group === "untracked") {
298
+ const lines = untrackedLines.get(e.path);
299
+ return lines === undefined ? e : { ...e, additions: lines, deletions: 0 };
300
+ }
301
+ const n = (e.group === "staged" ? staged : unstaged).get(e.path);
302
+ return n === undefined ? e : { ...e, additions: n.additions, deletions: n.deletions };
303
+ });
304
+ }
305
+
306
+ /** PURE — sum the loaded ± counts across entries (nulls contribute 0), for the
307
+ * header totals. */
308
+ export function totalCounts(entries: DiffEntry[]): { additions: number; deletions: number } {
309
+ let additions = 0;
310
+ let deletions = 0;
311
+ for (const e of entries) {
312
+ additions += e.additions ?? 0;
313
+ deletions += e.deletions ?? 0;
314
+ }
315
+ return { additions, deletions };
316
+ }
317
+
318
+ // ── Hunk math (M24.5) ────────────────────────────────────────────────────────
319
+
320
+ /** A parsed `@@ -a,b +c,d @@` header (counts default 1 when omitted). */
321
+ export interface HunkHeader {
322
+ oldStart: number;
323
+ oldCount: number;
324
+ newStart: number;
325
+ newCount: number;
326
+ }
327
+
328
+ /** PURE — parse a hunk header line; null for anything else. */
329
+ export function parseHunkHeader(text: string): HunkHeader | null {
330
+ const m = text.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
331
+ if (!m) return null;
332
+ return {
333
+ oldStart: parseInt(m[1]!, 10),
334
+ oldCount: m[2] !== undefined ? parseInt(m[2], 10) : 1,
335
+ newStart: parseInt(m[3]!, 10),
336
+ newCount: m[4] !== undefined ? parseInt(m[4], 10) : 1,
337
+ };
338
+ }
339
+
340
+ /** PURE — the indices of all hunk-header lines in a classified diff. */
341
+ export function hunkLineIndices(lines: DiffLine[]): number[] {
342
+ const out: number[] = [];
343
+ for (let i = 0; i < lines.length; i++) if (lines[i]!.kind === "hunk") out.push(i);
344
+ return out;
345
+ }
346
+
347
+ /** PURE — the `]`/`[` jump target: the first hunk-line index strictly after
348
+ * (`dir` 1) or before (`dir` -1) the current view top. null = no further hunk
349
+ * in that direction (the view stays put). */
350
+ export function nextHunkTop(lines: DiffLine[], top: number, dir: 1 | -1): number | null {
351
+ const hunks = hunkLineIndices(lines);
352
+ if (dir === 1) {
353
+ for (const i of hunks) if (i > top) return i;
354
+ return null;
355
+ }
356
+ for (let j = hunks.length - 1; j >= 0; j--) if (hunks[j]! < top) return hunks[j]!;
357
+ return null;
358
+ }
359
+
360
+ /**
361
+ * PURE — the `^e` open-editor target: the 0-based NEW-file line of the first
362
+ * changed (add/del) line of the SELECTED hunk — the hunk whose header is at or
363
+ * nearest above the current view top (the first hunk when the view is above
364
+ * them all). Walks from `+c` counting context/add lines; a del line targets
365
+ * the new-file position where the removal happened. Falls back to `c` itself
366
+ * for a hunk with no explicit ± lines, and null when the diff has no hunks
367
+ * (binary/meta-only/pseudo-diff — the caller opens at 0).
368
+ */
369
+ export function hunkEditTarget(lines: DiffLine[], top: number): number | null {
370
+ const hunks = hunkLineIndices(lines);
371
+ if (hunks.length === 0) return null;
372
+ let h = hunks[0]!;
373
+ for (const i of hunks) {
374
+ if (i <= top) h = i;
375
+ else break;
376
+ }
377
+ const hdr = parseHunkHeader(lines[h]!.text);
378
+ if (!hdr) return null;
379
+ let newLine = hdr.newStart; // 1-based
380
+ for (let i = h + 1; i < lines.length; i++) {
381
+ const kind = lines[i]!.kind;
382
+ if (kind === "hunk" || kind === "meta") break;
383
+ if (kind === "add" || kind === "del") return Math.max(0, newLine - 1);
384
+ newLine++; // context
385
+ }
386
+ return Math.max(0, hdr.newStart - 1);
387
+ }