tmux-ide 2.6.1 → 2.7.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 (83) hide show
  1. package/README.md +14 -9
  2. package/bin/cli.js +1013 -516
  3. package/bin/cli.ts +62 -5
  4. package/package.json +9 -6
  5. package/packages/daemon/dist/agent-explain.d.ts +8 -1
  6. package/packages/daemon/dist/agent-explain.js +19 -3
  7. package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
  8. package/packages/daemon/dist/lib/tui-binary.js +130 -0
  9. package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
  10. package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
  11. package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
  12. package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
  13. package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
  14. package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
  15. package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
  16. package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
  17. package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
  18. package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
  19. package/packages/daemon/src/agent-explain.ts +34 -6
  20. package/packages/daemon/src/lib/app-config.ts +93 -2
  21. package/packages/daemon/src/lib/tui-binary.ts +165 -0
  22. package/packages/daemon/src/tui/chrome/notify.ts +221 -34
  23. package/packages/daemon/src/tui/chrome/updater.ts +89 -7
  24. package/packages/daemon/src/tui/compiled.ts +11 -3
  25. package/packages/daemon/src/tui/detect/classify.ts +15 -0
  26. package/packages/daemon/src/tui/detect/manifest-loader.ts +2 -1
  27. package/packages/daemon/src/tui/detect/manifest.ts +15 -0
  28. package/packages/daemon/src/tui/detect/manifests.ts +161 -33
  29. package/packages/daemon/src/tui/detect/process-tree.ts +20 -0
  30. package/packages/daemon/src/tui/main.ts +13 -1
  31. package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
  32. package/packages/daemon/src/tui/mirror/agent-chip.ts +97 -0
  33. package/packages/daemon/src/tui/mirror/agent-rows.ts +133 -0
  34. package/packages/daemon/src/tui/mirror/app-state.ts +179 -0
  35. package/packages/daemon/src/tui/mirror/app.tsx +5265 -0
  36. package/packages/daemon/src/tui/mirror/blit.ts +186 -0
  37. package/packages/daemon/src/tui/mirror/control-client.ts +80 -9
  38. package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
  39. package/packages/daemon/src/tui/mirror/dialog-stack.ts +354 -0
  40. package/packages/daemon/src/tui/mirror/diff-model.ts +112 -0
  41. package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
  42. package/packages/daemon/src/tui/mirror/file-tree.ts +97 -0
  43. package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
  44. package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
  45. package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
  46. package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
  47. package/packages/daemon/src/tui/mirror/menu-model.ts +187 -0
  48. package/packages/daemon/src/tui/mirror/palette.ts +274 -0
  49. package/packages/daemon/src/tui/mirror/pane-mirror.ts +499 -19
  50. package/packages/daemon/src/tui/mirror/pane-surface.tsx +415 -0
  51. package/packages/daemon/src/tui/mirror/perf-tap.ts +160 -0
  52. package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
  53. package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
  54. package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
  55. package/packages/daemon/src/tui/mirror/selection.ts +262 -0
  56. package/packages/daemon/src/tui/mirror/session-mirror.ts +443 -0
  57. package/packages/daemon/src/tui/mirror/settings-model.ts +345 -0
  58. package/packages/daemon/src/tui/mirror/size-truth.ts +77 -0
  59. package/packages/daemon/src/tui/mirror/spans.ts +46 -0
  60. package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
  61. package/packages/daemon/src/tui/team/entry.ts +34 -7
  62. package/packages/daemon/src/tui/team/report.ts +11 -1
  63. package/packages/daemon/src/tui/team/sessions.ts +102 -13
  64. package/scripts/build-tui.mjs +11 -4
  65. package/scripts/perf-mirror.mjs +313 -0
  66. package/skill/SKILL.md +22 -0
  67. package/templates/AGENTS.md +14 -7
  68. package/templates/agent-team-monorepo.yml +8 -0
  69. package/templates/agent-team-nextjs.yml +8 -0
  70. package/templates/agent-team.yml +10 -0
  71. package/templates/convex.yml +2 -0
  72. package/templates/default.yml +11 -5
  73. package/templates/go.yml +4 -0
  74. package/templates/missions.yml +6 -0
  75. package/templates/nextjs.yml +4 -0
  76. package/templates/python.yml +4 -0
  77. package/templates/skills/backend.md +5 -12
  78. package/templates/skills/frontend.md +5 -12
  79. package/templates/skills/general-worker.md +5 -12
  80. package/templates/skills/researcher.md +7 -12
  81. package/templates/skills/reviewer.md +7 -16
  82. package/templates/vite.yml +4 -0
  83. package/packages/daemon/src/tui/mirror/viewer.tsx +0 -166
@@ -0,0 +1,354 @@
1
+ /**
2
+ * The global dialog STACK (M22.4) — one overlay mount in app.tsx renders
3
+ * whatever is on top; everything else is state here, framework-free so it
4
+ * unit-tests without OpenTUI.
5
+ *
6
+ * The API is Promise-based one-shots, so multi-step flows read as sequential
7
+ * awaits:
8
+ *
9
+ * const choice = await DialogSelect.show({ title, items });
10
+ * if (choice && (await DialogConfirm.show({ title: "Sure?" }))) { … }
11
+ *
12
+ * `push` may be called while another dialog is open — the new one stacks on
13
+ * top and only IT renders/receives input; resolving it reveals the one below.
14
+ * Escape (and a click outside the box) pops ONE level, resolving that dialog
15
+ * with its cancel value (`null`, or `false` for confirms). `replace` swaps the
16
+ * top in place; `clear` cancels the whole stack (quit paths).
17
+ *
18
+ * INPUT while a dialog is open is fully owned here: app.tsx feeds every key to
19
+ * {@link dialogKey} and every pointer event to its dialog route block FIRST, so
20
+ * nothing leaks to panes/editor underneath. Selection changes — keyboard and
21
+ * mouse alike — funnel through one place, which is where a select's `onMove`
22
+ * live-preview hook fires (never on open) and where an armed destructive row
23
+ * disarms.
24
+ *
25
+ * Solid bridge: the stack is not reactive; app.tsx subscribes and bumps a
26
+ * `dialogRev` signal on every notification (the `editorRev` idiom).
27
+ */
28
+ import {
29
+ filterDialogItems,
30
+ initialSelIndex,
31
+ followTop,
32
+ clampDialogTop,
33
+ DIALOG_ROWS,
34
+ type DialogConfirmSpec,
35
+ type DialogPromptSpec,
36
+ type DialogSelectItem,
37
+ type DialogSelectResult,
38
+ type DialogSelectSpec,
39
+ type DialogSpec,
40
+ } from "./dialog-model.ts";
41
+
42
+ /** Mutable interaction state of one open dialog. */
43
+ export interface DialogEntryState {
44
+ /** select: the filter query. */
45
+ query: string;
46
+ /** select/confirm: the selected row (select: index into the FILTERED list). */
47
+ sel: number;
48
+ /** select: the scroll window top. */
49
+ top: number;
50
+ /** select: the armed destructive row (filtered index), disarmed on any move. */
51
+ armed: number | null;
52
+ /** prompt: the typed value. */
53
+ input: string;
54
+ /** prompt: the current validation error. */
55
+ error: string | null;
56
+ /** prompt: a busy flag the caller may set while persisting. */
57
+ busy: boolean;
58
+ }
59
+
60
+ /** One stack entry: the spec, its interaction state, and the pending resolver. */
61
+ export interface DialogEntry {
62
+ spec: DialogSpec;
63
+ state: DialogEntryState;
64
+ resolve: (result: unknown) => void;
65
+ }
66
+
67
+ function freshState(spec: DialogSpec): DialogEntryState {
68
+ return {
69
+ query: "",
70
+ sel:
71
+ spec.kind === "select"
72
+ ? initialSelIndex(spec)
73
+ : spec.kind === "confirm" && spec.defaultNo
74
+ ? 1
75
+ : 0,
76
+ top: 0,
77
+ armed: null,
78
+ input: spec.kind === "prompt" ? (spec.initial ?? "") : "",
79
+ error: null,
80
+ busy: false,
81
+ };
82
+ }
83
+
84
+ /** The cancel value a dismissed dialog resolves with. */
85
+ function cancelValue(spec: DialogSpec): unknown {
86
+ return spec.kind === "confirm" ? false : null;
87
+ }
88
+
89
+ export interface DialogStack {
90
+ /** The rendered/driven entry — the top of the stack, or null. */
91
+ top(): DialogEntry | null;
92
+ depth(): number;
93
+ /** Re-render notifications (app.tsx bumps a signal). Returns unsubscribe. */
94
+ subscribe(fn: () => void): () => void;
95
+ /** Open a dialog ON TOP; resolves when it closes. */
96
+ push(spec: DialogSpec): Promise<unknown>;
97
+ /** Swap the top dialog in place (its promise resolves with the cancel value). */
98
+ replace(spec: DialogSpec): Promise<unknown>;
99
+ /** Resolve + remove the top entry. */
100
+ pop(result: unknown): void;
101
+ /** Pop ONE level with its cancel value — what Escape and a click outside do. */
102
+ dismiss(): void;
103
+ /** Cancel every open dialog (bottom-up resolves with cancel values). */
104
+ clear(): void;
105
+ /** The top select's rows for its current query. */
106
+ filtered(): DialogSelectItem[];
107
+ /** Move the selection by delta (select/confirm) — fires onMove, follows scroll. */
108
+ moveSel(delta: number): void;
109
+ /** Set the selection to a FILTERED index (mouse motion) — fires onMove. */
110
+ setSel(index: number): void;
111
+ /** Wheel: shift the select window top by delta rows. */
112
+ scrollBy(delta: number): void;
113
+ /** Activate a filtered row (enter / click): arms danger rows first. */
114
+ activate(index: number): void;
115
+ /** Confirm: resolve option 0 (true) / 1 (false). */
116
+ choose(option: number): void;
117
+ /** Prompt: mark busy (footer shows "saving…", input ignores keys). */
118
+ setBusy(busy: boolean): void;
119
+ /** Notify subscribers after a direct state mutation (query/input edits). */
120
+ touch(): void;
121
+ }
122
+
123
+ export function createDialogStack(): DialogStack {
124
+ const stack: DialogEntry[] = [];
125
+ const listeners = new Set<() => void>();
126
+ const notify = () => {
127
+ for (const fn of listeners) fn();
128
+ };
129
+
130
+ const top = () => stack[stack.length - 1] ?? null;
131
+
132
+ const filtered = (): DialogSelectItem[] => {
133
+ const e = top();
134
+ if (!e || e.spec.kind !== "select") return [];
135
+ return filterDialogItems(e.state.query, e.spec.items);
136
+ };
137
+
138
+ /** Central selection change — the ONLY place `sel` moves, so onMove and the
139
+ * danger disarm can't be bypassed by one of the two input paths. */
140
+ const applySel = (next: number) => {
141
+ const e = top();
142
+ if (!e) return;
143
+ if (e.spec.kind === "confirm") {
144
+ e.state.sel = Math.max(0, Math.min(1, next));
145
+ notify();
146
+ return;
147
+ }
148
+ if (e.spec.kind !== "select") return;
149
+ const rows = filtered();
150
+ if (rows.length === 0) return;
151
+ const clamped = Math.max(0, Math.min(rows.length - 1, next));
152
+ if (clamped === e.state.sel) return;
153
+ e.state.sel = clamped;
154
+ e.state.armed = null;
155
+ e.state.top = followTop(clamped, e.state.top, DIALOG_ROWS);
156
+ e.spec.onMove?.(rows[clamped]!);
157
+ notify();
158
+ };
159
+
160
+ const pop = (result: unknown) => {
161
+ const e = stack.pop();
162
+ if (!e) return;
163
+ e.resolve(result);
164
+ notify();
165
+ };
166
+
167
+ return {
168
+ top,
169
+ depth: () => stack.length,
170
+ subscribe(fn) {
171
+ listeners.add(fn);
172
+ return () => listeners.delete(fn);
173
+ },
174
+ push(spec) {
175
+ return new Promise<unknown>((resolve) => {
176
+ stack.push({ spec, state: freshState(spec), resolve });
177
+ notify();
178
+ });
179
+ },
180
+ replace(spec) {
181
+ const e = stack.pop();
182
+ if (e) e.resolve(cancelValue(e.spec));
183
+ return new Promise<unknown>((resolve) => {
184
+ stack.push({ spec, state: freshState(spec), resolve });
185
+ notify();
186
+ });
187
+ },
188
+ pop,
189
+ dismiss() {
190
+ const e = top();
191
+ if (e) pop(cancelValue(e.spec));
192
+ },
193
+ clear() {
194
+ while (stack.length > 0) {
195
+ const e = stack.pop()!;
196
+ e.resolve(cancelValue(e.spec));
197
+ }
198
+ notify();
199
+ },
200
+ filtered,
201
+ moveSel(delta) {
202
+ const e = top();
203
+ if (!e) return;
204
+ applySel(e.state.sel + delta);
205
+ },
206
+ setSel(index) {
207
+ applySel(index);
208
+ },
209
+ scrollBy(delta) {
210
+ const e = top();
211
+ if (!e || e.spec.kind !== "select") return;
212
+ e.state.top = clampDialogTop(e.state.top + delta, filtered().length, DIALOG_ROWS);
213
+ notify();
214
+ },
215
+ activate(index) {
216
+ const e = top();
217
+ if (!e || e.spec.kind !== "select") return;
218
+ const rows = filtered();
219
+ const item = rows[index];
220
+ if (!item) return;
221
+ if (index !== e.state.sel) applySel(index);
222
+ if (item.danger && e.state.armed !== index) {
223
+ // Inline destructive confirm — the row itself asks again, no modal.
224
+ e.state.armed = index;
225
+ notify();
226
+ return;
227
+ }
228
+ pop({ item } satisfies DialogSelectResult);
229
+ },
230
+ choose(option) {
231
+ const e = top();
232
+ if (!e || e.spec.kind !== "confirm") return;
233
+ pop(option === 0);
234
+ },
235
+ setBusy(busy) {
236
+ const e = top();
237
+ if (!e || e.spec.kind !== "prompt") return;
238
+ e.state.busy = busy;
239
+ notify();
240
+ },
241
+ touch: notify,
242
+ };
243
+ }
244
+
245
+ /** THE stack — one per app process; the overlay mount and the routers all
246
+ * address this instance. */
247
+ export const dialogStack = createDialogStack();
248
+
249
+ // ── One-shot façades (the exploration's API shape) ───────────────────────────
250
+
251
+ export const DialogSelect = {
252
+ /** Open a select; resolves the chosen row (+ action key) or null on cancel. */
253
+ show(spec: Omit<DialogSelectSpec, "kind">, stack: DialogStack = dialogStack) {
254
+ return stack.push({ kind: "select", ...spec }) as Promise<DialogSelectResult | null>;
255
+ },
256
+ };
257
+
258
+ export const DialogPrompt = {
259
+ /** Open a prompt; resolves the (validated) text or null on cancel. */
260
+ show(spec: Omit<DialogPromptSpec, "kind">, stack: DialogStack = dialogStack) {
261
+ return stack.push({ kind: "prompt", ...spec }) as Promise<string | null>;
262
+ },
263
+ };
264
+
265
+ export const DialogConfirm = {
266
+ /** Open a two-option confirm; resolves true only on the affirmative. */
267
+ show(spec: Omit<DialogConfirmSpec, "kind">, stack: DialogStack = dialogStack) {
268
+ return stack.push({ kind: "confirm", ...spec }) as Promise<boolean>;
269
+ },
270
+ };
271
+
272
+ // ── The keyboard reducer ─────────────────────────────────────────────────────
273
+
274
+ /** The key-event shape app.tsx feeds (matches its useKeyboard events). */
275
+ export interface DialogKeyEvent {
276
+ name: string;
277
+ ctrl: boolean;
278
+ meta: boolean;
279
+ shift: boolean;
280
+ }
281
+
282
+ /**
283
+ * Feed one key to the stack's top dialog. Centralized ESCAPE lives here: it
284
+ * pops exactly one level. The caller must treat EVERY key as consumed while
285
+ * `stack.depth() > 0` (input suppression — nothing forwards to panes).
286
+ */
287
+ export function dialogKey(stack: DialogStack, evt: DialogKeyEvent): void {
288
+ const e = stack.top();
289
+ if (!e) return;
290
+ const { spec, state } = e;
291
+ if (evt.name === "escape") {
292
+ stack.dismiss();
293
+ return;
294
+ }
295
+ if (spec.kind === "select") {
296
+ if (evt.name === "up") return stack.moveSel(-1);
297
+ if (evt.name === "down") return stack.moveSel(1);
298
+ if (evt.name === "return") return stack.activate(state.sel);
299
+ // Per-row actions ride ctrl+<key> so they never collide with filter typing.
300
+ if (evt.ctrl && evt.name.length === 1) {
301
+ const action = (spec.actions ?? []).find((a) => a.key === evt.name);
302
+ const item = stack.filtered()[state.sel];
303
+ if (action && item) {
304
+ stack.pop({ item, action: action.key } satisfies DialogSelectResult);
305
+ }
306
+ return;
307
+ }
308
+ if (spec.filterable === false) return;
309
+ if (evt.name === "backspace") {
310
+ state.query = state.query.slice(0, -1);
311
+ state.sel = 0;
312
+ state.top = 0;
313
+ state.armed = null;
314
+ return stack.touch();
315
+ }
316
+ if (evt.name.length === 1 && !evt.ctrl && !evt.meta) {
317
+ state.query += evt.shift ? evt.name.toUpperCase() : evt.name;
318
+ state.sel = 0;
319
+ state.top = 0;
320
+ state.armed = null;
321
+ return stack.touch();
322
+ }
323
+ return;
324
+ }
325
+ if (spec.kind === "prompt") {
326
+ if (state.busy) return; // a persisting prompt ignores edits
327
+ if (evt.name === "return") {
328
+ const err = spec.validate?.(state.input) ?? null;
329
+ if (err) {
330
+ state.error = err;
331
+ return stack.touch();
332
+ }
333
+ stack.pop(state.input);
334
+ return;
335
+ }
336
+ if (evt.name === "backspace") {
337
+ state.input = state.input.slice(0, -1);
338
+ state.error = null;
339
+ return stack.touch();
340
+ }
341
+ if (evt.name.length === 1 && !evt.ctrl && !evt.meta) {
342
+ state.input += evt.shift ? evt.name.toUpperCase() : evt.name;
343
+ state.error = null;
344
+ return stack.touch();
345
+ }
346
+ return;
347
+ }
348
+ // confirm
349
+ if (evt.name === "up" || evt.name === "left") return stack.setSel(0);
350
+ if (evt.name === "down" || evt.name === "right") return stack.setSel(1);
351
+ if (evt.name === "return") return stack.choose(state.sel);
352
+ if (evt.name === "y") return stack.choose(0);
353
+ if (evt.name === "n") return stack.choose(1);
354
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Pure, io-free helpers for the built-in git diff panel (M18.3).
3
+ *
4
+ * Like {@link ./editor-buffer.ts}, this imports NOTHING from @opentui/core: the
5
+ * app runs the git subprocesses (async execFile) and reads untracked files, but
6
+ * the PARSING and coordinate math live here so they unit-test as tables under
7
+ * the Node/vitest runner while app.tsx keeps the OpenTUI wiring.
8
+ *
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
12
+ * diff prints nothing for untracked paths).
13
+ */
14
+
15
+ /** One changed file from `git status --porcelain`, display-ready. */
16
+ export interface StatusEntry {
17
+ /** Single status letter for the list: M(odified) · A(dded) · D(eleted) ·
18
+ * R(enamed) · ?(untracked) · etc. Worktree state wins over index state so a
19
+ * file staged-then-edited still reads as M. */
20
+ status: string;
21
+ /** Repo-relative path (the NEW path for renames). */
22
+ path: string;
23
+ /** True when the index carries a change (a staged component exists). */
24
+ staged: boolean;
25
+ }
26
+
27
+ /**
28
+ * PURE — parse porcelain v1 output. Each line is `XY<space>PATH`, where X is the
29
+ * index state and Y the worktree state; renames/copies render as `orig -> new`.
30
+ * Untracked files come through as `??`. Malformed/short lines are skipped.
31
+ */
32
+ export function parseStatusPorcelain(out: string): StatusEntry[] {
33
+ const entries: StatusEntry[] = [];
34
+ for (const line of out.split("\n")) {
35
+ if (line.length < 4) continue;
36
+ const index = line[0]!;
37
+ const work = line[1]!;
38
+ let rest = line.slice(3);
39
+ const arrow = rest.indexOf(" -> ");
40
+ if (arrow !== -1) rest = rest.slice(arrow + 4);
41
+ const untracked = index === "?" && work === "?";
42
+ const status = untracked ? "?" : work !== " " ? work : index;
43
+ entries.push({ status, path: rest, staged: !untracked && index !== " " });
44
+ }
45
+ return entries;
46
+ }
47
+
48
+ /** How a unified-diff line renders: added / removed / hunk header / file-meta
49
+ * (diff/index/+++/---/mode/rename) / plain context. */
50
+ export type DiffLineKind = "add" | "del" | "hunk" | "meta" | "context";
51
+
52
+ /** One classified diff line: its kind and its raw text (leading sigil kept). */
53
+ export interface DiffLine {
54
+ kind: DiffLineKind;
55
+ text: string;
56
+ }
57
+
58
+ const META_PREFIXES = [
59
+ "diff ",
60
+ "index ",
61
+ "new file",
62
+ "deleted file",
63
+ "old mode",
64
+ "new mode",
65
+ "similarity ",
66
+ "dissimilarity ",
67
+ "rename ",
68
+ "copy ",
69
+ "Binary ",
70
+ "\\ No newline",
71
+ ];
72
+
73
+ /**
74
+ * PURE — classify one raw diff line. Order matters: the `+++`/`---` file headers
75
+ * must be caught as meta BEFORE the bare `+`/`-` add/del checks.
76
+ */
77
+ export function classifyDiffLine(line: string): DiffLineKind {
78
+ if (line.startsWith("@@")) return "hunk";
79
+ if (line.startsWith("+++") || line.startsWith("---")) return "meta";
80
+ for (const p of META_PREFIXES) if (line.startsWith(p)) return "meta";
81
+ if (line.startsWith("+")) return "add";
82
+ if (line.startsWith("-")) return "del";
83
+ return "context";
84
+ }
85
+
86
+ /**
87
+ * PURE — split a `git diff --no-color` blob into classified lines, dropping the
88
+ * single trailing empty produced by the final newline.
89
+ */
90
+ export function classifyDiff(diff: string): DiffLine[] {
91
+ if (!diff) return [];
92
+ const lines = diff.split("\n");
93
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
94
+ return lines.map((text) => ({ kind: classifyDiffLine(text), text }));
95
+ }
96
+
97
+ /**
98
+ * PURE — render an untracked file's contents as an all-additions pseudo-diff
99
+ * (git diff emits nothing for untracked paths). Each content line is prefixed
100
+ * with `+` so {@link classifyDiff} colors it as an addition.
101
+ */
102
+ export function untrackedDiffText(content: string): string {
103
+ const lines = content.split("\n");
104
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
105
+ return lines.map((l) => "+" + l).join("\n");
106
+ }
107
+
108
+ /** PURE — clamp a selection index into `[0, count)` (0 when the list is empty). */
109
+ export function clampSel(sel: number, count: number): number {
110
+ if (count <= 0) return 0;
111
+ return Math.max(0, Math.min(sel, count - 1));
112
+ }
@@ -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
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Pure model for the Files tab's one-level-expandable file list (M18.4). Like
3
+ * {@link ./diff-model.ts}, the io (async `fs.readdir`) stays in app.tsx; the
4
+ * ORDERING and the flat-tree splice/prune math live here so they unit-test as
5
+ * tables.
6
+ *
7
+ * The list is a FLAT array of {@link FileNode}s carrying a `depth`; a directory
8
+ * "expands" by splicing its freshly-read children in right after it and
9
+ * "collapses" by removing the contiguous run of deeper rows. Dirs sort before
10
+ * files, each group alphabetical (case-insensitive), so the tree reads the way
11
+ * a file manager does.
12
+ */
13
+
14
+ /** One row in the flat file list. `path` is absolute; `depth` is the indent
15
+ * level (0 = the context dir's immediate children). */
16
+ export interface FileNode {
17
+ name: string;
18
+ path: string;
19
+ isDir: boolean;
20
+ depth: number;
21
+ expanded: boolean;
22
+ }
23
+
24
+ /** A raw `fs.readdir(..., { withFileTypes: true })` entry, reduced to what we
25
+ * need (kept minimal so callers can map Dirent → this trivially). */
26
+ export interface RawEntry {
27
+ name: string;
28
+ isDir: boolean;
29
+ }
30
+
31
+ /**
32
+ * PURE — sort raw entries into display order: directories first, then files,
33
+ * each group alphabetical case-insensitively (ties broken by raw name for
34
+ * determinism). Dotfiles are kept (a hidden `.env` still matters to editing).
35
+ */
36
+ export function sortEntries(entries: RawEntry[]): RawEntry[] {
37
+ return [...entries].sort((a, b) => {
38
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
39
+ const an = a.name.toLowerCase();
40
+ const bn = b.name.toLowerCase();
41
+ if (an < bn) return -1;
42
+ if (an > bn) return 1;
43
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
44
+ });
45
+ }
46
+
47
+ /**
48
+ * PURE — build the flat node list for a directory's `entries` at `depth`,
49
+ * joining each name onto `dir` with a "/" (absolute-path aware; no node:path
50
+ * dependency so this stays trivially testable).
51
+ */
52
+ export function buildNodes(dir: string, entries: RawEntry[], depth: number): FileNode[] {
53
+ const base = dir.endsWith("/") ? dir.slice(0, -1) : dir;
54
+ return sortEntries(entries).map((e) => ({
55
+ name: e.name,
56
+ path: `${base}/${e.name}`,
57
+ isDir: e.isDir,
58
+ depth,
59
+ expanded: false,
60
+ }));
61
+ }
62
+
63
+ /**
64
+ * PURE — expand the directory at `index` by marking it expanded and splicing
65
+ * its `children` (already built at the parent's depth+1) in right after it.
66
+ * A no-op (returns the list unchanged) when the row is missing, is not a
67
+ * directory, or is already expanded.
68
+ */
69
+ export function insertChildrenAt(
70
+ list: FileNode[],
71
+ index: number,
72
+ children: FileNode[],
73
+ ): FileNode[] {
74
+ const parent = list[index];
75
+ if (!parent || !parent.isDir || parent.expanded) return list;
76
+ const next = [...list];
77
+ next[index] = { ...parent, expanded: true };
78
+ next.splice(index + 1, 0, ...children);
79
+ return next;
80
+ }
81
+
82
+ /**
83
+ * PURE — collapse the directory at `index` by marking it un-expanded and
84
+ * removing the contiguous run of rows deeper than it (its whole subtree,
85
+ * however many levels were expanded). A no-op when the row is missing, is not a
86
+ * directory, or is not expanded.
87
+ */
88
+ export function removeSubtreeAt(list: FileNode[], index: number): FileNode[] {
89
+ const parent = list[index];
90
+ if (!parent || !parent.isDir || !parent.expanded) return list;
91
+ let end = index + 1;
92
+ while (end < list.length && list[end]!.depth > parent.depth) end++;
93
+ const next = [...list];
94
+ next.splice(index + 1, end - (index + 1));
95
+ next[index] = { ...parent, expanded: false };
96
+ return next;
97
+ }