tmux-ide 2.9.6 → 2.9.8
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 +2881 -1336
- package/package.json +2 -2
- package/packages/contracts/src/index.ts +5 -0
- package/packages/contracts/src/pane-stream.ts +54 -0
- package/packages/contracts/src/terminal-graphics.ts +118 -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 +208 -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 +12 -4
- package/packages/daemon/dist/native/tmux/darwin-arm64/tmux +0 -0
- package/packages/daemon/dist/native/tmux/linux-arm64/manifest.json +9 -1
- package/packages/daemon/dist/native/tmux/linux-arm64/tmux +0 -0
- package/packages/daemon/dist/native/tmux/linux-x64/manifest.json +9 -1
- package/packages/daemon/dist/native/tmux/linux-x64/tmux +0 -0
- package/packages/daemon/dist/terminal/attachments/native-runtime.js +25 -12
- package/packages/daemon/dist/terminal/graphics/kitty-graphics.js +699 -0
- package/packages/daemon/dist/terminal/mirror/control-channel.js +17 -1
- package/packages/daemon/dist/terminal/mirror/mirror-service.js +7 -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 +56 -4
- package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +180 -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 +29 -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 +85 -1
- package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +30 -0
- package/packages/daemon/dist/terminal/session-runtime/transport-binding.js +53 -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/attachments/native-runtime.ts +28 -13
- package/packages/daemon/src/terminal/graphics/__fixtures__/full-frame-client-recorded.ctl +87 -0
- package/packages/daemon/src/terminal/graphics/__fixtures__/full-frame-client.ctl +10 -0
- package/packages/daemon/src/terminal/graphics/kitty-graphics.ts +756 -0
- package/packages/daemon/src/terminal/mirror/control-channel.ts +18 -1
- package/packages/daemon/src/terminal/mirror/mirror-service.ts +14 -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 +75 -3
- package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +199 -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 +38 -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 +99 -1
- package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +39 -0
- package/packages/daemon/src/terminal/session-runtime/transport-binding.ts +58 -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 +37 -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,699 @@
|
|
|
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
|
+
* kept across capture reseeds only when the coordinate basis is unchanged.
|
|
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 { deflateSync, 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; the interpreter validates cursor placement coordinates. */
|
|
300
|
+
export class KittyImageStore {
|
|
301
|
+
#images = new Map();
|
|
302
|
+
#used = new Map();
|
|
303
|
+
#cursorPlacements = [];
|
|
304
|
+
/** `U=1` virtual placements per image, by placement id (0 when unnamed). */
|
|
305
|
+
#virtual = new Map();
|
|
306
|
+
#pending = null;
|
|
307
|
+
#nextAnonymous = 0x7f000000;
|
|
308
|
+
#bytes = 0;
|
|
309
|
+
#cellWidth;
|
|
310
|
+
#cellHeight;
|
|
311
|
+
#budget;
|
|
312
|
+
#revision = 0;
|
|
313
|
+
constructor(options = {}) {
|
|
314
|
+
this.#cellWidth = options.cellWidthPx ?? 10;
|
|
315
|
+
this.#cellHeight = options.cellHeightPx ?? 20;
|
|
316
|
+
this.#budget = options.budget ?? DEFAULT_KITTY_IMAGE_BUDGET;
|
|
317
|
+
this.#budget.register(this);
|
|
318
|
+
}
|
|
319
|
+
/** Bumps whenever images or placements change. */
|
|
320
|
+
get revision() {
|
|
321
|
+
return this.#revision;
|
|
322
|
+
}
|
|
323
|
+
get bytes() {
|
|
324
|
+
return this.#bytes;
|
|
325
|
+
}
|
|
326
|
+
images() {
|
|
327
|
+
return this.#images;
|
|
328
|
+
}
|
|
329
|
+
imageByDigest(digest) {
|
|
330
|
+
for (const image of this.#images.values())
|
|
331
|
+
if (image.digest === digest)
|
|
332
|
+
return image;
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
cursorPlacements() {
|
|
336
|
+
return this.#cursorPlacements;
|
|
337
|
+
}
|
|
338
|
+
dispose() {
|
|
339
|
+
this.#budget.unregister(this);
|
|
340
|
+
this.#images.clear();
|
|
341
|
+
this.#used.clear();
|
|
342
|
+
this.#bytes = 0;
|
|
343
|
+
}
|
|
344
|
+
/** The coordinate basis changed: tmux holds no record to restore cursor anchors. */
|
|
345
|
+
dropCursorPlacements() {
|
|
346
|
+
if (this.#cursorPlacements.length === 0)
|
|
347
|
+
return;
|
|
348
|
+
this.#cursorPlacements.length = 0;
|
|
349
|
+
this.#revision += 1;
|
|
350
|
+
}
|
|
351
|
+
oldestUse() {
|
|
352
|
+
let oldest = Infinity;
|
|
353
|
+
for (const used of this.#used.values())
|
|
354
|
+
oldest = Math.min(oldest, used);
|
|
355
|
+
return oldest;
|
|
356
|
+
}
|
|
357
|
+
/** Evict the least recently used image; returns the bytes freed. */
|
|
358
|
+
evictOldest() {
|
|
359
|
+
let victim = null;
|
|
360
|
+
let oldest = Infinity;
|
|
361
|
+
for (const [id, used] of this.#used)
|
|
362
|
+
if (used < oldest) {
|
|
363
|
+
oldest = used;
|
|
364
|
+
victim = id;
|
|
365
|
+
}
|
|
366
|
+
if (victim === null)
|
|
367
|
+
return 0;
|
|
368
|
+
return this.#remove(victim);
|
|
369
|
+
}
|
|
370
|
+
#remove(imageId, keepPlacements = false) {
|
|
371
|
+
const image = this.#images.get(imageId);
|
|
372
|
+
if (!image)
|
|
373
|
+
return 0;
|
|
374
|
+
this.#images.delete(imageId);
|
|
375
|
+
this.#used.delete(imageId);
|
|
376
|
+
if (keepPlacements) {
|
|
377
|
+
this.#bytes -= image.data.byteLength;
|
|
378
|
+
this.#revision += 1;
|
|
379
|
+
return image.data.byteLength;
|
|
380
|
+
}
|
|
381
|
+
this.#virtual.delete(imageId);
|
|
382
|
+
this.#bytes -= image.data.byteLength;
|
|
383
|
+
const kept = this.#cursorPlacements.filter((p) => p.imageId !== imageId);
|
|
384
|
+
this.#cursorPlacements.length = 0;
|
|
385
|
+
this.#cursorPlacements.push(...kept);
|
|
386
|
+
this.#revision += 1;
|
|
387
|
+
return image.data.byteLength;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Apply one command. `cursor` is the parser's position at the command's byte
|
|
391
|
+
* offset (from the marker OSC); null when unavailable.
|
|
392
|
+
*/
|
|
393
|
+
apply(command, cursor) {
|
|
394
|
+
let { control } = command;
|
|
395
|
+
const more = control.get("m") === "1";
|
|
396
|
+
if (this.#pending) {
|
|
397
|
+
// Continuation chunks carry only m (and q); the first chunk's keys rule.
|
|
398
|
+
this.#pending.payload.push(command.payload);
|
|
399
|
+
this.#pending.bytes += command.payload.length;
|
|
400
|
+
if (this.#pending.bytes > MAX_COMMAND_BYTES) {
|
|
401
|
+
this.#pending = null;
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (more)
|
|
405
|
+
return;
|
|
406
|
+
control = this.#pending.control;
|
|
407
|
+
const payload = this.#pending.payload.join("");
|
|
408
|
+
this.#pending = null;
|
|
409
|
+
this.#finish(control, payload, cursor);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const action = control.get("a") ?? "t";
|
|
413
|
+
if ((action === "t" || action === "T" || action === "f") && more) {
|
|
414
|
+
this.#pending = { control, payload: [command.payload], bytes: command.payload.length };
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
this.#finish(control, command.payload, cursor);
|
|
418
|
+
}
|
|
419
|
+
#finish(control, payload, cursor) {
|
|
420
|
+
const action = control.get("a") ?? "t";
|
|
421
|
+
if (action === "d")
|
|
422
|
+
return this.#delete(control);
|
|
423
|
+
if (action === "q")
|
|
424
|
+
return; // Answered by the patched tmux; nothing to store.
|
|
425
|
+
let imageId = int(control, "i");
|
|
426
|
+
if (action === "t" || action === "T") {
|
|
427
|
+
if ((control.get("t") ?? "d") !== "d")
|
|
428
|
+
return; // file/shm cannot cross machines.
|
|
429
|
+
if (imageId === 0)
|
|
430
|
+
imageId = this.#nextAnonymous++;
|
|
431
|
+
const image = this.#decode(imageId, control, payload);
|
|
432
|
+
if (!image)
|
|
433
|
+
return;
|
|
434
|
+
// Re-transmitting an id replaces its pixels; its placements keep showing it.
|
|
435
|
+
this.#remove(imageId, true);
|
|
436
|
+
this.#images.set(imageId, image);
|
|
437
|
+
this.#bytes += image.data.byteLength;
|
|
438
|
+
this.#used.set(imageId, this.#budget.nextTick());
|
|
439
|
+
this.#revision += 1;
|
|
440
|
+
this.#enforceBudget();
|
|
441
|
+
if (action === "t" || !this.#images.has(imageId))
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (action === "f")
|
|
445
|
+
return this.#editFrame(imageId, control, payload);
|
|
446
|
+
if (action !== "T" && action !== "p")
|
|
447
|
+
return;
|
|
448
|
+
const image = this.#images.get(imageId);
|
|
449
|
+
if (!image)
|
|
450
|
+
return;
|
|
451
|
+
this.#used.set(imageId, this.#budget.nextTick());
|
|
452
|
+
const columns = clampCells(int(control, "c") || Math.ceil(image.width / this.#cellWidth));
|
|
453
|
+
const rows = clampCells(int(control, "r") || Math.ceil(image.height / this.#cellHeight));
|
|
454
|
+
const placementId = int(control, "p");
|
|
455
|
+
if (control.get("U") === "1") {
|
|
456
|
+
const placements = this.#virtual.get(imageId) ?? new Map();
|
|
457
|
+
placements.set(placementId, { columns, rows });
|
|
458
|
+
this.#virtual.set(imageId, placements);
|
|
459
|
+
let count = 0;
|
|
460
|
+
for (const entries of this.#virtual.values())
|
|
461
|
+
count += entries.size;
|
|
462
|
+
for (const [id, entries] of this.#virtual) {
|
|
463
|
+
for (const placement of entries.keys()) {
|
|
464
|
+
if (count <= TERMINAL_GRAPHICS_MAX_PLACEMENTS)
|
|
465
|
+
break;
|
|
466
|
+
entries.delete(placement);
|
|
467
|
+
count -= 1;
|
|
468
|
+
}
|
|
469
|
+
if (entries.size === 0)
|
|
470
|
+
this.#virtual.delete(id);
|
|
471
|
+
if (count <= TERMINAL_GRAPHICS_MAX_PLACEMENTS)
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
else if (cursor) {
|
|
476
|
+
const existing = this.#cursorPlacements.findIndex((p) => p.imageId === imageId && placementId !== 0 && p.placementId === placementId);
|
|
477
|
+
if (existing >= 0)
|
|
478
|
+
this.#cursorPlacements.splice(existing, 1);
|
|
479
|
+
if (this.#cursorPlacements.length >= TERMINAL_GRAPHICS_MAX_PLACEMENTS)
|
|
480
|
+
this.#cursorPlacements.shift();
|
|
481
|
+
this.#cursorPlacements.push({
|
|
482
|
+
imageId,
|
|
483
|
+
placementId,
|
|
484
|
+
line: cursor.line,
|
|
485
|
+
column: cursor.x,
|
|
486
|
+
columns,
|
|
487
|
+
rows,
|
|
488
|
+
alternate: cursor.alternate,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
this.#revision += 1;
|
|
492
|
+
}
|
|
493
|
+
#decode(imageId, control, payload) {
|
|
494
|
+
let data = Buffer.from(payload, "base64");
|
|
495
|
+
const f = int(control, "f", 32);
|
|
496
|
+
const zlib = control.get("o") === "z";
|
|
497
|
+
// Raw pixels stay compressed end to end: renderers inflate off their UI
|
|
498
|
+
// thread, and the stream carries a fraction of the bytes. PNG and other
|
|
499
|
+
// compressed payloads are inflated here.
|
|
500
|
+
const keepCompressed = zlib && (f === 32 || f === 24);
|
|
501
|
+
if (zlib && !keepCompressed) {
|
|
502
|
+
try {
|
|
503
|
+
data = inflateSync(data, { maxOutputLength: TERMINAL_GRAPHICS_MAX_IMAGE_BYTES });
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (data.byteLength === 0 || data.byteLength > TERMINAL_GRAPHICS_MAX_IMAGE_BYTES)
|
|
510
|
+
return null;
|
|
511
|
+
let format;
|
|
512
|
+
let width;
|
|
513
|
+
let height;
|
|
514
|
+
if (f === 100) {
|
|
515
|
+
const size = pngDimensions(data);
|
|
516
|
+
if (!size)
|
|
517
|
+
return null;
|
|
518
|
+
({ width, height } = size);
|
|
519
|
+
format = "png";
|
|
520
|
+
}
|
|
521
|
+
else if (f === 32 || f === 24) {
|
|
522
|
+
width = int(control, "s");
|
|
523
|
+
height = int(control, "v");
|
|
524
|
+
const bytes = width * height * (f === 32 ? 4 : 3);
|
|
525
|
+
if (!width || !height || bytes > MAX_RAW_IMAGE_BYTES)
|
|
526
|
+
return null;
|
|
527
|
+
if (keepCompressed)
|
|
528
|
+
format = f === 32 ? "rgba-zlib" : "rgb-zlib";
|
|
529
|
+
else {
|
|
530
|
+
format = f === 32 ? "rgba" : "rgb";
|
|
531
|
+
if (data.length !== bytes)
|
|
532
|
+
return null;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
else
|
|
536
|
+
return null;
|
|
537
|
+
if (width < 1 || height < 1 || width > MAX_IMAGE_SIDE || height > MAX_IMAGE_SIDE)
|
|
538
|
+
return null;
|
|
539
|
+
const digest = createHash("sha256").update(data).digest("hex").slice(0, 32);
|
|
540
|
+
return { imageId, format, width, height, data, digest };
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* `a=f` edits of the root frame (`r=1`): RGBA/RGB rectangles written at
|
|
544
|
+
* `x,y`, replacing (`X=1`) or alpha-blending the existing pixels. New
|
|
545
|
+
* animation frames, `c` base frames and PNG data are not modelled.
|
|
546
|
+
*/
|
|
547
|
+
#editFrame(imageId, control, payload) {
|
|
548
|
+
const image = this.#images.get(imageId);
|
|
549
|
+
if (!image || int(control, "r") !== 1 || (control.get("t") ?? "d") !== "d")
|
|
550
|
+
return;
|
|
551
|
+
const source = image.format === "rgba" || image.format === "rgba-zlib" ? 4 : 3;
|
|
552
|
+
if (image.format === "png")
|
|
553
|
+
return;
|
|
554
|
+
let pixels;
|
|
555
|
+
let patch = Buffer.from(payload, "base64");
|
|
556
|
+
try {
|
|
557
|
+
pixels =
|
|
558
|
+
image.format === "rgba-zlib" || image.format === "rgb-zlib"
|
|
559
|
+
? inflateSync(image.data, { maxOutputLength: MAX_RAW_IMAGE_BYTES })
|
|
560
|
+
: Uint8Array.from(image.data);
|
|
561
|
+
if (control.get("o") === "z")
|
|
562
|
+
patch = inflateSync(patch, { maxOutputLength: MAX_RAW_IMAGE_BYTES });
|
|
563
|
+
}
|
|
564
|
+
catch {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const f = int(control, "f", 32);
|
|
568
|
+
const depth = f === 24 ? 3 : 4;
|
|
569
|
+
const width = int(control, "s") || image.width;
|
|
570
|
+
const height = int(control, "v") || image.height;
|
|
571
|
+
const x0 = int(control, "x");
|
|
572
|
+
const y0 = int(control, "y");
|
|
573
|
+
if ((f !== 32 && f !== 24) || patch.length !== width * height * depth)
|
|
574
|
+
return;
|
|
575
|
+
if (pixels.length !== image.width * image.height * source)
|
|
576
|
+
return;
|
|
577
|
+
const replace = control.get("X") === "1";
|
|
578
|
+
for (let row = 0; row < height && y0 + row < image.height; row += 1)
|
|
579
|
+
for (let col = 0; col < width && x0 + col < image.width; col += 1) {
|
|
580
|
+
const from = (row * width + col) * depth;
|
|
581
|
+
const to = ((y0 + row) * image.width + x0 + col) * source;
|
|
582
|
+
const alpha = depth === 4 && !replace ? patch[from + 3] / 255 : 1;
|
|
583
|
+
for (let channel = 0; channel < 3; channel += 1)
|
|
584
|
+
pixels[to + channel] = Math.round(patch[from + channel] * alpha + pixels[to + channel] * (1 - alpha));
|
|
585
|
+
if (source === 4)
|
|
586
|
+
pixels[to + 3] =
|
|
587
|
+
depth === 4 && replace
|
|
588
|
+
? patch[from + 3]
|
|
589
|
+
: Math.round(255 * alpha + pixels[to + 3] * (1 - alpha));
|
|
590
|
+
}
|
|
591
|
+
const data = deflateSync(pixels, { level: 1 });
|
|
592
|
+
const format = source === 4 ? "rgba-zlib" : "rgb-zlib";
|
|
593
|
+
this.#remove(imageId, true);
|
|
594
|
+
this.#images.set(imageId, {
|
|
595
|
+
...image,
|
|
596
|
+
format,
|
|
597
|
+
data,
|
|
598
|
+
digest: createHash("sha256").update(data).digest("hex").slice(0, 32),
|
|
599
|
+
});
|
|
600
|
+
this.#bytes += data.byteLength;
|
|
601
|
+
this.#used.set(imageId, this.#budget.nextTick());
|
|
602
|
+
this.#revision += 1;
|
|
603
|
+
this.#enforceBudget();
|
|
604
|
+
}
|
|
605
|
+
#enforceBudget() {
|
|
606
|
+
while ((this.#bytes > this.#budget.perPaneBytes || this.#images.size > MAX_IMAGES_PER_PANE) &&
|
|
607
|
+
this.#images.size > 0)
|
|
608
|
+
this.evictOldest();
|
|
609
|
+
this.#budget.enforce();
|
|
610
|
+
}
|
|
611
|
+
#delete(control) {
|
|
612
|
+
const what = control.get("d") ?? "a";
|
|
613
|
+
const id = int(control, "i");
|
|
614
|
+
const all = what === "a" || what === "A";
|
|
615
|
+
const byId = (what === "i" || what === "I") && id !== 0;
|
|
616
|
+
if (!all && !byId)
|
|
617
|
+
return;
|
|
618
|
+
// With `p`, only that placement of the image goes (spec: d=i/I with p).
|
|
619
|
+
const placementId = control.has("p") ? int(control, "p") : null;
|
|
620
|
+
const doomed = (imageId, placement) => all || (imageId === id && (placementId === null || placement === placementId));
|
|
621
|
+
const kept = this.#cursorPlacements.filter((p) => !doomed(p.imageId, p.placementId));
|
|
622
|
+
if (kept.length !== this.#cursorPlacements.length) {
|
|
623
|
+
this.#cursorPlacements.length = 0;
|
|
624
|
+
this.#cursorPlacements.push(...kept);
|
|
625
|
+
this.#revision += 1;
|
|
626
|
+
}
|
|
627
|
+
for (const [imageId, placements] of [...this.#virtual])
|
|
628
|
+
for (const placement of [...placements.keys()])
|
|
629
|
+
if (doomed(imageId, placement)) {
|
|
630
|
+
placements.delete(placement);
|
|
631
|
+
if (placements.size === 0)
|
|
632
|
+
this.#virtual.delete(imageId);
|
|
633
|
+
this.#revision += 1;
|
|
634
|
+
}
|
|
635
|
+
// Upper-case variants also free the image data.
|
|
636
|
+
if (what === "A")
|
|
637
|
+
for (const key of [...this.#images.keys()])
|
|
638
|
+
this.#remove(key);
|
|
639
|
+
if (what === "I" && placementId === null)
|
|
640
|
+
this.#remove(id);
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* The pane's graphics state for renderers: the image registry (with each
|
|
644
|
+
* image's placeholder grid) and cursor placements clipped to the viewport.
|
|
645
|
+
* `viewportTop` is the absolute line of grid row 0.
|
|
646
|
+
*/
|
|
647
|
+
state(viewportTop, gridRows, cols, alternate) {
|
|
648
|
+
const images = [...this.#images.values()].slice(-TERMINAL_GRAPHICS_MAX_IMAGES).map((image) => ({
|
|
649
|
+
imageId: image.imageId,
|
|
650
|
+
digest: image.digest,
|
|
651
|
+
format: image.format,
|
|
652
|
+
width: image.width,
|
|
653
|
+
height: image.height,
|
|
654
|
+
bytes: image.data.byteLength,
|
|
655
|
+
placeholder: virtualGrid(this.#virtual.get(image.imageId)),
|
|
656
|
+
}));
|
|
657
|
+
const placements = [];
|
|
658
|
+
for (const placement of this.#cursorPlacements) {
|
|
659
|
+
if (placement.alternate !== alternate)
|
|
660
|
+
continue;
|
|
661
|
+
const image = this.#images.get(placement.imageId);
|
|
662
|
+
if (!image)
|
|
663
|
+
continue;
|
|
664
|
+
const top = placement.line - viewportTop;
|
|
665
|
+
const first = Math.max(0, top);
|
|
666
|
+
const last = Math.min(gridRows, top + placement.rows);
|
|
667
|
+
const right = Math.min(cols, placement.column + placement.columns);
|
|
668
|
+
if (first >= last || placement.column >= right)
|
|
669
|
+
continue;
|
|
670
|
+
placements.push({
|
|
671
|
+
digest: image.digest,
|
|
672
|
+
row: first,
|
|
673
|
+
column: placement.column,
|
|
674
|
+
rows: last - first,
|
|
675
|
+
columns: right - placement.column,
|
|
676
|
+
sourceRow: first - top,
|
|
677
|
+
sourceColumn: 0,
|
|
678
|
+
totalRows: placement.rows,
|
|
679
|
+
totalColumns: placement.columns,
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
return { revision: this.#revision, images, placements };
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
const MAX_IMAGES_PER_PANE = TERMINAL_GRAPHICS_MAX_IMAGES;
|
|
686
|
+
/** Largest decoded raw image (a 4K RGBA frame is ~33 MB). */
|
|
687
|
+
const MAX_RAW_IMAGE_BYTES = 64 * 1024 * 1024;
|
|
688
|
+
/**
|
|
689
|
+
* Placeholder cells carry the image id; with several virtual placements the
|
|
690
|
+
* protocol picks one by the placement id in the underline colour. Renderers
|
|
691
|
+
* resolve cells by image id, so publish the most recently placed grid.
|
|
692
|
+
*/
|
|
693
|
+
function virtualGrid(placements) {
|
|
694
|
+
if (!placements || placements.size === 0)
|
|
695
|
+
return null;
|
|
696
|
+
return [...placements.values()].at(-1);
|
|
697
|
+
}
|
|
698
|
+
const MAX_IMAGE_SIDE = 10_000;
|
|
699
|
+
const clampCells = (cells) => Math.max(1, Math.min(1_000, cells));
|