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
|
@@ -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"];
|
|
@@ -563,6 +565,17 @@ export class MirrorService {
|
|
|
563
565
|
entry.channel.fitWindowViewport(semanticWindowId, cols, rows);
|
|
564
566
|
}
|
|
565
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
|
+
|
|
566
579
|
clearWindowViewports(session: string): void {
|
|
567
580
|
const entry = this.channels.get(session);
|
|
568
581
|
if (entry && !entry.retired) entry.channel.clearWindowViewports();
|
|
@@ -737,6 +750,7 @@ export class MirrorService {
|
|
|
737
750
|
nativeServerIdentity: this.opts.nativeServerIdentity,
|
|
738
751
|
configFile: this.opts.configFile,
|
|
739
752
|
pauseAfterSeconds: this.opts.pauseAfterSeconds,
|
|
753
|
+
kittyGraphics: this.opts.kittyGraphics ?? process.env.TMUX_IDE_KITTY_GRAPHICS !== "0",
|
|
740
754
|
nowMicros: this.opts.nowMicros,
|
|
741
755
|
nativeViewer: ownedViewer?.controlOptions(),
|
|
742
756
|
nativeViewerReady: ownedViewer
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/** Upstream tmux 3.8 reuses -R for a textual grid dump, not our JSON extension.
|
|
2
|
+
* Require the generated grid and first-row headers; arbitrary malformed native
|
|
3
|
+
* replies must remain errors rather than silently downgrading capture fidelity.
|
|
4
|
+
*/
|
|
5
|
+
export function isStockRawGridCapture(lines: readonly string[]): boolean {
|
|
6
|
+
return (
|
|
7
|
+
/^G [1-9][0-9]*x[1-9][0-9]* \([0-9]+\/[0-9]+\)$/.test(lines[0] ?? "") &&
|
|
8
|
+
/^\tL 0 \((?:-|0)\) flags=/.test(lines[1] ?? "")
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
|
|
1
12
|
/** Private native capture experiment; this is not the canonical renderer schema. */
|
|
2
13
|
export interface NativeGridCaptureCell {
|
|
3
14
|
readonly flags: number;
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { MirrorChannelIo } from "./control-channel.ts";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
decodeNativeGridCapture,
|
|
4
|
+
isStockRawGridCapture,
|
|
5
|
+
type NativeGridCapture,
|
|
6
|
+
} from "./native-grid-capture.ts";
|
|
3
7
|
|
|
4
8
|
export type NativeGridReadResult =
|
|
5
9
|
| {
|
|
@@ -83,6 +87,11 @@ export class NativeGridCaptureReader {
|
|
|
83
87
|
finish({ status: "changed" });
|
|
84
88
|
return;
|
|
85
89
|
}
|
|
90
|
+
if (isStockRawGridCapture(reply.lines)) {
|
|
91
|
+
this.unsupported = true;
|
|
92
|
+
finish({ status: "unsupported" });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
86
95
|
const snapshot = decodeNativeGridCapture(reply.lines.join("\n"));
|
|
87
96
|
finish(
|
|
88
97
|
snapshot
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { boundedTmuxInteractionAppendCommand } from "../../lib/tmux-interaction-retention.ts";
|
|
11
11
|
import {
|
|
12
12
|
decodeNativeGridCapture,
|
|
13
|
+
isStockRawGridCapture,
|
|
13
14
|
isNativeBootstrapCapture,
|
|
14
15
|
type NativeGridCapture,
|
|
15
16
|
} from "./native-grid-capture.ts";
|
|
@@ -212,7 +213,10 @@ function nativeBootstrapUnsupported(
|
|
|
212
213
|
native: NativeGridCapture | null,
|
|
213
214
|
): boolean {
|
|
214
215
|
if (ok)
|
|
215
|
-
return
|
|
216
|
+
return (
|
|
217
|
+
isStockRawGridCapture(lines) ||
|
|
218
|
+
(native !== null && (native.version !== 2 || native.currentAttributes === undefined))
|
|
219
|
+
);
|
|
216
220
|
return lines.some((line) =>
|
|
217
221
|
/^(?:parse error: )?(?:command capture-pane: )?unknown flag -R$/.test(line.trim()),
|
|
218
222
|
);
|
|
@@ -345,6 +349,7 @@ interface SnapshotLease {
|
|
|
345
349
|
cancelFence: (() => void) | null;
|
|
346
350
|
cleanup?: () => void;
|
|
347
351
|
ownsPause?: boolean;
|
|
352
|
+
attemptCharged?: boolean;
|
|
348
353
|
}
|
|
349
354
|
|
|
350
355
|
interface RecoveryRecord {
|
|
@@ -1304,7 +1309,66 @@ export class SessionChannel {
|
|
|
1304
1309
|
this.input.flush();
|
|
1305
1310
|
this.clearWindowViewports();
|
|
1306
1311
|
this.restoreWindowSizing(this.attachedIdentity?.runtimeSessionId ?? `=${this.opts.session}`);
|
|
1307
|
-
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
|
+
);
|
|
1308
1372
|
}
|
|
1309
1373
|
|
|
1310
1374
|
private restoreWindowSizing(target: string): void {
|
|
@@ -1354,7 +1418,7 @@ export class SessionChannel {
|
|
|
1354
1418
|
if (previous?.cols === cols && previous.rows === rows) return;
|
|
1355
1419
|
this.input.flush();
|
|
1356
1420
|
this.restoreWindowSizing(window.runtimeId);
|
|
1357
|
-
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()}`);
|
|
1358
1422
|
this.fittedWindows.set(window.runtimeId, { cols, rows });
|
|
1359
1423
|
}
|
|
1360
1424
|
|
|
@@ -1959,6 +2023,7 @@ export class SessionChannel {
|
|
|
1959
2023
|
return;
|
|
1960
2024
|
}
|
|
1961
2025
|
recovery.attempts += 1;
|
|
2026
|
+
lease.attemptCharged = true;
|
|
1962
2027
|
recovery.stage = "stock-pause";
|
|
1963
2028
|
recovery.stock = new StockPaneSnapshot(this.snapshotContext(pane));
|
|
1964
2029
|
// These rows were received before this pause and belong to the new seed,
|
|
@@ -2135,6 +2200,7 @@ export class SessionChannel {
|
|
|
2135
2200
|
return;
|
|
2136
2201
|
}
|
|
2137
2202
|
recovery.attempts += 1;
|
|
2203
|
+
lease.attemptCharged = true;
|
|
2138
2204
|
const ordinal = ++recovery.reseedOrdinal;
|
|
2139
2205
|
const participants = [...pane.subs]
|
|
2140
2206
|
.filter((sub) => !sub.closed && !sub.frozen)
|
|
@@ -2924,6 +2990,12 @@ export class SessionChannel {
|
|
|
2924
2990
|
if (pane.windowRuntimeId !== change.windowId) continue;
|
|
2925
2991
|
const recovery = this.recoveries.get(pane.runtimeId);
|
|
2926
2992
|
if (recovery?.lease && !recovery.lease.retired) {
|
|
2993
|
+
// A newer layout cancels this capture; it is not a failed snapshot.
|
|
2994
|
+
// Refund only this lease, retaining earlier failures and the original deadline.
|
|
2995
|
+
if (recovery.lease.attemptCharged) {
|
|
2996
|
+
recovery.lease.attemptCharged = false;
|
|
2997
|
+
recovery.attempts -= 1;
|
|
2998
|
+
}
|
|
2927
2999
|
this.awaitSnapshotLayout(recovery);
|
|
2928
3000
|
this.retrySnapshot(recovery);
|
|
2929
3001
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { inflateSync } from "node:zlib";
|
|
1
2
|
import { PaneStreamLayoutSnapshotFrameSchemaZ, type WindowLinkTopology } from "@tmux-ide/contracts";
|
|
3
|
+
import type { TerminalDeliveryGraphics } from "../session-runtime/terminal-delivery-hub.ts";
|
|
2
4
|
import { z } from "zod";
|
|
3
5
|
import {
|
|
4
6
|
PANE_STREAM_MAX_HELD_DELTAS,
|
|
@@ -8,6 +10,12 @@ import {
|
|
|
8
10
|
PANE_STREAM_PROTOCOL_VERSION,
|
|
9
11
|
PANE_STREAM_REDEEM_PATH,
|
|
10
12
|
PANE_STREAM_WEBSOCKET_SUBPROTOCOL,
|
|
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,
|
|
11
19
|
PaneStreamClientFrameSchemaZ,
|
|
12
20
|
PaneStreamLoopbackWebSocketUrlSchemaZ,
|
|
13
21
|
PaneStreamRedeemFrameSchemaZ,
|
|
@@ -303,6 +311,7 @@ export interface SessionRuntimePaneStreamTransportBinding {
|
|
|
303
311
|
rows: number,
|
|
304
312
|
semanticWindowId?: string,
|
|
305
313
|
): void;
|
|
314
|
+
setCellPixels?(width: number, height: number, precise?: { width: number; height: number }): void;
|
|
306
315
|
close(): Promise<void>;
|
|
307
316
|
}
|
|
308
317
|
|
|
@@ -314,6 +323,7 @@ export interface SessionRuntimeTerminalDeliveryConnection {
|
|
|
314
323
|
nack(nack: TerminalDeliveryNack): void;
|
|
315
324
|
setVisibility(visibility: TerminalDeliveryVisibility): void;
|
|
316
325
|
close(): Promise<void>;
|
|
326
|
+
readonly graphics?: TerminalDeliveryGraphics;
|
|
317
327
|
}
|
|
318
328
|
|
|
319
329
|
interface PendingTicket {
|
|
@@ -1039,6 +1049,14 @@ interface PaneChannel {
|
|
|
1039
1049
|
frozenByWire: boolean;
|
|
1040
1050
|
closed: boolean;
|
|
1041
1051
|
batch: SeedBatch | null;
|
|
1052
|
+
/** Kitty graphics: state announced to this client, and blob digests queued. */
|
|
1053
|
+
graphicsAnnounced: boolean;
|
|
1054
|
+
graphicsQueue: string[];
|
|
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;
|
|
1042
1060
|
}
|
|
1043
1061
|
|
|
1044
1062
|
interface QueuedSend {
|
|
@@ -1046,6 +1064,29 @@ interface QueuedSend {
|
|
|
1046
1064
|
remaining: number;
|
|
1047
1065
|
}
|
|
1048
1066
|
|
|
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
|
+
}
|
|
1088
|
+
const GRAPHICS_SEND_WINDOW_BYTES = 2 * 1024 * 1024;
|
|
1089
|
+
|
|
1049
1090
|
export class PaneStreamLiveConnection {
|
|
1050
1091
|
readonly #clientId: string;
|
|
1051
1092
|
readonly #socket: DirectTerminalSocket;
|
|
@@ -1214,6 +1255,11 @@ export class PaneStreamLiveConnection {
|
|
|
1214
1255
|
frozenByWire: false,
|
|
1215
1256
|
closed: false,
|
|
1216
1257
|
batch: null,
|
|
1258
|
+
graphicsAnnounced: false,
|
|
1259
|
+
graphicsQueue: [],
|
|
1260
|
+
graphicsSending: false,
|
|
1261
|
+
graphicsServed: new Map(),
|
|
1262
|
+
graphicsState: null,
|
|
1217
1263
|
});
|
|
1218
1264
|
}
|
|
1219
1265
|
}
|
|
@@ -1619,6 +1665,7 @@ export class PaneStreamLiveConnection {
|
|
|
1619
1665
|
await this.#sendTerminalDelivery(channel.semanticPaneId, message);
|
|
1620
1666
|
pending.length = 0;
|
|
1621
1667
|
markReady();
|
|
1668
|
+
this.#startGraphics(channel);
|
|
1622
1669
|
}
|
|
1623
1670
|
this.#closeIfAllPanesGone();
|
|
1624
1671
|
}
|
|
@@ -1730,6 +1777,146 @@ export class PaneStreamLiveConnection {
|
|
|
1730
1777
|
await this.#awaitSemanticCredit(pane);
|
|
1731
1778
|
}
|
|
1732
1779
|
|
|
1780
|
+
/** Announce the pane's kitty graphics state; the client pulls bytes it lacks. */
|
|
1781
|
+
#startGraphics(channel: PaneChannel): void {
|
|
1782
|
+
const graphics = channel.delivery?.graphics;
|
|
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;
|
|
1802
|
+
graphics.subscribe((state) => {
|
|
1803
|
+
announced = true;
|
|
1804
|
+
if (this.#closed || channel.closed) return;
|
|
1805
|
+
channel.graphicsAnnounced = true;
|
|
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);
|
|
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
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
#requestGraphics(pane: string, digests: readonly string[]): void {
|
|
1843
|
+
const channel = this.#panes.get(pane);
|
|
1844
|
+
// Requests are only meaningful after an announcement; ignore stray ones.
|
|
1845
|
+
if (!channel || channel.closed || !channel.graphicsAnnounced) return;
|
|
1846
|
+
this.#queueGraphics(channel, digests);
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
#queueGraphics(channel: PaneChannel, digests: readonly string[]): void {
|
|
1850
|
+
for (const digest of digests)
|
|
1851
|
+
if (
|
|
1852
|
+
!channel.graphicsQueue.includes(digest) &&
|
|
1853
|
+
channel.graphicsQueue.length < GRAPHICS_MAX_QUEUED_DIGESTS
|
|
1854
|
+
)
|
|
1855
|
+
channel.graphicsQueue.push(digest);
|
|
1856
|
+
if (!channel.graphicsSending) void this.#drainGraphics(channel);
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
/**
|
|
1860
|
+
* Blob bytes yield to terminal frames: each chunk waits until the socket's
|
|
1861
|
+
* buffered bytes fall below a small window, so images never trip the
|
|
1862
|
+
* socket-wide backpressure ceiling or delay a pane's next seed for long.
|
|
1863
|
+
*/
|
|
1864
|
+
async #drainGraphics(channel: PaneChannel): Promise<void> {
|
|
1865
|
+
channel.graphicsSending = true;
|
|
1866
|
+
try {
|
|
1867
|
+
while (!this.#closed && !channel.closed && channel.graphicsQueue.length > 0) {
|
|
1868
|
+
const digest = channel.graphicsQueue.shift()!;
|
|
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;
|
|
1890
|
+
for (let offset = 0; offset < total; offset += TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES) {
|
|
1891
|
+
while ((this.#socket.bufferedAmount ?? 0) > GRAPHICS_SEND_WINDOW_BYTES) {
|
|
1892
|
+
if (this.#closed || channel.closed) return;
|
|
1893
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
1894
|
+
}
|
|
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;
|
|
1899
|
+
const end = Math.min(total, offset + TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES);
|
|
1900
|
+
this.#sendFrame(null, {
|
|
1901
|
+
type: "terminal-graphics-blob",
|
|
1902
|
+
pane: channel.semanticPaneId,
|
|
1903
|
+
blob: {
|
|
1904
|
+
digest,
|
|
1905
|
+
format,
|
|
1906
|
+
width: image.width,
|
|
1907
|
+
height: image.height,
|
|
1908
|
+
total,
|
|
1909
|
+
offset,
|
|
1910
|
+
},
|
|
1911
|
+
data: Buffer.from(data.subarray(offset, end)).toString("base64"),
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
} finally {
|
|
1916
|
+
channel.graphicsSending = false;
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1733
1920
|
#awaitSemanticCredit(pane: string): Promise<void> {
|
|
1734
1921
|
// Backpressure is pane-local. Aggregate pressure is guarded by the hard
|
|
1735
1922
|
// socket ceiling in #drainNow, but must not let a noisy sibling prevent a
|
|
@@ -2301,6 +2488,18 @@ export class PaneStreamLiveConnection {
|
|
|
2301
2488
|
this.#acceptSemanticIntent(frame.operationId, frame.intent);
|
|
2302
2489
|
return;
|
|
2303
2490
|
}
|
|
2491
|
+
if (frame.type === "terminal-graphics-request") {
|
|
2492
|
+
this.#requestGraphics(frame.pane, frame.digests);
|
|
2493
|
+
return;
|
|
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
|
+
}
|
|
2304
2503
|
if (frame.type === "viewport") {
|
|
2305
2504
|
if (
|
|
2306
2505
|
!this.#descriptor.terminalDelivery ||
|
|
@@ -183,7 +183,6 @@ export function parseSessionPaneDescriptorReply(
|
|
|
183
183
|
!bounded(type, 256) ||
|
|
184
184
|
!bounded(currentCommand, 512) ||
|
|
185
185
|
!bounded(cwd, 4_096) ||
|
|
186
|
-
!bounded(title, 1_024) ||
|
|
187
186
|
!bounded(windowName, 1_024) ||
|
|
188
187
|
!bounded(name, 256) ||
|
|
189
188
|
!bounded(missionStamp, 256) ||
|
|
@@ -203,7 +202,9 @@ export function parseSessionPaneDescriptorReply(
|
|
|
203
202
|
type: nonempty(type),
|
|
204
203
|
currentCommand: nonempty(currentCommand),
|
|
205
204
|
cwd: nonempty(cwd),
|
|
206
|
-
|
|
205
|
+
// Stock tmux turns any APC, including kitty graphics payloads, into the
|
|
206
|
+
// pane title; an overlong title names nothing and must not drop the pane.
|
|
207
|
+
title: bounded(title, 1_024) ? nonempty(title) : null,
|
|
207
208
|
windowIndex,
|
|
208
209
|
windowName: nonempty(windowName),
|
|
209
210
|
windowId: /^@(?:0|[1-9][0-9]*)$/u.test(windowId) ? windowId : null,
|