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.
- package/README.md +36 -14
- package/bin/cli.js +4174 -1235
- package/bin/cli.ts +426 -72
- package/package.json +10 -6
- 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/agent-explain.d.ts +8 -1
- package/packages/daemon/dist/agent-explain.js +19 -3
- 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/tui-binary.d.ts +57 -0
- package/packages/daemon/dist/lib/tui-binary.js +130 -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/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
- package/packages/daemon/src/agent-explain.ts +34 -6
- 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 +194 -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/tui-binary.ts +165 -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 +737 -52
- package/packages/daemon/src/tui/chrome/updater.ts +318 -30
- package/packages/daemon/src/tui/compiled.ts +11 -3
- package/packages/daemon/src/tui/detect/classify.ts +49 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +56 -6
- package/packages/daemon/src/tui/detect/manifest.ts +39 -3
- package/packages/daemon/src/tui/detect/manifests.ts +395 -33
- package/packages/daemon/src/tui/detect/process-tree.ts +33 -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/main.ts +13 -1
- package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
- package/packages/daemon/src/tui/mirror/agent-chip.ts +126 -0
- package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
- package/packages/daemon/src/tui/mirror/agent-rows.ts +155 -0
- package/packages/daemon/src/tui/mirror/app-state.ts +342 -0
- package/packages/daemon/src/tui/mirror/app.tsx +7048 -0
- package/packages/daemon/src/tui/mirror/attention.ts +110 -0
- package/packages/daemon/src/tui/mirror/blit.ts +186 -0
- package/packages/daemon/src/tui/mirror/control-client.ts +80 -9
- package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
- package/packages/daemon/src/tui/mirror/dialog-stack.ts +367 -0
- package/packages/daemon/src/tui/mirror/diff-model.ts +387 -0
- package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
- package/packages/daemon/src/tui/mirror/file-tree.ts +322 -0
- package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
- package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
- package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
- 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/input-coalescer.ts +105 -0
- package/packages/daemon/src/tui/mirror/layout-parse.ts +154 -0
- package/packages/daemon/src/tui/mirror/menu-model.ts +210 -0
- package/packages/daemon/src/tui/mirror/palette.ts +564 -0
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +578 -20
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +422 -0
- package/packages/daemon/src/tui/mirror/perf-tap.ts +186 -0
- package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
- package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
- package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
- package/packages/daemon/src/tui/mirror/selection.ts +376 -0
- package/packages/daemon/src/tui/mirror/session-mirror.ts +724 -0
- package/packages/daemon/src/tui/mirror/settings-model.ts +425 -0
- package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
- package/packages/daemon/src/tui/mirror/size-truth.ts +130 -0
- package/packages/daemon/src/tui/mirror/spans.ts +46 -0
- package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
- package/packages/daemon/src/tui/mirror/theme.ts +45 -0
- package/packages/daemon/src/tui/team/entry.ts +34 -7
- package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
- package/packages/daemon/src/tui/team/report.ts +11 -1
- package/packages/daemon/src/tui/team/sessions.ts +184 -17
- package/packages/daemon/src/tui/team/wait.ts +144 -0
- package/scripts/build-macos-notifier.mjs +160 -0
- package/scripts/build-tui.mjs +11 -4
- package/scripts/perf-mirror.mjs +313 -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 +110 -2
- package/templates/AGENTS.md +14 -7
- package/templates/agent-team-monorepo.yml +8 -0
- package/templates/agent-team-nextjs.yml +8 -0
- package/templates/agent-team.yml +10 -0
- package/templates/convex.yml +2 -0
- package/templates/default.yml +11 -5
- package/templates/go.yml +4 -0
- package/templates/missions.yml +6 -0
- package/templates/nextjs.yml +4 -0
- package/templates/python.yml +4 -0
- package/templates/skills/backend.md +5 -12
- package/templates/skills/frontend.md +5 -12
- package/templates/skills/general-worker.md +5 -12
- package/templates/skills/researcher.md +7 -12
- package/templates/skills/reviewer.md +7 -16
- package/templates/vite.yml +4 -0
- 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
|
+
}
|