tmux-ide 2.9.6 → 2.9.7
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/bin/cli.js +2597 -1322
- package/package.json +1 -1
- package/packages/contracts/src/index.ts +5 -0
- package/packages/contracts/src/pane-stream.ts +37 -0
- package/packages/contracts/src/terminal-graphics.ts +92 -0
- package/packages/contracts/src/terminal-history-anchor.ts +61 -0
- package/packages/contracts/src/tmux-server-diagnostics.ts +16 -0
- package/packages/core/src/index.ts +1 -0
- package/packages/core/src/kitty-placeholders.ts +190 -0
- package/packages/daemon/dist/command-center/tmux-server-diagnostics.js +24 -0
- package/packages/daemon/dist/command-center/tmux-server-history-anchor.js +48 -0
- package/packages/daemon/dist/command-center/tmux-servers.js +11 -0
- package/packages/daemon/dist/lib/canonical-daemon-bootstrap.js +23 -1
- package/packages/daemon/dist/lib/daemon-embed.js +21 -0
- package/packages/daemon/dist/lib/tmux-server-owner.js +10 -1
- package/packages/daemon/dist/lib/tmux-session-mutation-fence.js +2 -1
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +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 +2 -2
- package/packages/daemon/dist/native/tmux/darwin-arm64/lib/libevent_core-2.1.7.dylib +0 -0
- package/packages/daemon/dist/native/tmux/darwin-arm64/lib/libutf8proc.3.3.0.dylib +0 -0
- package/packages/daemon/dist/native/tmux/darwin-arm64/manifest.json +8 -4
- package/packages/daemon/dist/native/tmux/darwin-arm64/tmux +0 -0
- package/packages/daemon/dist/native/tmux/linux-arm64/manifest.json +5 -1
- package/packages/daemon/dist/native/tmux/linux-arm64/tmux +0 -0
- package/packages/daemon/dist/native/tmux/linux-x64/manifest.json +5 -1
- package/packages/daemon/dist/native/tmux/linux-x64/tmux +0 -0
- package/packages/daemon/dist/terminal/graphics/kitty-graphics.js +578 -0
- package/packages/daemon/dist/terminal/mirror/control-channel.js +17 -1
- package/packages/daemon/dist/terminal/mirror/mirror-service.js +1 -0
- package/packages/daemon/dist/terminal/mirror/native-grid-capture.js +8 -0
- package/packages/daemon/dist/terminal/mirror/native-grid-reader.js +6 -1
- package/packages/daemon/dist/terminal/mirror/session-channel.js +11 -2
- package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +78 -1
- package/packages/daemon/dist/terminal/protocol/session-descriptor-discovery.js +3 -2
- package/packages/daemon/dist/terminal/session-runtime/history-anchor-owner.js +171 -0
- package/packages/daemon/dist/terminal/session-runtime/registry.js +20 -0
- package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +20 -1
- package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +68 -1
- package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +30 -0
- package/packages/daemon/dist/terminal/session-runtime/transport-binding.js +49 -21
- package/packages/daemon/dist/terminal/session-runtime/xterm-terminal-interpreter-backend.js +11 -0
- package/packages/daemon/dist/tui/mirror/runtime/application-home-agents-owner.js +7 -1
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-input-ingress.js +35 -5
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-paste.js +2 -2
- package/packages/daemon/dist/tui/mirror/runtime/terminal-input-adapter.js +2 -2
- package/packages/daemon/src/command-center/tmux-server-diagnostics.ts +38 -0
- package/packages/daemon/src/command-center/tmux-server-history-anchor.ts +69 -0
- package/packages/daemon/src/command-center/tmux-servers.ts +11 -0
- package/packages/daemon/src/lib/canonical-daemon-bootstrap.ts +20 -2
- package/packages/daemon/src/lib/daemon-embed.ts +26 -0
- package/packages/daemon/src/lib/tmux-server-owner.ts +10 -0
- package/packages/daemon/src/lib/tmux-session-mutation-fence.ts +2 -1
- package/packages/daemon/src/terminal/graphics/kitty-graphics.ts +641 -0
- package/packages/daemon/src/terminal/mirror/control-channel.ts +18 -1
- package/packages/daemon/src/terminal/mirror/mirror-service.ts +3 -0
- package/packages/daemon/src/terminal/mirror/native-grid-capture.ts +11 -0
- package/packages/daemon/src/terminal/mirror/native-grid-reader.ts +10 -1
- package/packages/daemon/src/terminal/mirror/session-channel.ts +14 -1
- package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +82 -0
- package/packages/daemon/src/terminal/protocol/session-descriptor-discovery.ts +3 -2
- package/packages/daemon/src/terminal/session-runtime/history-anchor-owner.ts +190 -0
- package/packages/daemon/src/terminal/session-runtime/registry.ts +20 -0
- package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +38 -0
- package/packages/daemon/src/terminal/session-runtime/terminal-interpreter-backend.ts +4 -0
- package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +83 -1
- package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +39 -0
- package/packages/daemon/src/terminal/session-runtime/transport-binding.ts +53 -24
- package/packages/daemon/src/terminal/session-runtime/xterm-terminal-interpreter-backend.ts +13 -0
- package/packages/daemon/src/tui/mirror/runtime/application-home-agents-owner.ts +6 -1
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-input-ingress.ts +44 -9
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-paste.ts +2 -1
- package/packages/daemon/src/tui/mirror/runtime/terminal-input-adapter.ts +5 -2
- package/packages/daemon-client/src/fleet-connection-status.ts +1 -1
- package/packages/daemon-client/src/pane-creation-refusal.ts +48 -0
- package/packages/daemon-client/src/pane-stream-client.ts +29 -0
- package/packages/daemon-client/src/tmux-server-client.ts +133 -6
- package/packages/daemon/dist/native/tmux/darwin-arm64/lib/libutf8proc.3.2.3.dylib +0 -0
- /package/packages/daemon/dist/native/tmux/darwin-arm64/licenses/{libutf8proc.3.2.3.dylib.txt → libutf8proc.3.3.0.dylib.txt} +0 -0
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kitty graphics protocol extraction for the daemon pane feed.
|
|
3
|
+
*
|
|
4
|
+
* tmux parses and drops APC sequences, so its grid never holds images. Its
|
|
5
|
+
* control-mode `%output` stream, however, carries the pane's raw bytes before
|
|
6
|
+
* that parse (octal-escaped, then decoded by `decodeControlBytes`), so the
|
|
7
|
+
* daemon sees every `ESC _ G … ESC \` intact — split across arbitrary
|
|
8
|
+
* `%output` lines. This module is the pure half: a streaming scanner, a
|
|
9
|
+
* control-key parser, and a budgeted per-pane image store.
|
|
10
|
+
*
|
|
11
|
+
* Placement models:
|
|
12
|
+
*
|
|
13
|
+
* - Unicode placeholders (`U=1` virtual placement + U+10EEEE cells): the
|
|
14
|
+
* position is ordinary grid text, so tmux keeps it through scroll, resize,
|
|
15
|
+
* copy-mode and capture-pane. The store only records the image and its
|
|
16
|
+
* virtual cell grid; renderers resolve the cells (`@tmux-ide/core`).
|
|
17
|
+
* - cursor-anchored (`a=T`/`a=p` without `U=1`), best effort: positioned by a
|
|
18
|
+
* marker OSC the scanner substitutes in place of the APC, so the VT parser
|
|
19
|
+
* reports the cursor at the exact byte position. tmux never sees these
|
|
20
|
+
* placements: they do not move the cursor, do not follow reflow, and are
|
|
21
|
+
* dropped by a capture reseed.
|
|
22
|
+
*
|
|
23
|
+
* Limits: direct transmission (`t=d`) only — file and shared-memory media
|
|
24
|
+
* cannot cross machines; `a=d` handles all and by-id; animation, relative
|
|
25
|
+
* placements and z-ordering are not modelled. Acknowledgements (`a=q`) come
|
|
26
|
+
* from the patched tmux, in stream order (native/tmux/kitty-graphics.patch).
|
|
27
|
+
*/
|
|
28
|
+
import { createHash } from "node:crypto";
|
|
29
|
+
import { inflateSync } from "node:zlib";
|
|
30
|
+
import { TERMINAL_GRAPHICS_MAX_IMAGE_BYTES, TERMINAL_GRAPHICS_MAX_IMAGES, TERMINAL_GRAPHICS_MAX_PLACEMENTS, } from "@tmux-ide/contracts";
|
|
31
|
+
/** Private OSC used to mark an APC's byte position for the VT parser. */
|
|
32
|
+
export const KITTY_PLACEMENT_OSC = 6974;
|
|
33
|
+
const ESC = 0x1b;
|
|
34
|
+
const MAX_COMMAND_BYTES = 32 * 1024 * 1024;
|
|
35
|
+
const PASSTHROUGH_PREFIX = [0x74, 0x6d, 0x75, 0x78, 0x3b]; // "tmux;"
|
|
36
|
+
/** PURE — parse `G<control>;<payload>` (the APC body after `ESC _`). */
|
|
37
|
+
export function parseKittyCommand(body) {
|
|
38
|
+
if (!body.startsWith("G"))
|
|
39
|
+
return null;
|
|
40
|
+
const separator = body.indexOf(";");
|
|
41
|
+
const controlText = separator < 0 ? body.slice(1) : body.slice(1, separator);
|
|
42
|
+
const payload = separator < 0 ? "" : body.slice(separator + 1);
|
|
43
|
+
const control = new Map();
|
|
44
|
+
for (const pair of controlText.split(",")) {
|
|
45
|
+
if (!pair)
|
|
46
|
+
continue;
|
|
47
|
+
const eq = pair.indexOf("=");
|
|
48
|
+
if (eq !== 1)
|
|
49
|
+
return null;
|
|
50
|
+
control.set(pair[0], pair.slice(2));
|
|
51
|
+
}
|
|
52
|
+
return { control, payload };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Streaming extractor. One instance per pane feed; chunks may split any
|
|
56
|
+
* sequence at any byte. Also unwraps tmux DCS passthrough
|
|
57
|
+
* (`ESC P tmux; … ESC \` with doubled ESCs), which reaches control mode
|
|
58
|
+
* verbatim regardless of `allow-passthrough`.
|
|
59
|
+
*/
|
|
60
|
+
export class KittyApcScanner {
|
|
61
|
+
#state = "ground";
|
|
62
|
+
#held = [];
|
|
63
|
+
#body = [];
|
|
64
|
+
#bodyBytes = 0;
|
|
65
|
+
#prefixMatched = 0;
|
|
66
|
+
#inner = [];
|
|
67
|
+
#nextMarker = 1;
|
|
68
|
+
#overflowed = false;
|
|
69
|
+
scan(chunk) {
|
|
70
|
+
// Verbatim runs stay views of the chunk; ordinary output is never copied.
|
|
71
|
+
const parts = [];
|
|
72
|
+
const emit = (...bytes) => void parts.push(Uint8Array.from(bytes));
|
|
73
|
+
const commands = [];
|
|
74
|
+
let ground = -1; // start of a pending verbatim run
|
|
75
|
+
let escAt = -1; // index of a held ESC inside this chunk (-1: held from an earlier chunk)
|
|
76
|
+
const flushGround = (end) => {
|
|
77
|
+
if (ground >= 0) {
|
|
78
|
+
if (end > ground)
|
|
79
|
+
parts.push(chunk.subarray(ground, end));
|
|
80
|
+
ground = -1;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const complete = (bodyBytes) => {
|
|
84
|
+
const command = parseKittyCommand(Buffer.from(bodyBytes).toString("latin1"));
|
|
85
|
+
if (!command)
|
|
86
|
+
return;
|
|
87
|
+
const marker = this.#nextMarker++;
|
|
88
|
+
commands.push({ ...command, marker });
|
|
89
|
+
parts.push(Buffer.from(`\x1b]${KITTY_PLACEMENT_OSC};${marker}\x07`, "latin1"));
|
|
90
|
+
};
|
|
91
|
+
for (let i = 0; i < chunk.length; i += 1) {
|
|
92
|
+
const byte = chunk[i];
|
|
93
|
+
switch (this.#state) {
|
|
94
|
+
case "ground":
|
|
95
|
+
// The ESC stays in the verbatim run until it turns out to start an APC/DCS.
|
|
96
|
+
if (ground < 0)
|
|
97
|
+
ground = i;
|
|
98
|
+
if (byte === ESC) {
|
|
99
|
+
escAt = i;
|
|
100
|
+
this.#held = [byte];
|
|
101
|
+
this.#state = "esc";
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
case "esc":
|
|
105
|
+
if (byte === 0x5f || byte === 0x50) {
|
|
106
|
+
if (escAt >= 0)
|
|
107
|
+
flushGround(escAt);
|
|
108
|
+
this.#held.push(byte);
|
|
109
|
+
if (byte === 0x50)
|
|
110
|
+
this.#prefixMatched = 0;
|
|
111
|
+
this.#state = byte === 0x5f ? "apc-start" : "dcs-start";
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
if (escAt < 0) {
|
|
115
|
+
emit(...this.#held);
|
|
116
|
+
ground = i;
|
|
117
|
+
}
|
|
118
|
+
if (byte === ESC) {
|
|
119
|
+
escAt = i;
|
|
120
|
+
this.#held = [byte];
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
this.#held = [];
|
|
124
|
+
this.#state = "ground";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
break;
|
|
128
|
+
case "apc-start":
|
|
129
|
+
if (byte === 0x47) {
|
|
130
|
+
this.#body = [Uint8Array.of(byte)];
|
|
131
|
+
this.#bodyBytes = 1;
|
|
132
|
+
this.#overflowed = false;
|
|
133
|
+
this.#held = [];
|
|
134
|
+
this.#state = "apc-body";
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
// Another APC: the VT parser ignores it; pass it through untouched.
|
|
138
|
+
emit(...this.#held, byte);
|
|
139
|
+
this.#held = [];
|
|
140
|
+
this.#state = "ground";
|
|
141
|
+
}
|
|
142
|
+
break;
|
|
143
|
+
case "apc-body": {
|
|
144
|
+
const end = chunk.indexOf(ESC, i);
|
|
145
|
+
const stop = end < 0 ? chunk.length : end;
|
|
146
|
+
if (!this.#overflowed && this.#bodyBytes + (stop - i) <= MAX_COMMAND_BYTES) {
|
|
147
|
+
this.#body.push(chunk.subarray(i, stop).slice());
|
|
148
|
+
this.#bodyBytes += stop - i;
|
|
149
|
+
}
|
|
150
|
+
else
|
|
151
|
+
this.#overflowed = true;
|
|
152
|
+
i = stop - 1;
|
|
153
|
+
if (end >= 0) {
|
|
154
|
+
i = end;
|
|
155
|
+
this.#state = "apc-esc";
|
|
156
|
+
}
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
case "apc-esc": {
|
|
160
|
+
const body = this.#overflowed ? null : Buffer.concat(this.#body);
|
|
161
|
+
this.#body = [];
|
|
162
|
+
this.#bodyBytes = 0;
|
|
163
|
+
if (body)
|
|
164
|
+
complete(body);
|
|
165
|
+
this.#state = "ground";
|
|
166
|
+
// ESC \ is the terminator; anything else aborts the string and is reparsed.
|
|
167
|
+
if (byte !== 0x5c) {
|
|
168
|
+
this.#state = "esc";
|
|
169
|
+
this.#held = [ESC];
|
|
170
|
+
escAt = -1;
|
|
171
|
+
i -= 1;
|
|
172
|
+
}
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
case "dcs-start":
|
|
176
|
+
if (byte === PASSTHROUGH_PREFIX[this.#prefixMatched]) {
|
|
177
|
+
this.#held.push(byte);
|
|
178
|
+
this.#prefixMatched += 1;
|
|
179
|
+
if (this.#prefixMatched === PASSTHROUGH_PREFIX.length) {
|
|
180
|
+
this.#held = [];
|
|
181
|
+
this.#inner = [];
|
|
182
|
+
this.#state = "pt-body";
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
emit(...this.#held, byte);
|
|
187
|
+
this.#held = [];
|
|
188
|
+
this.#state = "ground";
|
|
189
|
+
}
|
|
190
|
+
break;
|
|
191
|
+
case "pt-body":
|
|
192
|
+
if (byte === ESC)
|
|
193
|
+
this.#state = "pt-esc";
|
|
194
|
+
else if (this.#inner.length < MAX_COMMAND_BYTES)
|
|
195
|
+
this.#inner.push(byte);
|
|
196
|
+
break;
|
|
197
|
+
case "pt-esc":
|
|
198
|
+
if (byte === ESC) {
|
|
199
|
+
this.#inner.push(ESC);
|
|
200
|
+
this.#state = "pt-body";
|
|
201
|
+
}
|
|
202
|
+
else if (byte === 0x5c) {
|
|
203
|
+
// Passthrough content targets the outer terminal — which is us.
|
|
204
|
+
// Only its kitty commands matter; other wrapped output is dropped.
|
|
205
|
+
const nested = new KittyApcScanner();
|
|
206
|
+
nested.#nextMarker = this.#nextMarker;
|
|
207
|
+
const result = nested.scan(Uint8Array.from(this.#inner));
|
|
208
|
+
this.#nextMarker = nested.#nextMarker;
|
|
209
|
+
for (const command of result.commands) {
|
|
210
|
+
commands.push(command);
|
|
211
|
+
parts.push(Buffer.from(`\x1b]${KITTY_PLACEMENT_OSC};${command.marker}\x07`, "latin1"));
|
|
212
|
+
}
|
|
213
|
+
this.#inner = [];
|
|
214
|
+
this.#state = "ground";
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
this.#inner.push(ESC, byte);
|
|
218
|
+
this.#state = "pt-body";
|
|
219
|
+
}
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// A trailing ESC is held for the next chunk, not forwarded twice.
|
|
224
|
+
flushGround(this.#state === "esc" && escAt >= 0 ? escAt : chunk.length);
|
|
225
|
+
const forward = parts.length === 1 && parts[0].length === chunk.length && parts[0].buffer === chunk.buffer
|
|
226
|
+
? chunk
|
|
227
|
+
: Uint8Array.from(Buffer.concat(parts));
|
|
228
|
+
return { forward, commands };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function int(control, key, fallback = 0) {
|
|
232
|
+
const value = control.get(key);
|
|
233
|
+
if (value === undefined || !/^-?[0-9]+$/u.test(value))
|
|
234
|
+
return fallback;
|
|
235
|
+
return Number(value);
|
|
236
|
+
}
|
|
237
|
+
/** PURE — width/height from a PNG IHDR, or null. */
|
|
238
|
+
export function pngDimensions(data) {
|
|
239
|
+
if (data.length < 24)
|
|
240
|
+
return null;
|
|
241
|
+
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
242
|
+
if (signature.some((b, i) => data[i] !== b))
|
|
243
|
+
return null;
|
|
244
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
245
|
+
return { width: view.getUint32(16), height: view.getUint32(20) };
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Byte quotas shared by every pane store in the daemon. Each pane keeps at
|
|
249
|
+
* most `perPaneBytes`; across panes the least recently used image goes first.
|
|
250
|
+
* An evicted image disappears from its pane's graphics state, so renderers
|
|
251
|
+
* stop placing it (placeholder cells then render as blank).
|
|
252
|
+
*/
|
|
253
|
+
export class KittyImageBudget {
|
|
254
|
+
perPaneBytes;
|
|
255
|
+
globalBytes;
|
|
256
|
+
#stores = new Set();
|
|
257
|
+
#tick = 0;
|
|
258
|
+
constructor(options) {
|
|
259
|
+
this.perPaneBytes = options.perPaneBytes;
|
|
260
|
+
this.globalBytes = options.globalBytes;
|
|
261
|
+
}
|
|
262
|
+
get totalBytes() {
|
|
263
|
+
let total = 0;
|
|
264
|
+
for (const store of this.#stores)
|
|
265
|
+
total += store.bytes;
|
|
266
|
+
return total;
|
|
267
|
+
}
|
|
268
|
+
register(store) {
|
|
269
|
+
this.#stores.add(store);
|
|
270
|
+
}
|
|
271
|
+
unregister(store) {
|
|
272
|
+
this.#stores.delete(store);
|
|
273
|
+
}
|
|
274
|
+
nextTick() {
|
|
275
|
+
return ++this.#tick;
|
|
276
|
+
}
|
|
277
|
+
enforce() {
|
|
278
|
+
let total = this.totalBytes;
|
|
279
|
+
while (total > this.globalBytes) {
|
|
280
|
+
let victim = null;
|
|
281
|
+
let oldest = Infinity;
|
|
282
|
+
for (const store of this.#stores) {
|
|
283
|
+
const used = store.oldestUse();
|
|
284
|
+
if (used < oldest) {
|
|
285
|
+
oldest = used;
|
|
286
|
+
victim = store;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (!victim)
|
|
290
|
+
return;
|
|
291
|
+
total -= victim.evictOldest();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
export const DEFAULT_KITTY_IMAGE_BUDGET = new KittyImageBudget({
|
|
296
|
+
perPaneBytes: 64 * 1024 * 1024,
|
|
297
|
+
globalBytes: 256 * 1024 * 1024,
|
|
298
|
+
});
|
|
299
|
+
/** Per-pane image store. Survives reseeds; cursor placements do not. */
|
|
300
|
+
export class KittyImageStore {
|
|
301
|
+
#images = new Map();
|
|
302
|
+
#used = new Map();
|
|
303
|
+
#cursorPlacements = [];
|
|
304
|
+
#virtual = new Map();
|
|
305
|
+
#pending = null;
|
|
306
|
+
#nextAnonymous = 0x7f000000;
|
|
307
|
+
#bytes = 0;
|
|
308
|
+
#cellWidth;
|
|
309
|
+
#cellHeight;
|
|
310
|
+
#budget;
|
|
311
|
+
#revision = 0;
|
|
312
|
+
constructor(options = {}) {
|
|
313
|
+
this.#cellWidth = options.cellWidthPx ?? 10;
|
|
314
|
+
this.#cellHeight = options.cellHeightPx ?? 20;
|
|
315
|
+
this.#budget = options.budget ?? DEFAULT_KITTY_IMAGE_BUDGET;
|
|
316
|
+
this.#budget.register(this);
|
|
317
|
+
}
|
|
318
|
+
/** Bumps whenever images or placements change. */
|
|
319
|
+
get revision() {
|
|
320
|
+
return this.#revision;
|
|
321
|
+
}
|
|
322
|
+
get bytes() {
|
|
323
|
+
return this.#bytes;
|
|
324
|
+
}
|
|
325
|
+
images() {
|
|
326
|
+
return this.#images;
|
|
327
|
+
}
|
|
328
|
+
imageByDigest(digest) {
|
|
329
|
+
for (const image of this.#images.values())
|
|
330
|
+
if (image.digest === digest)
|
|
331
|
+
return image;
|
|
332
|
+
return undefined;
|
|
333
|
+
}
|
|
334
|
+
cursorPlacements() {
|
|
335
|
+
return this.#cursorPlacements;
|
|
336
|
+
}
|
|
337
|
+
dispose() {
|
|
338
|
+
this.#budget.unregister(this);
|
|
339
|
+
this.#images.clear();
|
|
340
|
+
this.#used.clear();
|
|
341
|
+
this.#bytes = 0;
|
|
342
|
+
}
|
|
343
|
+
/** A capture reseed replaced the grid: tmux holds no record of cursor placements. */
|
|
344
|
+
dropCursorPlacements() {
|
|
345
|
+
if (this.#cursorPlacements.length === 0)
|
|
346
|
+
return;
|
|
347
|
+
this.#cursorPlacements.length = 0;
|
|
348
|
+
this.#revision += 1;
|
|
349
|
+
}
|
|
350
|
+
oldestUse() {
|
|
351
|
+
let oldest = Infinity;
|
|
352
|
+
for (const used of this.#used.values())
|
|
353
|
+
oldest = Math.min(oldest, used);
|
|
354
|
+
return oldest;
|
|
355
|
+
}
|
|
356
|
+
/** Evict the least recently used image; returns the bytes freed. */
|
|
357
|
+
evictOldest() {
|
|
358
|
+
let victim = null;
|
|
359
|
+
let oldest = Infinity;
|
|
360
|
+
for (const [id, used] of this.#used)
|
|
361
|
+
if (used < oldest) {
|
|
362
|
+
oldest = used;
|
|
363
|
+
victim = id;
|
|
364
|
+
}
|
|
365
|
+
if (victim === null)
|
|
366
|
+
return 0;
|
|
367
|
+
return this.#remove(victim);
|
|
368
|
+
}
|
|
369
|
+
#remove(imageId) {
|
|
370
|
+
const image = this.#images.get(imageId);
|
|
371
|
+
if (!image)
|
|
372
|
+
return 0;
|
|
373
|
+
this.#images.delete(imageId);
|
|
374
|
+
this.#used.delete(imageId);
|
|
375
|
+
this.#virtual.delete(imageId);
|
|
376
|
+
this.#bytes -= image.data.byteLength;
|
|
377
|
+
const kept = this.#cursorPlacements.filter((p) => p.imageId !== imageId);
|
|
378
|
+
this.#cursorPlacements.length = 0;
|
|
379
|
+
this.#cursorPlacements.push(...kept);
|
|
380
|
+
this.#revision += 1;
|
|
381
|
+
return image.data.byteLength;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Apply one command. `cursor` is the parser's position at the command's byte
|
|
385
|
+
* offset (from the marker OSC); null when unavailable.
|
|
386
|
+
*/
|
|
387
|
+
apply(command, cursor) {
|
|
388
|
+
let { control } = command;
|
|
389
|
+
const more = control.get("m") === "1";
|
|
390
|
+
if (this.#pending) {
|
|
391
|
+
// Continuation chunks carry only m (and q); the first chunk's keys rule.
|
|
392
|
+
this.#pending.payload.push(command.payload);
|
|
393
|
+
this.#pending.bytes += command.payload.length;
|
|
394
|
+
if (this.#pending.bytes > MAX_COMMAND_BYTES) {
|
|
395
|
+
this.#pending = null;
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (more)
|
|
399
|
+
return;
|
|
400
|
+
control = this.#pending.control;
|
|
401
|
+
const payload = this.#pending.payload.join("");
|
|
402
|
+
this.#pending = null;
|
|
403
|
+
this.#finish(control, payload, cursor);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const action = control.get("a") ?? "t";
|
|
407
|
+
if ((action === "t" || action === "T") && more) {
|
|
408
|
+
this.#pending = { control, payload: [command.payload], bytes: command.payload.length };
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
this.#finish(control, command.payload, cursor);
|
|
412
|
+
}
|
|
413
|
+
#finish(control, payload, cursor) {
|
|
414
|
+
const action = control.get("a") ?? "t";
|
|
415
|
+
if (action === "d")
|
|
416
|
+
return this.#delete(control);
|
|
417
|
+
if (action === "q")
|
|
418
|
+
return; // Answered by the patched tmux; nothing to store.
|
|
419
|
+
let imageId = int(control, "i");
|
|
420
|
+
if (action === "t" || action === "T") {
|
|
421
|
+
if ((control.get("t") ?? "d") !== "d")
|
|
422
|
+
return; // file/shm cannot cross machines.
|
|
423
|
+
if (imageId === 0)
|
|
424
|
+
imageId = this.#nextAnonymous++;
|
|
425
|
+
const image = this.#decode(imageId, control, payload);
|
|
426
|
+
if (!image)
|
|
427
|
+
return;
|
|
428
|
+
this.#remove(imageId);
|
|
429
|
+
this.#images.set(imageId, image);
|
|
430
|
+
this.#bytes += image.data.byteLength;
|
|
431
|
+
this.#used.set(imageId, this.#budget.nextTick());
|
|
432
|
+
this.#revision += 1;
|
|
433
|
+
while ((this.#bytes > this.#budget.perPaneBytes || this.#images.size > MAX_IMAGES_PER_PANE) &&
|
|
434
|
+
this.#images.size > 1)
|
|
435
|
+
this.evictOldest();
|
|
436
|
+
this.#budget.enforce();
|
|
437
|
+
if (action === "t" || !this.#images.has(imageId))
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (action !== "T" && action !== "p")
|
|
441
|
+
return;
|
|
442
|
+
const image = this.#images.get(imageId);
|
|
443
|
+
if (!image)
|
|
444
|
+
return;
|
|
445
|
+
this.#used.set(imageId, this.#budget.nextTick());
|
|
446
|
+
const columns = clampCells(int(control, "c") || Math.ceil(image.width / this.#cellWidth));
|
|
447
|
+
const rows = clampCells(int(control, "r") || Math.ceil(image.height / this.#cellHeight));
|
|
448
|
+
const placementId = int(control, "p");
|
|
449
|
+
if (control.get("U") === "1") {
|
|
450
|
+
this.#virtual.set(imageId, { columns, rows });
|
|
451
|
+
}
|
|
452
|
+
else if (cursor) {
|
|
453
|
+
const existing = this.#cursorPlacements.findIndex((p) => p.imageId === imageId && placementId !== 0 && p.placementId === placementId);
|
|
454
|
+
if (existing >= 0)
|
|
455
|
+
this.#cursorPlacements.splice(existing, 1);
|
|
456
|
+
if (this.#cursorPlacements.length >= TERMINAL_GRAPHICS_MAX_PLACEMENTS)
|
|
457
|
+
this.#cursorPlacements.shift();
|
|
458
|
+
this.#cursorPlacements.push({
|
|
459
|
+
imageId,
|
|
460
|
+
placementId,
|
|
461
|
+
line: cursor.line,
|
|
462
|
+
column: cursor.x,
|
|
463
|
+
columns,
|
|
464
|
+
rows,
|
|
465
|
+
alternate: cursor.alternate,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
this.#revision += 1;
|
|
469
|
+
}
|
|
470
|
+
#decode(imageId, control, payload) {
|
|
471
|
+
let data = Buffer.from(payload, "base64");
|
|
472
|
+
if (control.get("o") === "z") {
|
|
473
|
+
try {
|
|
474
|
+
data = inflateSync(data, { maxOutputLength: TERMINAL_GRAPHICS_MAX_IMAGE_BYTES });
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (data.byteLength === 0 || data.byteLength > TERMINAL_GRAPHICS_MAX_IMAGE_BYTES)
|
|
481
|
+
return null;
|
|
482
|
+
const f = int(control, "f", 32);
|
|
483
|
+
let format;
|
|
484
|
+
let width;
|
|
485
|
+
let height;
|
|
486
|
+
if (f === 100) {
|
|
487
|
+
const size = pngDimensions(data);
|
|
488
|
+
if (!size)
|
|
489
|
+
return null;
|
|
490
|
+
({ width, height } = size);
|
|
491
|
+
format = "png";
|
|
492
|
+
}
|
|
493
|
+
else if (f === 32 || f === 24) {
|
|
494
|
+
width = int(control, "s");
|
|
495
|
+
height = int(control, "v");
|
|
496
|
+
format = f === 32 ? "rgba" : "rgb";
|
|
497
|
+
if (!width || !height || data.length !== width * height * (f === 32 ? 4 : 3))
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
else
|
|
501
|
+
return null;
|
|
502
|
+
if (width < 1 || height < 1 || width > MAX_IMAGE_SIDE || height > MAX_IMAGE_SIDE)
|
|
503
|
+
return null;
|
|
504
|
+
const digest = createHash("sha256").update(data).digest("hex").slice(0, 32);
|
|
505
|
+
return { imageId, format, width, height, data, digest };
|
|
506
|
+
}
|
|
507
|
+
#delete(control) {
|
|
508
|
+
const what = control.get("d") ?? "a";
|
|
509
|
+
const id = int(control, "i");
|
|
510
|
+
const all = what === "a" || what === "A";
|
|
511
|
+
const byId = (what === "i" || what === "I") && id !== 0;
|
|
512
|
+
if (!all && !byId)
|
|
513
|
+
return;
|
|
514
|
+
const keep = (imageId) => !all && imageId !== id;
|
|
515
|
+
const kept = this.#cursorPlacements.filter((p) => keep(p.imageId));
|
|
516
|
+
if (kept.length !== this.#cursorPlacements.length) {
|
|
517
|
+
this.#cursorPlacements.length = 0;
|
|
518
|
+
this.#cursorPlacements.push(...kept);
|
|
519
|
+
this.#revision += 1;
|
|
520
|
+
}
|
|
521
|
+
for (const key of [...this.#virtual.keys()])
|
|
522
|
+
if (!keep(key)) {
|
|
523
|
+
this.#virtual.delete(key);
|
|
524
|
+
this.#revision += 1;
|
|
525
|
+
}
|
|
526
|
+
// Upper-case variants also free the image data.
|
|
527
|
+
if (what === "A")
|
|
528
|
+
for (const key of [...this.#images.keys()])
|
|
529
|
+
this.#remove(key);
|
|
530
|
+
if (what === "I")
|
|
531
|
+
this.#remove(id);
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* The pane's graphics state for renderers: the image registry (with each
|
|
535
|
+
* image's placeholder grid) and cursor placements clipped to the viewport.
|
|
536
|
+
* `viewportTop` is the absolute line of grid row 0.
|
|
537
|
+
*/
|
|
538
|
+
state(viewportTop, gridRows, cols, alternate) {
|
|
539
|
+
const images = [...this.#images.values()].slice(-TERMINAL_GRAPHICS_MAX_IMAGES).map((image) => ({
|
|
540
|
+
imageId: image.imageId,
|
|
541
|
+
digest: image.digest,
|
|
542
|
+
format: image.format,
|
|
543
|
+
width: image.width,
|
|
544
|
+
height: image.height,
|
|
545
|
+
bytes: image.data.byteLength,
|
|
546
|
+
placeholder: this.#virtual.get(image.imageId) ?? null,
|
|
547
|
+
}));
|
|
548
|
+
const placements = [];
|
|
549
|
+
for (const placement of this.#cursorPlacements) {
|
|
550
|
+
if (placement.alternate !== alternate)
|
|
551
|
+
continue;
|
|
552
|
+
const image = this.#images.get(placement.imageId);
|
|
553
|
+
if (!image)
|
|
554
|
+
continue;
|
|
555
|
+
const top = placement.line - viewportTop;
|
|
556
|
+
const first = Math.max(0, top);
|
|
557
|
+
const last = Math.min(gridRows, top + placement.rows);
|
|
558
|
+
const right = Math.min(cols, placement.column + placement.columns);
|
|
559
|
+
if (first >= last || placement.column >= right)
|
|
560
|
+
continue;
|
|
561
|
+
placements.push({
|
|
562
|
+
digest: image.digest,
|
|
563
|
+
row: first,
|
|
564
|
+
column: placement.column,
|
|
565
|
+
rows: last - first,
|
|
566
|
+
columns: right - placement.column,
|
|
567
|
+
sourceRow: first - top,
|
|
568
|
+
sourceColumn: 0,
|
|
569
|
+
totalRows: placement.rows,
|
|
570
|
+
totalColumns: placement.columns,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
return { revision: this.#revision, images, placements };
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
const MAX_IMAGES_PER_PANE = TERMINAL_GRAPHICS_MAX_IMAGES;
|
|
577
|
+
const MAX_IMAGE_SIDE = 10_000;
|
|
578
|
+
const clampCells = (cells) => Math.max(1, Math.min(1_000, cells));
|
|
@@ -868,6 +868,16 @@ export class ControlChannelCore {
|
|
|
868
868
|
}));
|
|
869
869
|
}
|
|
870
870
|
}
|
|
871
|
+
/**
|
|
872
|
+
* Server-wide, idempotent: `-q` keeps a stock tmux (no such option) quiet.
|
|
873
|
+
* Images reach this control client either way; the option only lets programs
|
|
874
|
+
* detect support, and the environment tells new panes' programs that this
|
|
875
|
+
* server's renderer shows placeholder images.
|
|
876
|
+
*/
|
|
877
|
+
export const KITTY_GRAPHICS_SETUP = [
|
|
878
|
+
"set-option -gq kitty-graphics on",
|
|
879
|
+
"set-environment -g TMUX_IDE_GRAPHICS kitty",
|
|
880
|
+
];
|
|
871
881
|
export function mirrorControlAttachArgs(options, pauseAfterSeconds = DEFAULT_PAUSE_AFTER_SECONDS) {
|
|
872
882
|
const attach = [
|
|
873
883
|
"attach",
|
|
@@ -1009,7 +1019,13 @@ export class MirrorControlChannel {
|
|
|
1009
1019
|
this.core.fail(String(err));
|
|
1010
1020
|
reject(err);
|
|
1011
1021
|
});
|
|
1012
|
-
}).then(() =>
|
|
1022
|
+
}).then(() => {
|
|
1023
|
+
// One command per send: tmux answers a `;` list with one block per command.
|
|
1024
|
+
if (this.opts.kittyGraphics)
|
|
1025
|
+
for (const command of KITTY_GRAPHICS_SETUP)
|
|
1026
|
+
this.send(command);
|
|
1027
|
+
return this.initializeNativeViewer();
|
|
1028
|
+
});
|
|
1013
1029
|
}
|
|
1014
1030
|
get nativeViewerIdentity() {
|
|
1015
1031
|
return this.viewerIdentity ? { ...this.viewerIdentity } : null;
|
|
@@ -532,6 +532,7 @@ export class MirrorService {
|
|
|
532
532
|
nativeServerIdentity: this.opts.nativeServerIdentity,
|
|
533
533
|
configFile: this.opts.configFile,
|
|
534
534
|
pauseAfterSeconds: this.opts.pauseAfterSeconds,
|
|
535
|
+
kittyGraphics: this.opts.kittyGraphics ?? process.env.TMUX_IDE_KITTY_GRAPHICS !== "0",
|
|
535
536
|
nowMicros: this.opts.nowMicros,
|
|
536
537
|
nativeViewer: ownedViewer?.controlOptions(),
|
|
537
538
|
nativeViewerReady: ownedViewer
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/** Upstream tmux 3.8 reuses -R for a textual grid dump, not our JSON extension.
|
|
2
|
+
* Require the generated grid and first-row headers; arbitrary malformed native
|
|
3
|
+
* replies must remain errors rather than silently downgrading capture fidelity.
|
|
4
|
+
*/
|
|
5
|
+
export function isStockRawGridCapture(lines) {
|
|
6
|
+
return (/^G [1-9][0-9]*x[1-9][0-9]* \([0-9]+\/[0-9]+\)$/.test(lines[0] ?? "") &&
|
|
7
|
+
/^\tL 0 \((?:-|0)\) flags=/.test(lines[1] ?? ""));
|
|
8
|
+
}
|
|
1
9
|
const MAX_BYTES = 16 * 1024 * 1024;
|
|
2
10
|
const MAX_ROWS = MAX_BYTES / 64;
|
|
3
11
|
const MAX_CELLS = 1_000_000;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { decodeNativeGridCapture } from "./native-grid-capture.js";
|
|
1
|
+
import { decodeNativeGridCapture, isStockRawGridCapture, } from "./native-grid-capture.js";
|
|
2
2
|
/** One experimental capability owner per control connection. */
|
|
3
3
|
export class NativeGridCaptureReader {
|
|
4
4
|
io;
|
|
@@ -57,6 +57,11 @@ export class NativeGridCaptureReader {
|
|
|
57
57
|
finish({ status: "changed" });
|
|
58
58
|
return;
|
|
59
59
|
}
|
|
60
|
+
if (isStockRawGridCapture(reply.lines)) {
|
|
61
|
+
this.unsupported = true;
|
|
62
|
+
finish({ status: "unsupported" });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
60
65
|
const snapshot = decodeNativeGridCapture(reply.lines.join("\n"));
|
|
61
66
|
finish(snapshot
|
|
62
67
|
? {
|