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
@@ -5,14 +5,24 @@
5
5
  * {@link ControlModeClient} attaches to the session, `refresh-client -C`
6
6
  * pins the virtual client size to our render area (so tmux computes pane
7
7
  * layout for OUR grid), and every pane of the active window gets a
8
- * {@link PaneMirror} fed by routed `%output` bytes. Layout notifications
9
- * (`%layout-change`, `%window-pane-changed`, …) trigger a re-sync that
10
- * diffs geometry and creates/resizes/disposes mirrors incrementally.
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`).
11
21
  *
12
22
  * tmux remains the multiplexer, PTY owner, and source of layout truth —
13
23
  * this class owns nothing but mirrors and routing. The pure helpers
14
- * ({@link parsePaneGeometry}, {@link diffPanes}) carry the logic and are
15
- * unit-tested without tmux.
24
+ * ({@link parsePaneGeometry}, {@link geometryFromLeaves}, layout-parse.ts)
25
+ * carry the logic and are unit-tested without tmux.
16
26
  */
17
27
  import { appendFileSync } from "node:fs";
18
28
  import { execFileSync } from "node:child_process";
@@ -25,7 +35,16 @@ import {
25
35
  type CursorState,
26
36
  } from "./pane-mirror.ts";
27
37
  import type { CellArrays } from "./blit.ts";
28
- import { tapInputOutput, tapRepin } from "./perf-tap.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";
29
48
 
30
49
  /** One pane's geometry inside the window, in cells (tmux coordinates). */
31
50
  export interface PaneGeometry {
@@ -45,7 +64,8 @@ export interface PaneGeometry {
45
64
 
46
65
  /** PURE — parse `list-panes -F "#{pane_id} #{pane_left} …"` reply lines. The
47
66
  * trailing `window_zoomed_flag` field is optional (absent lines parse as not
48
- * zoomed) so older format strings and fixtures stay valid. */
67
+ * zoomed) so older format strings and fixtures stay valid; any further
68
+ * trailing fields (sync appends `window_id`) are ignored here. */
49
69
  export function parsePaneGeometry(lines: string[]): PaneGeometry[] {
50
70
  const panes: PaneGeometry[] = [];
51
71
  for (const line of lines) {
@@ -78,28 +98,33 @@ export function parsePaneGeometry(lines: string[]): PaneGeometry[] {
78
98
  return panes;
79
99
  }
80
100
 
81
- /** PURE — what changed between two layouts, keyed by pane id. */
82
- export function diffPanes(
83
- prev: PaneGeometry[],
84
- next: PaneGeometry[],
85
- ): { added: PaneGeometry[]; removed: string[]; resized: PaneGeometry[]; moved: PaneGeometry[] } {
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[] {
86
114
  const prevById = new Map(prev.map((p) => [p.id, p]));
87
- const nextIds = new Set(next.map((p) => p.id));
88
- const added: PaneGeometry[] = [];
89
- const resized: PaneGeometry[] = [];
90
- const moved: PaneGeometry[] = [];
91
- for (const pane of next) {
92
- const was = prevById.get(pane.id);
93
- if (!was) {
94
- added.push(pane);
95
- } else if (was.width !== pane.width || was.height !== pane.height) {
96
- resized.push(pane);
97
- } else if (was.left !== pane.left || was.top !== pane.top) {
98
- moved.push(pane);
99
- }
100
- }
101
- const removed = prev.filter((p) => !nextIds.has(p.id)).map((p) => p.id);
102
- return { added, removed, resized, moved };
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
+ });
103
128
  }
104
129
 
105
130
  /** A live pane: geometry + its mirror snapshot. */
@@ -123,14 +148,14 @@ export interface SessionMirrorOptions {
123
148
  onExit?: () => void;
124
149
  }
125
150
 
126
- /** Control-mode notifications (sans `%`) that mean "the layout changed". */
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). */
127
155
  const STRUCTURAL_NOTIFICATIONS = new Set([
128
- "layout-change",
129
156
  "window-add",
130
157
  "window-close",
131
158
  "window-renamed",
132
- "window-pane-changed",
133
- "session-window-changed",
134
159
  "unlinked-window-close",
135
160
  ]);
136
161
 
@@ -140,6 +165,30 @@ export class SessionMirror {
140
165
  private geometry: PaneGeometry[] = [];
141
166
  private focused = "";
142
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>();
143
192
  /** Size policy (M22.8). "auto" (default): we pin our virtual client size via
144
193
  * `refresh-client -C` and let tmux's `window-size latest` cooperate — a
145
194
  * co-attached terminal may win, which we surface honestly rather than fight.
@@ -168,10 +217,14 @@ export class SessionMirror {
168
217
  attachTarget: opts.target,
169
218
  onOutput: (pane, data) => {
170
219
  tapInputOutput(pane); // t1: first echo back for a key we just forwarded
171
- this.mirrors.get(pane)?.write(data);
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);
172
225
  opts.onDirty?.();
173
226
  },
174
- onNotify: (name) => {
227
+ onNotify: (name, rest) => {
175
228
  if (process.env.TMUX_IDE_MIRROR_DEBUG) {
176
229
  try {
177
230
  appendFileSync("/tmp/zz-notify.log", name + "\n");
@@ -179,12 +232,15 @@ export class SessionMirror {
179
232
  // debug tap only
180
233
  }
181
234
  }
182
- // Any structural notification re-syncs the layout; the debounce keeps
183
- // bursts (e.g. a resize storm) to one list-panes round-trip. NOTE:
184
- // parseControlLine strips the leading `%` from notification names.
185
- if (STRUCTURAL_NOTIFICATIONS.has(name)) {
186
- this.queueSync();
187
- }
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();
188
244
  },
189
245
  onExit: () => opts.onExit?.(),
190
246
  });
@@ -193,9 +249,156 @@ export class SessionMirror {
193
249
  async start(): Promise<void> {
194
250
  await this.client.start();
195
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(() => {});
196
258
  await this.sync();
197
259
  }
198
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
+
199
402
  /**
200
403
  * The current panes with fresh grid snapshots, render-ready.
201
404
  *
@@ -245,6 +448,30 @@ export class SessionMirror {
245
448
  return this.mirrors.get(id)?.visibleRowTexts(scrollOffset) ?? [];
246
449
  }
247
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
+
248
475
  /** A pane's live cursor state (position + DECTCEM/DECSCUSR), for the hardware
249
476
  * cursor (M21.6). Null for an unknown pane. */
250
477
  cursorState(id: string): CursorState | null {
@@ -328,6 +555,7 @@ export class SessionMirror {
328
555
  this.opts.cols = cols;
329
556
  this.opts.rows = rows;
330
557
  tapRepin(cols, rows); // debug tap: assert one re-pin per settled size change
558
+ tapResize("repin", `${cols}x${rows}`);
331
559
  await this.client.command(`refresh-client -C ${cols}x${rows}`).catch(() => {});
332
560
  // Under the manual policy `refresh-client -C` no longer drives the window
333
561
  // size, so a terminal/sidebar resize after a reclaim must resize the window
@@ -386,6 +614,10 @@ export class SessionMirror {
386
614
  this.mirrors.clear();
387
615
  }
388
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. */
389
621
  private queueSync(): void {
390
622
  if (this.syncQueued) return;
391
623
  this.syncQueued = true;
@@ -397,47 +629,96 @@ export class SessionMirror {
397
629
 
398
630
  private async sync(): Promise<void> {
399
631
  const lines = await this.client.command(
400
- `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}"`,
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}"`,
401
633
  );
402
- const next = parsePaneGeometry(lines);
403
- const { added, removed, resized } = diffPanes(this.geometry, next);
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;
404
640
 
405
- for (const id of removed) {
406
- this.mirrors.get(id)?.dispose();
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();
407
650
  this.mirrors.delete(id);
651
+ this.unseeded.delete(id);
652
+ this.appMouseByPane.delete(id);
408
653
  if (this.focused === id) this.focused = "";
409
654
  }
410
- for (const pane of added) {
411
- const mirror = new PaneMirror(pane.width, pane.height);
412
- // Dirty must re-arm when bytes have PARSED, not just when they were
413
- // enqueued (onOutput) — with ack-paced writes an enqueue-time dirty can
414
- // be consumed by the tick before the grid changed, dropping the frame.
415
- mirror.onParsed = () => this.opts.onDirty?.();
416
- this.mirrors.set(pane.id, mirror);
417
- // Seed with history + current screen (-e keeps colors, -S reaches back
418
- // into tmux's scrollback, 2000 lines. The old 300 cap guarded a SYNC seed
419
- // write that blocked the event loop; with ack-paced writes (M21.5) the
420
- // write just enqueues (~0.01ms) and xterm parses async — 2000 lines parse
421
- // in ~8ms off the render loop (measured), so the deeper history is free.
422
- // -J joins wrapped lines so re-wrapping stays sane.
423
- const seed = await this.client
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
424
697
  .command(`capture-pane -p -e -J -S -2000 -t ${pane.id}`)
425
- .catch(() => []);
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;
426
710
  // The control client reads replies as latin1 (one JS char per byte —
427
711
  // required for the protocol), so the seed is a byte string in disguise:
428
712
  // re-encode latin1 → bytes before feeding the VT parser, or every
429
713
  // multibyte glyph shatters into mojibake (…→â¦, ⇡→â¡ — user-reported,
430
714
  // "weird a's with a roof"). The live %output path already decodes bytes.
431
715
  if (seed.length > 0) {
432
- mirror.write(Buffer.from(seed.join("\r\n") + "\r\n", "latin1"));
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`);
433
721
  }
434
722
  }
435
- for (const pane of resized) {
436
- this.mirrors.get(pane.id)?.resize(pane.width, pane.height);
437
- }
438
-
439
- this.geometry = next;
440
- this.opts.onStatus?.(`${next.length} panes`);
441
- this.opts.onDirty?.();
442
723
  }
443
724
  }