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,724 @@
1
+ /**
2
+ * SessionMirror — a whole tmux window rendered through one control client.
3
+ *
4
+ * The multi-pane composition of the proven single-pane pipeline: one
5
+ * {@link ControlModeClient} attaches to the session, `refresh-client -C`
6
+ * pins the virtual client size to our render area (so tmux computes pane
7
+ * layout for OUR grid), and every pane of the active window gets a
8
+ * {@link PaneMirror} fed by routed `%output` bytes.
9
+ *
10
+ * GEOMETRY IS EVENT-DRIVEN (M23.5). `%layout-change` arrives sub-ms after the
11
+ * server applies a layout and ALWAYS precedes the first new-size `%output`
12
+ * (measured on 3.7b: as little as 0.2ms ahead) — so the notification PAYLOAD
13
+ * itself (the visible-layout string, parsed by layout-parse.ts) resizes the
14
+ * PaneMirrors SYNCHRONOUSLY in the same event-loop turn. The old 40ms-debounced
15
+ * `list-panes` hop let new-size redraws parse into stale-sized xterms, which
16
+ * corrupted redraw-once apps (vim/less/shells) PERMANENTLY. A slow list-panes
17
+ * `sync` remains as the attach seed, the reconciler behind uncertain events,
18
+ * the flag source, and the ONLY owner of mirror disposal. `%window-pane-changed`
19
+ * drives the active pane; one `refresh-client -B` subscription pushes per-pane
20
+ * `mouse_any_flag` (~1s cadence, `%subscription-changed`).
21
+ *
22
+ * tmux remains the multiplexer, PTY owner, and source of layout truth —
23
+ * this class owns nothing but mirrors and routing. The pure helpers
24
+ * ({@link parsePaneGeometry}, {@link geometryFromLeaves}, layout-parse.ts)
25
+ * carry the logic and are unit-tested without tmux.
26
+ */
27
+ import { appendFileSync } from "node:fs";
28
+ import { execFileSync } from "node:child_process";
29
+ import { ControlModeClient } from "./control-client.ts";
30
+ import { InputCoalescer } from "./input-coalescer.ts";
31
+ import {
32
+ PaneMirror,
33
+ type MirrorSnapshot,
34
+ type BlitOptions,
35
+ type CursorState,
36
+ } from "./pane-mirror.ts";
37
+ import type { CellArrays } from "./blit.ts";
38
+ import { tapInputOutput, tapRepin, tapResize } from "./perf-tap.ts";
39
+ import {
40
+ parseLayout,
41
+ parseLayoutChange,
42
+ parseWindowPaneChanged,
43
+ parseSessionWindowChanged,
44
+ parseMouseSubscription,
45
+ type LayoutLeaf,
46
+ } from "./layout-parse.ts";
47
+ import { effectiveWindowSize, type Size } from "./size-truth.ts";
48
+
49
+ /** One pane's geometry inside the window, in cells (tmux coordinates). */
50
+ export interface PaneGeometry {
51
+ id: string;
52
+ left: number;
53
+ top: number;
54
+ width: number;
55
+ height: number;
56
+ active: boolean;
57
+ /** The pane's APP turned mouse reporting on (forward real SGR events). */
58
+ appMouse: boolean;
59
+ /** The pane's WINDOW is zoomed (`#{window_zoomed_flag}`). A window property, so
60
+ * every pane of the same window reports the same value; the app reads it off
61
+ * the focused pane to tint the zoom button and show the `[Z]` chip. */
62
+ zoomed: boolean;
63
+ }
64
+
65
+ /** PURE — parse `list-panes -F "#{pane_id} #{pane_left} …"` reply lines. The
66
+ * trailing `window_zoomed_flag` field is optional (absent lines parse as not
67
+ * zoomed) so older format strings and fixtures stay valid; any further
68
+ * trailing fields (sync appends `window_id`) are ignored here. */
69
+ export function parsePaneGeometry(lines: string[]): PaneGeometry[] {
70
+ const panes: PaneGeometry[] = [];
71
+ for (const line of lines) {
72
+ const parts = line.trim().split(/\s+/);
73
+ if (parts.length < 6) continue;
74
+ const [
75
+ id = "",
76
+ left = "",
77
+ top = "",
78
+ width = "",
79
+ height = "",
80
+ active = "",
81
+ mouse = "",
82
+ zoomed = "",
83
+ ] = parts;
84
+ if (!id.startsWith("%")) continue;
85
+ const nums = [left, top, width, height].map(Number);
86
+ if (nums.some((n) => !Number.isInteger(n) || n < 0)) continue;
87
+ panes.push({
88
+ id,
89
+ left: nums[0]!,
90
+ top: nums[1]!,
91
+ width: nums[2]!,
92
+ height: nums[3]!,
93
+ active: active === "1",
94
+ appMouse: mouse === "1",
95
+ zoomed: zoomed === "1",
96
+ });
97
+ }
98
+ return panes;
99
+ }
100
+
101
+ /** PURE — visible geometry from parsed layout leaves (M23.5). A layout string
102
+ * carries rects only, so the flags it can't encode merge in from elsewhere:
103
+ * `active` from the tracked active pane (falling back to the previous
104
+ * geometry's flag while it is still unknown), `appMouse` from the
105
+ * subscription-fed map (then the previous flag, then false for a brand-new
106
+ * pane), `zoomed` from the notification's flags field. */
107
+ export function geometryFromLeaves(
108
+ leaves: readonly LayoutLeaf[],
109
+ prev: readonly PaneGeometry[],
110
+ activePane: string,
111
+ appMouse: ReadonlyMap<string, boolean>,
112
+ zoomed: boolean,
113
+ ): PaneGeometry[] {
114
+ const prevById = new Map(prev.map((p) => [p.id, p]));
115
+ return leaves.map((l) => {
116
+ const was = prevById.get(l.id);
117
+ return {
118
+ id: l.id,
119
+ left: l.left,
120
+ top: l.top,
121
+ width: l.width,
122
+ height: l.height,
123
+ active: activePane ? l.id === activePane : (was?.active ?? false),
124
+ appMouse: appMouse.get(l.id) ?? was?.appMouse ?? false,
125
+ zoomed,
126
+ };
127
+ });
128
+ }
129
+
130
+ /** A live pane: geometry + its mirror snapshot. */
131
+ export interface LivePane extends PaneGeometry {
132
+ snapshot: MirrorSnapshot;
133
+ /** Lines available above the live viewport (scrollback budget). */
134
+ scrollbackDepth: number;
135
+ /** Per-pane content version (M21.4) — the `<pane_surface>` gates its walk on
136
+ * this, so an unchanged pane never re-reads its grid. */
137
+ version: number;
138
+ }
139
+
140
+ export interface SessionMirrorOptions {
141
+ target: string;
142
+ /** Render-area size in cells (the control client is pinned to this). */
143
+ cols: number;
144
+ rows: number;
145
+ /** Called whenever any pane's content or the layout changed (coalesce upstream). */
146
+ onDirty?: () => void;
147
+ onStatus?: (msg: string) => void;
148
+ onExit?: () => void;
149
+ }
150
+
151
+ /** Control-mode notifications (sans `%`) that still fall back to the slow
152
+ * re-sync — structure changed but the notification body doesn't carry enough
153
+ * to apply it directly (layout-change / window-pane-changed /
154
+ * session-window-changed have their own push handlers now). */
155
+ const STRUCTURAL_NOTIFICATIONS = new Set([
156
+ "window-add",
157
+ "window-close",
158
+ "window-renamed",
159
+ "unlinked-window-close",
160
+ ]);
161
+
162
+ export class SessionMirror {
163
+ private readonly client: ControlModeClient;
164
+ private readonly mirrors = new Map<string, PaneMirror>();
165
+ private geometry: PaneGeometry[] = [];
166
+ private focused = "";
167
+ private syncQueued = false;
168
+ // ── Push-geometry state (M23.5) ─────────────────────────────────────────
169
+ /** The mirrored session's active window id (`@N`) — gates which
170
+ * `%layout-change` events apply. Learned by sync, updated by
171
+ * `%session-window-changed`. Empty until the first sync lands. */
172
+ private activeWindow = "";
173
+ /** tmux's active pane (`%N`) — from `%window-pane-changed` and sync. */
174
+ private activePane = "";
175
+ /** The active window is zoomed (flags `*Z` / `window_zoomed_flag`). */
176
+ private zoomedNow = false;
177
+ /** Last APPLIED visible-layout string — dedupes notification bursts (every
178
+ * payload of a burst carries the final layout; the checksum differs iff the
179
+ * layout does). Reset on window switch so the new window's first layout
180
+ * always applies. */
181
+ private lastVisibleLayout = "";
182
+ /** The window's authoritative size: the latest layout root's WxH
183
+ * (event-driven), seeded/reconciled by sync's pane bounding box. */
184
+ private winSize: Size | null = null;
185
+ /** Per-pane `mouse_any_flag`, pushed by the control-mode subscription and
186
+ * reconciled by sync — the fix for the latent missed-toggle (a pane
187
+ * flipping mouse mode between two syncs was never re-read). */
188
+ private readonly appMouseByPane = new Map<string, boolean>();
189
+ /** Mirrors created (at correct size) whose CONTENT seed (capture-pane
190
+ * history + screen + cursor) hasn't run yet — sync drains this. */
191
+ private readonly unseeded = new Set<string>();
192
+ /** Size policy (M22.8). "auto" (default): we pin our virtual client size via
193
+ * `refresh-client -C` and let tmux's `window-size latest` cooperate — a
194
+ * co-attached terminal may win, which we surface honestly rather than fight.
195
+ * "manual": the user asked us to reclaim the window ({@link resizeToFit}), so
196
+ * we set `window-size manual` + `resize-window` (the ONLY mechanism that holds
197
+ * against a bigger real client — measured; plain `refresh-client -C` does not
198
+ * re-win once another client is latest). Manual is a WINDOW option that
199
+ * lingers past our detach, so {@link dispose} reverts it. */
200
+ private sizeMode: "auto" | "manual" = "auto";
201
+ private readonly opts: SessionMirrorOptions;
202
+ /** The input fast path (M21.5): literals coalesce per pane and flush on a
203
+ * microtask (same macrotask as the keystroke — no added latency); named
204
+ * keys flush pending literals first so ordering is preserved; everything
205
+ * leaves via the control client's fire-and-forget write. */
206
+ private readonly input = new InputCoalescer(
207
+ (a) => {
208
+ if (a.kind === "literal") this.client.sendText(a.pane, a.text);
209
+ else this.client.sendKey(a.pane, a.key);
210
+ },
211
+ (flush) => queueMicrotask(flush),
212
+ );
213
+
214
+ constructor(opts: SessionMirrorOptions) {
215
+ this.opts = opts;
216
+ this.client = new ControlModeClient({
217
+ attachTarget: opts.target,
218
+ onOutput: (pane, data) => {
219
+ tapInputOutput(pane); // t1: first echo back for a key we just forwarded
220
+ const mirror = this.mirrors.get(pane);
221
+ if (process.env.TMUX_IDE_ZZ_RESIZE_TAP && mirror) {
222
+ tapResize("output", `${pane} ${mirror.cols}x${mirror.rows} ${data.length}b`);
223
+ }
224
+ mirror?.write(data);
225
+ opts.onDirty?.();
226
+ },
227
+ onNotify: (name, rest) => {
228
+ if (process.env.TMUX_IDE_MIRROR_DEBUG) {
229
+ try {
230
+ appendFileSync("/tmp/zz-notify.log", name + "\n");
231
+ } catch {
232
+ // debug tap only
233
+ }
234
+ }
235
+ // NOTE: parseControlLine strips the leading `%` from notification
236
+ // names. Geometry-bearing notifications apply DIRECTLY from their
237
+ // payload (the M23.5 push path — see each handler); everything else
238
+ // structural falls back to the debounced re-sync.
239
+ if (name === "layout-change") this.onLayoutChange(rest);
240
+ else if (name === "window-pane-changed") this.onWindowPaneChanged(rest);
241
+ else if (name === "subscription-changed") this.onSubscriptionChanged(rest);
242
+ else if (name === "session-window-changed") this.onSessionWindowChanged(rest);
243
+ else if (STRUCTURAL_NOTIFICATIONS.has(name)) this.queueSync();
244
+ },
245
+ onExit: () => opts.onExit?.(),
246
+ });
247
+ }
248
+
249
+ async start(): Promise<void> {
250
+ await this.client.start();
251
+ await this.client.command(`refresh-client -C ${this.opts.cols}x${this.opts.rows}`);
252
+ // ONE control-mode subscription (M23.5): tmux re-evaluates the format on
253
+ // its ~1s tick and pushes `%subscription-changed` per pane on change — the
254
+ // push source for appMouse. The argument is DOUBLE-QUOTED on the control
255
+ // channel (the measured working form; see layout-parse.ts for the reply
256
+ // shape). Best-effort: an old tmux without -B just degrades to sync-only.
257
+ await this.client.command(`refresh-client -B "mouse:%*:#{mouse_any_flag}"`).catch(() => {});
258
+ await this.sync();
259
+ }
260
+
261
+ /** The mirrored window's authoritative size (layout root WxH), or null
262
+ * before the first layout/sync — the app's event-driven size truth. */
263
+ windowSize(): Size | null {
264
+ return this.winSize;
265
+ }
266
+
267
+ // ── The push-geometry handlers (M23.5) ─────────────────────────────────
268
+
269
+ /**
270
+ * `%layout-change` → parse the VISIBLE layout and resize mirrors NOW —
271
+ * synchronously, in the same event-loop turn, BEFORE the control client
272
+ * feeds any subsequent `%output` line (resize-first is safe: bytes emitted
273
+ * for the OLD size clamp into the new grid, exactly as a native terminal
274
+ * treats a process writing across a resize; the app repaints on its own
275
+ * SIGWINCH an instant later).
276
+ *
277
+ * AckWriter interaction: {@link PaneMirror.write} is ack-PACED, so `%output`
278
+ * bytes that arrived BEFORE this notification may still sit unparsed in the
279
+ * writer's queue while `term.resize()` applies immediately — those old-size
280
+ * bytes then parse into the new grid and clamp, which is the same
281
+ * native-terminal behavior as above. The invariant that matters is that the
282
+ * resize is ordered by the CONTROL-CLIENT READ ORDER (never held for
283
+ * content): everything the server sent for the new size parses at the new
284
+ * size.
285
+ */
286
+ private onLayoutChange(rest: string): void {
287
+ const ev = parseLayoutChange(rest);
288
+ if (!ev) return;
289
+ tapResize("notify", `${ev.windowId} ${ev.visible}`);
290
+ // Before the first sync the active window is unknown — reconcile slowly.
291
+ if (!this.activeWindow) {
292
+ this.queueSync();
293
+ return;
294
+ }
295
+ if (ev.windowId !== this.activeWindow) return; // a background window
296
+ if (ev.visible === this.lastVisibleLayout) return; // burst dedupe
297
+ const parsed = parseLayout(ev.visible);
298
+ if (!parsed) {
299
+ this.queueSync(); // never guess from a failed parse
300
+ return;
301
+ }
302
+ this.lastVisibleLayout = ev.visible;
303
+ this.zoomedNow = ev.zoomed;
304
+ this.winSize = { cols: parsed.width, rows: parsed.height };
305
+ this.applyLayout(parsed.leaves, ev.zoomed);
306
+ tapResize(
307
+ "geometry-applied",
308
+ `${parsed.width}x${parsed.height} panes=${parsed.leaves.length}${ev.zoomed ? " Z" : ""}`,
309
+ );
310
+ }
311
+
312
+ /** Create/resize mirrors for the visible leaves and swap the geometry — all
313
+ * synchronous. Mirror DISPOSAL stays with sync: a pane missing from the
314
+ * visible layout is hidden under zoom (keep it warm — unzoom is instant and
315
+ * scrollback survives), or genuinely closed (the queued sync checks against
316
+ * list-panes truth and disposes there). */
317
+ private applyLayout(leaves: readonly LayoutLeaf[], zoomed: boolean): void {
318
+ let needSync = false;
319
+ for (const leaf of leaves) {
320
+ const mirror = this.mirrors.get(leaf.id);
321
+ if (!mirror) {
322
+ // A brand-new pane (split) in the layout: create the mirror at the
323
+ // right size NOW so its very first %output parses into correct
324
+ // geometry; the content seed rides the queued slow sync.
325
+ const created = new PaneMirror(leaf.width, leaf.height);
326
+ // Dirty must re-arm when bytes have PARSED, not just when they were
327
+ // enqueued (onOutput) — with ack-paced writes an enqueue-time dirty can
328
+ // be consumed by the tick before the grid changed, dropping the frame.
329
+ created.onParsed = () => this.opts.onDirty?.();
330
+ this.mirrors.set(leaf.id, created);
331
+ this.unseeded.add(leaf.id);
332
+ needSync = true;
333
+ } else if (mirror.cols !== leaf.width || mirror.rows !== leaf.height) {
334
+ mirror.resize(leaf.width, leaf.height);
335
+ tapResize("pane-resize", `${leaf.id} ${leaf.width}x${leaf.height}`);
336
+ }
337
+ }
338
+ if (!zoomed) {
339
+ const visible = new Set(leaves.map((l) => l.id));
340
+ for (const id of this.mirrors.keys()) {
341
+ if (!visible.has(id)) {
342
+ needSync = true; // a pane closed — let sync dispose against truth
343
+ break;
344
+ }
345
+ }
346
+ }
347
+ this.geometry = geometryFromLeaves(
348
+ leaves,
349
+ this.geometry,
350
+ this.activePane,
351
+ this.appMouseByPane,
352
+ zoomed,
353
+ );
354
+ this.opts.onDirty?.();
355
+ if (needSync) this.queueSync();
356
+ }
357
+
358
+ /** `%window-pane-changed` — tmux's active pane moved (fires immediately,
359
+ * ahead of any sync). Track it and re-flag the geometry in place. */
360
+ private onWindowPaneChanged(rest: string): void {
361
+ const ev = parseWindowPaneChanged(rest);
362
+ if (!ev || (this.activeWindow && ev.windowId !== this.activeWindow)) return;
363
+ this.activePane = ev.paneId;
364
+ // Converge the LOCAL focus to tmux truth too: a select-pane we issued
365
+ // echoes back as this notification, and an external change (menu verb,
366
+ // another client) should move our focus the same way.
367
+ if (this.geometry.some((g) => g.id === ev.paneId)) this.focused = ev.paneId;
368
+ let changed = false;
369
+ this.geometry = this.geometry.map((g) => {
370
+ if (g.active === (g.id === ev.paneId)) return g;
371
+ changed = true;
372
+ return { ...g, active: g.id === ev.paneId };
373
+ });
374
+ if (changed) this.opts.onDirty?.();
375
+ }
376
+
377
+ /** `%subscription-changed` for the `mouse` subscription — a pane's app
378
+ * turned mouse reporting on/off (~1s cadence; see {@link start}). */
379
+ private onSubscriptionChanged(rest: string): void {
380
+ const ev = parseMouseSubscription(rest);
381
+ if (!ev || this.appMouseByPane.get(ev.paneId) === ev.on) return;
382
+ this.appMouseByPane.set(ev.paneId, ev.on);
383
+ let changed = false;
384
+ this.geometry = this.geometry.map((g) => {
385
+ if (g.id !== ev.paneId || g.appMouse === ev.on) return g;
386
+ changed = true;
387
+ return { ...g, appMouse: ev.on };
388
+ });
389
+ if (changed) this.opts.onDirty?.();
390
+ }
391
+
392
+ /** `%session-window-changed` — the mirrored session switched windows: a new
393
+ * pane set, so the slow path reseeds everything. */
394
+ private onSessionWindowChanged(rest: string): void {
395
+ const ev = parseSessionWindowChanged(rest);
396
+ if (!ev) return;
397
+ this.activeWindow = ev.windowId;
398
+ this.lastVisibleLayout = "";
399
+ this.queueSync();
400
+ }
401
+
402
+ /**
403
+ * The current panes with fresh grid snapshots, render-ready.
404
+ *
405
+ * @param scrollOffsets Per-pane scrollback offsets (lines above live view);
406
+ * panes absent from the map render live. The focused pane gets its cursor
407
+ * painted.
408
+ * @param includeRows Serialize each pane's styled rows. `false` (the
409
+ * framebuffer-blit path, M21.3) returns geometry + cursor/offset only and
410
+ * skips the run rebuild — the `<pane_surface>` reads cells via {@link blitPane}.
411
+ */
412
+ panes(scrollOffsets?: ReadonlyMap<string, number>, includeRows = true): LivePane[] {
413
+ const focused = this.focusedPane();
414
+ return this.geometry.map((g) => {
415
+ const mirror = this.mirrors.get(g.id);
416
+ const offset = scrollOffsets?.get(g.id) ?? 0;
417
+ return {
418
+ ...g,
419
+ active: g.id === this.focused || (this.focused === "" && g.active),
420
+ scrollbackDepth: mirror?.scrollbackDepth() ?? 0,
421
+ version: mirror?.contentVersion() ?? 0,
422
+ snapshot:
423
+ mirror?.snapshot(offset, g.id === focused, includeRows) ??
424
+ ({ rows: [], cursorX: 0, cursorY: 0, scrollOffset: 0 } as const),
425
+ };
426
+ });
427
+ }
428
+
429
+ /** Blit a pane's visible grid into a framebuffer's packed arrays (M21.3) — the
430
+ * `<pane_surface>` render path. No-op for an unknown pane. See
431
+ * {@link PaneMirror.blit}. */
432
+ blitPane(
433
+ id: string,
434
+ buffers: CellArrays,
435
+ width: number,
436
+ height: number,
437
+ scrollOffset: number,
438
+ defaultFg: number,
439
+ defaultBg: number,
440
+ opts: BlitOptions,
441
+ ): void {
442
+ this.mirrors.get(id)?.blit(buffers, width, height, scrollOffset, defaultFg, defaultBg, opts);
443
+ }
444
+
445
+ /** A pane's visible rows as plain text — the on-demand OSC52 copy read for the
446
+ * blit path (which omits styled rows). Empty for an unknown pane. */
447
+ visibleRowTexts(id: string, scrollOffset = 0): string[] {
448
+ return this.mirrors.get(id)?.visibleRowTexts(scrollOffset) ?? [];
449
+ }
450
+
451
+ /** A pane's LIVE scrollback depth (M25.6) — read at event time by the drag
452
+ * machine, ahead of the 8ms tick's LivePane snapshot. 0 for an unknown pane. */
453
+ scrollbackDepth(id: string): number {
454
+ return this.mirrors.get(id)?.scrollbackDepth() ?? 0;
455
+ }
456
+
457
+ /** A pane's monotonic trimmed-lines counter (M25.6) — see
458
+ * {@link PaneMirror.lineTrim}. 0 for an unknown pane. */
459
+ lineTrim(id: string): number {
460
+ return this.mirrors.get(id)?.lineTrim() ?? 0;
461
+ }
462
+
463
+ /** Extract an ORDERED absolute-line range from a pane's buffer (M25.6) — the
464
+ * selection release path. Empty for an unknown pane. See
465
+ * {@link PaneMirror.extractAbsoluteText}. */
466
+ extractText(
467
+ id: string,
468
+ start: { row: number; col: number },
469
+ end: { row: number; col: number },
470
+ maxBytes: number,
471
+ ): string {
472
+ return this.mirrors.get(id)?.extractAbsoluteText(start, end, maxBytes) ?? "";
473
+ }
474
+
475
+ /** A pane's live cursor state (position + DECTCEM/DECSCUSR), for the hardware
476
+ * cursor (M21.6). Null for an unknown pane. */
477
+ cursorState(id: string): CursorState | null {
478
+ return this.mirrors.get(id)?.cursorState() ?? null;
479
+ }
480
+
481
+ focusedPane(): string {
482
+ return this.focused || this.geometry.find((g) => g.active)?.id || "";
483
+ }
484
+
485
+ /** The whole mirror buffer (scrollback + viewport) of a pane as plain text
486
+ * lines — the corpus for scrollback search. Empty for an unknown pane. */
487
+ bufferLines(paneId: string): string[] {
488
+ return this.mirrors.get(paneId)?.bufferLines() ?? [];
489
+ }
490
+
491
+ /** Focus a pane locally AND in tmux (so splits/new panes open where expected). */
492
+ focus(id: string): void {
493
+ if (!this.geometry.some((g) => g.id === id)) return;
494
+ this.focused = id;
495
+ void this.command(`select-pane -t ${id}`).catch(() => {});
496
+ this.opts.onDirty?.();
497
+ }
498
+
499
+ /** Type literal text into the focused pane — coalesced, fire-and-forget. */
500
+ sendText(text: string): void {
501
+ const pane = this.focusedPane();
502
+ if (pane) this.input.literal(pane, text);
503
+ }
504
+
505
+ /** Send a named tmux key to the focused pane — fire-and-forget, after any
506
+ * pending literal batch (ordering invariant). */
507
+ sendKey(key: string): void {
508
+ const pane = this.focusedPane();
509
+ if (pane) this.input.key(pane, key);
510
+ }
511
+
512
+ /** Run any tmux command over the control channel (splits, zoom, …).
513
+ * Pending coalesced input flushes FIRST so a structural command can never
514
+ * overtake keystrokes typed before it. */
515
+ command(cmd: string): Promise<string[]> {
516
+ this.input.flush();
517
+ return this.client.command(cmd);
518
+ }
519
+
520
+ /** Windows (tabs) of the mirrored session, for the app's tab strip. `sync` is
521
+ * the window's `synchronize-panes` option (`#{?synchronize-panes,1,0}`) — a
522
+ * window property, so it rides here alongside index/name/active and the app
523
+ * reads the active window's value to drive the `[SYNC]` chip. */
524
+ async windows(): Promise<Array<{ index: number; name: string; active: boolean; sync: boolean }>> {
525
+ const lines = await this.client
526
+ .command(
527
+ `list-windows -t ${this.opts.target} -F "#{window_index}\t#{window_name}\t#{window_active}\t#{?synchronize-panes,1,0}"`,
528
+ )
529
+ .catch(() => [] as string[]);
530
+ return lines
531
+ .map((l) => l.split("\t"))
532
+ .filter((p) => p.length >= 3)
533
+ .map(([i = "", name = "", active = "", sync = ""]) => ({
534
+ index: Number(i),
535
+ name,
536
+ active: active === "1",
537
+ sync: sync === "1",
538
+ }))
539
+ .filter((w) => Number.isInteger(w.index));
540
+ }
541
+
542
+ /** Switch the mirrored session's active window (the tab click). */
543
+ switchWindow(index: number): void {
544
+ void this.command(`select-window -t ${this.opts.target}:${index}`).catch(() => {});
545
+ this.queueSync();
546
+ }
547
+
548
+ /** Type raw text (incl. escape sequences — mouse SGR, paste chunks) into a
549
+ * SPECIFIC pane — coalesced with typed literals, fire-and-forget. */
550
+ sendTextTo(pane: string, text: string): void {
551
+ this.input.literal(pane, text);
552
+ }
553
+
554
+ async resize(cols: number, rows: number): Promise<void> {
555
+ this.opts.cols = cols;
556
+ this.opts.rows = rows;
557
+ tapRepin(cols, rows); // debug tap: assert one re-pin per settled size change
558
+ tapResize("repin", `${cols}x${rows}`);
559
+ await this.client.command(`refresh-client -C ${cols}x${rows}`).catch(() => {});
560
+ // Under the manual policy `refresh-client -C` no longer drives the window
561
+ // size, so a terminal/sidebar resize after a reclaim must resize the window
562
+ // directly to keep it matched to our canvas.
563
+ if (this.sizeMode === "manual") {
564
+ await this.client
565
+ .command(`resize-window -t ${this.opts.target} -x ${cols} -y ${rows}`)
566
+ .catch(() => {});
567
+ }
568
+ this.queueSync();
569
+ }
570
+
571
+ /**
572
+ * Reclaim the window at our current canvas size (M22.8 — the palette's "Resize
573
+ * to fit this window"). A co-attached smaller terminal wins `window-size
574
+ * latest`; the ONLY mechanism that overrides it and HOLDS is `window-size
575
+ * manual` + `resize-window` (measured — a bare `refresh-client -C` re-issue
576
+ * does not re-win). We flip to the manual policy so subsequent resizes keep the
577
+ * window matched, and {@link dispose} reverts the option on detach.
578
+ */
579
+ async resizeToFit(): Promise<void> {
580
+ const { cols, rows } = this.opts;
581
+ this.sizeMode = "manual";
582
+ await this.client.command(`refresh-client -C ${cols}x${rows}`).catch(() => {});
583
+ await this.client
584
+ .command(`set-window-option -t ${this.opts.target} window-size manual`)
585
+ .catch(() => {});
586
+ await this.client
587
+ .command(`resize-window -t ${this.opts.target} -x ${cols} -y ${rows}`)
588
+ .catch(() => {});
589
+ this.queueSync();
590
+ }
591
+
592
+ dispose(): void {
593
+ this.input.flush(); // last typed bytes leave before the detach
594
+ // Detach cleanliness (M22.8): a `window-size manual` override we set to
595
+ // reclaim the window lingers on the session past our client's death — it
596
+ // would leave a still-attached real terminal permanently letterboxed. Revert
597
+ // it out-of-band (a plain tmux call, independent of the control client we are
598
+ // about to tear down) so the remaining clients reclaim their own size. The
599
+ // "auto" policy needs no cleanup: tmux drops our size vote when the control
600
+ // client dies (measured). Sync + guarded — dispose runs at shutdown/re-attach,
601
+ // not the render loop, and correctness here outranks the brief block.
602
+ if (this.sizeMode === "manual") {
603
+ try {
604
+ execFileSync("tmux", ["set-window-option", "-t", this.opts.target, "-u", "window-size"], {
605
+ stdio: "ignore",
606
+ });
607
+ } catch {
608
+ // best-effort revert; the session may already be gone
609
+ }
610
+ this.sizeMode = "auto";
611
+ }
612
+ this.client.dispose();
613
+ for (const m of this.mirrors.values()) m.dispose();
614
+ this.mirrors.clear();
615
+ }
616
+
617
+ /** Queue the SLOW path (M23.5: reconciler, flag source, seed driver, sole
618
+ * mirror disposer — geometry itself is pushed by {@link onLayoutChange}).
619
+ * The 40ms debounce coalesces notification bursts to one round-trip; it no
620
+ * longer gates any resize. */
621
+ private queueSync(): void {
622
+ if (this.syncQueued) return;
623
+ this.syncQueued = true;
624
+ setTimeout(() => {
625
+ this.syncQueued = false;
626
+ void this.sync().catch(() => {});
627
+ }, 40);
628
+ }
629
+
630
+ private async sync(): Promise<void> {
631
+ const lines = await this.client.command(
632
+ `list-panes -t ${this.opts.target} -F "#{pane_id} #{pane_left} #{pane_top} #{pane_width} #{pane_height} #{pane_active} #{mouse_any_flag} #{window_zoomed_flag} #{window_id}"`,
633
+ );
634
+ // `list-panes` on a session target lists the CURRENT window — every pane
635
+ // of it, including the ones zoom hides. The trailing window id is the
636
+ // active-window gate for %layout-change (parsePaneGeometry ignores it).
637
+ const all = parsePaneGeometry(lines);
638
+ const win = lines[0]?.trim().split(/\s+/)[8];
639
+ if (win?.startsWith("@")) this.activeWindow = win;
640
+
641
+ // Everything from here to the geometry swap is SYNCHRONOUS. The reply
642
+ // reflects server state at least as new as any notification already
643
+ // processed (control mode serializes both on one channel), and nothing
644
+ // interleaves before the swap — so a sync can never clobber a NEWER
645
+ // pushed layout with stale rects.
646
+ const listed = new Set(all.map((p) => p.id));
647
+ for (const [id, mirror] of this.mirrors) {
648
+ if (listed.has(id)) continue;
649
+ mirror.dispose();
650
+ this.mirrors.delete(id);
651
+ this.unseeded.delete(id);
652
+ this.appMouseByPane.delete(id);
653
+ if (this.focused === id) this.focused = "";
654
+ }
655
+ for (const pane of all) {
656
+ const mirror = this.mirrors.get(pane.id);
657
+ if (!mirror) {
658
+ const created = new PaneMirror(pane.width, pane.height);
659
+ // Dirty must re-arm when bytes have PARSED, not just when they were
660
+ // enqueued (onOutput) — with ack-paced writes an enqueue-time dirty can
661
+ // be consumed by the tick before the grid changed, dropping the frame.
662
+ created.onParsed = () => this.opts.onDirty?.();
663
+ this.mirrors.set(pane.id, created);
664
+ this.unseeded.add(pane.id);
665
+ } else if (mirror.cols !== pane.width || mirror.rows !== pane.height) {
666
+ mirror.resize(pane.width, pane.height);
667
+ tapResize("pane-resize", `${pane.id} ${pane.width}x${pane.height} sync`);
668
+ }
669
+ this.appMouseByPane.set(pane.id, pane.appMouse);
670
+ }
671
+ this.zoomedNow = all.some((p) => p.zoomed);
672
+ const active = all.find((p) => p.active);
673
+ if (active) this.activePane = active.id;
674
+ // Visible geometry: under zoom list-panes still reports the HIDDEN panes
675
+ // at their unzoomed rects (measured on 3.7b: they overlap the zoomed pane
676
+ // and would steal first-match hit-tests — D3). Only the active (= zoomed)
677
+ // pane is visible, and its listed rect is the full window.
678
+ this.geometry = this.zoomedNow ? all.filter((p) => p.active) : all;
679
+ this.winSize = effectiveWindowSize(all) ?? this.winSize;
680
+ this.opts.onStatus?.(`${this.geometry.length} panes`);
681
+ this.opts.onDirty?.();
682
+
683
+ // CONTENT seeds last — the awaits below can span chunks, so nothing after
684
+ // this point touches geometry. Seed with history + current screen (-e
685
+ // keeps colors, -S reaches back into tmux's scrollback, 2000 lines. The
686
+ // old 300 cap guarded a SYNC seed write that blocked the event loop; with
687
+ // ack-paced writes (M21.5) the write just enqueues (~0.01ms) and xterm
688
+ // parses async — 2000 lines parse in ~8ms off the render loop (measured),
689
+ // so the deeper history is free. -J joins wrapped lines so re-wrapping
690
+ // stays sane.)
691
+ for (const pane of all) {
692
+ if (!this.unseeded.has(pane.id)) continue;
693
+ this.unseeded.delete(pane.id);
694
+ const mirror = this.mirrors.get(pane.id);
695
+ if (!mirror) continue;
696
+ const seedReply = this.client
697
+ .command(`capture-pane -p -e -J -S -2000 -t ${pane.id}`)
698
+ .catch(() => [] as string[]);
699
+ // The pane's REAL cursor (D2): the seed replay leaves xterm's cursor
700
+ // wherever the last captured byte fell — and the trailing CRLF the seed
701
+ // used to append scrolled one extra row on a full viewport, drifting
702
+ // the whole grid up. Dropped now; instead read tmux's cursor and CUP it
703
+ // home (CUP is viewport-relative — the same coordinates
704
+ // #{cursor_x}/#{cursor_y} report). Issued back-to-back with the capture
705
+ // so both ride one round-trip.
706
+ const cursorReply = this.client
707
+ .command(`display-message -p -t ${pane.id} "#{cursor_x} #{cursor_y}"`)
708
+ .catch(() => [] as string[]);
709
+ const seed = await seedReply;
710
+ // The control client reads replies as latin1 (one JS char per byte —
711
+ // required for the protocol), so the seed is a byte string in disguise:
712
+ // re-encode latin1 → bytes before feeding the VT parser, or every
713
+ // multibyte glyph shatters into mojibake (…→â¦, ⇡→â¡ — user-reported,
714
+ // "weird a's with a roof"). The live %output path already decodes bytes.
715
+ if (seed.length > 0) {
716
+ mirror.write(Buffer.from(seed.join("\r\n"), "latin1"));
717
+ }
718
+ const [cx, cy] = ((await cursorReply)[0] ?? "").trim().split(/\s+/).map(Number);
719
+ if (Number.isInteger(cx) && Number.isInteger(cy)) {
720
+ mirror.write(`\x1b[${cy! + 1};${cx! + 1}H`);
721
+ }
722
+ }
723
+ }
724
+ }