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.
- package/README.md +22 -5
- package/bin/cli.js +3532 -1090
- package/bin/cli.ts +368 -71
- package/package.json +2 -1
- package/packages/contracts/src/__tests__/control.test.ts +154 -0
- package/packages/contracts/src/control.ts +217 -0
- package/packages/contracts/src/index.ts +1 -0
- package/packages/daemon/dist/control/client.d.ts +23 -0
- package/packages/daemon/dist/control/client.js +105 -0
- package/packages/daemon/dist/control/dispatch.d.ts +34 -0
- package/packages/daemon/dist/control/dispatch.js +83 -0
- package/packages/daemon/dist/control/fanout.d.ts +19 -0
- package/packages/daemon/dist/control/fanout.js +37 -0
- package/packages/daemon/dist/control/frames.d.ts +23 -0
- package/packages/daemon/dist/control/frames.js +37 -0
- package/packages/daemon/dist/control/lifecycle.d.ts +45 -0
- package/packages/daemon/dist/control/lifecycle.js +114 -0
- package/packages/daemon/dist/control/server.d.ts +16 -0
- package/packages/daemon/dist/control/server.js +214 -0
- package/packages/daemon/dist/control/verbs.d.ts +11 -0
- package/packages/daemon/dist/control/verbs.js +91 -0
- package/packages/daemon/dist/doctor.d.ts +18 -0
- package/packages/daemon/dist/doctor.js +105 -15
- package/packages/daemon/dist/lib/agent-discovery.d.ts +27 -2
- package/packages/daemon/dist/lib/agent-discovery.js +29 -14
- package/packages/daemon/dist/lib/app-config.d.ts +106 -0
- package/packages/daemon/dist/lib/app-config.js +104 -5
- package/packages/daemon/dist/lib/manifest-pack.d.ts +79 -0
- package/packages/daemon/dist/lib/manifest-pack.js +232 -0
- package/packages/daemon/dist/lib/state-home.d.ts +2 -0
- package/packages/daemon/dist/lib/state-home.js +12 -0
- package/packages/daemon/dist/lib/update-check.js +5 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +34 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/PkgInfo +1 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +139 -0
- package/packages/daemon/dist/restore.d.ts +35 -8
- package/packages/daemon/dist/restore.js +52 -15
- package/packages/daemon/dist/send.d.ts +33 -1
- package/packages/daemon/dist/send.js +32 -19
- package/packages/daemon/src/control/client.ts +128 -0
- package/packages/daemon/src/control/dispatch.ts +107 -0
- package/packages/daemon/src/control/fanout.ts +44 -0
- package/packages/daemon/src/control/frames.ts +40 -0
- package/packages/daemon/src/control/lifecycle.ts +151 -0
- package/packages/daemon/src/control/server.ts +237 -0
- package/packages/daemon/src/control/verbs.ts +118 -0
- package/packages/daemon/src/doctor.ts +113 -28
- package/packages/daemon/src/lib/agent-discovery.ts +53 -13
- package/packages/daemon/src/lib/app-config.ts +103 -5
- package/packages/daemon/src/lib/manifest-pack.ts +255 -0
- package/packages/daemon/src/lib/state-home.ts +13 -0
- package/packages/daemon/src/lib/update-check.ts +5 -0
- package/packages/daemon/src/restore.ts +53 -15
- package/packages/daemon/src/send.ts +55 -21
- package/packages/daemon/src/tui/chrome/events.ts +4 -4
- package/packages/daemon/src/tui/chrome/front-door.ts +39 -0
- package/packages/daemon/src/tui/chrome/notify-prefs.ts +58 -0
- package/packages/daemon/src/tui/chrome/notify-state.ts +76 -0
- package/packages/daemon/src/tui/chrome/notify.ts +582 -84
- package/packages/daemon/src/tui/chrome/updater.ts +268 -62
- package/packages/daemon/src/tui/detect/classify.ts +34 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +54 -5
- package/packages/daemon/src/tui/detect/manifest.ts +24 -3
- package/packages/daemon/src/tui/detect/manifests.ts +240 -6
- package/packages/daemon/src/tui/detect/process-tree.ts +13 -3
- package/packages/daemon/src/tui/detect/session-id.ts +503 -0
- package/packages/daemon/src/tui/integrations/opencode.ts +121 -0
- package/packages/daemon/src/tui/mirror/agent-chip.ts +40 -11
- package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
- package/packages/daemon/src/tui/mirror/agent-rows.ts +27 -5
- package/packages/daemon/src/tui/mirror/app-state.ts +171 -8
- package/packages/daemon/src/tui/mirror/app.tsx +2182 -399
- package/packages/daemon/src/tui/mirror/attention.ts +110 -0
- package/packages/daemon/src/tui/mirror/dialog-stack.ts +17 -4
- package/packages/daemon/src/tui/mirror/diff-model.ts +279 -4
- package/packages/daemon/src/tui/mirror/file-tree.ts +231 -6
- package/packages/daemon/src/tui/mirror/host-terminal.ts +49 -0
- package/packages/daemon/src/tui/mirror/hosted.ts +205 -0
- package/packages/daemon/src/tui/mirror/layout-parse.ts +154 -0
- package/packages/daemon/src/tui/mirror/menu-model.ts +27 -4
- package/packages/daemon/src/tui/mirror/palette.ts +299 -9
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +82 -4
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +18 -11
- package/packages/daemon/src/tui/mirror/perf-tap.ts +29 -3
- package/packages/daemon/src/tui/mirror/selection.ts +122 -8
- package/packages/daemon/src/tui/mirror/session-mirror.ts +349 -68
- package/packages/daemon/src/tui/mirror/settings-model.ts +96 -16
- package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
- package/packages/daemon/src/tui/mirror/size-truth.ts +53 -0
- package/packages/daemon/src/tui/mirror/theme.ts +45 -0
- package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
- package/packages/daemon/src/tui/team/sessions.ts +85 -7
- package/packages/daemon/src/tui/team/wait.ts +144 -0
- package/scripts/build-macos-notifier.mjs +160 -0
- package/scripts/postinstall.js +8 -1
- package/scripts/prepublish-check.mjs +37 -1
- package/scripts/publish-tap.sh +55 -0
- 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.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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 —
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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)
|
|
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
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
403
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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
|
|
411
|
-
const mirror =
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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")
|
|
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
|
}
|