tmux-ide 2.6.1 → 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 (154) hide show
  1. package/README.md +36 -14
  2. package/bin/cli.js +4174 -1235
  3. package/bin/cli.ts +426 -72
  4. package/package.json +10 -6
  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/agent-explain.d.ts +8 -1
  9. package/packages/daemon/dist/agent-explain.js +19 -3
  10. package/packages/daemon/dist/control/client.d.ts +23 -0
  11. package/packages/daemon/dist/control/client.js +105 -0
  12. package/packages/daemon/dist/control/dispatch.d.ts +34 -0
  13. package/packages/daemon/dist/control/dispatch.js +83 -0
  14. package/packages/daemon/dist/control/fanout.d.ts +19 -0
  15. package/packages/daemon/dist/control/fanout.js +37 -0
  16. package/packages/daemon/dist/control/frames.d.ts +23 -0
  17. package/packages/daemon/dist/control/frames.js +37 -0
  18. package/packages/daemon/dist/control/lifecycle.d.ts +45 -0
  19. package/packages/daemon/dist/control/lifecycle.js +114 -0
  20. package/packages/daemon/dist/control/server.d.ts +16 -0
  21. package/packages/daemon/dist/control/server.js +214 -0
  22. package/packages/daemon/dist/control/verbs.d.ts +11 -0
  23. package/packages/daemon/dist/control/verbs.js +91 -0
  24. package/packages/daemon/dist/doctor.d.ts +18 -0
  25. package/packages/daemon/dist/doctor.js +105 -15
  26. package/packages/daemon/dist/lib/agent-discovery.d.ts +27 -2
  27. package/packages/daemon/dist/lib/agent-discovery.js +29 -14
  28. package/packages/daemon/dist/lib/app-config.d.ts +106 -0
  29. package/packages/daemon/dist/lib/app-config.js +104 -5
  30. package/packages/daemon/dist/lib/manifest-pack.d.ts +79 -0
  31. package/packages/daemon/dist/lib/manifest-pack.js +232 -0
  32. package/packages/daemon/dist/lib/state-home.d.ts +2 -0
  33. package/packages/daemon/dist/lib/state-home.js +12 -0
  34. package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
  35. package/packages/daemon/dist/lib/tui-binary.js +130 -0
  36. package/packages/daemon/dist/lib/update-check.js +5 -0
  37. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +34 -0
  38. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  39. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/PkgInfo +1 -0
  40. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
  41. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  42. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +139 -0
  43. package/packages/daemon/dist/restore.d.ts +35 -8
  44. package/packages/daemon/dist/restore.js +52 -15
  45. package/packages/daemon/dist/send.d.ts +33 -1
  46. package/packages/daemon/dist/send.js +32 -19
  47. package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
  48. package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
  49. package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
  50. package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
  51. package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
  52. package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
  53. package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
  54. package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
  55. package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
  56. package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
  57. package/packages/daemon/src/agent-explain.ts +34 -6
  58. package/packages/daemon/src/control/client.ts +128 -0
  59. package/packages/daemon/src/control/dispatch.ts +107 -0
  60. package/packages/daemon/src/control/fanout.ts +44 -0
  61. package/packages/daemon/src/control/frames.ts +40 -0
  62. package/packages/daemon/src/control/lifecycle.ts +151 -0
  63. package/packages/daemon/src/control/server.ts +237 -0
  64. package/packages/daemon/src/control/verbs.ts +118 -0
  65. package/packages/daemon/src/doctor.ts +113 -28
  66. package/packages/daemon/src/lib/agent-discovery.ts +53 -13
  67. package/packages/daemon/src/lib/app-config.ts +194 -5
  68. package/packages/daemon/src/lib/manifest-pack.ts +255 -0
  69. package/packages/daemon/src/lib/state-home.ts +13 -0
  70. package/packages/daemon/src/lib/tui-binary.ts +165 -0
  71. package/packages/daemon/src/lib/update-check.ts +5 -0
  72. package/packages/daemon/src/restore.ts +53 -15
  73. package/packages/daemon/src/send.ts +55 -21
  74. package/packages/daemon/src/tui/chrome/events.ts +4 -4
  75. package/packages/daemon/src/tui/chrome/front-door.ts +39 -0
  76. package/packages/daemon/src/tui/chrome/notify-prefs.ts +58 -0
  77. package/packages/daemon/src/tui/chrome/notify-state.ts +76 -0
  78. package/packages/daemon/src/tui/chrome/notify.ts +737 -52
  79. package/packages/daemon/src/tui/chrome/updater.ts +318 -30
  80. package/packages/daemon/src/tui/compiled.ts +11 -3
  81. package/packages/daemon/src/tui/detect/classify.ts +49 -0
  82. package/packages/daemon/src/tui/detect/manifest-loader.ts +56 -6
  83. package/packages/daemon/src/tui/detect/manifest.ts +39 -3
  84. package/packages/daemon/src/tui/detect/manifests.ts +395 -33
  85. package/packages/daemon/src/tui/detect/process-tree.ts +33 -3
  86. package/packages/daemon/src/tui/detect/session-id.ts +503 -0
  87. package/packages/daemon/src/tui/integrations/opencode.ts +121 -0
  88. package/packages/daemon/src/tui/main.ts +13 -1
  89. package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
  90. package/packages/daemon/src/tui/mirror/agent-chip.ts +126 -0
  91. package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
  92. package/packages/daemon/src/tui/mirror/agent-rows.ts +155 -0
  93. package/packages/daemon/src/tui/mirror/app-state.ts +342 -0
  94. package/packages/daemon/src/tui/mirror/app.tsx +7048 -0
  95. package/packages/daemon/src/tui/mirror/attention.ts +110 -0
  96. package/packages/daemon/src/tui/mirror/blit.ts +186 -0
  97. package/packages/daemon/src/tui/mirror/control-client.ts +80 -9
  98. package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
  99. package/packages/daemon/src/tui/mirror/dialog-stack.ts +367 -0
  100. package/packages/daemon/src/tui/mirror/diff-model.ts +387 -0
  101. package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
  102. package/packages/daemon/src/tui/mirror/file-tree.ts +322 -0
  103. package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
  104. package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
  105. package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
  106. package/packages/daemon/src/tui/mirror/host-terminal.ts +49 -0
  107. package/packages/daemon/src/tui/mirror/hosted.ts +205 -0
  108. package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
  109. package/packages/daemon/src/tui/mirror/layout-parse.ts +154 -0
  110. package/packages/daemon/src/tui/mirror/menu-model.ts +210 -0
  111. package/packages/daemon/src/tui/mirror/palette.ts +564 -0
  112. package/packages/daemon/src/tui/mirror/pane-mirror.ts +578 -20
  113. package/packages/daemon/src/tui/mirror/pane-surface.tsx +422 -0
  114. package/packages/daemon/src/tui/mirror/perf-tap.ts +186 -0
  115. package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
  116. package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
  117. package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
  118. package/packages/daemon/src/tui/mirror/selection.ts +376 -0
  119. package/packages/daemon/src/tui/mirror/session-mirror.ts +724 -0
  120. package/packages/daemon/src/tui/mirror/settings-model.ts +425 -0
  121. package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
  122. package/packages/daemon/src/tui/mirror/size-truth.ts +130 -0
  123. package/packages/daemon/src/tui/mirror/spans.ts +46 -0
  124. package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
  125. package/packages/daemon/src/tui/mirror/theme.ts +45 -0
  126. package/packages/daemon/src/tui/team/entry.ts +34 -7
  127. package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
  128. package/packages/daemon/src/tui/team/report.ts +11 -1
  129. package/packages/daemon/src/tui/team/sessions.ts +184 -17
  130. package/packages/daemon/src/tui/team/wait.ts +144 -0
  131. package/scripts/build-macos-notifier.mjs +160 -0
  132. package/scripts/build-tui.mjs +11 -4
  133. package/scripts/perf-mirror.mjs +313 -0
  134. package/scripts/postinstall.js +8 -1
  135. package/scripts/prepublish-check.mjs +37 -1
  136. package/scripts/publish-tap.sh +55 -0
  137. package/skill/SKILL.md +110 -2
  138. package/templates/AGENTS.md +14 -7
  139. package/templates/agent-team-monorepo.yml +8 -0
  140. package/templates/agent-team-nextjs.yml +8 -0
  141. package/templates/agent-team.yml +10 -0
  142. package/templates/convex.yml +2 -0
  143. package/templates/default.yml +11 -5
  144. package/templates/go.yml +4 -0
  145. package/templates/missions.yml +6 -0
  146. package/templates/nextjs.yml +4 -0
  147. package/templates/python.yml +4 -0
  148. package/templates/skills/backend.md +5 -12
  149. package/templates/skills/frontend.md +5 -12
  150. package/templates/skills/general-worker.md +5 -12
  151. package/templates/skills/researcher.md +7 -12
  152. package/templates/skills/reviewer.md +7 -16
  153. package/templates/vite.yml +4 -0
  154. package/packages/daemon/src/tui/mirror/viewer.tsx +0 -166
@@ -0,0 +1,387 @@
1
+ /**
2
+ * Pure, io-free helpers for the built-in git diff panel (M18.3; grouped +
3
+ * stage-aware M24.5).
4
+ *
5
+ * Like {@link ./editor-buffer.ts}, this imports NOTHING from @opentui/core: the
6
+ * app runs the git subprocesses (async execFile) and reads untracked files, but
7
+ * the PARSING and coordinate math live here so they unit-test as tables under
8
+ * the Node/vitest runner while app.tsx keeps the OpenTUI wiring.
9
+ *
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
20
+ * diff prints nothing for untracked paths).
21
+ */
22
+
23
+ /** One changed file from `git status --porcelain`, display-ready. */
24
+ export interface StatusEntry {
25
+ /** Single status letter for the list: M(odified) · A(dded) · D(eleted) ·
26
+ * R(enamed) · ?(untracked) · etc. Worktree state wins over index state so a
27
+ * file staged-then-edited still reads as M. */
28
+ status: string;
29
+ /** Repo-relative path (the NEW path for renames). */
30
+ path: string;
31
+ /** True when the index carries a change (a staged component exists). */
32
+ staged: boolean;
33
+ }
34
+
35
+ /**
36
+ * PURE — parse porcelain v1 output. Each line is `XY<space>PATH`, where X is the
37
+ * index state and Y the worktree state; renames/copies render as `orig -> new`.
38
+ * Untracked files come through as `??`. Malformed/short lines are skipped.
39
+ */
40
+ export function parseStatusPorcelain(out: string): StatusEntry[] {
41
+ const entries: StatusEntry[] = [];
42
+ for (const line of out.split("\n")) {
43
+ if (line.length < 4) continue;
44
+ const index = line[0]!;
45
+ const work = line[1]!;
46
+ let rest = line.slice(3);
47
+ const arrow = rest.indexOf(" -> ");
48
+ if (arrow !== -1) rest = rest.slice(arrow + 4);
49
+ const untracked = index === "?" && work === "?";
50
+ const status = untracked ? "?" : work !== " " ? work : index;
51
+ entries.push({ status, path: rest, staged: !untracked && index !== " " });
52
+ }
53
+ return entries;
54
+ }
55
+
56
+ /** How a unified-diff line renders: added / removed / hunk header / file-meta
57
+ * (diff/index/+++/---/mode/rename) / plain context. */
58
+ export type DiffLineKind = "add" | "del" | "hunk" | "meta" | "context";
59
+
60
+ /** One classified diff line: its kind and its raw text (leading sigil kept). */
61
+ export interface DiffLine {
62
+ kind: DiffLineKind;
63
+ text: string;
64
+ }
65
+
66
+ const META_PREFIXES = [
67
+ "diff ",
68
+ "index ",
69
+ "new file",
70
+ "deleted file",
71
+ "old mode",
72
+ "new mode",
73
+ "similarity ",
74
+ "dissimilarity ",
75
+ "rename ",
76
+ "copy ",
77
+ "Binary ",
78
+ "\\ No newline",
79
+ ];
80
+
81
+ /**
82
+ * PURE — classify one raw diff line. Order matters: the `+++`/`---` file headers
83
+ * must be caught as meta BEFORE the bare `+`/`-` add/del checks.
84
+ */
85
+ export function classifyDiffLine(line: string): DiffLineKind {
86
+ if (line.startsWith("@@")) return "hunk";
87
+ if (line.startsWith("+++") || line.startsWith("---")) return "meta";
88
+ for (const p of META_PREFIXES) if (line.startsWith(p)) return "meta";
89
+ if (line.startsWith("+")) return "add";
90
+ if (line.startsWith("-")) return "del";
91
+ return "context";
92
+ }
93
+
94
+ /**
95
+ * PURE — split a `git diff --no-color` blob into classified lines, dropping the
96
+ * single trailing empty produced by the final newline.
97
+ */
98
+ export function classifyDiff(diff: string): DiffLine[] {
99
+ if (!diff) return [];
100
+ const lines = diff.split("\n");
101
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
102
+ return lines.map((text) => ({ kind: classifyDiffLine(text), text }));
103
+ }
104
+
105
+ /**
106
+ * PURE — render an untracked file's contents as an all-additions pseudo-diff
107
+ * (git diff emits nothing for untracked paths). Each content line is prefixed
108
+ * with `+` so {@link classifyDiff} colors it as an addition.
109
+ */
110
+ export function untrackedDiffText(content: string): string {
111
+ const lines = content.split("\n");
112
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
113
+ return lines.map((l) => "+" + l).join("\n");
114
+ }
115
+
116
+ /** PURE — clamp a selection index into `[0, count)` (0 when the list is empty). */
117
+ export function clampSel(sel: number, count: number): number {
118
+ if (count <= 0) return 0;
119
+ return Math.max(0, Math.min(sel, count - 1));
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
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Pure, io-free helpers for the built-in file editor panel (M18.2).
3
+ *
4
+ * Deliberately imports NOTHING from @opentui/core: the editing ENGINE is a
5
+ * native `EditBuffer` (bun:ffi), which cannot load under Node/vitest. Everything
6
+ * that can be unit-tested — binary sniffing, read-only classification, the
7
+ * line-number gutter, viewport slicing, and click→cursor coordinate math —
8
+ * lives here so it runs green under the Node test runner while app.tsx keeps
9
+ * the bun-only EditBuffer wiring.
10
+ */
11
+
12
+ /** Files at or above this byte length open read-only (no full-buffer edits on
13
+ * the render loop). */
14
+ export const MAX_EDITABLE_BYTES = 1_000_000;
15
+ /** How many leading bytes we sniff for a NUL before calling a file binary. */
16
+ export const BINARY_SNIFF_BYTES = 8000;
17
+
18
+ /** NUL-byte sniff: a single 0x00 in the leading window means "not text". */
19
+ export function isBinary(bytes: Uint8Array): boolean {
20
+ const n = Math.min(bytes.length, BINARY_SNIFF_BYTES);
21
+ for (let i = 0; i < n; i++) if (bytes[i] === 0) return true;
22
+ return false;
23
+ }
24
+
25
+ export type ReadOnlyReason = "binary" | "large" | null;
26
+
27
+ /** Decide whether a freshly-read file is editable, and why not. */
28
+ export function classifyFile(byteLength: number, binary: boolean): ReadOnlyReason {
29
+ if (binary) return "binary";
30
+ if (byteLength >= MAX_EDITABLE_BYTES) return "large";
31
+ return null;
32
+ }
33
+
34
+ /** Human banner for a read-only reason (null = editable, no banner). */
35
+ export function readOnlyBanner(reason: ReadOnlyReason): string | null {
36
+ if (reason === "binary") return "read-only · binary file (null byte detected)";
37
+ if (reason === "large") return `read-only · file ≥ ${MAX_EDITABLE_BYTES / 1_000_000} MB`;
38
+ return null;
39
+ }
40
+
41
+ /** Render a binary/undisplayable buffer as a safe ASCII preview: control and
42
+ * high bytes become "·" so the rope never carries NULs and the panel stays
43
+ * legible. Newlines/tabs are preserved so line structure survives. */
44
+ export function sanitizeForDisplay(bytes: Uint8Array): string {
45
+ let out = "";
46
+ for (let i = 0; i < bytes.length; i++) {
47
+ const b = bytes[i]!;
48
+ if (b === 0x09 || b === 0x0a || b === 0x0d || (b >= 0x20 && b <= 0x7e)) {
49
+ out += String.fromCharCode(b);
50
+ } else {
51
+ out += "·";
52
+ }
53
+ }
54
+ return out;
55
+ }
56
+
57
+ /** Total width of the line-number gutter, including its trailing space. */
58
+ export function gutterWidth(totalLines: number): number {
59
+ return Math.max(3, String(Math.max(1, totalLines)).length) + 1;
60
+ }
61
+
62
+ /** Right-aligned line number filling `width` (last column is the separator). */
63
+ export function formatGutter(lineNumber: number, width: number): string {
64
+ return String(lineNumber).padStart(width - 1, " ") + " ";
65
+ }
66
+
67
+ /** Clamp the top (first-visible) line so the viewport never scrolls past the
68
+ * last screenful. `rows` is the number of text rows on screen. */
69
+ export function clampTop(top: number, totalLines: number, rows: number): number {
70
+ const max = Math.max(0, totalLines - rows);
71
+ if (top < 0) return 0;
72
+ if (top > max) return max;
73
+ return top;
74
+ }
75
+
76
+ /** The [start,end) line range visible for a given scroll position. */
77
+ export function visibleRange(
78
+ totalLines: number,
79
+ top: number,
80
+ rows: number,
81
+ ): { start: number; end: number } {
82
+ const start = clampTop(top, totalLines, rows);
83
+ return { start, end: Math.min(totalLines, start + rows) };
84
+ }
85
+
86
+ /** Ensure the cursor line sits inside [top, top+rows); returns the new top. */
87
+ export function scrollToCursor(
88
+ cursorLine: number,
89
+ top: number,
90
+ rows: number,
91
+ totalLines: number,
92
+ ): number {
93
+ let next = top;
94
+ if (cursorLine < top) next = cursorLine;
95
+ else if (cursorLine >= top + rows) next = cursorLine - rows + 1;
96
+ return clampTop(next, totalLines, rows);
97
+ }
98
+
99
+ /**
100
+ * Map a click at content-relative coordinates to a buffer (line, col).
101
+ * `contentY` is rows below the editor header; `cx` is columns right of the
102
+ * sidebar (gutter included). Both results are clamped to the buffer.
103
+ */
104
+ export function clickToCursor(params: {
105
+ cx: number;
106
+ contentY: number;
107
+ gutterW: number;
108
+ top: number;
109
+ lines: string[];
110
+ }): { line: number; col: number } {
111
+ const { cx, contentY, gutterW, top, lines } = params;
112
+ const total = Math.max(1, lines.length);
113
+ const line = Math.max(0, Math.min(total - 1, top + contentY));
114
+ const lineText = lines[line] ?? "";
115
+ const col = Math.max(0, Math.min(lineText.length, cx - gutterW));
116
+ return { line, col };
117
+ }