tmux-ide 2.9.7 → 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 +322 -52
- package/package.json +2 -2
- package/packages/contracts/src/pane-stream.ts +17 -0
- package/packages/contracts/src/terminal-graphics.ts +29 -3
- package/packages/core/src/kitty-placeholders.ts +29 -11
- 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/manifest.json +6 -2
- package/packages/daemon/dist/native/tmux/darwin-arm64/tmux +0 -0
- package/packages/daemon/dist/native/tmux/linux-arm64/manifest.json +6 -2
- package/packages/daemon/dist/native/tmux/linux-arm64/tmux +0 -0
- package/packages/daemon/dist/native/tmux/linux-x64/manifest.json +6 -2
- 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 +146 -25
- package/packages/daemon/dist/terminal/mirror/mirror-service.js +6 -0
- package/packages/daemon/dist/terminal/mirror/session-channel.js +45 -2
- package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +109 -7
- package/packages/daemon/dist/terminal/session-runtime/registry.js +9 -0
- package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +19 -2
- package/packages/daemon/dist/terminal/session-runtime/transport-binding.js +4 -0
- 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 +143 -28
- package/packages/daemon/src/terminal/mirror/mirror-service.ts +11 -0
- package/packages/daemon/src/terminal/mirror/session-channel.ts +61 -2
- package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +123 -6
- package/packages/daemon/src/terminal/session-runtime/registry.ts +18 -0
- package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +18 -2
- package/packages/daemon/src/terminal/session-runtime/transport-binding.ts +5 -0
- package/packages/daemon-client/src/pane-stream-client.ts +8 -0
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* marker OSC the scanner substitutes in place of the APC, so the VT parser
|
|
19
19
|
* reports the cursor at the exact byte position. tmux never sees these
|
|
20
20
|
* placements: they do not move the cursor, do not follow reflow, and are
|
|
21
|
-
*
|
|
21
|
+
* kept across capture reseeds only when the coordinate basis is unchanged.
|
|
22
22
|
*
|
|
23
23
|
* Limits: direct transmission (`t=d`) only — file and shared-memory media
|
|
24
24
|
* cannot cross machines; `a=d` handles all and by-id; animation, relative
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* from the patched tmux, in stream order (native/tmux/kitty-graphics.patch).
|
|
27
27
|
*/
|
|
28
28
|
import { createHash } from "node:crypto";
|
|
29
|
-
import { inflateSync } from "node:zlib";
|
|
29
|
+
import { deflateSync, inflateSync } from "node:zlib";
|
|
30
30
|
import {
|
|
31
31
|
TERMINAL_GRAPHICS_MAX_IMAGE_BYTES,
|
|
32
32
|
TERMINAL_GRAPHICS_MAX_IMAGES,
|
|
@@ -356,12 +356,13 @@ export interface KittyImageStoreOptions {
|
|
|
356
356
|
readonly budget?: KittyImageBudget;
|
|
357
357
|
}
|
|
358
358
|
|
|
359
|
-
/** Per-pane image store. Survives reseeds; cursor
|
|
359
|
+
/** Per-pane image store. Survives reseeds; the interpreter validates cursor placement coordinates. */
|
|
360
360
|
export class KittyImageStore {
|
|
361
361
|
readonly #images = new Map<number, KittyImage>();
|
|
362
362
|
readonly #used = new Map<number, number>();
|
|
363
363
|
readonly #cursorPlacements: KittyCursorPlacement[] = [];
|
|
364
|
-
|
|
364
|
+
/** `U=1` virtual placements per image, by placement id (0 when unnamed). */
|
|
365
|
+
readonly #virtual = new Map<number, Map<number, { columns: number; rows: number }>>();
|
|
365
366
|
#pending: { control: ReadonlyMap<string, string>; payload: string[]; bytes: number } | null =
|
|
366
367
|
null;
|
|
367
368
|
#nextAnonymous = 0x7f000000;
|
|
@@ -407,7 +408,7 @@ export class KittyImageStore {
|
|
|
407
408
|
this.#bytes = 0;
|
|
408
409
|
}
|
|
409
410
|
|
|
410
|
-
/**
|
|
411
|
+
/** The coordinate basis changed: tmux holds no record to restore cursor anchors. */
|
|
411
412
|
dropCursorPlacements(): void {
|
|
412
413
|
if (this.#cursorPlacements.length === 0) return;
|
|
413
414
|
this.#cursorPlacements.length = 0;
|
|
@@ -433,11 +434,16 @@ export class KittyImageStore {
|
|
|
433
434
|
return this.#remove(victim);
|
|
434
435
|
}
|
|
435
436
|
|
|
436
|
-
#remove(imageId: number): number {
|
|
437
|
+
#remove(imageId: number, keepPlacements = false): number {
|
|
437
438
|
const image = this.#images.get(imageId);
|
|
438
439
|
if (!image) return 0;
|
|
439
440
|
this.#images.delete(imageId);
|
|
440
441
|
this.#used.delete(imageId);
|
|
442
|
+
if (keepPlacements) {
|
|
443
|
+
this.#bytes -= image.data.byteLength;
|
|
444
|
+
this.#revision += 1;
|
|
445
|
+
return image.data.byteLength;
|
|
446
|
+
}
|
|
441
447
|
this.#virtual.delete(imageId);
|
|
442
448
|
this.#bytes -= image.data.byteLength;
|
|
443
449
|
const kept = this.#cursorPlacements.filter((p) => p.imageId !== imageId);
|
|
@@ -470,7 +476,7 @@ export class KittyImageStore {
|
|
|
470
476
|
return;
|
|
471
477
|
}
|
|
472
478
|
const action = control.get("a") ?? "t";
|
|
473
|
-
if ((action === "t" || action === "T") && more) {
|
|
479
|
+
if ((action === "t" || action === "T" || action === "f") && more) {
|
|
474
480
|
this.#pending = { control, payload: [command.payload], bytes: command.payload.length };
|
|
475
481
|
return;
|
|
476
482
|
}
|
|
@@ -491,19 +497,16 @@ export class KittyImageStore {
|
|
|
491
497
|
if (imageId === 0) imageId = this.#nextAnonymous++;
|
|
492
498
|
const image = this.#decode(imageId, control, payload);
|
|
493
499
|
if (!image) return;
|
|
494
|
-
|
|
500
|
+
// Re-transmitting an id replaces its pixels; its placements keep showing it.
|
|
501
|
+
this.#remove(imageId, true);
|
|
495
502
|
this.#images.set(imageId, image);
|
|
496
503
|
this.#bytes += image.data.byteLength;
|
|
497
504
|
this.#used.set(imageId, this.#budget.nextTick());
|
|
498
505
|
this.#revision += 1;
|
|
499
|
-
|
|
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();
|
|
506
|
+
this.#enforceBudget();
|
|
505
507
|
if (action === "t" || !this.#images.has(imageId)) return;
|
|
506
508
|
}
|
|
509
|
+
if (action === "f") return this.#editFrame(imageId, control, payload);
|
|
507
510
|
if (action !== "T" && action !== "p") return;
|
|
508
511
|
const image = this.#images.get(imageId);
|
|
509
512
|
if (!image) return;
|
|
@@ -512,7 +515,20 @@ export class KittyImageStore {
|
|
|
512
515
|
const rows = clampCells(int(control, "r") || Math.ceil(image.height / this.#cellHeight));
|
|
513
516
|
const placementId = int(control, "p");
|
|
514
517
|
if (control.get("U") === "1") {
|
|
515
|
-
this.#virtual.
|
|
518
|
+
const placements = this.#virtual.get(imageId) ?? new Map();
|
|
519
|
+
placements.set(placementId, { columns, rows });
|
|
520
|
+
this.#virtual.set(imageId, placements);
|
|
521
|
+
let count = 0;
|
|
522
|
+
for (const entries of this.#virtual.values()) count += entries.size;
|
|
523
|
+
for (const [id, entries] of this.#virtual) {
|
|
524
|
+
for (const placement of entries.keys()) {
|
|
525
|
+
if (count <= TERMINAL_GRAPHICS_MAX_PLACEMENTS) break;
|
|
526
|
+
entries.delete(placement);
|
|
527
|
+
count -= 1;
|
|
528
|
+
}
|
|
529
|
+
if (entries.size === 0) this.#virtual.delete(id);
|
|
530
|
+
if (count <= TERMINAL_GRAPHICS_MAX_PLACEMENTS) break;
|
|
531
|
+
}
|
|
516
532
|
} else if (cursor) {
|
|
517
533
|
const existing = this.#cursorPlacements.findIndex(
|
|
518
534
|
(p) => p.imageId === imageId && placementId !== 0 && p.placementId === placementId,
|
|
@@ -539,7 +555,13 @@ export class KittyImageStore {
|
|
|
539
555
|
payload: string,
|
|
540
556
|
): KittyImage | null {
|
|
541
557
|
let data: Uint8Array = Buffer.from(payload, "base64");
|
|
542
|
-
|
|
558
|
+
const f = int(control, "f", 32);
|
|
559
|
+
const zlib = control.get("o") === "z";
|
|
560
|
+
// Raw pixels stay compressed end to end: renderers inflate off their UI
|
|
561
|
+
// thread, and the stream carries a fraction of the bytes. PNG and other
|
|
562
|
+
// compressed payloads are inflated here.
|
|
563
|
+
const keepCompressed = zlib && (f === 32 || f === 24);
|
|
564
|
+
if (zlib && !keepCompressed) {
|
|
543
565
|
try {
|
|
544
566
|
data = inflateSync(data, { maxOutputLength: TERMINAL_GRAPHICS_MAX_IMAGE_BYTES });
|
|
545
567
|
} catch {
|
|
@@ -547,7 +569,6 @@ export class KittyImageStore {
|
|
|
547
569
|
}
|
|
548
570
|
}
|
|
549
571
|
if (data.byteLength === 0 || data.byteLength > TERMINAL_GRAPHICS_MAX_IMAGE_BYTES) return null;
|
|
550
|
-
const f = int(control, "f", 32);
|
|
551
572
|
let format: KittyImageFormat;
|
|
552
573
|
let width: number;
|
|
553
574
|
let height: number;
|
|
@@ -559,35 +580,115 @@ export class KittyImageStore {
|
|
|
559
580
|
} else if (f === 32 || f === 24) {
|
|
560
581
|
width = int(control, "s");
|
|
561
582
|
height = int(control, "v");
|
|
562
|
-
|
|
563
|
-
if (!width || !height ||
|
|
583
|
+
const bytes = width * height * (f === 32 ? 4 : 3);
|
|
584
|
+
if (!width || !height || bytes > MAX_RAW_IMAGE_BYTES) return null;
|
|
585
|
+
if (keepCompressed) format = f === 32 ? "rgba-zlib" : "rgb-zlib";
|
|
586
|
+
else {
|
|
587
|
+
format = f === 32 ? "rgba" : "rgb";
|
|
588
|
+
if (data.length !== bytes) return null;
|
|
589
|
+
}
|
|
564
590
|
} else return null;
|
|
565
591
|
if (width < 1 || height < 1 || width > MAX_IMAGE_SIDE || height > MAX_IMAGE_SIDE) return null;
|
|
566
592
|
const digest = createHash("sha256").update(data).digest("hex").slice(0, 32);
|
|
567
593
|
return { imageId, format, width, height, data, digest };
|
|
568
594
|
}
|
|
569
595
|
|
|
596
|
+
/**
|
|
597
|
+
* `a=f` edits of the root frame (`r=1`): RGBA/RGB rectangles written at
|
|
598
|
+
* `x,y`, replacing (`X=1`) or alpha-blending the existing pixels. New
|
|
599
|
+
* animation frames, `c` base frames and PNG data are not modelled.
|
|
600
|
+
*/
|
|
601
|
+
#editFrame(imageId: number, control: ReadonlyMap<string, string>, payload: string): void {
|
|
602
|
+
const image = this.#images.get(imageId);
|
|
603
|
+
if (!image || int(control, "r") !== 1 || (control.get("t") ?? "d") !== "d") return;
|
|
604
|
+
const source = image.format === "rgba" || image.format === "rgba-zlib" ? 4 : 3;
|
|
605
|
+
if (image.format === "png") return;
|
|
606
|
+
let pixels: Uint8Array;
|
|
607
|
+
let patch: Uint8Array = Buffer.from(payload, "base64");
|
|
608
|
+
try {
|
|
609
|
+
pixels =
|
|
610
|
+
image.format === "rgba-zlib" || image.format === "rgb-zlib"
|
|
611
|
+
? inflateSync(image.data, { maxOutputLength: MAX_RAW_IMAGE_BYTES })
|
|
612
|
+
: Uint8Array.from(image.data);
|
|
613
|
+
if (control.get("o") === "z")
|
|
614
|
+
patch = inflateSync(patch, { maxOutputLength: MAX_RAW_IMAGE_BYTES });
|
|
615
|
+
} catch {
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const f = int(control, "f", 32);
|
|
619
|
+
const depth = f === 24 ? 3 : 4;
|
|
620
|
+
const width = int(control, "s") || image.width;
|
|
621
|
+
const height = int(control, "v") || image.height;
|
|
622
|
+
const x0 = int(control, "x");
|
|
623
|
+
const y0 = int(control, "y");
|
|
624
|
+
if ((f !== 32 && f !== 24) || patch.length !== width * height * depth) return;
|
|
625
|
+
if (pixels.length !== image.width * image.height * source) return;
|
|
626
|
+
const replace = control.get("X") === "1";
|
|
627
|
+
for (let row = 0; row < height && y0 + row < image.height; row += 1)
|
|
628
|
+
for (let col = 0; col < width && x0 + col < image.width; col += 1) {
|
|
629
|
+
const from = (row * width + col) * depth;
|
|
630
|
+
const to = ((y0 + row) * image.width + x0 + col) * source;
|
|
631
|
+
const alpha = depth === 4 && !replace ? patch[from + 3]! / 255 : 1;
|
|
632
|
+
for (let channel = 0; channel < 3; channel += 1)
|
|
633
|
+
pixels[to + channel] = Math.round(
|
|
634
|
+
patch[from + channel]! * alpha + pixels[to + channel]! * (1 - alpha),
|
|
635
|
+
);
|
|
636
|
+
if (source === 4)
|
|
637
|
+
pixels[to + 3] =
|
|
638
|
+
depth === 4 && replace
|
|
639
|
+
? patch[from + 3]!
|
|
640
|
+
: Math.round(255 * alpha + pixels[to + 3]! * (1 - alpha));
|
|
641
|
+
}
|
|
642
|
+
const data = deflateSync(pixels, { level: 1 });
|
|
643
|
+
const format = source === 4 ? "rgba-zlib" : "rgb-zlib";
|
|
644
|
+
this.#remove(imageId, true);
|
|
645
|
+
this.#images.set(imageId, {
|
|
646
|
+
...image,
|
|
647
|
+
format,
|
|
648
|
+
data,
|
|
649
|
+
digest: createHash("sha256").update(data).digest("hex").slice(0, 32),
|
|
650
|
+
});
|
|
651
|
+
this.#bytes += data.byteLength;
|
|
652
|
+
this.#used.set(imageId, this.#budget.nextTick());
|
|
653
|
+
this.#revision += 1;
|
|
654
|
+
this.#enforceBudget();
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
#enforceBudget(): void {
|
|
658
|
+
while (
|
|
659
|
+
(this.#bytes > this.#budget.perPaneBytes || this.#images.size > MAX_IMAGES_PER_PANE) &&
|
|
660
|
+
this.#images.size > 0
|
|
661
|
+
)
|
|
662
|
+
this.evictOldest();
|
|
663
|
+
this.#budget.enforce();
|
|
664
|
+
}
|
|
665
|
+
|
|
570
666
|
#delete(control: ReadonlyMap<string, string>): void {
|
|
571
667
|
const what = control.get("d") ?? "a";
|
|
572
668
|
const id = int(control, "i");
|
|
573
669
|
const all = what === "a" || what === "A";
|
|
574
670
|
const byId = (what === "i" || what === "I") && id !== 0;
|
|
575
671
|
if (!all && !byId) return;
|
|
576
|
-
|
|
577
|
-
const
|
|
672
|
+
// With `p`, only that placement of the image goes (spec: d=i/I with p).
|
|
673
|
+
const placementId = control.has("p") ? int(control, "p") : null;
|
|
674
|
+
const doomed = (imageId: number, placement: number) =>
|
|
675
|
+
all || (imageId === id && (placementId === null || placement === placementId));
|
|
676
|
+
const kept = this.#cursorPlacements.filter((p) => !doomed(p.imageId, p.placementId));
|
|
578
677
|
if (kept.length !== this.#cursorPlacements.length) {
|
|
579
678
|
this.#cursorPlacements.length = 0;
|
|
580
679
|
this.#cursorPlacements.push(...kept);
|
|
581
680
|
this.#revision += 1;
|
|
582
681
|
}
|
|
583
|
-
for (const
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
682
|
+
for (const [imageId, placements] of [...this.#virtual])
|
|
683
|
+
for (const placement of [...placements.keys()])
|
|
684
|
+
if (doomed(imageId, placement)) {
|
|
685
|
+
placements.delete(placement);
|
|
686
|
+
if (placements.size === 0) this.#virtual.delete(imageId);
|
|
687
|
+
this.#revision += 1;
|
|
688
|
+
}
|
|
588
689
|
// Upper-case variants also free the image data.
|
|
589
690
|
if (what === "A") for (const key of [...this.#images.keys()]) this.#remove(key);
|
|
590
|
-
if (what === "I") this.#remove(id);
|
|
691
|
+
if (what === "I" && placementId === null) this.#remove(id);
|
|
591
692
|
}
|
|
592
693
|
|
|
593
694
|
/**
|
|
@@ -608,7 +709,7 @@ export class KittyImageStore {
|
|
|
608
709
|
width: image.width,
|
|
609
710
|
height: image.height,
|
|
610
711
|
bytes: image.data.byteLength,
|
|
611
|
-
placeholder: this.#virtual.get(image.imageId)
|
|
712
|
+
placeholder: virtualGrid(this.#virtual.get(image.imageId)),
|
|
612
713
|
}));
|
|
613
714
|
const placements: TerminalGraphicsPlacement[] = [];
|
|
614
715
|
for (const placement of this.#cursorPlacements) {
|
|
@@ -637,5 +738,19 @@ export class KittyImageStore {
|
|
|
637
738
|
}
|
|
638
739
|
|
|
639
740
|
const MAX_IMAGES_PER_PANE = TERMINAL_GRAPHICS_MAX_IMAGES;
|
|
741
|
+
/** Largest decoded raw image (a 4K RGBA frame is ~33 MB). */
|
|
742
|
+
const MAX_RAW_IMAGE_BYTES = 64 * 1024 * 1024;
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Placeholder cells carry the image id; with several virtual placements the
|
|
746
|
+
* protocol picks one by the placement id in the underline colour. Renderers
|
|
747
|
+
* resolve cells by image id, so publish the most recently placed grid.
|
|
748
|
+
*/
|
|
749
|
+
function virtualGrid(
|
|
750
|
+
placements: ReadonlyMap<number, { columns: number; rows: number }> | undefined,
|
|
751
|
+
): { columns: number; rows: number } | null {
|
|
752
|
+
if (!placements || placements.size === 0) return null;
|
|
753
|
+
return [...placements.values()].at(-1)!;
|
|
754
|
+
}
|
|
640
755
|
const MAX_IMAGE_SIDE = 10_000;
|
|
641
756
|
const clampCells = (cells: number) => Math.max(1, Math.min(1_000, cells));
|
|
@@ -565,6 +565,17 @@ export class MirrorService {
|
|
|
565
565
|
entry.channel.fitWindowViewport(semanticWindowId, cols, rows);
|
|
566
566
|
}
|
|
567
567
|
|
|
568
|
+
setCellPixels(
|
|
569
|
+
session: string,
|
|
570
|
+
width: number,
|
|
571
|
+
height: number,
|
|
572
|
+
precise?: { width: number; height: number },
|
|
573
|
+
): void {
|
|
574
|
+
const entry = this.channels.get(session);
|
|
575
|
+
if (!entry || entry.retired) return;
|
|
576
|
+
entry.channel.setCellPixels(width, height, precise);
|
|
577
|
+
}
|
|
578
|
+
|
|
568
579
|
clearWindowViewports(session: string): void {
|
|
569
580
|
const entry = this.channels.get(session);
|
|
570
581
|
if (entry && !entry.retired) entry.channel.clearWindowViewports();
|
|
@@ -1309,7 +1309,66 @@ export class SessionChannel {
|
|
|
1309
1309
|
this.input.flush();
|
|
1310
1310
|
this.clearWindowViewports();
|
|
1311
1311
|
this.restoreWindowSizing(this.attachedIdentity?.runtimeSessionId ?? `=${this.opts.session}`);
|
|
1312
|
-
this.io.send(`refresh-client -C ${cols}x${rows}`);
|
|
1312
|
+
this.io.send(`refresh-client -C ${cols}x${rows}${this.cellPixelSuffix()}`);
|
|
1313
|
+
this.clientViewport = { cols, rows };
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
private clientViewport: { cols: number; rows: number } | null = null;
|
|
1317
|
+
private cellPixels: {
|
|
1318
|
+
width: number;
|
|
1319
|
+
height: number;
|
|
1320
|
+
precise?: { width: number; height: number };
|
|
1321
|
+
} | null = null;
|
|
1322
|
+
|
|
1323
|
+
/** "/WxH" for tmux's refresh-client -C (patched tmux; stock ignores it). */
|
|
1324
|
+
private cellPixelSuffix(): string {
|
|
1325
|
+
if (!this.cellPixels) return "";
|
|
1326
|
+
const { width, height, precise } = this.cellPixels;
|
|
1327
|
+
return `/${width}x${height}` + (precise ? `/${precise.width}x${precise.height}` : "");
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/**
|
|
1331
|
+
* The renderer's cell size in device pixels. tmux answers CSI 14t/16t and
|
|
1332
|
+
* sets TIOCGWINSZ pixels from it, so image programs render 1:1 for the
|
|
1333
|
+
* window. Re-applies the current viewport when the size changes.
|
|
1334
|
+
*/
|
|
1335
|
+
setCellPixels(width: number, height: number, precise?: { width: number; height: number }): void {
|
|
1336
|
+
if (
|
|
1337
|
+
!Number.isSafeInteger(width) ||
|
|
1338
|
+
!Number.isSafeInteger(height) ||
|
|
1339
|
+
width < 1 ||
|
|
1340
|
+
height < 1 ||
|
|
1341
|
+
width > 1024 ||
|
|
1342
|
+
height > 1024
|
|
1343
|
+
)
|
|
1344
|
+
throw new RangeError("cell pixel size out of range");
|
|
1345
|
+
if (
|
|
1346
|
+
precise &&
|
|
1347
|
+
[precise.width, precise.height].some(
|
|
1348
|
+
(value) => !Number.isSafeInteger(value) || value < 1000 || value > 1024000,
|
|
1349
|
+
)
|
|
1350
|
+
)
|
|
1351
|
+
throw new RangeError("precise cell pixel size out of range");
|
|
1352
|
+
if (
|
|
1353
|
+
this.cellPixels?.width === width &&
|
|
1354
|
+
this.cellPixels.height === height &&
|
|
1355
|
+
this.cellPixels.precise?.width === precise?.width &&
|
|
1356
|
+
this.cellPixels.precise?.height === precise?.height
|
|
1357
|
+
)
|
|
1358
|
+
return;
|
|
1359
|
+
this.cellPixels = { width, height, ...(precise ? { precise } : {}) };
|
|
1360
|
+
if (!this.geometryParticipating) return;
|
|
1361
|
+
this.input.flush();
|
|
1362
|
+
if (this.fittedWindows.size > 0) {
|
|
1363
|
+
for (const [runtimeId, size] of this.fittedWindows)
|
|
1364
|
+
if (this.windowsByRuntime.has(runtimeId))
|
|
1365
|
+
this.io.send(
|
|
1366
|
+
`refresh-client -C ${runtimeId}:${size.cols}x${size.rows}${this.cellPixelSuffix()}`,
|
|
1367
|
+
);
|
|
1368
|
+
} else if (this.clientViewport)
|
|
1369
|
+
this.io.send(
|
|
1370
|
+
`refresh-client -C ${this.clientViewport.cols}x${this.clientViewport.rows}${this.cellPixelSuffix()}`,
|
|
1371
|
+
);
|
|
1313
1372
|
}
|
|
1314
1373
|
|
|
1315
1374
|
private restoreWindowSizing(target: string): void {
|
|
@@ -1359,7 +1418,7 @@ export class SessionChannel {
|
|
|
1359
1418
|
if (previous?.cols === cols && previous.rows === rows) return;
|
|
1360
1419
|
this.input.flush();
|
|
1361
1420
|
this.restoreWindowSizing(window.runtimeId);
|
|
1362
|
-
this.io.send(`refresh-client -C ${window.runtimeId}:${cols}x${rows}`);
|
|
1421
|
+
this.io.send(`refresh-client -C ${window.runtimeId}:${cols}x${rows}${this.cellPixelSuffix()}`);
|
|
1363
1422
|
this.fittedWindows.set(window.runtimeId, { cols, rows });
|
|
1364
1423
|
}
|
|
1365
1424
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { inflateSync } from "node:zlib";
|
|
1
2
|
import { PaneStreamLayoutSnapshotFrameSchemaZ, type WindowLinkTopology } from "@tmux-ide/contracts";
|
|
2
3
|
import type { TerminalDeliveryGraphics } from "../session-runtime/terminal-delivery-hub.ts";
|
|
3
4
|
import { z } from "zod";
|
|
@@ -10,6 +11,11 @@ import {
|
|
|
10
11
|
PANE_STREAM_REDEEM_PATH,
|
|
11
12
|
PANE_STREAM_WEBSOCKET_SUBPROTOCOL,
|
|
12
13
|
TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES,
|
|
14
|
+
TERMINAL_GRAPHICS_CELL_PIXELS_CAPABILITY_VERSION,
|
|
15
|
+
TERMINAL_GRAPHICS_PRECISE_PIXELS_CAPABILITY_VERSION,
|
|
16
|
+
TERMINAL_GRAPHICS_LEGACY_MAX_IMAGE_BYTES,
|
|
17
|
+
TERMINAL_GRAPHICS_ZLIB_CAPABILITY_VERSION,
|
|
18
|
+
type TerminalGraphicsState,
|
|
13
19
|
PaneStreamClientFrameSchemaZ,
|
|
14
20
|
PaneStreamLoopbackWebSocketUrlSchemaZ,
|
|
15
21
|
PaneStreamRedeemFrameSchemaZ,
|
|
@@ -305,6 +311,7 @@ export interface SessionRuntimePaneStreamTransportBinding {
|
|
|
305
311
|
rows: number,
|
|
306
312
|
semanticWindowId?: string,
|
|
307
313
|
): void;
|
|
314
|
+
setCellPixels?(width: number, height: number, precise?: { width: number; height: number }): void;
|
|
308
315
|
close(): Promise<void>;
|
|
309
316
|
}
|
|
310
317
|
|
|
@@ -1046,6 +1053,10 @@ interface PaneChannel {
|
|
|
1046
1053
|
graphicsAnnounced: boolean;
|
|
1047
1054
|
graphicsQueue: string[];
|
|
1048
1055
|
graphicsSending: boolean;
|
|
1056
|
+
/** Image id → digest last served; a replacement for the same id is pushed. */
|
|
1057
|
+
graphicsServed: Map<number, string>;
|
|
1058
|
+
graphicsState: TerminalGraphicsState | null;
|
|
1059
|
+
graphicsZlib?: boolean;
|
|
1049
1060
|
}
|
|
1050
1061
|
|
|
1051
1062
|
interface QueuedSend {
|
|
@@ -1054,6 +1065,26 @@ interface QueuedSend {
|
|
|
1054
1065
|
}
|
|
1055
1066
|
|
|
1056
1067
|
const GRAPHICS_MAX_QUEUED_DIGESTS = 256;
|
|
1068
|
+
|
|
1069
|
+
/** Graphics state as renderers without zlib support must see it. */
|
|
1070
|
+
export function rawGraphicsState(state: TerminalGraphicsState): TerminalGraphicsState {
|
|
1071
|
+
const legacy = state.images.map((image): TerminalGraphicsState["images"][number] =>
|
|
1072
|
+
image.format === "rgba-zlib" || image.format === "rgb-zlib"
|
|
1073
|
+
? {
|
|
1074
|
+
...image,
|
|
1075
|
+
format: image.format === "rgba-zlib" ? "rgba" : "rgb",
|
|
1076
|
+
bytes: image.width * image.height * (image.format === "rgba-zlib" ? 4 : 3),
|
|
1077
|
+
}
|
|
1078
|
+
: image,
|
|
1079
|
+
);
|
|
1080
|
+
const images = legacy.filter((image) => image.bytes <= TERMINAL_GRAPHICS_LEGACY_MAX_IMAGE_BYTES);
|
|
1081
|
+
const kept = new Set(images.map((image) => image.digest));
|
|
1082
|
+
return {
|
|
1083
|
+
...state,
|
|
1084
|
+
images,
|
|
1085
|
+
placements: state.placements.filter((placement) => kept.has(placement.digest)),
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1057
1088
|
const GRAPHICS_SEND_WINDOW_BYTES = 2 * 1024 * 1024;
|
|
1058
1089
|
|
|
1059
1090
|
export class PaneStreamLiveConnection {
|
|
@@ -1227,6 +1258,8 @@ export class PaneStreamLiveConnection {
|
|
|
1227
1258
|
graphicsAnnounced: false,
|
|
1228
1259
|
graphicsQueue: [],
|
|
1229
1260
|
graphicsSending: false,
|
|
1261
|
+
graphicsServed: new Map(),
|
|
1262
|
+
graphicsState: null,
|
|
1230
1263
|
});
|
|
1231
1264
|
}
|
|
1232
1265
|
}
|
|
@@ -1748,17 +1781,72 @@ export class PaneStreamLiveConnection {
|
|
|
1748
1781
|
#startGraphics(channel: PaneChannel): void {
|
|
1749
1782
|
const graphics = channel.delivery?.graphics;
|
|
1750
1783
|
if (!graphics) return;
|
|
1784
|
+
// Renderers that cannot inflate see raw formats; their bytes are inflated
|
|
1785
|
+
// when served.
|
|
1786
|
+
const zlib =
|
|
1787
|
+
this.#descriptor.terminalDelivery?.protocolVersions.includes(
|
|
1788
|
+
TERMINAL_GRAPHICS_ZLIB_CAPABILITY_VERSION,
|
|
1789
|
+
) === true;
|
|
1790
|
+
channel.graphicsZlib = zlib;
|
|
1791
|
+
// Renderers that report cell pixels learn up front that this daemon
|
|
1792
|
+
// accepts them, before any image exists.
|
|
1793
|
+
const cellPixels =
|
|
1794
|
+
this.#descriptor.terminalDelivery?.protocolVersions.includes(
|
|
1795
|
+
TERMINAL_GRAPHICS_CELL_PIXELS_CAPABILITY_VERSION,
|
|
1796
|
+
) === true;
|
|
1797
|
+
const preciseCellPixels =
|
|
1798
|
+
this.#descriptor.terminalDelivery?.protocolVersions.includes(
|
|
1799
|
+
TERMINAL_GRAPHICS_PRECISE_PIXELS_CAPABILITY_VERSION,
|
|
1800
|
+
) === true;
|
|
1801
|
+
let announced = false;
|
|
1751
1802
|
graphics.subscribe((state) => {
|
|
1803
|
+
announced = true;
|
|
1752
1804
|
if (this.#closed || channel.closed) return;
|
|
1753
1805
|
channel.graphicsAnnounced = true;
|
|
1754
|
-
|
|
1806
|
+
channel.graphicsState = state;
|
|
1807
|
+
// A program redrawing one image id (every frame of an animation or a
|
|
1808
|
+
// page) needs the new bytes at once: push them instead of waiting for
|
|
1809
|
+
// the renderer's round trip.
|
|
1810
|
+
const pushed: string[] = [];
|
|
1811
|
+
for (const image of state.images) {
|
|
1812
|
+
const served = channel.graphicsServed.get(image.imageId);
|
|
1813
|
+
if (served !== undefined && served !== image.digest) pushed.push(image.digest);
|
|
1814
|
+
}
|
|
1815
|
+
this.#sendFrame(null, {
|
|
1816
|
+
type: "terminal-graphics",
|
|
1817
|
+
pane: channel.semanticPaneId,
|
|
1818
|
+
state: {
|
|
1819
|
+
...(zlib ? state : rawGraphicsState(state)),
|
|
1820
|
+
...(cellPixels ? { cellPixels: true as const } : {}),
|
|
1821
|
+
...(preciseCellPixels ? { preciseCellPixels: true as const } : {}),
|
|
1822
|
+
},
|
|
1823
|
+
});
|
|
1824
|
+
if (pushed.length > 0) this.#queueGraphics(channel, pushed);
|
|
1755
1825
|
});
|
|
1826
|
+
if (cellPixels && !announced && !this.#closed && !channel.closed) {
|
|
1827
|
+
channel.graphicsAnnounced = true;
|
|
1828
|
+
this.#sendFrame(null, {
|
|
1829
|
+
type: "terminal-graphics",
|
|
1830
|
+
pane: channel.semanticPaneId,
|
|
1831
|
+
state: {
|
|
1832
|
+
revision: 0,
|
|
1833
|
+
images: [],
|
|
1834
|
+
placements: [],
|
|
1835
|
+
cellPixels: true,
|
|
1836
|
+
...(preciseCellPixels ? { preciseCellPixels: true as const } : {}),
|
|
1837
|
+
},
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1756
1840
|
}
|
|
1757
1841
|
|
|
1758
1842
|
#requestGraphics(pane: string, digests: readonly string[]): void {
|
|
1759
1843
|
const channel = this.#panes.get(pane);
|
|
1760
1844
|
// Requests are only meaningful after an announcement; ignore stray ones.
|
|
1761
1845
|
if (!channel || channel.closed || !channel.graphicsAnnounced) return;
|
|
1846
|
+
this.#queueGraphics(channel, digests);
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
#queueGraphics(channel: PaneChannel, digests: readonly string[]): void {
|
|
1762
1850
|
for (const digest of digests)
|
|
1763
1851
|
if (
|
|
1764
1852
|
!channel.graphicsQueue.includes(digest) &&
|
|
@@ -1778,28 +1866,49 @@ export class PaneStreamLiveConnection {
|
|
|
1778
1866
|
try {
|
|
1779
1867
|
while (!this.#closed && !channel.closed && channel.graphicsQueue.length > 0) {
|
|
1780
1868
|
const digest = channel.graphicsQueue.shift()!;
|
|
1781
|
-
const
|
|
1782
|
-
|
|
1783
|
-
|
|
1869
|
+
const graphics = channel.delivery?.graphics;
|
|
1870
|
+
const image = graphics?.image(digest);
|
|
1871
|
+
if (!graphics || !image) continue;
|
|
1872
|
+
for (const entry of channel.graphicsState?.images ?? [])
|
|
1873
|
+
if (entry.digest === digest) channel.graphicsServed.set(entry.imageId, digest);
|
|
1874
|
+
const raw = !channel.graphicsZlib && image.format.endsWith("-zlib");
|
|
1875
|
+
let data: Uint8Array = image.data;
|
|
1876
|
+
if (raw) {
|
|
1877
|
+
const expected = image.width * image.height * (image.format === "rgba-zlib" ? 4 : 3);
|
|
1878
|
+
if (expected < 1 || expected > TERMINAL_GRAPHICS_LEGACY_MAX_IMAGE_BYTES) continue;
|
|
1879
|
+
try {
|
|
1880
|
+
data = inflateSync(image.data, { maxOutputLength: expected });
|
|
1881
|
+
} catch {
|
|
1882
|
+
continue;
|
|
1883
|
+
}
|
|
1884
|
+
if (data.byteLength !== expected) continue;
|
|
1885
|
+
}
|
|
1886
|
+
if (!channel.graphicsZlib && data.byteLength > TERMINAL_GRAPHICS_LEGACY_MAX_IMAGE_BYTES)
|
|
1887
|
+
continue;
|
|
1888
|
+
const format = raw ? (image.format === "rgba-zlib" ? "rgba" : "rgb") : image.format;
|
|
1889
|
+
const total = data.byteLength;
|
|
1784
1890
|
for (let offset = 0; offset < total; offset += TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES) {
|
|
1785
1891
|
while ((this.#socket.bufferedAmount ?? 0) > GRAPHICS_SEND_WINDOW_BYTES) {
|
|
1786
1892
|
if (this.#closed || channel.closed) return;
|
|
1787
1893
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
1788
1894
|
}
|
|
1789
1895
|
if (this.#closed || channel.closed) return;
|
|
1896
|
+
// A superseded frame (the id was redrawn) is abandoned mid-transfer;
|
|
1897
|
+
// the renderer drops the partial image and shows the newer one.
|
|
1898
|
+
if (offset > 0 && graphics.image(digest) !== image) break;
|
|
1790
1899
|
const end = Math.min(total, offset + TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES);
|
|
1791
1900
|
this.#sendFrame(null, {
|
|
1792
1901
|
type: "terminal-graphics-blob",
|
|
1793
1902
|
pane: channel.semanticPaneId,
|
|
1794
1903
|
blob: {
|
|
1795
1904
|
digest,
|
|
1796
|
-
format
|
|
1905
|
+
format,
|
|
1797
1906
|
width: image.width,
|
|
1798
1907
|
height: image.height,
|
|
1799
1908
|
total,
|
|
1800
1909
|
offset,
|
|
1801
1910
|
},
|
|
1802
|
-
data: Buffer.from(
|
|
1911
|
+
data: Buffer.from(data.subarray(offset, end)).toString("base64"),
|
|
1803
1912
|
});
|
|
1804
1913
|
}
|
|
1805
1914
|
}
|
|
@@ -2383,6 +2492,14 @@ export class PaneStreamLiveConnection {
|
|
|
2383
2492
|
this.#requestGraphics(frame.pane, frame.digests);
|
|
2384
2493
|
return;
|
|
2385
2494
|
}
|
|
2495
|
+
if (frame.type === "terminal-cell-pixels") {
|
|
2496
|
+
try {
|
|
2497
|
+
this.#sessionRuntimeBinding?.setCellPixels?.(frame.width, frame.height, frame.precise);
|
|
2498
|
+
} catch {
|
|
2499
|
+
// A retired runtime simply keeps tmux's default cell size.
|
|
2500
|
+
}
|
|
2501
|
+
return;
|
|
2502
|
+
}
|
|
2386
2503
|
if (frame.type === "viewport") {
|
|
2387
2504
|
if (
|
|
2388
2505
|
!this.#descriptor.terminalDelivery ||
|
|
@@ -256,6 +256,8 @@ export interface SessionRuntimeConsumer {
|
|
|
256
256
|
rows: number,
|
|
257
257
|
semanticWindowId?: string,
|
|
258
258
|
): void;
|
|
259
|
+
/** Renderer cell size in device pixels, forwarded to tmux. */
|
|
260
|
+
setCellPixels?(width: number, height: number, precise?: { width: number; height: number }): void;
|
|
259
261
|
fitViewportWithAuthority(
|
|
260
262
|
lease: SessionRuntimeAuthorityLease,
|
|
261
263
|
cols: number,
|
|
@@ -1767,6 +1769,17 @@ class SessionRuntime {
|
|
|
1767
1769
|
}
|
|
1768
1770
|
}
|
|
1769
1771
|
|
|
1772
|
+
/** Renderer cell size in device pixels; benign, so any connected client may set it. */
|
|
1773
|
+
setCellPixels(
|
|
1774
|
+
clientId: string,
|
|
1775
|
+
width: number,
|
|
1776
|
+
height: number,
|
|
1777
|
+
precise?: { width: number; height: number },
|
|
1778
|
+
): void {
|
|
1779
|
+
this.#assertConnected(clientId);
|
|
1780
|
+
this.#mirror.setCellPixels?.(this.session, width, height, precise);
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1770
1783
|
fitViewportWithAuthority(
|
|
1771
1784
|
clientId: string,
|
|
1772
1785
|
lease: SessionRuntimeAuthorityLease,
|
|
@@ -2342,6 +2355,11 @@ class SessionRuntimeConsumerImpl implements SessionRuntimeConsumer {
|
|
|
2342
2355
|
this.#runtime.fitViewport(this.clientId, lease, cols, rows, semanticWindowId);
|
|
2343
2356
|
}
|
|
2344
2357
|
|
|
2358
|
+
setCellPixels(width: number, height: number, precise?: { width: number; height: number }): void {
|
|
2359
|
+
this.#assertOpen();
|
|
2360
|
+
this.#runtime.setCellPixels(this.clientId, width, height, precise);
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2345
2363
|
fitViewportWithAuthority(
|
|
2346
2364
|
lease: SessionRuntimeAuthorityLease,
|
|
2347
2365
|
cols: number,
|
|
@@ -422,8 +422,17 @@ export class TerminalReplicaInterpreter {
|
|
|
422
422
|
const previous = this.#backend;
|
|
423
423
|
this.#backend = replacement;
|
|
424
424
|
this.#attachKitty(replacement);
|
|
425
|
-
//
|
|
426
|
-
|
|
425
|
+
// A capture replay does not erase graphics already observed by this owner.
|
|
426
|
+
// Keep anchors only when both backends agree on their coordinate basis;
|
|
427
|
+
// reflow, history replacement and authoritative stream resets are ambiguous.
|
|
428
|
+
const sameGraphicsBasis =
|
|
429
|
+
operation.bootstrap === "painted-capture" &&
|
|
430
|
+
previous.cols === replacement.cols &&
|
|
431
|
+
previous.rows === replacement.rows &&
|
|
432
|
+
previous.graphicsViewportTop?.() !== undefined &&
|
|
433
|
+
previous.graphicsViewportTop?.() === replacement.graphicsViewportTop?.() &&
|
|
434
|
+
previous.modes().alternateScreen === replacement.modes().alternateScreen;
|
|
435
|
+
if (!sameGraphicsBasis) this.#kitty.dropCursorPlacements();
|
|
427
436
|
this.#nativeReseedRequested = false;
|
|
428
437
|
this.#scrollback = scrollback;
|
|
429
438
|
this.#bootstrap = {
|
|
@@ -459,6 +468,8 @@ export class TerminalReplicaInterpreter {
|
|
|
459
468
|
return;
|
|
460
469
|
}
|
|
461
470
|
if (operation.type === "resize") {
|
|
471
|
+
if (operation.cols !== this.#backend.cols || operation.rows !== this.#backend.rows)
|
|
472
|
+
this.#kitty.dropCursorPlacements();
|
|
462
473
|
this.#causalCell?.fail("geometry-drift");
|
|
463
474
|
if (this.#backend.modes().synchronizedOutput) {
|
|
464
475
|
// Geometry is part of the admitted FIFO even while publication is
|
|
@@ -512,6 +523,11 @@ export class TerminalReplicaInterpreter {
|
|
|
512
523
|
// Always forward the scanner's output: tmux splits %output (~2 KiB) inside
|
|
513
524
|
// kitty APCs (~4 KiB chunks), so a chunk with no completed command can still
|
|
514
525
|
// be all image payload that must never reach the VT parser as text.
|
|
526
|
+
// A chunk entirely inside image payload changes nothing the parser can
|
|
527
|
+
// see. Skipping it matters: every parser write settles on a later timer
|
|
528
|
+
// turn, so a frame split into dozens of %output chunks would otherwise
|
|
529
|
+
// serialize dozens of turns and fall behind a 30 fps image stream.
|
|
530
|
+
if (scanned.forward.byteLength === 0) return;
|
|
515
531
|
await this.#writeToBackend(this.#backend, scanned.forward);
|
|
516
532
|
if (this.#observability.enabled)
|
|
517
533
|
this.#observability.recordSpan(
|