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,641 @@
|
|
|
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 {
|
|
31
|
+
TERMINAL_GRAPHICS_MAX_IMAGE_BYTES,
|
|
32
|
+
TERMINAL_GRAPHICS_MAX_IMAGES,
|
|
33
|
+
TERMINAL_GRAPHICS_MAX_PLACEMENTS,
|
|
34
|
+
type TerminalGraphicsFormat,
|
|
35
|
+
type TerminalGraphicsPlacement,
|
|
36
|
+
type TerminalGraphicsState,
|
|
37
|
+
} from "@tmux-ide/contracts";
|
|
38
|
+
|
|
39
|
+
/** Private OSC used to mark an APC's byte position for the VT parser. */
|
|
40
|
+
export const KITTY_PLACEMENT_OSC = 6974;
|
|
41
|
+
const ESC = 0x1b;
|
|
42
|
+
const MAX_COMMAND_BYTES = 32 * 1024 * 1024;
|
|
43
|
+
const PASSTHROUGH_PREFIX = [0x74, 0x6d, 0x75, 0x78, 0x3b]; // "tmux;"
|
|
44
|
+
|
|
45
|
+
export interface KittyCommand {
|
|
46
|
+
readonly control: ReadonlyMap<string, string>;
|
|
47
|
+
readonly payload: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface KittyScanResult {
|
|
51
|
+
/** Bytes for the VT parser: APCs replaced by marker OSCs, everything else verbatim. */
|
|
52
|
+
readonly forward: Uint8Array;
|
|
53
|
+
/** Commands in stream order; `marker` matches the OSC payload forwarded in their place. */
|
|
54
|
+
readonly commands: readonly (KittyCommand & { readonly marker: number })[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type State =
|
|
58
|
+
| "ground"
|
|
59
|
+
| "esc"
|
|
60
|
+
| "apc-start"
|
|
61
|
+
| "apc-body"
|
|
62
|
+
| "apc-esc"
|
|
63
|
+
| "dcs-start"
|
|
64
|
+
| "pt-body"
|
|
65
|
+
| "pt-esc";
|
|
66
|
+
|
|
67
|
+
/** PURE — parse `G<control>;<payload>` (the APC body after `ESC _`). */
|
|
68
|
+
export function parseKittyCommand(body: string): KittyCommand | null {
|
|
69
|
+
if (!body.startsWith("G")) return null;
|
|
70
|
+
const separator = body.indexOf(";");
|
|
71
|
+
const controlText = separator < 0 ? body.slice(1) : body.slice(1, separator);
|
|
72
|
+
const payload = separator < 0 ? "" : body.slice(separator + 1);
|
|
73
|
+
const control = new Map<string, string>();
|
|
74
|
+
for (const pair of controlText.split(",")) {
|
|
75
|
+
if (!pair) continue;
|
|
76
|
+
const eq = pair.indexOf("=");
|
|
77
|
+
if (eq !== 1) return null;
|
|
78
|
+
control.set(pair[0]!, pair.slice(2));
|
|
79
|
+
}
|
|
80
|
+
return { control, payload };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Streaming extractor. One instance per pane feed; chunks may split any
|
|
85
|
+
* sequence at any byte. Also unwraps tmux DCS passthrough
|
|
86
|
+
* (`ESC P tmux; … ESC \` with doubled ESCs), which reaches control mode
|
|
87
|
+
* verbatim regardless of `allow-passthrough`.
|
|
88
|
+
*/
|
|
89
|
+
export class KittyApcScanner {
|
|
90
|
+
#state: State = "ground";
|
|
91
|
+
#held: number[] = [];
|
|
92
|
+
#body: Uint8Array[] = [];
|
|
93
|
+
#bodyBytes = 0;
|
|
94
|
+
#prefixMatched = 0;
|
|
95
|
+
#inner: number[] = [];
|
|
96
|
+
#nextMarker = 1;
|
|
97
|
+
#overflowed = false;
|
|
98
|
+
|
|
99
|
+
scan(chunk: Uint8Array): KittyScanResult {
|
|
100
|
+
// Verbatim runs stay views of the chunk; ordinary output is never copied.
|
|
101
|
+
const parts: Uint8Array[] = [];
|
|
102
|
+
const emit = (...bytes: number[]) => void parts.push(Uint8Array.from(bytes));
|
|
103
|
+
const commands: (KittyCommand & { marker: number })[] = [];
|
|
104
|
+
let ground = -1; // start of a pending verbatim run
|
|
105
|
+
let escAt = -1; // index of a held ESC inside this chunk (-1: held from an earlier chunk)
|
|
106
|
+
const flushGround = (end: number) => {
|
|
107
|
+
if (ground >= 0) {
|
|
108
|
+
if (end > ground) parts.push(chunk.subarray(ground, end));
|
|
109
|
+
ground = -1;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const complete = (bodyBytes: Uint8Array) => {
|
|
113
|
+
const command = parseKittyCommand(Buffer.from(bodyBytes).toString("latin1"));
|
|
114
|
+
if (!command) return;
|
|
115
|
+
const marker = this.#nextMarker++;
|
|
116
|
+
commands.push({ ...command, marker });
|
|
117
|
+
parts.push(Buffer.from(`\x1b]${KITTY_PLACEMENT_OSC};${marker}\x07`, "latin1"));
|
|
118
|
+
};
|
|
119
|
+
for (let i = 0; i < chunk.length; i += 1) {
|
|
120
|
+
const byte = chunk[i]!;
|
|
121
|
+
switch (this.#state) {
|
|
122
|
+
case "ground":
|
|
123
|
+
// The ESC stays in the verbatim run until it turns out to start an APC/DCS.
|
|
124
|
+
if (ground < 0) ground = i;
|
|
125
|
+
if (byte === ESC) {
|
|
126
|
+
escAt = i;
|
|
127
|
+
this.#held = [byte];
|
|
128
|
+
this.#state = "esc";
|
|
129
|
+
}
|
|
130
|
+
break;
|
|
131
|
+
case "esc":
|
|
132
|
+
if (byte === 0x5f || byte === 0x50) {
|
|
133
|
+
if (escAt >= 0) flushGround(escAt);
|
|
134
|
+
this.#held.push(byte);
|
|
135
|
+
if (byte === 0x50) this.#prefixMatched = 0;
|
|
136
|
+
this.#state = byte === 0x5f ? "apc-start" : "dcs-start";
|
|
137
|
+
} else {
|
|
138
|
+
if (escAt < 0) {
|
|
139
|
+
emit(...this.#held);
|
|
140
|
+
ground = i;
|
|
141
|
+
}
|
|
142
|
+
if (byte === ESC) {
|
|
143
|
+
escAt = i;
|
|
144
|
+
this.#held = [byte];
|
|
145
|
+
} else {
|
|
146
|
+
this.#held = [];
|
|
147
|
+
this.#state = "ground";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
break;
|
|
151
|
+
case "apc-start":
|
|
152
|
+
if (byte === 0x47) {
|
|
153
|
+
this.#body = [Uint8Array.of(byte)];
|
|
154
|
+
this.#bodyBytes = 1;
|
|
155
|
+
this.#overflowed = false;
|
|
156
|
+
this.#held = [];
|
|
157
|
+
this.#state = "apc-body";
|
|
158
|
+
} else {
|
|
159
|
+
// Another APC: the VT parser ignores it; pass it through untouched.
|
|
160
|
+
emit(...this.#held, byte);
|
|
161
|
+
this.#held = [];
|
|
162
|
+
this.#state = "ground";
|
|
163
|
+
}
|
|
164
|
+
break;
|
|
165
|
+
case "apc-body": {
|
|
166
|
+
const end = chunk.indexOf(ESC, i);
|
|
167
|
+
const stop = end < 0 ? chunk.length : end;
|
|
168
|
+
if (!this.#overflowed && this.#bodyBytes + (stop - i) <= MAX_COMMAND_BYTES) {
|
|
169
|
+
this.#body.push(chunk.subarray(i, stop).slice());
|
|
170
|
+
this.#bodyBytes += stop - i;
|
|
171
|
+
} else this.#overflowed = true;
|
|
172
|
+
i = stop - 1;
|
|
173
|
+
if (end >= 0) {
|
|
174
|
+
i = end;
|
|
175
|
+
this.#state = "apc-esc";
|
|
176
|
+
}
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
case "apc-esc": {
|
|
180
|
+
const body = this.#overflowed ? null : Buffer.concat(this.#body);
|
|
181
|
+
this.#body = [];
|
|
182
|
+
this.#bodyBytes = 0;
|
|
183
|
+
if (body) complete(body);
|
|
184
|
+
this.#state = "ground";
|
|
185
|
+
// ESC \ is the terminator; anything else aborts the string and is reparsed.
|
|
186
|
+
if (byte !== 0x5c) {
|
|
187
|
+
this.#state = "esc";
|
|
188
|
+
this.#held = [ESC];
|
|
189
|
+
escAt = -1;
|
|
190
|
+
i -= 1;
|
|
191
|
+
}
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
case "dcs-start":
|
|
195
|
+
if (byte === PASSTHROUGH_PREFIX[this.#prefixMatched]) {
|
|
196
|
+
this.#held.push(byte);
|
|
197
|
+
this.#prefixMatched += 1;
|
|
198
|
+
if (this.#prefixMatched === PASSTHROUGH_PREFIX.length) {
|
|
199
|
+
this.#held = [];
|
|
200
|
+
this.#inner = [];
|
|
201
|
+
this.#state = "pt-body";
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
emit(...this.#held, byte);
|
|
205
|
+
this.#held = [];
|
|
206
|
+
this.#state = "ground";
|
|
207
|
+
}
|
|
208
|
+
break;
|
|
209
|
+
case "pt-body":
|
|
210
|
+
if (byte === ESC) this.#state = "pt-esc";
|
|
211
|
+
else if (this.#inner.length < MAX_COMMAND_BYTES) this.#inner.push(byte);
|
|
212
|
+
break;
|
|
213
|
+
case "pt-esc":
|
|
214
|
+
if (byte === ESC) {
|
|
215
|
+
this.#inner.push(ESC);
|
|
216
|
+
this.#state = "pt-body";
|
|
217
|
+
} else if (byte === 0x5c) {
|
|
218
|
+
// Passthrough content targets the outer terminal — which is us.
|
|
219
|
+
// Only its kitty commands matter; other wrapped output is dropped.
|
|
220
|
+
const nested = new KittyApcScanner();
|
|
221
|
+
nested.#nextMarker = this.#nextMarker;
|
|
222
|
+
const result = nested.scan(Uint8Array.from(this.#inner));
|
|
223
|
+
this.#nextMarker = nested.#nextMarker;
|
|
224
|
+
for (const command of result.commands) {
|
|
225
|
+
commands.push(command);
|
|
226
|
+
parts.push(
|
|
227
|
+
Buffer.from(`\x1b]${KITTY_PLACEMENT_OSC};${command.marker}\x07`, "latin1"),
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
this.#inner = [];
|
|
231
|
+
this.#state = "ground";
|
|
232
|
+
} else {
|
|
233
|
+
this.#inner.push(ESC, byte);
|
|
234
|
+
this.#state = "pt-body";
|
|
235
|
+
}
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// A trailing ESC is held for the next chunk, not forwarded twice.
|
|
240
|
+
flushGround(this.#state === "esc" && escAt >= 0 ? escAt : chunk.length);
|
|
241
|
+
const forward =
|
|
242
|
+
parts.length === 1 && parts[0]!.length === chunk.length && parts[0]!.buffer === chunk.buffer
|
|
243
|
+
? chunk
|
|
244
|
+
: Uint8Array.from(Buffer.concat(parts));
|
|
245
|
+
return { forward, commands };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export type KittyImageFormat = TerminalGraphicsFormat;
|
|
250
|
+
|
|
251
|
+
export interface KittyImage {
|
|
252
|
+
readonly imageId: number;
|
|
253
|
+
readonly format: KittyImageFormat;
|
|
254
|
+
readonly width: number;
|
|
255
|
+
readonly height: number;
|
|
256
|
+
readonly data: Uint8Array;
|
|
257
|
+
/** Content address; renderers fetch and cache bytes by it. */
|
|
258
|
+
readonly digest: string;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export interface KittyCursorPlacement {
|
|
262
|
+
readonly imageId: number;
|
|
263
|
+
readonly placementId: number;
|
|
264
|
+
/** Absolute buffer line (history + viewport) of the top-left cell. */
|
|
265
|
+
readonly line: number;
|
|
266
|
+
readonly column: number;
|
|
267
|
+
readonly columns: number;
|
|
268
|
+
readonly rows: number;
|
|
269
|
+
readonly alternate: boolean;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export interface KittyCursorProbe {
|
|
273
|
+
readonly x: number;
|
|
274
|
+
/** Absolute buffer line. */
|
|
275
|
+
readonly line: number;
|
|
276
|
+
readonly alternate: boolean;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function int(control: ReadonlyMap<string, string>, key: string, fallback = 0): number {
|
|
280
|
+
const value = control.get(key);
|
|
281
|
+
if (value === undefined || !/^-?[0-9]+$/u.test(value)) return fallback;
|
|
282
|
+
return Number(value);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** PURE — width/height from a PNG IHDR, or null. */
|
|
286
|
+
export function pngDimensions(data: Uint8Array): { width: number; height: number } | null {
|
|
287
|
+
if (data.length < 24) return null;
|
|
288
|
+
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
289
|
+
if (signature.some((b, i) => data[i] !== b)) return null;
|
|
290
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
291
|
+
return { width: view.getUint32(16), height: view.getUint32(20) };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Byte quotas shared by every pane store in the daemon. Each pane keeps at
|
|
296
|
+
* most `perPaneBytes`; across panes the least recently used image goes first.
|
|
297
|
+
* An evicted image disappears from its pane's graphics state, so renderers
|
|
298
|
+
* stop placing it (placeholder cells then render as blank).
|
|
299
|
+
*/
|
|
300
|
+
export class KittyImageBudget {
|
|
301
|
+
readonly perPaneBytes: number;
|
|
302
|
+
readonly globalBytes: number;
|
|
303
|
+
readonly #stores = new Set<KittyImageStore>();
|
|
304
|
+
#tick = 0;
|
|
305
|
+
|
|
306
|
+
constructor(options: { perPaneBytes: number; globalBytes: number }) {
|
|
307
|
+
this.perPaneBytes = options.perPaneBytes;
|
|
308
|
+
this.globalBytes = options.globalBytes;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
get totalBytes(): number {
|
|
312
|
+
let total = 0;
|
|
313
|
+
for (const store of this.#stores) total += store.bytes;
|
|
314
|
+
return total;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
register(store: KittyImageStore): void {
|
|
318
|
+
this.#stores.add(store);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
unregister(store: KittyImageStore): void {
|
|
322
|
+
this.#stores.delete(store);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
nextTick(): number {
|
|
326
|
+
return ++this.#tick;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
enforce(): void {
|
|
330
|
+
let total = this.totalBytes;
|
|
331
|
+
while (total > this.globalBytes) {
|
|
332
|
+
let victim: KittyImageStore | null = null;
|
|
333
|
+
let oldest = Infinity;
|
|
334
|
+
for (const store of this.#stores) {
|
|
335
|
+
const used = store.oldestUse();
|
|
336
|
+
if (used < oldest) {
|
|
337
|
+
oldest = used;
|
|
338
|
+
victim = store;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (!victim) return;
|
|
342
|
+
total -= victim.evictOldest();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export const DEFAULT_KITTY_IMAGE_BUDGET = new KittyImageBudget({
|
|
348
|
+
perPaneBytes: 64 * 1024 * 1024,
|
|
349
|
+
globalBytes: 256 * 1024 * 1024,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
export interface KittyImageStoreOptions {
|
|
353
|
+
/** Nominal cell size used when a placement gives no `c`/`r`. */
|
|
354
|
+
readonly cellWidthPx?: number;
|
|
355
|
+
readonly cellHeightPx?: number;
|
|
356
|
+
readonly budget?: KittyImageBudget;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Per-pane image store. Survives reseeds; cursor placements do not. */
|
|
360
|
+
export class KittyImageStore {
|
|
361
|
+
readonly #images = new Map<number, KittyImage>();
|
|
362
|
+
readonly #used = new Map<number, number>();
|
|
363
|
+
readonly #cursorPlacements: KittyCursorPlacement[] = [];
|
|
364
|
+
readonly #virtual = new Map<number, { columns: number; rows: number }>();
|
|
365
|
+
#pending: { control: ReadonlyMap<string, string>; payload: string[]; bytes: number } | null =
|
|
366
|
+
null;
|
|
367
|
+
#nextAnonymous = 0x7f000000;
|
|
368
|
+
#bytes = 0;
|
|
369
|
+
readonly #cellWidth: number;
|
|
370
|
+
readonly #cellHeight: number;
|
|
371
|
+
readonly #budget: KittyImageBudget;
|
|
372
|
+
#revision = 0;
|
|
373
|
+
|
|
374
|
+
constructor(options: KittyImageStoreOptions = {}) {
|
|
375
|
+
this.#cellWidth = options.cellWidthPx ?? 10;
|
|
376
|
+
this.#cellHeight = options.cellHeightPx ?? 20;
|
|
377
|
+
this.#budget = options.budget ?? DEFAULT_KITTY_IMAGE_BUDGET;
|
|
378
|
+
this.#budget.register(this);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Bumps whenever images or placements change. */
|
|
382
|
+
get revision(): number {
|
|
383
|
+
return this.#revision;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
get bytes(): number {
|
|
387
|
+
return this.#bytes;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
images(): ReadonlyMap<number, KittyImage> {
|
|
391
|
+
return this.#images;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
imageByDigest(digest: string): KittyImage | undefined {
|
|
395
|
+
for (const image of this.#images.values()) if (image.digest === digest) return image;
|
|
396
|
+
return undefined;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
cursorPlacements(): readonly KittyCursorPlacement[] {
|
|
400
|
+
return this.#cursorPlacements;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
dispose(): void {
|
|
404
|
+
this.#budget.unregister(this);
|
|
405
|
+
this.#images.clear();
|
|
406
|
+
this.#used.clear();
|
|
407
|
+
this.#bytes = 0;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** A capture reseed replaced the grid: tmux holds no record of cursor placements. */
|
|
411
|
+
dropCursorPlacements(): void {
|
|
412
|
+
if (this.#cursorPlacements.length === 0) return;
|
|
413
|
+
this.#cursorPlacements.length = 0;
|
|
414
|
+
this.#revision += 1;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
oldestUse(): number {
|
|
418
|
+
let oldest = Infinity;
|
|
419
|
+
for (const used of this.#used.values()) oldest = Math.min(oldest, used);
|
|
420
|
+
return oldest;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Evict the least recently used image; returns the bytes freed. */
|
|
424
|
+
evictOldest(): number {
|
|
425
|
+
let victim: number | null = null;
|
|
426
|
+
let oldest = Infinity;
|
|
427
|
+
for (const [id, used] of this.#used)
|
|
428
|
+
if (used < oldest) {
|
|
429
|
+
oldest = used;
|
|
430
|
+
victim = id;
|
|
431
|
+
}
|
|
432
|
+
if (victim === null) return 0;
|
|
433
|
+
return this.#remove(victim);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
#remove(imageId: number): number {
|
|
437
|
+
const image = this.#images.get(imageId);
|
|
438
|
+
if (!image) return 0;
|
|
439
|
+
this.#images.delete(imageId);
|
|
440
|
+
this.#used.delete(imageId);
|
|
441
|
+
this.#virtual.delete(imageId);
|
|
442
|
+
this.#bytes -= image.data.byteLength;
|
|
443
|
+
const kept = this.#cursorPlacements.filter((p) => p.imageId !== imageId);
|
|
444
|
+
this.#cursorPlacements.length = 0;
|
|
445
|
+
this.#cursorPlacements.push(...kept);
|
|
446
|
+
this.#revision += 1;
|
|
447
|
+
return image.data.byteLength;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Apply one command. `cursor` is the parser's position at the command's byte
|
|
452
|
+
* offset (from the marker OSC); null when unavailable.
|
|
453
|
+
*/
|
|
454
|
+
apply(command: KittyCommand, cursor: KittyCursorProbe | null): void {
|
|
455
|
+
let { control } = command;
|
|
456
|
+
const more = control.get("m") === "1";
|
|
457
|
+
if (this.#pending) {
|
|
458
|
+
// Continuation chunks carry only m (and q); the first chunk's keys rule.
|
|
459
|
+
this.#pending.payload.push(command.payload);
|
|
460
|
+
this.#pending.bytes += command.payload.length;
|
|
461
|
+
if (this.#pending.bytes > MAX_COMMAND_BYTES) {
|
|
462
|
+
this.#pending = null;
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (more) return;
|
|
466
|
+
control = this.#pending.control;
|
|
467
|
+
const payload = this.#pending.payload.join("");
|
|
468
|
+
this.#pending = null;
|
|
469
|
+
this.#finish(control, payload, cursor);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const action = control.get("a") ?? "t";
|
|
473
|
+
if ((action === "t" || action === "T") && more) {
|
|
474
|
+
this.#pending = { control, payload: [command.payload], bytes: command.payload.length };
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
this.#finish(control, command.payload, cursor);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
#finish(
|
|
481
|
+
control: ReadonlyMap<string, string>,
|
|
482
|
+
payload: string,
|
|
483
|
+
cursor: KittyCursorProbe | null,
|
|
484
|
+
): void {
|
|
485
|
+
const action = control.get("a") ?? "t";
|
|
486
|
+
if (action === "d") return this.#delete(control);
|
|
487
|
+
if (action === "q") return; // Answered by the patched tmux; nothing to store.
|
|
488
|
+
let imageId = int(control, "i");
|
|
489
|
+
if (action === "t" || action === "T") {
|
|
490
|
+
if ((control.get("t") ?? "d") !== "d") return; // file/shm cannot cross machines.
|
|
491
|
+
if (imageId === 0) imageId = this.#nextAnonymous++;
|
|
492
|
+
const image = this.#decode(imageId, control, payload);
|
|
493
|
+
if (!image) return;
|
|
494
|
+
this.#remove(imageId);
|
|
495
|
+
this.#images.set(imageId, image);
|
|
496
|
+
this.#bytes += image.data.byteLength;
|
|
497
|
+
this.#used.set(imageId, this.#budget.nextTick());
|
|
498
|
+
this.#revision += 1;
|
|
499
|
+
while (
|
|
500
|
+
(this.#bytes > this.#budget.perPaneBytes || this.#images.size > MAX_IMAGES_PER_PANE) &&
|
|
501
|
+
this.#images.size > 1
|
|
502
|
+
)
|
|
503
|
+
this.evictOldest();
|
|
504
|
+
this.#budget.enforce();
|
|
505
|
+
if (action === "t" || !this.#images.has(imageId)) return;
|
|
506
|
+
}
|
|
507
|
+
if (action !== "T" && action !== "p") return;
|
|
508
|
+
const image = this.#images.get(imageId);
|
|
509
|
+
if (!image) return;
|
|
510
|
+
this.#used.set(imageId, this.#budget.nextTick());
|
|
511
|
+
const columns = clampCells(int(control, "c") || Math.ceil(image.width / this.#cellWidth));
|
|
512
|
+
const rows = clampCells(int(control, "r") || Math.ceil(image.height / this.#cellHeight));
|
|
513
|
+
const placementId = int(control, "p");
|
|
514
|
+
if (control.get("U") === "1") {
|
|
515
|
+
this.#virtual.set(imageId, { columns, rows });
|
|
516
|
+
} else if (cursor) {
|
|
517
|
+
const existing = this.#cursorPlacements.findIndex(
|
|
518
|
+
(p) => p.imageId === imageId && placementId !== 0 && p.placementId === placementId,
|
|
519
|
+
);
|
|
520
|
+
if (existing >= 0) this.#cursorPlacements.splice(existing, 1);
|
|
521
|
+
if (this.#cursorPlacements.length >= TERMINAL_GRAPHICS_MAX_PLACEMENTS)
|
|
522
|
+
this.#cursorPlacements.shift();
|
|
523
|
+
this.#cursorPlacements.push({
|
|
524
|
+
imageId,
|
|
525
|
+
placementId,
|
|
526
|
+
line: cursor.line,
|
|
527
|
+
column: cursor.x,
|
|
528
|
+
columns,
|
|
529
|
+
rows,
|
|
530
|
+
alternate: cursor.alternate,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
this.#revision += 1;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
#decode(
|
|
537
|
+
imageId: number,
|
|
538
|
+
control: ReadonlyMap<string, string>,
|
|
539
|
+
payload: string,
|
|
540
|
+
): KittyImage | null {
|
|
541
|
+
let data: Uint8Array = Buffer.from(payload, "base64");
|
|
542
|
+
if (control.get("o") === "z") {
|
|
543
|
+
try {
|
|
544
|
+
data = inflateSync(data, { maxOutputLength: TERMINAL_GRAPHICS_MAX_IMAGE_BYTES });
|
|
545
|
+
} catch {
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (data.byteLength === 0 || data.byteLength > TERMINAL_GRAPHICS_MAX_IMAGE_BYTES) return null;
|
|
550
|
+
const f = int(control, "f", 32);
|
|
551
|
+
let format: KittyImageFormat;
|
|
552
|
+
let width: number;
|
|
553
|
+
let height: number;
|
|
554
|
+
if (f === 100) {
|
|
555
|
+
const size = pngDimensions(data);
|
|
556
|
+
if (!size) return null;
|
|
557
|
+
({ width, height } = size);
|
|
558
|
+
format = "png";
|
|
559
|
+
} else if (f === 32 || f === 24) {
|
|
560
|
+
width = int(control, "s");
|
|
561
|
+
height = int(control, "v");
|
|
562
|
+
format = f === 32 ? "rgba" : "rgb";
|
|
563
|
+
if (!width || !height || data.length !== width * height * (f === 32 ? 4 : 3)) return null;
|
|
564
|
+
} else return null;
|
|
565
|
+
if (width < 1 || height < 1 || width > MAX_IMAGE_SIDE || height > MAX_IMAGE_SIDE) return null;
|
|
566
|
+
const digest = createHash("sha256").update(data).digest("hex").slice(0, 32);
|
|
567
|
+
return { imageId, format, width, height, data, digest };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
#delete(control: ReadonlyMap<string, string>): void {
|
|
571
|
+
const what = control.get("d") ?? "a";
|
|
572
|
+
const id = int(control, "i");
|
|
573
|
+
const all = what === "a" || what === "A";
|
|
574
|
+
const byId = (what === "i" || what === "I") && id !== 0;
|
|
575
|
+
if (!all && !byId) return;
|
|
576
|
+
const keep = (imageId: number) => !all && imageId !== id;
|
|
577
|
+
const kept = this.#cursorPlacements.filter((p) => keep(p.imageId));
|
|
578
|
+
if (kept.length !== this.#cursorPlacements.length) {
|
|
579
|
+
this.#cursorPlacements.length = 0;
|
|
580
|
+
this.#cursorPlacements.push(...kept);
|
|
581
|
+
this.#revision += 1;
|
|
582
|
+
}
|
|
583
|
+
for (const key of [...this.#virtual.keys()])
|
|
584
|
+
if (!keep(key)) {
|
|
585
|
+
this.#virtual.delete(key);
|
|
586
|
+
this.#revision += 1;
|
|
587
|
+
}
|
|
588
|
+
// Upper-case variants also free the image data.
|
|
589
|
+
if (what === "A") for (const key of [...this.#images.keys()]) this.#remove(key);
|
|
590
|
+
if (what === "I") this.#remove(id);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* The pane's graphics state for renderers: the image registry (with each
|
|
595
|
+
* image's placeholder grid) and cursor placements clipped to the viewport.
|
|
596
|
+
* `viewportTop` is the absolute line of grid row 0.
|
|
597
|
+
*/
|
|
598
|
+
state(
|
|
599
|
+
viewportTop: number,
|
|
600
|
+
gridRows: number,
|
|
601
|
+
cols: number,
|
|
602
|
+
alternate: boolean,
|
|
603
|
+
): TerminalGraphicsState {
|
|
604
|
+
const images = [...this.#images.values()].slice(-TERMINAL_GRAPHICS_MAX_IMAGES).map((image) => ({
|
|
605
|
+
imageId: image.imageId,
|
|
606
|
+
digest: image.digest,
|
|
607
|
+
format: image.format,
|
|
608
|
+
width: image.width,
|
|
609
|
+
height: image.height,
|
|
610
|
+
bytes: image.data.byteLength,
|
|
611
|
+
placeholder: this.#virtual.get(image.imageId) ?? null,
|
|
612
|
+
}));
|
|
613
|
+
const placements: TerminalGraphicsPlacement[] = [];
|
|
614
|
+
for (const placement of this.#cursorPlacements) {
|
|
615
|
+
if (placement.alternate !== alternate) continue;
|
|
616
|
+
const image = this.#images.get(placement.imageId);
|
|
617
|
+
if (!image) continue;
|
|
618
|
+
const top = placement.line - viewportTop;
|
|
619
|
+
const first = Math.max(0, top);
|
|
620
|
+
const last = Math.min(gridRows, top + placement.rows);
|
|
621
|
+
const right = Math.min(cols, placement.column + placement.columns);
|
|
622
|
+
if (first >= last || placement.column >= right) continue;
|
|
623
|
+
placements.push({
|
|
624
|
+
digest: image.digest,
|
|
625
|
+
row: first,
|
|
626
|
+
column: placement.column,
|
|
627
|
+
rows: last - first,
|
|
628
|
+
columns: right - placement.column,
|
|
629
|
+
sourceRow: first - top,
|
|
630
|
+
sourceColumn: 0,
|
|
631
|
+
totalRows: placement.rows,
|
|
632
|
+
totalColumns: placement.columns,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
return { revision: this.#revision, images, placements };
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const MAX_IMAGES_PER_PANE = TERMINAL_GRAPHICS_MAX_IMAGES;
|
|
640
|
+
const MAX_IMAGE_SIDE = 10_000;
|
|
641
|
+
const clampCells = (cells: number) => Math.max(1, Math.min(1_000, cells));
|
|
@@ -1189,8 +1189,21 @@ export interface MirrorControlChannelOptions {
|
|
|
1189
1189
|
pauseAfterSeconds?: number;
|
|
1190
1190
|
/** Qualification-only clock. Omitted in production's zero-observer path. */
|
|
1191
1191
|
nowMicros?: () => number;
|
|
1192
|
+
/** Ask tmux to acknowledge kitty graphics commands (patched tmux; stock ignores it). */
|
|
1193
|
+
kittyGraphics?: boolean;
|
|
1192
1194
|
}
|
|
1193
1195
|
|
|
1196
|
+
/**
|
|
1197
|
+
* Server-wide, idempotent: `-q` keeps a stock tmux (no such option) quiet.
|
|
1198
|
+
* Images reach this control client either way; the option only lets programs
|
|
1199
|
+
* detect support, and the environment tells new panes' programs that this
|
|
1200
|
+
* server's renderer shows placeholder images.
|
|
1201
|
+
*/
|
|
1202
|
+
export const KITTY_GRAPHICS_SETUP = [
|
|
1203
|
+
"set-option -gq kitty-graphics on",
|
|
1204
|
+
"set-environment -g TMUX_IDE_GRAPHICS kitty",
|
|
1205
|
+
] as const;
|
|
1206
|
+
|
|
1194
1207
|
export function mirrorControlAttachArgs(
|
|
1195
1208
|
options: Pick<
|
|
1196
1209
|
MirrorControlChannelOptions,
|
|
@@ -1348,7 +1361,11 @@ export class MirrorControlChannel implements MirrorChannelIo {
|
|
|
1348
1361
|
this.core.fail(String(err));
|
|
1349
1362
|
reject(err);
|
|
1350
1363
|
});
|
|
1351
|
-
}).then(() =>
|
|
1364
|
+
}).then(() => {
|
|
1365
|
+
// One command per send: tmux answers a `;` list with one block per command.
|
|
1366
|
+
if (this.opts.kittyGraphics) for (const command of KITTY_GRAPHICS_SETUP) this.send(command);
|
|
1367
|
+
return this.initializeNativeViewer();
|
|
1368
|
+
});
|
|
1352
1369
|
}
|
|
1353
1370
|
|
|
1354
1371
|
get nativeViewerIdentity(): NativeJournalIdentity | null {
|
|
@@ -79,6 +79,8 @@ export interface MirrorServiceOptions {
|
|
|
79
79
|
/** `tmux -f <file>` — tests pass /dev/null. */
|
|
80
80
|
configFile?: string;
|
|
81
81
|
pauseAfterSeconds?: number;
|
|
82
|
+
/** Enable kitty graphics acknowledgements on attach (default: on unless TMUX_IDE_KITTY_GRAPHICS=0). */
|
|
83
|
+
kittyGraphics?: boolean;
|
|
82
84
|
/** Explicit capture tail override. Omitted preserves native retained history. */
|
|
83
85
|
historyLines?: number;
|
|
84
86
|
internalReadHookEmission?: SessionChannelOptions["internalReadHookEmission"];
|
|
@@ -737,6 +739,7 @@ export class MirrorService {
|
|
|
737
739
|
nativeServerIdentity: this.opts.nativeServerIdentity,
|
|
738
740
|
configFile: this.opts.configFile,
|
|
739
741
|
pauseAfterSeconds: this.opts.pauseAfterSeconds,
|
|
742
|
+
kittyGraphics: this.opts.kittyGraphics ?? process.env.TMUX_IDE_KITTY_GRAPHICS !== "0",
|
|
740
743
|
nowMicros: this.opts.nowMicros,
|
|
741
744
|
nativeViewer: ownedViewer?.controlOptions(),
|
|
742
745
|
nativeViewerReady: ownedViewer
|