tmux-ide 2.9.6 → 2.9.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +2597 -1322
- package/package.json +1 -1
- package/packages/contracts/src/index.ts +5 -0
- package/packages/contracts/src/pane-stream.ts +37 -0
- package/packages/contracts/src/terminal-graphics.ts +92 -0
- package/packages/contracts/src/terminal-history-anchor.ts +61 -0
- package/packages/contracts/src/tmux-server-diagnostics.ts +16 -0
- package/packages/core/src/index.ts +1 -0
- package/packages/core/src/kitty-placeholders.ts +190 -0
- package/packages/daemon/dist/command-center/tmux-server-diagnostics.js +24 -0
- package/packages/daemon/dist/command-center/tmux-server-history-anchor.js +48 -0
- package/packages/daemon/dist/command-center/tmux-servers.js +11 -0
- package/packages/daemon/dist/lib/canonical-daemon-bootstrap.js +23 -1
- package/packages/daemon/dist/lib/daemon-embed.js +21 -0
- package/packages/daemon/dist/lib/tmux-server-owner.js +10 -1
- package/packages/daemon/dist/lib/tmux-session-mutation-fence.js +2 -1
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
- package/packages/daemon/dist/native/tmux/darwin-arm64/lib/libevent_core-2.1.7.dylib +0 -0
- package/packages/daemon/dist/native/tmux/darwin-arm64/lib/libutf8proc.3.3.0.dylib +0 -0
- package/packages/daemon/dist/native/tmux/darwin-arm64/manifest.json +8 -4
- package/packages/daemon/dist/native/tmux/darwin-arm64/tmux +0 -0
- package/packages/daemon/dist/native/tmux/linux-arm64/manifest.json +5 -1
- package/packages/daemon/dist/native/tmux/linux-arm64/tmux +0 -0
- package/packages/daemon/dist/native/tmux/linux-x64/manifest.json +5 -1
- package/packages/daemon/dist/native/tmux/linux-x64/tmux +0 -0
- package/packages/daemon/dist/terminal/graphics/kitty-graphics.js +578 -0
- package/packages/daemon/dist/terminal/mirror/control-channel.js +17 -1
- package/packages/daemon/dist/terminal/mirror/mirror-service.js +1 -0
- package/packages/daemon/dist/terminal/mirror/native-grid-capture.js +8 -0
- package/packages/daemon/dist/terminal/mirror/native-grid-reader.js +6 -1
- package/packages/daemon/dist/terminal/mirror/session-channel.js +11 -2
- package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +78 -1
- package/packages/daemon/dist/terminal/protocol/session-descriptor-discovery.js +3 -2
- package/packages/daemon/dist/terminal/session-runtime/history-anchor-owner.js +171 -0
- package/packages/daemon/dist/terminal/session-runtime/registry.js +20 -0
- package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +20 -1
- package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +68 -1
- package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +30 -0
- package/packages/daemon/dist/terminal/session-runtime/transport-binding.js +49 -21
- package/packages/daemon/dist/terminal/session-runtime/xterm-terminal-interpreter-backend.js +11 -0
- package/packages/daemon/dist/tui/mirror/runtime/application-home-agents-owner.js +7 -1
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-input-ingress.js +35 -5
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-paste.js +2 -2
- package/packages/daemon/dist/tui/mirror/runtime/terminal-input-adapter.js +2 -2
- package/packages/daemon/src/command-center/tmux-server-diagnostics.ts +38 -0
- package/packages/daemon/src/command-center/tmux-server-history-anchor.ts +69 -0
- package/packages/daemon/src/command-center/tmux-servers.ts +11 -0
- package/packages/daemon/src/lib/canonical-daemon-bootstrap.ts +20 -2
- package/packages/daemon/src/lib/daemon-embed.ts +26 -0
- package/packages/daemon/src/lib/tmux-server-owner.ts +10 -0
- package/packages/daemon/src/lib/tmux-session-mutation-fence.ts +2 -1
- package/packages/daemon/src/terminal/graphics/kitty-graphics.ts +641 -0
- package/packages/daemon/src/terminal/mirror/control-channel.ts +18 -1
- package/packages/daemon/src/terminal/mirror/mirror-service.ts +3 -0
- package/packages/daemon/src/terminal/mirror/native-grid-capture.ts +11 -0
- package/packages/daemon/src/terminal/mirror/native-grid-reader.ts +10 -1
- package/packages/daemon/src/terminal/mirror/session-channel.ts +14 -1
- package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +82 -0
- package/packages/daemon/src/terminal/protocol/session-descriptor-discovery.ts +3 -2
- package/packages/daemon/src/terminal/session-runtime/history-anchor-owner.ts +190 -0
- package/packages/daemon/src/terminal/session-runtime/registry.ts +20 -0
- package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +38 -0
- package/packages/daemon/src/terminal/session-runtime/terminal-interpreter-backend.ts +4 -0
- package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +83 -1
- package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +39 -0
- package/packages/daemon/src/terminal/session-runtime/transport-binding.ts +53 -24
- package/packages/daemon/src/terminal/session-runtime/xterm-terminal-interpreter-backend.ts +13 -0
- package/packages/daemon/src/tui/mirror/runtime/application-home-agents-owner.ts +6 -1
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-input-ingress.ts +44 -9
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-paste.ts +2 -1
- package/packages/daemon/src/tui/mirror/runtime/terminal-input-adapter.ts +5 -2
- package/packages/daemon-client/src/fleet-connection-status.ts +1 -1
- package/packages/daemon-client/src/pane-creation-refusal.ts +48 -0
- package/packages/daemon-client/src/pane-stream-client.ts +29 -0
- package/packages/daemon-client/src/tmux-server-client.ts +133 -6
- package/packages/daemon/dist/native/tmux/darwin-arm64/lib/libutf8proc.3.2.3.dylib +0 -0
- /package/packages/daemon/dist/native/tmux/darwin-arm64/licenses/{libutf8proc.3.2.3.dylib.txt → libutf8proc.3.3.0.dylib.txt} +0 -0
|
@@ -44,6 +44,14 @@ import {
|
|
|
44
44
|
cellsEqual,
|
|
45
45
|
type CausalCellLedgerResult,
|
|
46
46
|
} from "./causal-cell-ledger.ts";
|
|
47
|
+
import {
|
|
48
|
+
KITTY_PLACEMENT_OSC,
|
|
49
|
+
KittyApcScanner,
|
|
50
|
+
KittyImageStore,
|
|
51
|
+
type KittyCommand,
|
|
52
|
+
type KittyImageBudget,
|
|
53
|
+
} from "../graphics/kitty-graphics.ts";
|
|
54
|
+
import type { TerminalGraphicsState } from "@tmux-ide/contracts";
|
|
47
55
|
|
|
48
56
|
type CloseReason = "pane-closed" | "session-restarted" | "runtime-disposed";
|
|
49
57
|
export type TerminalReplicaInterpreterOperation =
|
|
@@ -115,6 +123,8 @@ export interface TerminalReplicaInterpreterOptions {
|
|
|
115
123
|
readonly observability?: SessionRuntimeObservability;
|
|
116
124
|
/** Parser implementation only; SessionRuntime remains the sole state authority. */
|
|
117
125
|
readonly backendFactory?: TerminalInterpreterBackendFactory;
|
|
126
|
+
/** Shared kitty image byte quotas; defaults to the daemon-wide budget. */
|
|
127
|
+
readonly kittyImageBudget?: KittyImageBudget;
|
|
118
128
|
}
|
|
119
129
|
|
|
120
130
|
export interface TerminalReplicaInterpreterStats {
|
|
@@ -165,6 +175,13 @@ export class TerminalReplicaInterpreter {
|
|
|
165
175
|
}> = [];
|
|
166
176
|
#writeFlushScheduled = false;
|
|
167
177
|
#widgetGate = false;
|
|
178
|
+
// kitty graphics: images outlive reseeds; the scanner owns split APCs.
|
|
179
|
+
readonly #kittyScanner = new KittyApcScanner();
|
|
180
|
+
readonly #kitty: KittyImageStore;
|
|
181
|
+
readonly #kittyPending = new Map<number, KittyCommand>();
|
|
182
|
+
readonly #graphicsListeners = new Set<(state: TerminalGraphicsState) => void>();
|
|
183
|
+
#graphics: TerminalGraphicsState | null = null;
|
|
184
|
+
#graphicsKey = "";
|
|
168
185
|
#markerTail = "";
|
|
169
186
|
#pendingResize: { cols: number; rows: number } | null = null;
|
|
170
187
|
// null: no unpublished writes; undefined: a write requires full projection.
|
|
@@ -208,6 +225,10 @@ export class TerminalReplicaInterpreter {
|
|
|
208
225
|
rows: options.rows,
|
|
209
226
|
scrollback: this.#scrollback,
|
|
210
227
|
});
|
|
228
|
+
this.#kitty = new KittyImageStore(
|
|
229
|
+
options.kittyImageBudget ? { budget: options.kittyImageBudget } : {},
|
|
230
|
+
);
|
|
231
|
+
this.#attachKitty(this.#backend);
|
|
211
232
|
this.#snapshot = blankTerminalReplicaSnapshot(options.cols, options.rows);
|
|
212
233
|
this.#seedReady = new Promise((resolve, reject) => {
|
|
213
234
|
this.#resolveSeedReady = resolve;
|
|
@@ -400,6 +421,9 @@ export class TerminalReplicaInterpreter {
|
|
|
400
421
|
}
|
|
401
422
|
const previous = this.#backend;
|
|
402
423
|
this.#backend = replacement;
|
|
424
|
+
this.#attachKitty(replacement);
|
|
425
|
+
// tmux never saw cursor-anchored placements; a capture cannot restore them.
|
|
426
|
+
this.#kitty.dropCursorPlacements();
|
|
403
427
|
this.#nativeReseedRequested = false;
|
|
404
428
|
this.#scrollback = scrollback;
|
|
405
429
|
this.#bootstrap = {
|
|
@@ -421,6 +445,7 @@ export class TerminalReplicaInterpreter {
|
|
|
421
445
|
this.#nativeSeedBackingCandidate = undefined;
|
|
422
446
|
}
|
|
423
447
|
previous.dispose();
|
|
448
|
+
this.#publishGraphics();
|
|
424
449
|
return;
|
|
425
450
|
}
|
|
426
451
|
if (operation.type === "cursor") {
|
|
@@ -445,6 +470,7 @@ export class TerminalReplicaInterpreter {
|
|
|
445
470
|
}
|
|
446
471
|
this.#backend.resize(operation.cols, operation.rows);
|
|
447
472
|
this.#commit(false);
|
|
473
|
+
this.#publishGraphics();
|
|
448
474
|
return;
|
|
449
475
|
}
|
|
450
476
|
const baseRevision = this.#revision;
|
|
@@ -462,6 +488,7 @@ export class TerminalReplicaInterpreter {
|
|
|
462
488
|
};
|
|
463
489
|
this.#revision = revision;
|
|
464
490
|
this.#closed = true;
|
|
491
|
+
this.#kitty.dispose();
|
|
465
492
|
this.#causalCell?.fail("transport-closed");
|
|
466
493
|
this.#clearSyncRecovery();
|
|
467
494
|
if (this.#needsSeed)
|
|
@@ -479,8 +506,13 @@ export class TerminalReplicaInterpreter {
|
|
|
479
506
|
this.#admitRaw(data);
|
|
480
507
|
this.#stats.parseBatches += 1;
|
|
481
508
|
this.#observeMarkerBytes(data);
|
|
509
|
+
const scanned = this.#kittyScanner.scan(data);
|
|
510
|
+
for (const command of scanned.commands) this.#kittyPending.set(command.marker, command);
|
|
482
511
|
const parseStarted = this.#observability.enabled ? this.#observability.nowMicros() : 0;
|
|
483
|
-
|
|
512
|
+
// Always forward the scanner's output: tmux splits %output (~2 KiB) inside
|
|
513
|
+
// kitty APCs (~4 KiB chunks), so a chunk with no completed command can still
|
|
514
|
+
// be all image payload that must never reach the VT parser as text.
|
|
515
|
+
await this.#writeToBackend(this.#backend, scanned.forward);
|
|
484
516
|
if (this.#observability.enabled)
|
|
485
517
|
this.#observability.recordSpan(
|
|
486
518
|
"parse",
|
|
@@ -519,6 +551,7 @@ export class TerminalReplicaInterpreter {
|
|
|
519
551
|
} else {
|
|
520
552
|
this.#commit(false, this.#backend.dirtyRange(), trace);
|
|
521
553
|
}
|
|
554
|
+
this.#publishGraphics();
|
|
522
555
|
}
|
|
523
556
|
|
|
524
557
|
#scheduleSyncRecovery(): void {
|
|
@@ -719,6 +752,55 @@ export class TerminalReplicaInterpreter {
|
|
|
719
752
|
});
|
|
720
753
|
}
|
|
721
754
|
|
|
755
|
+
/** The pane's kitty image store (bytes for the graphics side channel). */
|
|
756
|
+
kittyImages(): KittyImageStore {
|
|
757
|
+
return this.#kitty;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** Latest graphics state, or null while the pane has never held an image. */
|
|
761
|
+
currentGraphics(): TerminalGraphicsState | null {
|
|
762
|
+
return this.#graphics;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
onGraphics(listener: (state: TerminalGraphicsState) => void): () => void {
|
|
766
|
+
this.#graphicsListeners.add(listener);
|
|
767
|
+
return () => this.#graphicsListeners.delete(listener);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** Publish graphics state when the store or the viewport-relative placements moved. */
|
|
771
|
+
#publishGraphics(): void {
|
|
772
|
+
if (this.#graphics === null && this.#kitty.revision === 0) return;
|
|
773
|
+
const state = this.#kitty.state(
|
|
774
|
+
this.#backend.graphicsViewportTop?.() ?? 0,
|
|
775
|
+
this.#backend.rows,
|
|
776
|
+
this.#backend.cols,
|
|
777
|
+
this.#backend.modes().alternateScreen,
|
|
778
|
+
);
|
|
779
|
+
const key = JSON.stringify([state.revision, state.placements]);
|
|
780
|
+
if (key === this.#graphicsKey) return;
|
|
781
|
+
this.#graphicsKey = key;
|
|
782
|
+
this.#graphics = state;
|
|
783
|
+
for (const listener of this.#graphicsListeners) {
|
|
784
|
+
try {
|
|
785
|
+
listener(state);
|
|
786
|
+
} catch {
|
|
787
|
+
// Renderer callbacks never poison the authoritative parser FIFO.
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
#attachKitty(backend: TerminalInterpreterBackend): void {
|
|
793
|
+
// Minimal test backends omit the OSC seam; they simply never place images.
|
|
794
|
+
if (typeof backend.registerOscHandler !== "function") return;
|
|
795
|
+
backend.registerOscHandler(KITTY_PLACEMENT_OSC, (data) => {
|
|
796
|
+
const command = this.#kittyPending.get(Number(data));
|
|
797
|
+
if (!command) return true;
|
|
798
|
+
this.#kittyPending.delete(Number(data));
|
|
799
|
+
this.#kitty.apply(command, backend.graphicsCursor?.() ?? null);
|
|
800
|
+
return true;
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
|
|
722
804
|
#currentSnapshotHash(): string {
|
|
723
805
|
return (this.#snapshotHash ??= hashTerminalReplicaSnapshot(this.#snapshot));
|
|
724
806
|
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HistoryAnchorOwner,
|
|
3
|
+
type HistoryAnchorIdentity,
|
|
4
|
+
type HistoryAnchorProof,
|
|
5
|
+
} from "./history-anchor-owner.ts";
|
|
1
6
|
import { layoutContentRows } from "../mirror/layout-content-rows.ts";
|
|
2
7
|
import type { NativeGridCapture } from "../mirror/native-grid-capture.ts";
|
|
3
8
|
import type {
|
|
@@ -27,6 +32,8 @@ import type {
|
|
|
27
32
|
} from "./runtime-observability.ts";
|
|
28
33
|
import { DISABLED_SESSION_RUNTIME_OBSERVABILITY } from "./runtime-observability.ts";
|
|
29
34
|
import type { CausalCellLedgerResult } from "./causal-cell-ledger.ts";
|
|
35
|
+
import type { KittyImage } from "../graphics/kitty-graphics.ts";
|
|
36
|
+
import type { TerminalGraphicsState } from "@tmux-ide/contracts";
|
|
30
37
|
|
|
31
38
|
export interface TerminalReplicaSubscription {
|
|
32
39
|
readonly generation: SessionRuntimeGeneration;
|
|
@@ -96,6 +103,7 @@ export class SessionRuntimeTerminalReplicaOwner {
|
|
|
96
103
|
readonly #start: Promise<void>;
|
|
97
104
|
#upstream: MirrorSubscription | null = null;
|
|
98
105
|
#disposed = false;
|
|
106
|
+
readonly #historyAnchors = new HistoryAnchorOwner();
|
|
99
107
|
#cols = 80;
|
|
100
108
|
#rows = 24;
|
|
101
109
|
#layoutEpoch = 0;
|
|
@@ -144,6 +152,7 @@ export class SessionRuntimeTerminalReplicaOwner {
|
|
|
144
152
|
onNativeReseedRequired: () => {
|
|
145
153
|
if (this.#disposed || this.#reseed || this.#waitingForGeometryCapture) return;
|
|
146
154
|
this.#waitingForGeometryCapture = true;
|
|
155
|
+
this.#historyAnchors.invalidate();
|
|
147
156
|
this.#supervise(
|
|
148
157
|
this.#start.then(() => {
|
|
149
158
|
if (!this.#disposed && this.#waitingForGeometryCapture) this.#upstream?.reseed();
|
|
@@ -151,6 +160,7 @@ export class SessionRuntimeTerminalReplicaOwner {
|
|
|
151
160
|
);
|
|
152
161
|
},
|
|
153
162
|
onUpdate: (update, trace) => {
|
|
163
|
+
this.#historyAnchors.observe(update);
|
|
154
164
|
this.#publishedIncarnation = update.incarnation;
|
|
155
165
|
if (update.type === "terminal.seed") {
|
|
156
166
|
this.#bootstrapped = true;
|
|
@@ -232,6 +242,18 @@ export class SessionRuntimeTerminalReplicaOwner {
|
|
|
232
242
|
this.#interpreter.failCausalCell(reason, traceId);
|
|
233
243
|
}
|
|
234
244
|
|
|
245
|
+
captureHistoryAnchor(proof: HistoryAnchorProof, historyIndex: number) {
|
|
246
|
+
if (this.#disposed || this.#reseed || this.#waitingForGeometryCapture)
|
|
247
|
+
return { status: "unavailable" as const };
|
|
248
|
+
return this.#historyAnchors.capture(proof, historyIndex);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
resolveHistoryAnchor(proof: HistoryAnchorIdentity, token: string) {
|
|
252
|
+
if (this.#disposed || this.#reseed || this.#waitingForGeometryCapture)
|
|
253
|
+
return { status: "unavailable" as const };
|
|
254
|
+
return this.#historyAnchors.resolve(proof, token);
|
|
255
|
+
}
|
|
256
|
+
|
|
235
257
|
/** Scalar diagnostic only: no seed construction, hashing, or owner creation. */
|
|
236
258
|
inputDispatchIncarnation(): string | null {
|
|
237
259
|
return this.#disposed ? null : this.#publishedIncarnation;
|
|
@@ -350,6 +372,18 @@ export class SessionRuntimeTerminalReplicaOwner {
|
|
|
350
372
|
};
|
|
351
373
|
}
|
|
352
374
|
|
|
375
|
+
/** Kitty graphics side channel: current state now (if any), then every change. */
|
|
376
|
+
subscribeGraphics(listener: (state: TerminalGraphicsState) => void): () => void {
|
|
377
|
+
const current = this.#interpreter.currentGraphics();
|
|
378
|
+
if (current) listener(current);
|
|
379
|
+
return this.#interpreter.onGraphics(listener);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Image bytes by content digest, while the pane's store still holds them. */
|
|
383
|
+
graphicsImage(digest: string): KittyImage | null {
|
|
384
|
+
return this.#interpreter.kittyImages().imageByDigest(digest) ?? null;
|
|
385
|
+
}
|
|
386
|
+
|
|
353
387
|
async subscribeSource(
|
|
354
388
|
listener: (
|
|
355
389
|
update: CanonicalTerminalReplicaUpdate,
|
|
@@ -377,6 +411,7 @@ export class SessionRuntimeTerminalReplicaOwner {
|
|
|
377
411
|
if (this.#disposed) return;
|
|
378
412
|
this.#noteLifecycle(`dispose-${reason}`);
|
|
379
413
|
this.#disposed = true;
|
|
414
|
+
this.#historyAnchors.invalidate();
|
|
380
415
|
this.#historyTimer?.cancel();
|
|
381
416
|
this.#historyTimer = null;
|
|
382
417
|
this.#subscriptionEpoch += 1;
|
|
@@ -437,6 +472,7 @@ export class SessionRuntimeTerminalReplicaOwner {
|
|
|
437
472
|
// Deterministic capture point: reset opens the atomic capture. A probe
|
|
438
473
|
// armed after reset belongs to the next post-capture delta, never to this
|
|
439
474
|
// already-started reseed.
|
|
475
|
+
this.#historyAnchors.invalidate();
|
|
440
476
|
this.#reseed = {
|
|
441
477
|
nativeCols: event.cols,
|
|
442
478
|
nativeRows: event.rows,
|
|
@@ -561,6 +597,7 @@ export class SessionRuntimeTerminalReplicaOwner {
|
|
|
561
597
|
this.#reseedRetryCount = 0;
|
|
562
598
|
if (!this.#bootstrapped || this.#reseed || this.#waitingForGeometryCapture) return;
|
|
563
599
|
this.#waitingForGeometryCapture = true;
|
|
600
|
+
this.#historyAnchors.invalidate();
|
|
564
601
|
void this.#start.then(() => {
|
|
565
602
|
if (!this.#disposed && this.#waitingForGeometryCapture) this.#upstream?.reseed();
|
|
566
603
|
});
|
|
@@ -690,6 +727,7 @@ export class SessionRuntimeTerminalReplicaOwner {
|
|
|
690
727
|
this.#noteLifecycle("reseed-retry", diagnostic);
|
|
691
728
|
this.#reseedRetryCount += 1;
|
|
692
729
|
this.#waitingForGeometryCapture = true;
|
|
730
|
+
this.#historyAnchors.invalidate();
|
|
693
731
|
void this.#start.then(() => {
|
|
694
732
|
if (!this.#disposed) this.#upstream?.reseed();
|
|
695
733
|
});
|
|
@@ -697,6 +735,7 @@ export class SessionRuntimeTerminalReplicaOwner {
|
|
|
697
735
|
|
|
698
736
|
#supervise(operation: Promise<void>): void {
|
|
699
737
|
void operation.catch((error) => {
|
|
738
|
+
this.#historyAnchors.invalidate();
|
|
700
739
|
this.#noteLifecycle("interpreter-fault");
|
|
701
740
|
this.#interpreter.abort(error);
|
|
702
741
|
this.#onFault?.(error);
|
|
@@ -104,6 +104,13 @@ function assertLiveControllerPrincipal(
|
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
function holdsCurrentLease(shared: SharedClient): boolean {
|
|
108
|
+
const lease = shared.lease;
|
|
109
|
+
if (!lease) return false;
|
|
110
|
+
const snapshot = shared.consumer.controllerSnapshot();
|
|
111
|
+
return snapshot.controllerClientId === lease.clientId && snapshot.revision === lease.revision;
|
|
112
|
+
}
|
|
113
|
+
|
|
107
114
|
const clientsByRegistry = new WeakMap<object, Map<string, SharedClient>>();
|
|
108
115
|
|
|
109
116
|
function authenticatedSurface(transport: SessionRuntimeTransport, hostClientId: string): string {
|
|
@@ -695,20 +702,37 @@ export class SessionRuntimeTransportBinder {
|
|
|
695
702
|
}
|
|
696
703
|
|
|
697
704
|
activateController(target: SharedClient): void {
|
|
698
|
-
if (target.lease) return;
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
705
|
+
if (target.lease && holdsCurrentLease(target)) return;
|
|
706
|
+
target.lease = null;
|
|
707
|
+
const controllerClientId = target.consumer.controllerSnapshot().controllerClientId;
|
|
708
|
+
const current =
|
|
709
|
+
controllerClientId === null || controllerClientId === target.consumer.clientId
|
|
710
|
+
? undefined
|
|
711
|
+
: [...this.#clients.values()].find(
|
|
712
|
+
(candidate) =>
|
|
713
|
+
candidate !== target &&
|
|
714
|
+
candidate.consumer.session === target.consumer.session &&
|
|
715
|
+
candidate.consumer.clientId === controllerClientId,
|
|
716
|
+
);
|
|
717
|
+
if (!current) {
|
|
718
|
+
// Free, already ours, or held by a principal outside this binder (for
|
|
719
|
+
// example the command-center owner route): plain acquisition decides.
|
|
720
|
+
target.lease = target.consumer.acquireController();
|
|
709
721
|
return;
|
|
710
722
|
}
|
|
711
|
-
|
|
723
|
+
// A transport-bound holder whose binding lost its executable lease can
|
|
724
|
+
// never type again, yet it would keep every other viewer locked out. Re-
|
|
725
|
+
// derive its exact current lease so the handoff below reclaims it; a live
|
|
726
|
+
// holder simply hands off as before. Either way only one client can type.
|
|
727
|
+
const previous =
|
|
728
|
+
current.lease && holdsCurrentLease(current)
|
|
729
|
+
? current.lease
|
|
730
|
+
: current.consumer.acquireController();
|
|
731
|
+
const handedOff = current.consumer.handoffController(previous, target.consumer.clientId);
|
|
732
|
+
// Drop the holder's lease only after the runtime accepted the handoff; a
|
|
733
|
+
// refusal (e.g. during topology verification) must leave it executable.
|
|
734
|
+
current.lease = null;
|
|
735
|
+
target.lease = handedOff;
|
|
712
736
|
}
|
|
713
737
|
|
|
714
738
|
async release(
|
|
@@ -727,18 +751,23 @@ export class SessionRuntimeTransportBinder {
|
|
|
727
751
|
}
|
|
728
752
|
shared.refs -= 1;
|
|
729
753
|
if (shared.refs > 0) return;
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
754
|
+
try {
|
|
755
|
+
if (shared.lease) {
|
|
756
|
+
// A same-host passive session channel keeps the controller PRINCIPAL
|
|
757
|
+
// continuous across interactive pane retargeting. It contributes no
|
|
758
|
+
// source-pane grant and cannot type; the lease retires with the last
|
|
759
|
+
// host/session ref, while a passive-only host never acquires one.
|
|
760
|
+
const retired = shared.lease;
|
|
761
|
+
shared.lease = null;
|
|
762
|
+
shared.consumer.releaseController(retired);
|
|
763
|
+
}
|
|
764
|
+
} finally {
|
|
765
|
+
// A refused surrender must never strand the controller on a client with
|
|
766
|
+
// no transport: closing the consumer retires whatever it still owns.
|
|
767
|
+
for (const [key, candidate] of this.#clients) {
|
|
768
|
+
if (candidate === shared) this.#clients.delete(key);
|
|
769
|
+
}
|
|
770
|
+
await shared.consumer.close();
|
|
741
771
|
}
|
|
742
|
-
await shared.consumer.close();
|
|
743
772
|
}
|
|
744
773
|
}
|
|
@@ -102,6 +102,19 @@ export class XtermTerminalInterpreterBackend implements TerminalInterpreterBacke
|
|
|
102
102
|
return this.#terminal.rows;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
graphicsCursor(): { x: number; line: number; alternate: boolean } {
|
|
106
|
+
const buffer = this.#terminal.buffer.active;
|
|
107
|
+
return {
|
|
108
|
+
x: Math.min(buffer.cursorX, this.#terminal.cols - 1),
|
|
109
|
+
line: buffer.baseY + buffer.cursorY,
|
|
110
|
+
alternate: buffer.type === "alternate",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
graphicsViewportTop(): number {
|
|
115
|
+
return this.#terminal.buffer.active.baseY;
|
|
116
|
+
}
|
|
117
|
+
|
|
105
118
|
write(data: Uint8Array | string): Promise<void> {
|
|
106
119
|
return new Promise((resolve) => this.#terminal.write(data, resolve));
|
|
107
120
|
}
|
|
@@ -326,7 +326,12 @@ export function createApplicationHomeNavigationOwner(options: {
|
|
|
326
326
|
command === "switch-session"
|
|
327
327
|
)
|
|
328
328
|
return null;
|
|
329
|
-
|
|
329
|
+
const status = options.sessionOwner()?.snapshot()?.status;
|
|
330
|
+
if (status !== "live") {
|
|
331
|
+
if (status === "rebinding") return "Terminal is updating. Try again shortly.";
|
|
332
|
+
if (status === "connecting") return "Opening terminal…";
|
|
333
|
+
return "Open a live session first";
|
|
334
|
+
}
|
|
330
335
|
return command !== "new-window" && command !== "new-agent" && !options.focusedPane()
|
|
331
336
|
? "Select a live pane first"
|
|
332
337
|
: null;
|
|
@@ -108,7 +108,9 @@ export function createApplicationTerminalInputIngress(
|
|
|
108
108
|
recovery = null;
|
|
109
109
|
};
|
|
110
110
|
const recover = (
|
|
111
|
-
inputs:
|
|
111
|
+
inputs:
|
|
112
|
+
| ReturnType<typeof terminalInputsForPaste>
|
|
113
|
+
| (() => ReturnType<typeof terminalInputsForPaste> | null),
|
|
112
114
|
bytes: Uint8Array,
|
|
113
115
|
kind: "input" | "paste",
|
|
114
116
|
): boolean => {
|
|
@@ -145,7 +147,15 @@ export function createApplicationTerminalInputIngress(
|
|
|
145
147
|
}
|
|
146
148
|
: undefined;
|
|
147
149
|
recovery.inputs.push(async () => {
|
|
148
|
-
|
|
150
|
+
if (
|
|
151
|
+
version !== recoveryVersion ||
|
|
152
|
+
!sameScope(target, scope()) ||
|
|
153
|
+
generation()?.status !== "live"
|
|
154
|
+
)
|
|
155
|
+
return false;
|
|
156
|
+
const framed = typeof inputs === "function" ? inputs() : inputs;
|
|
157
|
+
if (framed === null) return false;
|
|
158
|
+
for (const [index, input] of framed.entries()) {
|
|
149
159
|
if (
|
|
150
160
|
version !== recoveryVersion ||
|
|
151
161
|
!sameScope(target, scope()) ||
|
|
@@ -197,6 +207,23 @@ export function createApplicationTerminalInputIngress(
|
|
|
197
207
|
}
|
|
198
208
|
})();
|
|
199
209
|
};
|
|
210
|
+
const pasteMode = (): boolean | null => {
|
|
211
|
+
const active = generation();
|
|
212
|
+
const pane = focusedPane();
|
|
213
|
+
if (active?.status !== "live" || !active.fastLane || !pane) return null;
|
|
214
|
+
const mode = active.fastLane.lane.paneState(pane)?.snapshot?.modes.bracketedPaste;
|
|
215
|
+
return typeof mode === "boolean" ? mode : null;
|
|
216
|
+
};
|
|
217
|
+
const deliverPaste = (bytes: Uint8Array): void => {
|
|
218
|
+
const mode = pasteMode();
|
|
219
|
+
if (mode === null) {
|
|
220
|
+
// Do not let the successful drain of the surrounding FIFO hide refusal.
|
|
221
|
+
ownsNote = false;
|
|
222
|
+
setNote("terminal unavailable · paste was not sent");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
sendApplicationTerminalPaste(interaction, bytes, captureOrigin(), mode);
|
|
226
|
+
};
|
|
200
227
|
const captureOrigin = (): boolean =>
|
|
201
228
|
Boolean(currentTuiPerformanceEventSink()?.terminalInputOrigin);
|
|
202
229
|
const flush = (): void => {
|
|
@@ -291,7 +318,7 @@ export function createApplicationTerminalInputIngress(
|
|
|
291
318
|
if (bytes.length === 0) return;
|
|
292
319
|
const copy = Uint8Array.from(bytes);
|
|
293
320
|
try {
|
|
294
|
-
terminalInputsForPaste(Buffer.from(copy).toString("utf8"));
|
|
321
|
+
terminalInputsForPaste(Buffer.from(copy).toString("utf8"), false);
|
|
295
322
|
} catch {
|
|
296
323
|
setNote("terminal unavailable · paste was rejected");
|
|
297
324
|
return;
|
|
@@ -304,16 +331,24 @@ export function createApplicationTerminalInputIngress(
|
|
|
304
331
|
active.fastLane &&
|
|
305
332
|
focusedPane()
|
|
306
333
|
) {
|
|
307
|
-
|
|
334
|
+
deliverPaste(copy);
|
|
308
335
|
return;
|
|
309
336
|
}
|
|
310
|
-
if (
|
|
337
|
+
if (
|
|
338
|
+
recover(
|
|
339
|
+
() => {
|
|
340
|
+
const mode = pasteMode();
|
|
341
|
+
return mode === null
|
|
342
|
+
? null
|
|
343
|
+
: terminalInputsForPaste(Buffer.from(copy).toString("utf8"), mode);
|
|
344
|
+
},
|
|
345
|
+
copy,
|
|
346
|
+
"paste",
|
|
347
|
+
)
|
|
348
|
+
)
|
|
311
349
|
return;
|
|
312
350
|
noteAdmission(
|
|
313
|
-
pending.enqueue(
|
|
314
|
-
() => sendApplicationTerminalPaste(interaction, copy, captureOrigin()),
|
|
315
|
-
copy.byteLength,
|
|
316
|
-
),
|
|
351
|
+
pending.enqueue(() => deliverPaste(copy), copy.byteLength),
|
|
317
352
|
"paste",
|
|
318
353
|
);
|
|
319
354
|
},
|
|
@@ -20,9 +20,10 @@ export function sendApplicationTerminalPaste(
|
|
|
20
20
|
interaction: ApplicationTerminalInteractionController,
|
|
21
21
|
bytes: Uint8Array,
|
|
22
22
|
captureOrigin: boolean,
|
|
23
|
+
bracketedPaste: boolean,
|
|
23
24
|
): void {
|
|
24
25
|
const text = Buffer.from(bytes).toString("utf8");
|
|
25
|
-
const inputs = terminalInputsForPaste(text);
|
|
26
|
+
const inputs = terminalInputsForPaste(text, bracketedPaste);
|
|
26
27
|
for (let index = 0; index < inputs.length; index += 1) {
|
|
27
28
|
const parserOrigin =
|
|
28
29
|
index === 0 && captureOrigin
|
|
@@ -48,10 +48,13 @@ export function terminalInputForOpenTuiKey(
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/** Preserve paste ordering and bracketed-paste semantics without an unbounded frame. */
|
|
51
|
-
export function terminalInputsForPaste(
|
|
51
|
+
export function terminalInputsForPaste(
|
|
52
|
+
text: string,
|
|
53
|
+
bracketedPaste: boolean,
|
|
54
|
+
): readonly SessionRuntimeTerminalInput[] {
|
|
52
55
|
if (text.length === 0) return [];
|
|
53
56
|
if (text.includes("\0")) throw new TypeError("terminal paste must not contain NUL");
|
|
54
|
-
const framed = `\u001b[200~${text}\u001b[201
|
|
57
|
+
const framed = bracketedPaste ? `\u001b[200~${text}\u001b[201~` : text;
|
|
55
58
|
const inputs: SessionRuntimeTerminalInput[] = [];
|
|
56
59
|
for (let offset = 0; offset < framed.length; ) {
|
|
57
60
|
let end = Math.min(offset + SESSION_RUNTIME_MAX_TERMINAL_INPUT_TEXT_CHARS, framed.length);
|
|
@@ -25,7 +25,7 @@ export function fleetConnectionMessage(status: FleetConnectionStatus): string {
|
|
|
25
25
|
case "invalid-target":
|
|
26
26
|
return "Check the SSH alias in this machine's settings.";
|
|
27
27
|
case "incompatible":
|
|
28
|
-
return "
|
|
28
|
+
return "App and remote daemon protocols are incompatible. Check that this app and tmux-ide on the remote machine are up to date, then retry.";
|
|
29
29
|
case "invalid-descriptor":
|
|
30
30
|
return "Remote daemon discovery returned an invalid response. Check the installation.";
|
|
31
31
|
case "daemon-missing":
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Read only a fixed refusal code; never retain server messages or private context. */
|
|
2
|
+
export async function readPaneCreationRefusal(
|
|
3
|
+
response: Response,
|
|
4
|
+
signal: AbortSignal,
|
|
5
|
+
): Promise<"harness_unavailable" | undefined> {
|
|
6
|
+
if (response.status !== 409 || !response.body) return undefined;
|
|
7
|
+
const reader = response.body.getReader();
|
|
8
|
+
const chunks: Uint8Array[] = [];
|
|
9
|
+
let length = 0;
|
|
10
|
+
try {
|
|
11
|
+
for (;;) {
|
|
12
|
+
signal.throwIfAborted();
|
|
13
|
+
const { done, value } = await reader.read();
|
|
14
|
+
if (done) break;
|
|
15
|
+
length += value.byteLength;
|
|
16
|
+
if (length > 1024) return undefined;
|
|
17
|
+
chunks.push(value);
|
|
18
|
+
}
|
|
19
|
+
signal.throwIfAborted();
|
|
20
|
+
const bytes = new Uint8Array(length);
|
|
21
|
+
let offset = 0;
|
|
22
|
+
for (const chunk of chunks) {
|
|
23
|
+
bytes.set(chunk, offset);
|
|
24
|
+
offset += chunk.byteLength;
|
|
25
|
+
}
|
|
26
|
+
const value: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
27
|
+
if (
|
|
28
|
+
!value ||
|
|
29
|
+
typeof value !== "object" ||
|
|
30
|
+
Object.keys(value).length !== 1 ||
|
|
31
|
+
!("error" in value)
|
|
32
|
+
)
|
|
33
|
+
return undefined;
|
|
34
|
+
const error = value.error;
|
|
35
|
+
return error &&
|
|
36
|
+
typeof error === "object" &&
|
|
37
|
+
Object.keys(error).length === 1 &&
|
|
38
|
+
"code" in error &&
|
|
39
|
+
error.code === "harness_unavailable"
|
|
40
|
+
? "harness_unavailable"
|
|
41
|
+
: undefined;
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
} finally {
|
|
45
|
+
await reader.cancel().catch(() => undefined);
|
|
46
|
+
reader.releaseLock();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -33,6 +33,9 @@ import {
|
|
|
33
33
|
type TerminalDeliveryVisibility,
|
|
34
34
|
type WorkspaceMultiplexerMutationResult,
|
|
35
35
|
type WindowLinkTopology,
|
|
36
|
+
TERMINAL_GRAPHICS_MAX_REQUEST_DIGESTS,
|
|
37
|
+
type TerminalGraphicsBlob,
|
|
38
|
+
type TerminalGraphicsState,
|
|
36
39
|
} from "@tmux-ide/contracts";
|
|
37
40
|
import {
|
|
38
41
|
calibratePaneStreamClocks,
|
|
@@ -270,6 +273,14 @@ export interface OpenPaneStreamClientOptions {
|
|
|
270
273
|
readonly onClockCalibrationOutcome?: (outcome: PaneStreamClockCalibrationOutcome) => void;
|
|
271
274
|
readonly diagnosticNextTurn?: (callback: () => void) => () => void;
|
|
272
275
|
readonly onLayout?: (frame: Extract<PaneStreamServerFrame, { type: "layout" }>) => void;
|
|
276
|
+
/** Kitty graphics state; only sent when the offer carried the graphics capability. */
|
|
277
|
+
readonly onTerminalGraphics?: (pane: string, state: TerminalGraphicsState) => void;
|
|
278
|
+
/** One chunk of requested image bytes (`blob.offset` into `blob.total`). */
|
|
279
|
+
readonly onTerminalGraphicsBlob?: (
|
|
280
|
+
pane: string,
|
|
281
|
+
blob: TerminalGraphicsBlob,
|
|
282
|
+
bytes: Uint8Array,
|
|
283
|
+
) => void;
|
|
273
284
|
readonly onLayoutSnapshot?: (
|
|
274
285
|
frame: Extract<PaneStreamServerFrame, { type: "layout-snapshot" }>,
|
|
275
286
|
) => void;
|
|
@@ -313,6 +324,8 @@ export type ConnectIssuedPaneStreamRuntimeClientOptions = Pick<
|
|
|
313
324
|
| "onClockCalibrationOutcome"
|
|
314
325
|
| "diagnosticNextTurn"
|
|
315
326
|
| "onLayout"
|
|
327
|
+
| "onTerminalGraphics"
|
|
328
|
+
| "onTerminalGraphicsBlob"
|
|
316
329
|
| "onLayoutSnapshot"
|
|
317
330
|
| "onInputAck"
|
|
318
331
|
| "onCausalCellProof"
|
|
@@ -369,6 +382,8 @@ export interface PaneStreamRuntimeClient {
|
|
|
369
382
|
operationId: string,
|
|
370
383
|
intent: SessionRuntimeSemanticIntent,
|
|
371
384
|
): Promise<WorkspaceMultiplexerMutationResult | null>;
|
|
385
|
+
/** Pull image bytes announced in graphics state; the daemon sends each once per request. */
|
|
386
|
+
requestTerminalGraphics(pane: string, digests: readonly string[]): void;
|
|
372
387
|
close(): void;
|
|
373
388
|
}
|
|
374
389
|
|
|
@@ -1590,6 +1605,14 @@ export async function connectIssuedPaneStreamRuntimeClient(
|
|
|
1590
1605
|
nack: (nack) => send({ type: "terminal-delivery-nack", nack }),
|
|
1591
1606
|
setVisibility: (address, visibility) =>
|
|
1592
1607
|
send({ type: "terminal-delivery-visibility", ...address, visibility }),
|
|
1608
|
+
requestTerminalGraphics: (pane, digests) => {
|
|
1609
|
+
for (let start = 0; start < digests.length; start += TERMINAL_GRAPHICS_MAX_REQUEST_DIGESTS)
|
|
1610
|
+
send({
|
|
1611
|
+
type: "terminal-graphics-request",
|
|
1612
|
+
pane,
|
|
1613
|
+
digests: digests.slice(start, start + TERMINAL_GRAPHICS_MAX_REQUEST_DIGESTS),
|
|
1614
|
+
});
|
|
1615
|
+
},
|
|
1593
1616
|
submitIntent: (operationId, intent) =>
|
|
1594
1617
|
new Promise<WorkspaceMultiplexerMutationResult | null>((resolve, reject) => {
|
|
1595
1618
|
const timer = createRuntimeTimer(() => {
|
|
@@ -1651,6 +1674,12 @@ function routeFrame(
|
|
|
1651
1674
|
case "layout":
|
|
1652
1675
|
options.onLayout?.(frame);
|
|
1653
1676
|
return;
|
|
1677
|
+
case "terminal-graphics":
|
|
1678
|
+
options.onTerminalGraphics?.(frame.pane, frame.state);
|
|
1679
|
+
return;
|
|
1680
|
+
case "terminal-graphics-blob":
|
|
1681
|
+
options.onTerminalGraphicsBlob?.(frame.pane, frame.blob, decodeBase64(frame.data));
|
|
1682
|
+
return;
|
|
1654
1683
|
case "layout-snapshot":
|
|
1655
1684
|
options.onLayoutSnapshot?.(frame);
|
|
1656
1685
|
return;
|