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
@@ -1,8 +1,10 @@
1
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.
2
+ * Pure model for the Files tab's expandable file list (M18.4, uplifted M24.6).
3
+ * Like {@link ./diff-model.ts}, the io (async `fs.readdir`, git subprocesses,
4
+ * the `ignore` matcher fed from .gitignore) stays in app.tsx; the ORDERING, the
5
+ * flat-tree splice/prune math, the ignore/hidden FILTERING, the git-status
6
+ * DECORATION merge, the changed-file WALK for `[`/`]`, the `/` FILTER view and
7
+ * the expansion-preserving REBUILD all live here so they unit-test as tables.
6
8
  *
7
9
  * The list is a FLAT array of {@link FileNode}s carrying a `depth`; a directory
8
10
  * "expands" by splicing its freshly-read children in right after it and
@@ -11,21 +13,69 @@
11
13
  * a file manager does.
12
14
  */
13
15
 
16
+ /** Names that are NEVER listed, whatever the toggles say (the explorer
17
+ * widget's battle-tested list — build artifacts and VCS internals that would
18
+ * only bury the code). */
19
+ export const ALWAYS_IGNORE: ReadonlySet<string> = new Set([
20
+ "node_modules",
21
+ ".git",
22
+ ".svn",
23
+ ".hg",
24
+ "dist",
25
+ "build",
26
+ "out",
27
+ ".next",
28
+ ".turbo",
29
+ ".cache",
30
+ "__pycache__",
31
+ "coverage",
32
+ ".nyc_output",
33
+ "target",
34
+ "vendor",
35
+ "bower_components",
36
+ ]);
37
+
14
38
  /** One row in the flat file list. `path` is absolute; `depth` is the indent
15
- * level (0 = the context dir's immediate children). */
39
+ * level (0 = the context dir's immediate children). `ignored` marks a
40
+ * gitignored entry — visible only when the I toggle shows them, dimmed. */
16
41
  export interface FileNode {
17
42
  name: string;
18
43
  path: string;
19
44
  isDir: boolean;
20
45
  depth: number;
21
46
  expanded: boolean;
47
+ ignored: boolean;
22
48
  }
23
49
 
24
50
  /** A raw `fs.readdir(..., { withFileTypes: true })` entry, reduced to what we
25
- * need (kept minimal so callers can map Dirent → this trivially). */
51
+ * need (kept minimal so callers can map Dirent → this trivially). `ignored`
52
+ * is stamped by the caller's gitignore matcher (io stays outside). */
26
53
  export interface RawEntry {
27
54
  name: string;
28
55
  isDir: boolean;
56
+ ignored?: boolean;
57
+ }
58
+
59
+ /** The two Files-surface visibility toggles (both default OFF = filtered). */
60
+ export interface ListFilter {
61
+ /** Show dotfiles (H). */
62
+ showHidden: boolean;
63
+ /** Show gitignored entries (I) — they render dimmed. */
64
+ showIgnored: boolean;
65
+ }
66
+
67
+ /**
68
+ * PURE — drop the entries the current toggles hide: {@link ALWAYS_IGNORE}
69
+ * names always, dotfiles unless `showHidden`, gitignored entries (as stamped
70
+ * by the caller) unless `showIgnored`.
71
+ */
72
+ export function filterEntries(entries: readonly RawEntry[], filter: ListFilter): RawEntry[] {
73
+ return entries.filter((e) => {
74
+ if (ALWAYS_IGNORE.has(e.name)) return false;
75
+ if (!filter.showHidden && e.name.startsWith(".")) return false;
76
+ if (!filter.showIgnored && e.ignored) return false;
77
+ return true;
78
+ });
29
79
  }
30
80
 
31
81
  /**
@@ -57,6 +107,7 @@ export function buildNodes(dir: string, entries: RawEntry[], depth: number): Fil
57
107
  isDir: e.isDir,
58
108
  depth,
59
109
  expanded: false,
110
+ ignored: e.ignored ?? false,
60
111
  }));
61
112
  }
62
113
 
@@ -95,3 +146,177 @@ export function removeSubtreeAt(list: FileNode[], index: number): FileNode[] {
95
146
  next[index] = { ...parent, expanded: false };
96
147
  return next;
97
148
  }
149
+
150
+ // ── M24.6 — decoration, changed walk, filter view, rebuild ──────────────────
151
+
152
+ /** PURE — `abs` relative to `root` ("" when equal or not under root). Both are
153
+ * plain string paths; a trailing slash on root is tolerated. */
154
+ export function relPath(root: string, abs: string): string {
155
+ const base = root.endsWith("/") ? root.slice(0, -1) : root;
156
+ if (abs === base) return "";
157
+ return abs.startsWith(base + "/") ? abs.slice(base.length + 1) : "";
158
+ }
159
+
160
+ /** PURE — every ancestor DIRECTORY of a repo-relative path, outermost first:
161
+ * `a/b/c.ts` → `["a", "a/b"]`. A top-level path has none. */
162
+ export function ancestorDirs(rel: string): string[] {
163
+ const out: string[] = [];
164
+ let idx = rel.indexOf("/");
165
+ while (idx !== -1) {
166
+ out.push(rel.slice(0, idx));
167
+ idx = rel.indexOf("/", idx + 1);
168
+ }
169
+ return out;
170
+ }
171
+
172
+ /**
173
+ * PURE — the per-path git status map from parsed porcelain entries
174
+ * (repo-relative `path` + one-letter `status`), with the explorer widget's
175
+ * parent-dir propagation: every ancestor directory inherits the FIRST child
176
+ * status seen, so a collapsed dir still shows that something changed inside.
177
+ */
178
+ export function statusMapFromEntries(
179
+ entries: readonly { path: string; status: string }[],
180
+ ): Map<string, string> {
181
+ const map = new Map<string, string>();
182
+ for (const file of entries) {
183
+ map.set(file.path, file.status);
184
+ let parent = file.path;
185
+ while (parent.includes("/")) {
186
+ parent = parent.slice(0, parent.lastIndexOf("/"));
187
+ if (!map.has(parent)) map.set(parent, file.status);
188
+ }
189
+ }
190
+ return map;
191
+ }
192
+
193
+ /** PURE — compare two repo-relative paths in TREE DISPLAY order: walk the
194
+ * segments; where they diverge, a directory component (more segments follow)
195
+ * sorts before a terminal file segment — matching {@link sortEntries}'
196
+ * dirs-first, case-insensitive ordering. */
197
+ export function treePathCompare(a: string, b: string): number {
198
+ const as = a.split("/");
199
+ const bs = b.split("/");
200
+ const n = Math.min(as.length, bs.length);
201
+ for (let i = 0; i < n; i++) {
202
+ const aDir = i < as.length - 1;
203
+ const bDir = i < bs.length - 1;
204
+ const av = as[i]!;
205
+ const bv = bs[i]!;
206
+ if (av === bv && aDir && bDir) continue;
207
+ // At the divergence point a directory component sorts before a file
208
+ // segment, regardless of name (dirs-first display order).
209
+ if (aDir !== bDir) return aDir ? -1 : 1;
210
+ const al = av.toLowerCase();
211
+ const bl = bv.toLowerCase();
212
+ if (al !== bl) return al < bl ? -1 : 1;
213
+ if (av !== bv) return av < bv ? -1 : 1;
214
+ }
215
+ return as.length - bs.length;
216
+ }
217
+
218
+ /**
219
+ * PURE — the ordered changed-FILE walk for `[`/`]`: the porcelain entries'
220
+ * paths, deduped, minus anything the surface can never show (a DELETED file
221
+ * has no row at all, an {@link ALWAYS_IGNORE} segment or a dot segment while
222
+ * hidden files are off is filtered out — hopping to an unrevealable row would
223
+ * strand the selection and wedge the chain), sorted in tree display order.
224
+ */
225
+ export function changedFileWalk(
226
+ entries: readonly { path: string; status?: string }[],
227
+ opts: { showHidden: boolean },
228
+ ): string[] {
229
+ const seen = new Set<string>();
230
+ for (const e of entries) {
231
+ if (!e.path) continue;
232
+ if (e.status === "D") continue;
233
+ const segs = e.path.split("/");
234
+ if (segs.some((s) => ALWAYS_IGNORE.has(s))) continue;
235
+ if (!opts.showHidden && segs.some((s) => s.startsWith("."))) continue;
236
+ seen.add(e.path);
237
+ }
238
+ return [...seen].sort(treePathCompare);
239
+ }
240
+
241
+ /**
242
+ * PURE — the next/previous changed path from `current` (repo-relative, or null
243
+ * when the selection is nowhere useful), wrapping around. `current` need not
244
+ * be IN the walk — the step lands on the nearest entry in walk order, so a hop
245
+ * from an unchanged file between two changed ones does the right thing.
246
+ */
247
+ export function nextChangedPath(
248
+ walk: readonly string[],
249
+ current: string | null,
250
+ dir: 1 | -1,
251
+ ): string | null {
252
+ if (walk.length === 0) return null;
253
+ if (current === null) return dir === 1 ? walk[0]! : walk[walk.length - 1]!;
254
+ if (dir === 1) {
255
+ for (const p of walk) if (treePathCompare(p, current) > 0) return p;
256
+ return walk[0]!;
257
+ }
258
+ for (let i = walk.length - 1; i >= 0; i--) {
259
+ if (treePathCompare(walk[i]!, current) < 0) return walk[i]!;
260
+ }
261
+ return walk[walk.length - 1]!;
262
+ }
263
+
264
+ /** One visible row of the `/`-filtered tree: the node plus its index into the
265
+ * UNDERLYING flat list (so activation/expansion math still applies there). */
266
+ export interface FilteredRow {
267
+ node: FileNode;
268
+ index: number;
269
+ }
270
+
271
+ /**
272
+ * PURE — the `/` filter view over the flat list: rows whose NAME contains the
273
+ * query case-insensitively, each carrying its underlying index. A null or
274
+ * empty query is "filter off" — every row, in order. The list itself is never
275
+ * touched, so expanded state trivially survives the filter.
276
+ */
277
+ export function filterView(list: readonly FileNode[], query: string | null): FilteredRow[] {
278
+ if (!query) return list.map((node, index) => ({ node, index }));
279
+ const q = query.toLowerCase();
280
+ const out: FilteredRow[] = [];
281
+ for (let index = 0; index < list.length; index++) {
282
+ const node = list[index]!;
283
+ if (node.name.toLowerCase().includes(q)) out.push({ node, index });
284
+ }
285
+ return out;
286
+ }
287
+
288
+ /** PURE — the index of `path` in the flat list, or -1. */
289
+ export function indexOfPath(list: readonly FileNode[], path: string): number {
290
+ for (let i = 0; i < list.length; i++) if (list[i]!.path === path) return i;
291
+ return -1;
292
+ }
293
+
294
+ /**
295
+ * PURE — rebuild the whole flat tree from fresh directory listings while
296
+ * PRESERVING expansion: `listing` maps a directory's absolute path → its
297
+ * fresh (already filtered/annotated) entries, `expanded` is the set of dir
298
+ * paths that were expanded before the refresh. A dir stays expanded only when
299
+ * it survived the refresh AND its fresh listing was provided; a vanished or
300
+ * newly-hidden dir simply drops out. Keys must be the exact `FileNode.path`
301
+ * strings (and the root exactly as passed).
302
+ */
303
+ export function rebuildTree(
304
+ rootDir: string,
305
+ listing: ReadonlyMap<string, readonly RawEntry[]>,
306
+ expanded: ReadonlySet<string>,
307
+ ): FileNode[] {
308
+ const walk = (dir: string, depth: number): FileNode[] => {
309
+ const ents = listing.get(dir);
310
+ if (!ents) return [];
311
+ const out: FileNode[] = [];
312
+ for (const node of buildNodes(dir, [...ents], depth)) {
313
+ if (node.isDir && expanded.has(node.path) && listing.has(node.path)) {
314
+ out.push({ ...node, expanded: true }, ...walk(node.path, depth + 1));
315
+ } else {
316
+ out.push(node);
317
+ }
318
+ }
319
+ return out;
320
+ };
321
+ return walk(rootDir, 0);
322
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Host-terminal modes owned by the unified app.
3
+ *
4
+ * OpenTUI renders each changed row as ANSI runs. With DECAWM (host autowrap)
5
+ * enabled, a wide/right-edge run can physically wrap into column 1 of the next
6
+ * row while OpenTUI's shadow records the intended cells. That strands pane text
7
+ * over the left sidebar until an outer full repaint. The app never relies on
8
+ * host autowrap—it positions runs absolutely—so disable it for the app's
9
+ * lifetime and restore it exactly once on every exit path.
10
+ */
11
+
12
+ export const HOST_AUTOWRAP_DISABLE = "\x1b[?7l";
13
+ export const HOST_AUTOWRAP_ENABLE = "\x1b[?7h";
14
+
15
+ export interface HostTerminalExitLifecycle {
16
+ onExit(listener: () => void): void;
17
+ offExit(listener: () => void): void;
18
+ }
19
+
20
+ export interface HostAutowrapGuard {
21
+ restore(): void;
22
+ }
23
+
24
+ /**
25
+ * Disable host autowrap immediately and arm an exit fallback. `restore` is
26
+ * idempotent and removes the fallback before writing the enable sequence, so a
27
+ * normal renderer teardown followed by process exit cannot double-write it.
28
+ *
29
+ * The caller supplies a synchronous writer: process `exit` listeners cannot
30
+ * rely on queued/asynchronous stdout writes being flushed.
31
+ */
32
+ export function installHostAutowrapGuard(
33
+ write: (sequence: string) => void,
34
+ lifecycle: HostTerminalExitLifecycle,
35
+ ): HostAutowrapGuard {
36
+ let restored = false;
37
+
38
+ const restore = () => {
39
+ if (restored) return;
40
+ restored = true;
41
+ lifecycle.offExit(restore);
42
+ write(HOST_AUTOWRAP_ENABLE);
43
+ };
44
+
45
+ write(HOST_AUTOWRAP_DISABLE);
46
+ lifecycle.onExit(restore);
47
+
48
+ return { restore };
49
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * The detachable cockpit (M23.2) — tmux keeps the app itself alive.
3
+ *
4
+ * `tmux-ide app --detachable` (alias `--hosted`) doesn't run the app in the
5
+ * invoking terminal: it ensures an internal `_tmux-ide-app` session exists
6
+ * running the app full-screen, then attaches the terminal to it. ^q under
7
+ * hosting DETACHES the client (the app keeps running); re-invocation from any
8
+ * terminal — including a phone over ssh — reattaches the SAME cockpit with
9
+ * scroll positions, dialogs, and workspace context intact.
10
+ *
11
+ * These are the PURE pieces of that contract: the entry decision, the tmux
12
+ * argv builders, and the shell quoting for the host pane's command line. The
13
+ * io (spawning tmux, resolving bun vs the compiled binary) stays in the CLI;
14
+ * the app side only reads {@link HOSTED_ENV} to flip ^q from quit to detach.
15
+ */
16
+
17
+ /** The internal host session. `_`-prefixed so every fleet surface (team --json,
18
+ * sidebar, home) and the snapshot/restore path already filter it out. */
19
+ export const APP_HOST_SESSION = "_tmux-ide-app";
20
+
21
+ /** The env marker the launcher sets on the hosted app process. The app flips
22
+ * ^q from "quit" to "detach the client" when it sees `=1`; the CLI treats it
23
+ * as a recursion guard (a hosted app never re-hosts). */
24
+ export const HOSTED_ENV = "TMUX_IDE_HOSTED";
25
+
26
+ /** Everything the entry decision reads — flags, config, and the guard. */
27
+ export interface HostedEntryInput {
28
+ /** `--detachable` (the primary flag). */
29
+ flagDetachable: boolean;
30
+ /** `--hosted` (the alias). */
31
+ flagHosted: boolean;
32
+ /** `app.detachable` from the typed config — makes bare `tmux-ide app` (and
33
+ * the frontDoor entry) hosted without the flag. */
34
+ configDetachable: boolean;
35
+ /** Whether WE are already the hosted app ({@link HOSTED_ENV} set). */
36
+ hostedEnv: boolean;
37
+ }
38
+
39
+ /**
40
+ * PURE — should this `tmux-ide app` invocation run hosted? Flags and config
41
+ * both opt in; the env marker vetoes everything (the app inside the host
42
+ * session must launch plain, or it would try to attach to itself).
43
+ */
44
+ export function wantsHostedApp(input: HostedEntryInput): boolean {
45
+ if (input.hostedEnv) return false;
46
+ return input.flagDetachable || input.flagHosted || input.configDetachable;
47
+ }
48
+
49
+ /**
50
+ * PURE — POSIX single-quote a word for a tmux `new-session` shell command
51
+ * (tmux hands the string to `sh -c`). Single quotes pass everything literally;
52
+ * an embedded `'` closes, escapes, and reopens.
53
+ */
54
+ export function shellQuote(word: string): string {
55
+ return `'${word.replaceAll("'", `'\\''`)}'`;
56
+ }
57
+
58
+ /**
59
+ * PURE — the env vars the host pane's app process needs, assembled for the
60
+ * command line rather than tmux's session environment: the tmux server may
61
+ * have been started elsewhere with a different environment, so nothing can be
62
+ * assumed to inherit. PATH rides along for the same reason (the `bun` launch
63
+ * mode resolves the binary by name).
64
+ */
65
+ export function hostedEnvVars(base: {
66
+ /** The user's real invocation dir (in-app prompts default here). */
67
+ cwd: string;
68
+ /** The node-runnable CLI path (`TMUX_IDE_CLI`) for in-app subprocesses. */
69
+ cli: string;
70
+ /** The invoking shell's PATH. */
71
+ path?: string;
72
+ /** `TMUX_IDE_HOME` / `TMUX_IDE_CONFIG` / `TMUX_IDE_TUI_BIN` pass-throughs
73
+ * (set in test rigs; must reach the hosted app or it reads real state). */
74
+ home?: string;
75
+ config?: string;
76
+ tuiBin?: string;
77
+ }): Record<string, string> {
78
+ const env: Record<string, string> = {
79
+ [HOSTED_ENV]: "1",
80
+ TMUX_IDE_CWD: base.cwd,
81
+ TMUX_IDE_CLI: base.cli,
82
+ };
83
+ if (base.path) env.PATH = base.path;
84
+ if (base.home) env.TMUX_IDE_HOME = base.home;
85
+ if (base.config) env.TMUX_IDE_CONFIG = base.config;
86
+ if (base.tuiBin) env.TMUX_IDE_TUI_BIN = base.tuiBin;
87
+ return env;
88
+ }
89
+
90
+ /**
91
+ * PURE — the shell command the host pane runs: `exec env K=V… bin args…`,
92
+ * every value quoted. `exec` replaces the pane's shell with the app so a quit
93
+ * (the palette verb) ends the pane — and with it the single-window session.
94
+ */
95
+ export function hostedCommandLine(
96
+ bin: string,
97
+ argv: readonly string[],
98
+ env: Record<string, string>,
99
+ ): string {
100
+ const assigns = Object.entries(env).map(([k, v]) => `${k}=${shellQuote(v)}`);
101
+ return ["exec", "env", ...assigns, shellQuote(bin), ...argv.map(shellQuote)].join(" ");
102
+ }
103
+
104
+ /** PURE — exact-match existence probe (`=` prefix: `has-session -t` would
105
+ * otherwise PREFIX-match, e.g. a user session named `_tmux-ide-app-notes`). */
106
+ export function hostExistsArgv(): string[] {
107
+ return ["has-session", "-t", `=${APP_HOST_SESSION}`];
108
+ }
109
+
110
+ /** PURE — create the detached host session running the app command line. */
111
+ export function hostCreateArgv(opts: { cwd: string; commandLine: string }): string[] {
112
+ return ["new-session", "-d", "-s", APP_HOST_SESSION, "-c", opts.cwd, opts.commandLine];
113
+ }
114
+
115
+ /**
116
+ * The client events that re-assert `window-size latest` on the host (M25.5).
117
+ * Each hook's effect was MEASURED on tmux 3.7b in an isolated two-client rig
118
+ * (220x60 local + 120x40 ssh-sim):
119
+ *
120
+ * - `client-attached` / `client-focus-in` / `client-session-changed`: tmux
121
+ * 3.7b already re-adopts the event client's size natively on all of these
122
+ * (attach ~8ms, focus-in ~17ms, switch-client ~17ms — window-resized hook
123
+ * timestamps; detach of the latest client also re-adopts natively, ~13ms).
124
+ * The hooks are NOT what makes those paths work; they are the SELF-HEAL for
125
+ * the one measured way the host gets permanently stuck: any `resize-window`
126
+ * against it (a stray tool or user command) flips `window-size` to manual —
127
+ * after which NO client event re-adopts, ever (measured: a fresh 220x60
128
+ * attach left a manually-80x24 host at 80x24). Re-asserting the option is
129
+ * the heal (measured: instant re-adopt) — and it is safe to fire
130
+ * redundantly: setting `window-size latest` when it is already latest just
131
+ * recomputes the same size. Fire counts are bounded and linear (measured:
132
+ * 10 rapid focus alternations → exactly 20 focus-in fires; 10 attach cycles
133
+ * → 10 attached + 10 session-changed fires — attach fires both — each a
134
+ * single in-server set-option, no storm).
135
+ *
136
+ * NO `client-detached` hook: measured on 3.7b it never fires as a SESSION
137
+ * hook (the detaching client has already left the session when hooks are
138
+ * resolved — 0 fires across 10 detach cycles while the same rig's global
139
+ * hook logged every one), and the native detach re-adopt covers the path.
140
+ *
141
+ * Deliberately NOT `resize-window -a`: per the tmux manual, EVERY
142
+ * resize-window form — -a included — "will automatically set window-size to
143
+ * manual", i.e. it would cause the exact stuck state it is meant to fix. And
144
+ * no `run-shell`: these are tmux-native commands executed in-server
145
+ * (run-shell hooks serialize the server — prior measurement).
146
+ */
147
+ export const HOST_RESIZE_HOOKS = [
148
+ "client-attached",
149
+ "client-focus-in",
150
+ "client-session-changed",
151
+ ] as const;
152
+
153
+ /**
154
+ * PURE — the post-create/ensure session setup: status OFF so the app owns
155
+ * every row (under `status on` the host steals the bottom row and the app
156
+ * renders one short), and `window-size latest` pinned explicitly so the most
157
+ * recently active client dictates the size — a smaller second client
158
+ * letterboxes the larger one (tmux's own dot-fill), which is the documented
159
+ * behavior.
160
+ *
161
+ * M25.5 additions (each measured on 3.7b — see {@link HOST_RESIZE_HOOKS}):
162
+ *
163
+ * - `focus-events on` (server option — the ONE non-session-scoped line, and
164
+ * the load-bearing one): it defaults OFF, so real terminals are never asked
165
+ * for focus reporting and `client-focus-in` never fires. With it on, coming
166
+ * BACK to a terminal that stayed attached re-adopts that client's size on
167
+ * the focus event alone (measured: focus-in → client-active →
168
+ * window-resized in ~17ms) — no keystroke needed. This is the user's exact
169
+ * "reopen it on my computer locally" moment when the local client never
170
+ * detached. Side effect is the widely-recommended one (panes that request
171
+ * focus, e.g. editors, start receiving it).
172
+ * - the {@link HOST_RESIZE_HOOKS} self-heal hooks, session-scoped to the host
173
+ * (zero effect on user sessions).
174
+ *
175
+ * The whole list is idempotent — the CLI applies it on EVERY ensure, not just
176
+ * create, so upgrading tmux-ide fixes an already-running cockpit on its next
177
+ * `tmux-ide app` (and un-sticks a manually-resized host).
178
+ *
179
+ * No `=` exact-match prefix here: tmux (measured on 3.7b) rejects it on
180
+ * `set-option` session targets ("no such session") even though has-session
181
+ * and attach accept it. Plain names are safe in THIS builder only because
182
+ * setup runs right after the exists-probe/create — the exact session exists,
183
+ * and tmux prefers an exact match over a prefix match when one does.
184
+ */
185
+ export function hostSetupArgvs(): string[][] {
186
+ const heal = `set-option -w -t ${APP_HOST_SESSION}: window-size latest`;
187
+ return [
188
+ ["set-option", "-t", APP_HOST_SESSION, "status", "off"],
189
+ ["set-option", "-w", "-t", `${APP_HOST_SESSION}:`, "window-size", "latest"],
190
+ ["set-option", "-s", "focus-events", "on"],
191
+ ...HOST_RESIZE_HOOKS.map((hook) => ["set-hook", "-t", APP_HOST_SESSION, hook, heal]),
192
+ ];
193
+ }
194
+
195
+ /**
196
+ * PURE — how the invoking terminal reaches the cockpit: inside tmux the
197
+ * client is already attached to a server, so `switch-client` moves it (a
198
+ * nested `attach` would complain and double-render); a plain terminal
199
+ * attaches. Both exact-match the host name.
200
+ */
201
+ export function hostAttachArgv(insideTmux: boolean): string[] {
202
+ return insideTmux
203
+ ? ["switch-client", "-t", `=${APP_HOST_SESSION}`]
204
+ : ["attach-session", "-t", `=${APP_HOST_SESSION}`];
205
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * PURE — tmux layout-string and control-notification parsing (M23.5).
3
+ *
4
+ * `%layout-change` arrives sub-millisecond after the server applies a layout
5
+ * and ALWAYS precedes the first new-size `%output` (measured on tmux 3.7b:
6
+ * the follow-up output can trail by as little as 0.2ms). The mirror therefore
7
+ * derives pane geometry from the notification PAYLOAD itself instead of a
8
+ * debounced `list-panes` round-trip — these parsers are that push path.
9
+ *
10
+ * The layout grammar mirrors tmux's `layout_parse.c`: a 4-hex-digit checksum,
11
+ * a comma, then a cell. A cell is `WxH,X,Y` followed by either `,<paneId>`
12
+ * (a leaf; the numeric pane id sans `%`), `{…}` (horizontal split) or `[…]`
13
+ * (vertical split) with comma-separated child cells. The ROOT cell's WxH is
14
+ * the authoritative window size. Parse the VISIBLE layout — the THIRD field
15
+ * of `%layout-change @win <layout> <visible-layout> <flags>` — because zoom
16
+ * collapses it to the single zoomed pane (`*Z` in flags = zoomed); the second
17
+ * field keeps reporting the saved multi-pane layout.
18
+ *
19
+ * Everything here is unit-tested against layout strings captured from a real
20
+ * tmux 3.7b server (splits, zoom, storms) — no tmux at test time.
21
+ */
22
+
23
+ /** One visible pane rectangle, in window cells. `id` is `%`-prefixed. */
24
+ export interface LayoutLeaf {
25
+ id: string;
26
+ left: number;
27
+ top: number;
28
+ width: number;
29
+ height: number;
30
+ }
31
+
32
+ /** A parsed (visible) layout: the window size + the leaves in layout order. */
33
+ export interface ParsedLayout {
34
+ /** The root cell's WxH — the authoritative window size. */
35
+ width: number;
36
+ height: number;
37
+ leaves: LayoutLeaf[];
38
+ }
39
+
40
+ /** Parse a tmux layout string (`csum,WxH,X,Y…`). Null on any malformed input
41
+ * (the caller falls back to the slow list-panes path — never throw here). */
42
+ export function parseLayout(layout: string): ParsedLayout | null {
43
+ if (!/^[0-9a-fA-F]{4},/.test(layout)) return null;
44
+ const s = layout.slice(5);
45
+ const leaves: LayoutLeaf[] = [];
46
+ const root = parseCell(s, 0, leaves);
47
+ if (!root || root.pos !== s.length) return null;
48
+ return { width: root.width, height: root.height, leaves };
49
+ }
50
+
51
+ /** Recursive-descent cell parse from `pos`; appends leaves in layout order. */
52
+ function parseCell(
53
+ s: string,
54
+ pos: number,
55
+ leaves: LayoutLeaf[],
56
+ ): { width: number; height: number; pos: number } | null {
57
+ const dims = readDims(s, pos);
58
+ if (!dims) return null;
59
+ const { width, height, left, top } = dims;
60
+ pos = dims.pos;
61
+ const ch = s[pos];
62
+ if (ch === ",") {
63
+ // Leaf: the numeric pane id.
64
+ const id = readInt(s, pos + 1);
65
+ if (!id) return null;
66
+ leaves.push({ id: `%${id.value}`, left, top, width, height });
67
+ return { width, height, pos: id.pos };
68
+ }
69
+ if (ch === "{" || ch === "[") {
70
+ const close = ch === "{" ? "}" : "]";
71
+ pos++;
72
+ for (;;) {
73
+ const child = parseCell(s, pos, leaves);
74
+ if (!child) return null;
75
+ pos = child.pos;
76
+ if (s[pos] === ",") {
77
+ pos++;
78
+ continue;
79
+ }
80
+ if (s[pos] === close) return { width, height, pos: pos + 1 };
81
+ return null;
82
+ }
83
+ }
84
+ // A bare root leaf ends the string (`…,0,0,445`): ch is undefined only when
85
+ // the leaf id was consumed above, so anything else here is malformed.
86
+ return null;
87
+ }
88
+
89
+ /** Read `WxH,X,Y` at `pos`. */
90
+ function readDims(
91
+ s: string,
92
+ pos: number,
93
+ ): { width: number; height: number; left: number; top: number; pos: number } | null {
94
+ const w = readInt(s, pos);
95
+ if (!w || s[w.pos] !== "x") return null;
96
+ const h = readInt(s, w.pos + 1);
97
+ if (!h || s[h.pos] !== ",") return null;
98
+ const x = readInt(s, h.pos + 1);
99
+ if (!x || s[x.pos] !== ",") return null;
100
+ const y = readInt(s, x.pos + 1);
101
+ if (!y) return null;
102
+ return { width: w.value, height: h.value, left: x.value, top: y.value, pos: y.pos };
103
+ }
104
+
105
+ /** Read a decimal integer at `pos` (at least one digit). */
106
+ function readInt(s: string, pos: number): { value: number; pos: number } | null {
107
+ let end = pos;
108
+ while (end < s.length && s.charCodeAt(end) >= 0x30 && s.charCodeAt(end) <= 0x39) end++;
109
+ if (end === pos) return null;
110
+ return { value: Number(s.slice(pos, end)), pos: end };
111
+ }
112
+
113
+ /** A parsed `%layout-change` notification body. */
114
+ export interface LayoutChange {
115
+ windowId: string;
116
+ /** The saved (full) layout — kept for debugging; geometry uses `visible`. */
117
+ layout: string;
118
+ /** The VISIBLE layout — collapses to the single zoomed pane under zoom. */
119
+ visible: string;
120
+ /** `Z` present in the flags field (`*Z`). */
121
+ zoomed: boolean;
122
+ }
123
+
124
+ /** Parse the body after `%layout-change ` (tmux 3.7b:
125
+ * `@387 <layout> <visible-layout> *Z`). Null when the shape is off. */
126
+ export function parseLayoutChange(rest: string): LayoutChange | null {
127
+ const parts = rest.trim().split(/\s+/);
128
+ const [windowId = "", layout = "", visible = "", flags = ""] = parts;
129
+ if (parts.length < 3 || !windowId.startsWith("@")) return null;
130
+ return { windowId, layout, visible, zoomed: flags.includes("Z") };
131
+ }
132
+
133
+ /** Parse the body after `%window-pane-changed ` (`@387 %443`). */
134
+ export function parseWindowPaneChanged(rest: string): { windowId: string; paneId: string } | null {
135
+ const [windowId = "", paneId = ""] = rest.trim().split(/\s+/);
136
+ if (!windowId.startsWith("@") || !paneId.startsWith("%")) return null;
137
+ return { windowId, paneId };
138
+ }
139
+
140
+ /** Parse the body after `%session-window-changed ` (`$353 @388`). */
141
+ export function parseSessionWindowChanged(rest: string): { windowId: string } | null {
142
+ const [, windowId = ""] = rest.trim().split(/\s+/);
143
+ if (!windowId.startsWith("@")) return null;
144
+ return { windowId };
145
+ }
146
+
147
+ /** Parse the body after `%subscription-changed ` for the mirror's `mouse`
148
+ * subscription (tmux 3.7b: `mouse $353 @387 0 %445 : 1`). Null for other
149
+ * subscription names or an off shape. */
150
+ export function parseMouseSubscription(rest: string): { paneId: string; on: boolean } | null {
151
+ const m = /^mouse\s+\$\S+\s+@\S+\s+\S+\s+(%\S+)\s*:\s*(.*)$/.exec(rest.trim());
152
+ if (!m) return null;
153
+ return { paneId: m[1]!, on: m[2]!.trim() === "1" };
154
+ }