tmux-ide 2.9.7 → 2.9.9
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 +323 -55
- 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 +49 -5
- package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +109 -7
- package/packages/daemon/dist/terminal/session-runtime/registry.js +9 -1
- 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 +65 -4
- package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +123 -6
- package/packages/daemon/src/terminal/session-runtime/registry.ts +18 -1
- 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
|
@@ -853,7 +853,50 @@ export class SessionChannel {
|
|
|
853
853
|
this.input.flush();
|
|
854
854
|
this.clearWindowViewports();
|
|
855
855
|
this.restoreWindowSizing(this.attachedIdentity?.runtimeSessionId ?? `=${this.opts.session}`);
|
|
856
|
-
this.io.send(`refresh-client -C ${cols}x${rows}`);
|
|
856
|
+
this.io.send(`refresh-client -C ${cols}x${rows}${this.cellPixelSuffix()}`);
|
|
857
|
+
this.clientViewport = { cols, rows };
|
|
858
|
+
}
|
|
859
|
+
clientViewport = null;
|
|
860
|
+
cellPixels = null;
|
|
861
|
+
/** "/WxH" for tmux's refresh-client -C (patched tmux; stock ignores it). */
|
|
862
|
+
cellPixelSuffix() {
|
|
863
|
+
if (!this.cellPixels)
|
|
864
|
+
return "";
|
|
865
|
+
const { width, height, precise } = this.cellPixels;
|
|
866
|
+
return `/${width}x${height}` + (precise ? `/${precise.width}x${precise.height}` : "");
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* The renderer's cell size in device pixels. tmux answers CSI 14t/16t and
|
|
870
|
+
* sets TIOCGWINSZ pixels from it, so image programs render 1:1 for the
|
|
871
|
+
* window. Re-applies the current viewport when the size changes.
|
|
872
|
+
*/
|
|
873
|
+
setCellPixels(width, height, precise) {
|
|
874
|
+
if (!Number.isSafeInteger(width) ||
|
|
875
|
+
!Number.isSafeInteger(height) ||
|
|
876
|
+
width < 1 ||
|
|
877
|
+
height < 1 ||
|
|
878
|
+
width > 1024 ||
|
|
879
|
+
height > 1024)
|
|
880
|
+
throw new RangeError("cell pixel size out of range");
|
|
881
|
+
if (precise &&
|
|
882
|
+
[precise.width, precise.height].some((value) => !Number.isSafeInteger(value) || value < 1000 || value > 1024000))
|
|
883
|
+
throw new RangeError("precise cell pixel size out of range");
|
|
884
|
+
if (this.cellPixels?.width === width &&
|
|
885
|
+
this.cellPixels.height === height &&
|
|
886
|
+
this.cellPixels.precise?.width === precise?.width &&
|
|
887
|
+
this.cellPixels.precise?.height === precise?.height)
|
|
888
|
+
return;
|
|
889
|
+
this.cellPixels = { width, height, ...(precise ? { precise } : {}) };
|
|
890
|
+
if (!this.geometryParticipating)
|
|
891
|
+
return;
|
|
892
|
+
this.input.flush();
|
|
893
|
+
if (this.fittedWindows.size > 0) {
|
|
894
|
+
for (const [runtimeId, size] of this.fittedWindows)
|
|
895
|
+
if (this.windowsByRuntime.has(runtimeId))
|
|
896
|
+
this.io.send(`refresh-client -C ${runtimeId}:${size.cols}x${size.rows}${this.cellPixelSuffix()}`);
|
|
897
|
+
}
|
|
898
|
+
else if (this.clientViewport)
|
|
899
|
+
this.io.send(`refresh-client -C ${this.clientViewport.cols}x${this.clientViewport.rows}${this.cellPixelSuffix()}`);
|
|
857
900
|
}
|
|
858
901
|
restoreWindowSizing(target) {
|
|
859
902
|
if (!this.geometryParticipating)
|
|
@@ -894,10 +937,10 @@ export class SessionChannel {
|
|
|
894
937
|
return;
|
|
895
938
|
this.input.flush();
|
|
896
939
|
this.restoreWindowSizing(window.runtimeId);
|
|
897
|
-
this.io.send(`refresh-client -C ${window.runtimeId}:${cols}x${rows}`);
|
|
940
|
+
this.io.send(`refresh-client -C ${window.runtimeId}:${cols}x${rows}${this.cellPixelSuffix()}`);
|
|
898
941
|
this.fittedWindows.set(window.runtimeId, { cols, rows });
|
|
899
942
|
}
|
|
900
|
-
/**
|
|
943
|
+
/** Remove scoped overrides when a fresh global viewport supersedes them. */
|
|
901
944
|
clearWindowViewports() {
|
|
902
945
|
if (!this.fittedWindows.size)
|
|
903
946
|
return;
|
|
@@ -911,8 +954,9 @@ export class SessionChannel {
|
|
|
911
954
|
}
|
|
912
955
|
/** Toggle whether the retained control client participates in tmux sizing. */
|
|
913
956
|
setGeometryParticipation(active) {
|
|
914
|
-
|
|
915
|
-
|
|
957
|
+
// Keep dormant per-window sizes while excluded. Even an ignored client's
|
|
958
|
+
// refresh-client -C window: clear can shrink a detached window to 80x24.
|
|
959
|
+
// Fresh viewport fits replace these sizes; a global fit clears them.
|
|
916
960
|
if (this.geometryParticipating === active)
|
|
917
961
|
return;
|
|
918
962
|
this.geometryParticipating = active;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { inflateSync } from "node:zlib";
|
|
1
2
|
import { PaneStreamLayoutSnapshotFrameSchemaZ } from "@tmux-ide/contracts";
|
|
2
3
|
import { z } from "zod";
|
|
3
|
-
import { PANE_STREAM_MAX_HELD_DELTAS, PANE_STREAM_MAX_OUTPUT_BYTES, PANE_STREAM_MAX_PANES, PANE_STREAM_MAX_SEED_BYTES, PANE_STREAM_PROTOCOL_VERSION, PANE_STREAM_REDEEM_PATH, PANE_STREAM_WEBSOCKET_SUBPROTOCOL, TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES, PaneStreamClientFrameSchemaZ, PaneStreamLoopbackWebSocketUrlSchemaZ, PaneStreamRedeemFrameSchemaZ, PaneStreamServerFrameSchemaZ, sharedMonotonicMicros, } from "@tmux-ide/contracts";
|
|
4
|
+
import { PANE_STREAM_MAX_HELD_DELTAS, PANE_STREAM_MAX_OUTPUT_BYTES, PANE_STREAM_MAX_PANES, PANE_STREAM_MAX_SEED_BYTES, PANE_STREAM_PROTOCOL_VERSION, PANE_STREAM_REDEEM_PATH, PANE_STREAM_WEBSOCKET_SUBPROTOCOL, TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES, TERMINAL_GRAPHICS_CELL_PIXELS_CAPABILITY_VERSION, TERMINAL_GRAPHICS_PRECISE_PIXELS_CAPABILITY_VERSION, TERMINAL_GRAPHICS_LEGACY_MAX_IMAGE_BYTES, TERMINAL_GRAPHICS_ZLIB_CAPABILITY_VERSION, PaneStreamClientFrameSchemaZ, PaneStreamLoopbackWebSocketUrlSchemaZ, PaneStreamRedeemFrameSchemaZ, PaneStreamServerFrameSchemaZ, sharedMonotonicMicros, } from "@tmux-ide/contracts";
|
|
4
5
|
import { canonicalOriginOrNull, digestSecret, digestsEqual, rawDataByteLength, rawDataToBuffer, safeCloseSocket, strictJsonParse, } from "../attachments/admission-util.js";
|
|
5
6
|
import { SessionRuntimeControllerLeaseError } from "../session-runtime/registry.js";
|
|
6
7
|
import { terminalDeliveryObservationOrdinal } from "../session-runtime/terminal-delivery-observation-identity.js";
|
|
@@ -593,6 +594,23 @@ class PreAuthAdmission {
|
|
|
593
594
|
}
|
|
594
595
|
}
|
|
595
596
|
const GRAPHICS_MAX_QUEUED_DIGESTS = 256;
|
|
597
|
+
/** Graphics state as renderers without zlib support must see it. */
|
|
598
|
+
export function rawGraphicsState(state) {
|
|
599
|
+
const legacy = state.images.map((image) => image.format === "rgba-zlib" || image.format === "rgb-zlib"
|
|
600
|
+
? {
|
|
601
|
+
...image,
|
|
602
|
+
format: image.format === "rgba-zlib" ? "rgba" : "rgb",
|
|
603
|
+
bytes: image.width * image.height * (image.format === "rgba-zlib" ? 4 : 3),
|
|
604
|
+
}
|
|
605
|
+
: image);
|
|
606
|
+
const images = legacy.filter((image) => image.bytes <= TERMINAL_GRAPHICS_LEGACY_MAX_IMAGE_BYTES);
|
|
607
|
+
const kept = new Set(images.map((image) => image.digest));
|
|
608
|
+
return {
|
|
609
|
+
...state,
|
|
610
|
+
images,
|
|
611
|
+
placements: state.placements.filter((placement) => kept.has(placement.digest)),
|
|
612
|
+
};
|
|
613
|
+
}
|
|
596
614
|
const GRAPHICS_SEND_WINDOW_BYTES = 2 * 1024 * 1024;
|
|
597
615
|
export class PaneStreamLiveConnection {
|
|
598
616
|
#clientId;
|
|
@@ -728,6 +746,8 @@ export class PaneStreamLiveConnection {
|
|
|
728
746
|
graphicsAnnounced: false,
|
|
729
747
|
graphicsQueue: [],
|
|
730
748
|
graphicsSending: false,
|
|
749
|
+
graphicsServed: new Map(),
|
|
750
|
+
graphicsState: null,
|
|
731
751
|
});
|
|
732
752
|
}
|
|
733
753
|
}
|
|
@@ -1212,18 +1232,65 @@ export class PaneStreamLiveConnection {
|
|
|
1212
1232
|
const graphics = channel.delivery?.graphics;
|
|
1213
1233
|
if (!graphics)
|
|
1214
1234
|
return;
|
|
1235
|
+
// Renderers that cannot inflate see raw formats; their bytes are inflated
|
|
1236
|
+
// when served.
|
|
1237
|
+
const zlib = this.#descriptor.terminalDelivery?.protocolVersions.includes(TERMINAL_GRAPHICS_ZLIB_CAPABILITY_VERSION) === true;
|
|
1238
|
+
channel.graphicsZlib = zlib;
|
|
1239
|
+
// Renderers that report cell pixels learn up front that this daemon
|
|
1240
|
+
// accepts them, before any image exists.
|
|
1241
|
+
const cellPixels = this.#descriptor.terminalDelivery?.protocolVersions.includes(TERMINAL_GRAPHICS_CELL_PIXELS_CAPABILITY_VERSION) === true;
|
|
1242
|
+
const preciseCellPixels = this.#descriptor.terminalDelivery?.protocolVersions.includes(TERMINAL_GRAPHICS_PRECISE_PIXELS_CAPABILITY_VERSION) === true;
|
|
1243
|
+
let announced = false;
|
|
1215
1244
|
graphics.subscribe((state) => {
|
|
1245
|
+
announced = true;
|
|
1216
1246
|
if (this.#closed || channel.closed)
|
|
1217
1247
|
return;
|
|
1218
1248
|
channel.graphicsAnnounced = true;
|
|
1219
|
-
|
|
1249
|
+
channel.graphicsState = state;
|
|
1250
|
+
// A program redrawing one image id (every frame of an animation or a
|
|
1251
|
+
// page) needs the new bytes at once: push them instead of waiting for
|
|
1252
|
+
// the renderer's round trip.
|
|
1253
|
+
const pushed = [];
|
|
1254
|
+
for (const image of state.images) {
|
|
1255
|
+
const served = channel.graphicsServed.get(image.imageId);
|
|
1256
|
+
if (served !== undefined && served !== image.digest)
|
|
1257
|
+
pushed.push(image.digest);
|
|
1258
|
+
}
|
|
1259
|
+
this.#sendFrame(null, {
|
|
1260
|
+
type: "terminal-graphics",
|
|
1261
|
+
pane: channel.semanticPaneId,
|
|
1262
|
+
state: {
|
|
1263
|
+
...(zlib ? state : rawGraphicsState(state)),
|
|
1264
|
+
...(cellPixels ? { cellPixels: true } : {}),
|
|
1265
|
+
...(preciseCellPixels ? { preciseCellPixels: true } : {}),
|
|
1266
|
+
},
|
|
1267
|
+
});
|
|
1268
|
+
if (pushed.length > 0)
|
|
1269
|
+
this.#queueGraphics(channel, pushed);
|
|
1220
1270
|
});
|
|
1271
|
+
if (cellPixels && !announced && !this.#closed && !channel.closed) {
|
|
1272
|
+
channel.graphicsAnnounced = true;
|
|
1273
|
+
this.#sendFrame(null, {
|
|
1274
|
+
type: "terminal-graphics",
|
|
1275
|
+
pane: channel.semanticPaneId,
|
|
1276
|
+
state: {
|
|
1277
|
+
revision: 0,
|
|
1278
|
+
images: [],
|
|
1279
|
+
placements: [],
|
|
1280
|
+
cellPixels: true,
|
|
1281
|
+
...(preciseCellPixels ? { preciseCellPixels: true } : {}),
|
|
1282
|
+
},
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1221
1285
|
}
|
|
1222
1286
|
#requestGraphics(pane, digests) {
|
|
1223
1287
|
const channel = this.#panes.get(pane);
|
|
1224
1288
|
// Requests are only meaningful after an announcement; ignore stray ones.
|
|
1225
1289
|
if (!channel || channel.closed || !channel.graphicsAnnounced)
|
|
1226
1290
|
return;
|
|
1291
|
+
this.#queueGraphics(channel, digests);
|
|
1292
|
+
}
|
|
1293
|
+
#queueGraphics(channel, digests) {
|
|
1227
1294
|
for (const digest of digests)
|
|
1228
1295
|
if (!channel.graphicsQueue.includes(digest) &&
|
|
1229
1296
|
channel.graphicsQueue.length < GRAPHICS_MAX_QUEUED_DIGESTS)
|
|
@@ -1241,10 +1308,32 @@ export class PaneStreamLiveConnection {
|
|
|
1241
1308
|
try {
|
|
1242
1309
|
while (!this.#closed && !channel.closed && channel.graphicsQueue.length > 0) {
|
|
1243
1310
|
const digest = channel.graphicsQueue.shift();
|
|
1244
|
-
const
|
|
1245
|
-
|
|
1311
|
+
const graphics = channel.delivery?.graphics;
|
|
1312
|
+
const image = graphics?.image(digest);
|
|
1313
|
+
if (!graphics || !image)
|
|
1314
|
+
continue;
|
|
1315
|
+
for (const entry of channel.graphicsState?.images ?? [])
|
|
1316
|
+
if (entry.digest === digest)
|
|
1317
|
+
channel.graphicsServed.set(entry.imageId, digest);
|
|
1318
|
+
const raw = !channel.graphicsZlib && image.format.endsWith("-zlib");
|
|
1319
|
+
let data = image.data;
|
|
1320
|
+
if (raw) {
|
|
1321
|
+
const expected = image.width * image.height * (image.format === "rgba-zlib" ? 4 : 3);
|
|
1322
|
+
if (expected < 1 || expected > TERMINAL_GRAPHICS_LEGACY_MAX_IMAGE_BYTES)
|
|
1323
|
+
continue;
|
|
1324
|
+
try {
|
|
1325
|
+
data = inflateSync(image.data, { maxOutputLength: expected });
|
|
1326
|
+
}
|
|
1327
|
+
catch {
|
|
1328
|
+
continue;
|
|
1329
|
+
}
|
|
1330
|
+
if (data.byteLength !== expected)
|
|
1331
|
+
continue;
|
|
1332
|
+
}
|
|
1333
|
+
if (!channel.graphicsZlib && data.byteLength > TERMINAL_GRAPHICS_LEGACY_MAX_IMAGE_BYTES)
|
|
1246
1334
|
continue;
|
|
1247
|
-
const
|
|
1335
|
+
const format = raw ? (image.format === "rgba-zlib" ? "rgba" : "rgb") : image.format;
|
|
1336
|
+
const total = data.byteLength;
|
|
1248
1337
|
for (let offset = 0; offset < total; offset += TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES) {
|
|
1249
1338
|
while ((this.#socket.bufferedAmount ?? 0) > GRAPHICS_SEND_WINDOW_BYTES) {
|
|
1250
1339
|
if (this.#closed || channel.closed)
|
|
@@ -1253,19 +1342,23 @@ export class PaneStreamLiveConnection {
|
|
|
1253
1342
|
}
|
|
1254
1343
|
if (this.#closed || channel.closed)
|
|
1255
1344
|
return;
|
|
1345
|
+
// A superseded frame (the id was redrawn) is abandoned mid-transfer;
|
|
1346
|
+
// the renderer drops the partial image and shows the newer one.
|
|
1347
|
+
if (offset > 0 && graphics.image(digest) !== image)
|
|
1348
|
+
break;
|
|
1256
1349
|
const end = Math.min(total, offset + TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES);
|
|
1257
1350
|
this.#sendFrame(null, {
|
|
1258
1351
|
type: "terminal-graphics-blob",
|
|
1259
1352
|
pane: channel.semanticPaneId,
|
|
1260
1353
|
blob: {
|
|
1261
1354
|
digest,
|
|
1262
|
-
format
|
|
1355
|
+
format,
|
|
1263
1356
|
width: image.width,
|
|
1264
1357
|
height: image.height,
|
|
1265
1358
|
total,
|
|
1266
1359
|
offset,
|
|
1267
1360
|
},
|
|
1268
|
-
data: Buffer.from(
|
|
1361
|
+
data: Buffer.from(data.subarray(offset, end)).toString("base64"),
|
|
1269
1362
|
});
|
|
1270
1363
|
}
|
|
1271
1364
|
}
|
|
@@ -1851,6 +1944,15 @@ export class PaneStreamLiveConnection {
|
|
|
1851
1944
|
this.#requestGraphics(frame.pane, frame.digests);
|
|
1852
1945
|
return;
|
|
1853
1946
|
}
|
|
1947
|
+
if (frame.type === "terminal-cell-pixels") {
|
|
1948
|
+
try {
|
|
1949
|
+
this.#sessionRuntimeBinding?.setCellPixels?.(frame.width, frame.height, frame.precise);
|
|
1950
|
+
}
|
|
1951
|
+
catch {
|
|
1952
|
+
// A retired runtime simply keeps tmux's default cell size.
|
|
1953
|
+
}
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1854
1956
|
if (frame.type === "viewport") {
|
|
1855
1957
|
if (!this.#descriptor.terminalDelivery ||
|
|
1856
1958
|
this.#descriptor.viewerMode !== "interactive" ||
|
|
@@ -791,7 +791,6 @@ class SessionRuntime {
|
|
|
791
791
|
scheduler,
|
|
792
792
|
nativeGeometryHysteresisMs,
|
|
793
793
|
onGeometryAuthorityChanged: (clientId) => {
|
|
794
|
-
this.#mirror.clearWindowViewports(this.session);
|
|
795
794
|
this.#mirror.setGeometryParticipation(this.session, clientId !== null);
|
|
796
795
|
},
|
|
797
796
|
onNativeGeometryYieldExpired: () => this.#publishAuthority(),
|
|
@@ -1134,6 +1133,11 @@ class SessionRuntime {
|
|
|
1134
1133
|
throw new SessionRuntimeControllerLeaseError("invalid-client-capability", "The client does not own this geometry authority lease.");
|
|
1135
1134
|
}
|
|
1136
1135
|
}
|
|
1136
|
+
/** Renderer cell size in device pixels; benign, so any connected client may set it. */
|
|
1137
|
+
setCellPixels(clientId, width, height, precise) {
|
|
1138
|
+
this.#assertConnected(clientId);
|
|
1139
|
+
this.#mirror.setCellPixels?.(this.session, width, height, precise);
|
|
1140
|
+
}
|
|
1137
1141
|
fitViewportWithAuthority(clientId, lease, cols, rows, semanticWindowId) {
|
|
1138
1142
|
this.assertGeometryAuthority(clientId, lease);
|
|
1139
1143
|
this.#mirror.setGeometryParticipation(this.session, true);
|
|
@@ -1571,6 +1575,10 @@ class SessionRuntimeConsumerImpl {
|
|
|
1571
1575
|
this.#assertOpen();
|
|
1572
1576
|
this.#runtime.fitViewport(this.clientId, lease, cols, rows, semanticWindowId);
|
|
1573
1577
|
}
|
|
1578
|
+
setCellPixels(width, height, precise) {
|
|
1579
|
+
this.#assertOpen();
|
|
1580
|
+
this.#runtime.setCellPixels(this.clientId, width, height, precise);
|
|
1581
|
+
}
|
|
1574
1582
|
fitViewportWithAuthority(lease, cols, rows, semanticWindowId) {
|
|
1575
1583
|
this.#assertOpen();
|
|
1576
1584
|
this.#runtime.fitViewportWithAuthority(this.clientId, lease, cols, rows, semanticWindowId);
|
|
@@ -250,8 +250,17 @@ export class TerminalReplicaInterpreter {
|
|
|
250
250
|
const previous = this.#backend;
|
|
251
251
|
this.#backend = replacement;
|
|
252
252
|
this.#attachKitty(replacement);
|
|
253
|
-
//
|
|
254
|
-
|
|
253
|
+
// A capture replay does not erase graphics already observed by this owner.
|
|
254
|
+
// Keep anchors only when both backends agree on their coordinate basis;
|
|
255
|
+
// reflow, history replacement and authoritative stream resets are ambiguous.
|
|
256
|
+
const sameGraphicsBasis = operation.bootstrap === "painted-capture" &&
|
|
257
|
+
previous.cols === replacement.cols &&
|
|
258
|
+
previous.rows === replacement.rows &&
|
|
259
|
+
previous.graphicsViewportTop?.() !== undefined &&
|
|
260
|
+
previous.graphicsViewportTop?.() === replacement.graphicsViewportTop?.() &&
|
|
261
|
+
previous.modes().alternateScreen === replacement.modes().alternateScreen;
|
|
262
|
+
if (!sameGraphicsBasis)
|
|
263
|
+
this.#kitty.dropCursorPlacements();
|
|
255
264
|
this.#nativeReseedRequested = false;
|
|
256
265
|
this.#scrollback = scrollback;
|
|
257
266
|
this.#bootstrap = {
|
|
@@ -290,6 +299,8 @@ export class TerminalReplicaInterpreter {
|
|
|
290
299
|
return;
|
|
291
300
|
}
|
|
292
301
|
if (operation.type === "resize") {
|
|
302
|
+
if (operation.cols !== this.#backend.cols || operation.rows !== this.#backend.rows)
|
|
303
|
+
this.#kitty.dropCursorPlacements();
|
|
293
304
|
this.#causalCell?.fail("geometry-drift");
|
|
294
305
|
if (this.#backend.modes().synchronizedOutput) {
|
|
295
306
|
// Geometry is part of the admitted FIFO even while publication is
|
|
@@ -344,6 +355,12 @@ export class TerminalReplicaInterpreter {
|
|
|
344
355
|
// Always forward the scanner's output: tmux splits %output (~2 KiB) inside
|
|
345
356
|
// kitty APCs (~4 KiB chunks), so a chunk with no completed command can still
|
|
346
357
|
// be all image payload that must never reach the VT parser as text.
|
|
358
|
+
// A chunk entirely inside image payload changes nothing the parser can
|
|
359
|
+
// see. Skipping it matters: every parser write settles on a later timer
|
|
360
|
+
// turn, so a frame split into dozens of %output chunks would otherwise
|
|
361
|
+
// serialize dozens of turns and fall behind a 30 fps image stream.
|
|
362
|
+
if (scanned.forward.byteLength === 0)
|
|
363
|
+
return;
|
|
347
364
|
await this.#writeToBackend(this.#backend, scanned.forward);
|
|
348
365
|
if (this.#observability.enabled)
|
|
349
366
|
this.#observability.recordSpan("parse", "terminal-replica-write", parseStarted, this.#observability.nowMicros(), trace);
|
|
@@ -297,6 +297,10 @@ export class SessionRuntimeTransportBinding {
|
|
|
297
297
|
throw error;
|
|
298
298
|
}
|
|
299
299
|
}
|
|
300
|
+
setCellPixels(width, height, precise) {
|
|
301
|
+
this.#assertOpen();
|
|
302
|
+
this.#shared.consumer.setCellPixels?.(width, height, precise);
|
|
303
|
+
}
|
|
300
304
|
fitViewport(expectedLease, cols, rows, semanticWindowId) {
|
|
301
305
|
this.#assertOpen();
|
|
302
306
|
if (this.#shared.geometryTransportLeaseIds.at(-1) !== this.#transportLeaseId) {
|
|
@@ -918,6 +918,11 @@ function parsePaneSnapshot(
|
|
|
918
918
|
* application-shell inventory. Names are resolved exactly to `$session_id`
|
|
919
919
|
* before any pane query, and a second identity/topology snapshot closes races.
|
|
920
920
|
*/
|
|
921
|
+
const INVENTORY_FENCE_ATTEMPTS = 4;
|
|
922
|
+
/** 25, 50, 100 ms: enough for a mutation's identity stamps, bounded for every reader. */
|
|
923
|
+
const settleDelay = (attempt: number) =>
|
|
924
|
+
new Promise<void>((resolve) => setTimeout(resolve, 25 * 2 ** (attempt - 1)));
|
|
925
|
+
|
|
921
926
|
export async function discoverWorkspaceRegistryTerminalInventory(
|
|
922
927
|
registry: WorkspaceRegistry,
|
|
923
928
|
runner: NativeTerminalInventoryReadRunner,
|
|
@@ -941,12 +946,6 @@ export async function discoverWorkspaceRegistryTerminalInventory(
|
|
|
941
946
|
const argv = ["list-panes", "-s", "-t", identity.id, "-F", PANE_FORMAT] as const;
|
|
942
947
|
const before = await requiredTmuxResult(runner, argv, signal);
|
|
943
948
|
if (before === null) continue;
|
|
944
|
-
const first = parsePaneSnapshot(before, identity);
|
|
945
|
-
const panes = first.panes;
|
|
946
|
-
const after = await requiredTmuxResult(runner, argv, signal);
|
|
947
|
-
if (after === null) throw new NativeTerminalAttachmentRuntimeError("discovery-failed");
|
|
948
|
-
const second = parsePaneSnapshot(after, identity);
|
|
949
|
-
const latest = second.panes;
|
|
950
949
|
// Foreground commands, titles, focus and paths can change while the same
|
|
951
950
|
// pane remains valid. Fence binding/topology only, then publish one complete
|
|
952
951
|
// latest snapshot rather than mixing presentation values across reads.
|
|
@@ -961,14 +960,30 @@ export async function discoverWorkspaceRegistryTerminalInventory(
|
|
|
961
960
|
"windowStamp",
|
|
962
961
|
"index",
|
|
963
962
|
] as const;
|
|
964
|
-
|
|
965
|
-
first
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
963
|
+
const matches = (
|
|
964
|
+
first: ReturnType<typeof parsePaneSnapshot>,
|
|
965
|
+
second: ReturnType<typeof parsePaneSnapshot>,
|
|
966
|
+
) =>
|
|
967
|
+
first.linksProof === second.linksProof &&
|
|
968
|
+
first.panes.length === second.panes.length &&
|
|
969
|
+
first.panes.every((pane, index) =>
|
|
970
|
+
proofKeys.every((key) => pane[key] === second.panes[index]![key]),
|
|
971
|
+
);
|
|
972
|
+
// A just-created window or split is still being stamped, so topology can
|
|
973
|
+
// move between two reads. Let it settle for a bounded number of re-reads;
|
|
974
|
+
// only two consecutive matching reads are ever published.
|
|
975
|
+
let previous = parsePaneSnapshot(before, identity);
|
|
976
|
+
let stable: ReturnType<typeof parsePaneSnapshot> | null = null;
|
|
977
|
+
for (let attempt = 0; attempt < INVENTORY_FENCE_ATTEMPTS && !stable; attempt += 1) {
|
|
978
|
+
if (attempt > 0) await settleDelay(attempt);
|
|
979
|
+
const after = await requiredTmuxResult(runner, argv, signal);
|
|
980
|
+
if (after === null) throw new NativeTerminalAttachmentRuntimeError("discovery-failed");
|
|
981
|
+
const next = parsePaneSnapshot(after, identity);
|
|
982
|
+
if (matches(previous, next)) stable = next;
|
|
983
|
+
else previous = next;
|
|
970
984
|
}
|
|
971
|
-
|
|
985
|
+
if (!stable) throw new NativeTerminalAttachmentRuntimeError("discovery-failed");
|
|
986
|
+
bySessionName.set(sessionName, stable.panes);
|
|
972
987
|
}
|
|
973
988
|
|
|
974
989
|
const panes: NativeTerminalInventoryPaneSnapshot[] = [];
|