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
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { CanonicalTerminalReplicaUpdate } from "@tmux-ide/contracts";
|
|
3
|
+
|
|
4
|
+
export interface HistoryAnchorProof {
|
|
5
|
+
readonly workspaceName: string;
|
|
6
|
+
readonly semanticPaneId: string;
|
|
7
|
+
readonly generation: string;
|
|
8
|
+
readonly incarnation: string;
|
|
9
|
+
readonly revision: number;
|
|
10
|
+
readonly stateHash: string;
|
|
11
|
+
}
|
|
12
|
+
export type HistoryAnchorIdentity = Pick<
|
|
13
|
+
HistoryAnchorProof,
|
|
14
|
+
"workspaceName" | "semanticPaneId" | "generation" | "incarnation"
|
|
15
|
+
>;
|
|
16
|
+
type Failure = { readonly status: "unavailable" | "stale" | "lost" };
|
|
17
|
+
const MAX_TOKENS = 32;
|
|
18
|
+
const PROOF_TTL_MS = 5 * 60_000;
|
|
19
|
+
|
|
20
|
+
/** Owner-private row ordinals. Never changes terminal v1 payloads or hashes. */
|
|
21
|
+
export class HistoryAnchorOwner {
|
|
22
|
+
#current: (HistoryAnchorProof & { cols: number; rows: number; alternate: boolean }) | null = null;
|
|
23
|
+
#first = 0;
|
|
24
|
+
#length = 0;
|
|
25
|
+
#proofs = new Map<string, { first: number; length: number; expires: number }>();
|
|
26
|
+
#tokens = new Map<string, { ordinal: number }>();
|
|
27
|
+
constructor(private readonly now: () => number = () => performance.now()) {}
|
|
28
|
+
|
|
29
|
+
invalidate(): void {
|
|
30
|
+
this.#current = null;
|
|
31
|
+
this.#first = 0;
|
|
32
|
+
this.#length = 0;
|
|
33
|
+
this.#tokens.clear();
|
|
34
|
+
this.#proofs.clear();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
observe(update: CanonicalTerminalReplicaUpdate): void {
|
|
38
|
+
if (update.type === "terminal.tombstone") {
|
|
39
|
+
this.invalidate();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (update.type === "terminal.seed") {
|
|
43
|
+
this.invalidate();
|
|
44
|
+
this.#length = update.snapshot.history.length;
|
|
45
|
+
this.#current = {
|
|
46
|
+
workspaceName: update.workspaceName,
|
|
47
|
+
semanticPaneId: update.semanticPaneId,
|
|
48
|
+
generation: update.generation,
|
|
49
|
+
incarnation: update.incarnation,
|
|
50
|
+
revision: update.revision,
|
|
51
|
+
stateHash: update.stateHash,
|
|
52
|
+
cols: update.cols,
|
|
53
|
+
rows: update.rows,
|
|
54
|
+
alternate: update.snapshot.modes.alternateScreen,
|
|
55
|
+
};
|
|
56
|
+
this.#remember();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const old = this.#current;
|
|
60
|
+
if (
|
|
61
|
+
!old ||
|
|
62
|
+
old.workspaceName !== update.workspaceName ||
|
|
63
|
+
old.semanticPaneId !== update.semanticPaneId ||
|
|
64
|
+
old.generation !== update.generation ||
|
|
65
|
+
old.incarnation !== update.incarnation ||
|
|
66
|
+
old.revision !== update.baseRevision
|
|
67
|
+
) {
|
|
68
|
+
this.invalidate();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const delta = update.patch.historyDelta;
|
|
72
|
+
const alternate = update.patch.modes?.alternateScreen ?? old.alternate;
|
|
73
|
+
if (
|
|
74
|
+
delta &&
|
|
75
|
+
(!Number.isSafeInteger(delta.trim) || delta.trim < 0 || delta.trim > this.#length)
|
|
76
|
+
) {
|
|
77
|
+
this.invalidate();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const length =
|
|
81
|
+
update.patch.history?.length ??
|
|
82
|
+
this.#length - (delta?.trim ?? 0) + (delta?.append.length ?? 0);
|
|
83
|
+
const reset =
|
|
84
|
+
update.patch.history !== undefined ||
|
|
85
|
+
update.cols !== old.cols ||
|
|
86
|
+
update.rows !== old.rows ||
|
|
87
|
+
alternate !== old.alternate;
|
|
88
|
+
if (reset) {
|
|
89
|
+
this.#tokens.clear();
|
|
90
|
+
this.#proofs.clear();
|
|
91
|
+
this.#first = 0;
|
|
92
|
+
} else this.#first += delta?.trim ?? 0;
|
|
93
|
+
if (!Number.isSafeInteger(this.#first + length)) {
|
|
94
|
+
this.invalidate();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
this.#length = length;
|
|
98
|
+
this.#current = {
|
|
99
|
+
workspaceName: update.workspaceName,
|
|
100
|
+
semanticPaneId: update.semanticPaneId,
|
|
101
|
+
generation: update.generation,
|
|
102
|
+
incarnation: update.incarnation,
|
|
103
|
+
revision: update.revision,
|
|
104
|
+
stateHash: update.stateHash,
|
|
105
|
+
cols: update.cols,
|
|
106
|
+
rows: update.rows,
|
|
107
|
+
alternate,
|
|
108
|
+
};
|
|
109
|
+
this.#prune();
|
|
110
|
+
this.#remember();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
capture(
|
|
114
|
+
proof: HistoryAnchorProof,
|
|
115
|
+
historyIndex: number,
|
|
116
|
+
): Failure | { status: "captured"; token: string } {
|
|
117
|
+
const failure = this.#admitIdentity(proof);
|
|
118
|
+
if (failure) return failure;
|
|
119
|
+
this.#prune();
|
|
120
|
+
const known =
|
|
121
|
+
this.#current && this.#key(proof) === this.#key(this.#current)
|
|
122
|
+
? { first: this.#first, length: this.#length }
|
|
123
|
+
: this.#proofs.get(this.#key(proof));
|
|
124
|
+
if (!known) return { status: "stale" };
|
|
125
|
+
if (!Number.isSafeInteger(historyIndex) || historyIndex < 0 || historyIndex >= known.length)
|
|
126
|
+
return { status: "lost" };
|
|
127
|
+
const ordinal = known.first + historyIndex;
|
|
128
|
+
if (ordinal < this.#first || ordinal >= this.#first + this.#length) return { status: "lost" };
|
|
129
|
+
while (this.#tokens.size >= MAX_TOKENS) this.#tokens.delete(this.#tokens.keys().next().value!);
|
|
130
|
+
const token = randomUUID();
|
|
131
|
+
// Saved tabs retain identity until capacity eviction or actual history loss.
|
|
132
|
+
this.#tokens.set(token, { ordinal });
|
|
133
|
+
return { status: "captured", token };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
resolve(
|
|
137
|
+
proof: HistoryAnchorIdentity,
|
|
138
|
+
token: string,
|
|
139
|
+
): Failure | { status: "resolved"; proof: HistoryAnchorProof; historyIndex: number } {
|
|
140
|
+
const failure = this.#admitIdentity(proof);
|
|
141
|
+
if (failure) return failure;
|
|
142
|
+
this.#prune();
|
|
143
|
+
const entry = this.#tokens.get(token);
|
|
144
|
+
if (!entry) return { status: "lost" };
|
|
145
|
+
const { workspaceName, semanticPaneId, generation, incarnation, revision, stateHash } =
|
|
146
|
+
this.#current!;
|
|
147
|
+
return {
|
|
148
|
+
status: "resolved",
|
|
149
|
+
proof: { workspaceName, semanticPaneId, generation, incarnation, revision, stateHash },
|
|
150
|
+
historyIndex: entry.ordinal - this.#first,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#admitIdentity(proof: HistoryAnchorIdentity): Failure | null {
|
|
155
|
+
const current = this.#current;
|
|
156
|
+
if (!current || current.alternate) return { status: "unavailable" };
|
|
157
|
+
return current.workspaceName === proof.workspaceName &&
|
|
158
|
+
current.semanticPaneId === proof.semanticPaneId &&
|
|
159
|
+
current.generation === proof.generation &&
|
|
160
|
+
current.incarnation === proof.incarnation
|
|
161
|
+
? null
|
|
162
|
+
: { status: "stale" };
|
|
163
|
+
}
|
|
164
|
+
#key(proof: HistoryAnchorProof): string {
|
|
165
|
+
return JSON.stringify([
|
|
166
|
+
proof.workspaceName,
|
|
167
|
+
proof.semanticPaneId,
|
|
168
|
+
proof.generation,
|
|
169
|
+
proof.incarnation,
|
|
170
|
+
proof.revision,
|
|
171
|
+
proof.stateHash,
|
|
172
|
+
]);
|
|
173
|
+
}
|
|
174
|
+
#remember(): void {
|
|
175
|
+
if (!this.#current || this.#current.alternate) return;
|
|
176
|
+
while (this.#proofs.size >= 64) this.#proofs.delete(this.#proofs.keys().next().value!);
|
|
177
|
+
this.#proofs.set(this.#key(this.#current), {
|
|
178
|
+
first: this.#first,
|
|
179
|
+
length: this.#length,
|
|
180
|
+
expires: this.now() + PROOF_TTL_MS,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
#prune(): void {
|
|
184
|
+
const now = this.now();
|
|
185
|
+
for (const [key, value] of this.#proofs) if (value.expires <= now) this.#proofs.delete(key);
|
|
186
|
+
for (const [token, entry] of this.#tokens)
|
|
187
|
+
if (entry.ordinal < this.#first || entry.ordinal >= this.#first + this.#length)
|
|
188
|
+
this.#tokens.delete(token);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { HistoryAnchorRequest, HistoryAnchorResult } from "@tmux-ide/contracts";
|
|
1
2
|
import { StockCaptureTabUnavailableError } from "./stock-capture-fidelity.ts";
|
|
2
3
|
import type { OwnedNativePlanCompletion } from "../../lib/owned-native-interaction-bindings.ts";
|
|
3
4
|
import type {
|
|
@@ -255,6 +256,8 @@ export interface SessionRuntimeConsumer {
|
|
|
255
256
|
rows: number,
|
|
256
257
|
semanticWindowId?: string,
|
|
257
258
|
): void;
|
|
259
|
+
/** Renderer cell size in device pixels, forwarded to tmux. */
|
|
260
|
+
setCellPixels?(width: number, height: number, precise?: { width: number; height: number }): void;
|
|
258
261
|
fitViewportWithAuthority(
|
|
259
262
|
lease: SessionRuntimeAuthorityLease,
|
|
260
263
|
cols: number,
|
|
@@ -875,6 +878,12 @@ export class SessionRuntimeRegistry implements PaneStreamMirror {
|
|
|
875
878
|
return await this.#runtime(session).describe();
|
|
876
879
|
}
|
|
877
880
|
|
|
881
|
+
historyAnchor(session: string, request: HistoryAnchorRequest): HistoryAnchorResult {
|
|
882
|
+
const runtime = this.#sessions.get(session);
|
|
883
|
+
if (this.#disposed || !runtime) return { status: "unavailable" };
|
|
884
|
+
return runtime.historyAnchor(request);
|
|
885
|
+
}
|
|
886
|
+
|
|
878
887
|
async captureNativeBacking(
|
|
879
888
|
session: string,
|
|
880
889
|
semanticPaneId: string,
|
|
@@ -1760,6 +1769,17 @@ class SessionRuntime {
|
|
|
1760
1769
|
}
|
|
1761
1770
|
}
|
|
1762
1771
|
|
|
1772
|
+
/** Renderer cell size in device pixels; benign, so any connected client may set it. */
|
|
1773
|
+
setCellPixels(
|
|
1774
|
+
clientId: string,
|
|
1775
|
+
width: number,
|
|
1776
|
+
height: number,
|
|
1777
|
+
precise?: { width: number; height: number },
|
|
1778
|
+
): void {
|
|
1779
|
+
this.#assertConnected(clientId);
|
|
1780
|
+
this.#mirror.setCellPixels?.(this.session, width, height, precise);
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1763
1783
|
fitViewportWithAuthority(
|
|
1764
1784
|
clientId: string,
|
|
1765
1785
|
lease: SessionRuntimeAuthorityLease,
|
|
@@ -1880,6 +1900,18 @@ class SessionRuntime {
|
|
|
1880
1900
|
}
|
|
1881
1901
|
}
|
|
1882
1902
|
|
|
1903
|
+
historyAnchor(request: HistoryAnchorRequest): HistoryAnchorResult {
|
|
1904
|
+
if (this.#disposed) return { status: "unavailable" };
|
|
1905
|
+
const owner = this.#terminalReplicas.get(request.proof.semanticPaneId);
|
|
1906
|
+
if (!owner) return { status: "unavailable" };
|
|
1907
|
+
if (request.operation.kind === "capture") {
|
|
1908
|
+
if (!("revision" in request.proof) || !("stateHash" in request.proof))
|
|
1909
|
+
return { status: "stale" };
|
|
1910
|
+
return owner.captureHistoryAnchor(request.proof, request.operation.historyIndex);
|
|
1911
|
+
}
|
|
1912
|
+
return owner.resolveHistoryAnchor(request.proof, request.operation.token);
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1883
1915
|
async captureNativeBacking(
|
|
1884
1916
|
semanticPaneId: string,
|
|
1885
1917
|
expected?: NativeBackingIdentity,
|
|
@@ -2323,6 +2355,11 @@ class SessionRuntimeConsumerImpl implements SessionRuntimeConsumer {
|
|
|
2323
2355
|
this.#runtime.fitViewport(this.clientId, lease, cols, rows, semanticWindowId);
|
|
2324
2356
|
}
|
|
2325
2357
|
|
|
2358
|
+
setCellPixels(width: number, height: number, precise?: { width: number; height: number }): void {
|
|
2359
|
+
this.#assertOpen();
|
|
2360
|
+
this.#runtime.setCellPixels(this.clientId, width, height, precise);
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2326
2363
|
fitViewportWithAuthority(
|
|
2327
2364
|
lease: SessionRuntimeAuthorityLease,
|
|
2328
2365
|
cols: number,
|
|
@@ -2424,6 +2461,7 @@ class SessionRuntimeConsumerImpl implements SessionRuntimeConsumer {
|
|
|
2424
2461
|
this.#deliveryConnections.delete(connection);
|
|
2425
2462
|
await upstream.close();
|
|
2426
2463
|
},
|
|
2464
|
+
...(upstream.graphics ? { graphics: upstream.graphics } : {}),
|
|
2427
2465
|
};
|
|
2428
2466
|
this.#deliveryConnections.add(connection);
|
|
2429
2467
|
return connection;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { KittyImage } from "../graphics/kitty-graphics.ts";
|
|
1
2
|
import { StockCaptureTabUnavailableError } from "./stock-capture-fidelity.ts";
|
|
2
3
|
import {
|
|
3
4
|
MAX_RETAINED_NATIVE_BACKING_BYTES,
|
|
@@ -8,6 +9,8 @@ import {
|
|
|
8
9
|
} from "./native-seed-backing.ts";
|
|
9
10
|
import {
|
|
10
11
|
WorkspaceIdSchemaZ,
|
|
12
|
+
TERMINAL_GRAPHICS_CAPABILITY_VERSION,
|
|
13
|
+
type TerminalGraphicsState,
|
|
11
14
|
TERMINAL_DELIVERY_PATCH_TO_SEED_BYTES,
|
|
12
15
|
TERMINAL_DELIVERY_MAX_REPRESENTATION_BYTES,
|
|
13
16
|
TerminalDeliveryEnvelopeSchemaZ,
|
|
@@ -124,6 +127,17 @@ export interface TerminalDeliveryConnection {
|
|
|
124
127
|
nack(nack: TerminalDeliveryNack): void;
|
|
125
128
|
setVisibility(visibility: TerminalDeliveryVisibility): void;
|
|
126
129
|
close(): Promise<void>;
|
|
130
|
+
/**
|
|
131
|
+
* Kitty graphics side channel, present only when the client offered
|
|
132
|
+
* TERMINAL_GRAPHICS_CAPABILITY_VERSION. Delivery is independent of the
|
|
133
|
+
* canonical FIFO: renderers resolve state against cells they already hold.
|
|
134
|
+
*/
|
|
135
|
+
readonly graphics?: TerminalDeliveryGraphics;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface TerminalDeliveryGraphics {
|
|
139
|
+
subscribe(listener: (state: TerminalGraphicsState) => void): () => void;
|
|
140
|
+
image(digest: string): KittyImage | null;
|
|
127
141
|
}
|
|
128
142
|
|
|
129
143
|
interface NativeBackingLease {
|
|
@@ -173,6 +187,8 @@ export interface TerminalDeliverySourceOwner {
|
|
|
173
187
|
) => void,
|
|
174
188
|
onRaw: (record: TerminalReplicaCommittedRaw) => void,
|
|
175
189
|
): Promise<TerminalReplicaSourceSubscription>;
|
|
190
|
+
subscribeGraphics?(listener: (state: TerminalGraphicsState) => void): () => void;
|
|
191
|
+
graphicsImage?(digest: string): KittyImage | null;
|
|
176
192
|
}
|
|
177
193
|
|
|
178
194
|
interface ClientState {
|
|
@@ -451,6 +467,9 @@ export class SessionRuntimeTerminalDeliveryHub {
|
|
|
451
467
|
this.#recordDeliveryStatus(client, this.#panes.get(client.paneId));
|
|
452
468
|
},
|
|
453
469
|
close: async () => this.#closeClient(client),
|
|
470
|
+
...(offer.protocolVersions.includes(TERMINAL_GRAPHICS_CAPABILITY_VERSION)
|
|
471
|
+
? { graphics: this.#graphicsFor(semanticPaneId, client, closed) }
|
|
472
|
+
: {}),
|
|
454
473
|
};
|
|
455
474
|
} catch (error) {
|
|
456
475
|
if (!(error instanceof StockCaptureTabUnavailableError)) throw error;
|
|
@@ -474,6 +493,25 @@ export class SessionRuntimeTerminalDeliveryHub {
|
|
|
474
493
|
}
|
|
475
494
|
}
|
|
476
495
|
|
|
496
|
+
#graphicsFor(
|
|
497
|
+
semanticPaneId: string,
|
|
498
|
+
client: ClientState,
|
|
499
|
+
closed: Promise<unknown>,
|
|
500
|
+
): TerminalDeliveryGraphics {
|
|
501
|
+
const owner = this.#ownerForPane(semanticPaneId);
|
|
502
|
+
return {
|
|
503
|
+
subscribe: (listener) => {
|
|
504
|
+
if (client.closed || !owner.subscribeGraphics) return () => {};
|
|
505
|
+
const unsubscribe = owner.subscribeGraphics((state) => {
|
|
506
|
+
if (!client.closed) listener(state);
|
|
507
|
+
});
|
|
508
|
+
void closed.then(unsubscribe);
|
|
509
|
+
return unsubscribe;
|
|
510
|
+
},
|
|
511
|
+
image: (digest) => (client.closed ? null : (owner.graphicsImage?.(digest) ?? null)),
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
477
515
|
/** Retire only this pane's delivery owners before its canonical owner is disposed. */
|
|
478
516
|
failPaneCapture(semanticPaneId: string, error: StockCaptureTabUnavailableError): void {
|
|
479
517
|
for (const client of this.#clients.values())
|
|
@@ -25,6 +25,10 @@ export interface TerminalInterpreterBackend {
|
|
|
25
25
|
/** Public-parser diagnostic OSC seam; installed only for an active probe. */
|
|
26
26
|
registerOscHandler(identifier: number, handler: (data: string) => boolean): () => void;
|
|
27
27
|
write(data: Uint8Array | string): Promise<void>;
|
|
28
|
+
/** SPIKE (kitty graphics): cursor as an absolute buffer line, read mid-parse. */
|
|
29
|
+
graphicsCursor?(): { readonly x: number; readonly line: number; readonly alternate: boolean };
|
|
30
|
+
/** SPIKE (kitty graphics): absolute buffer line of viewport row 0. */
|
|
31
|
+
graphicsViewportTop?(): number;
|
|
28
32
|
/** Exact native painted state; false means this parser cannot import it. */
|
|
29
33
|
canImportNativeGrid?(): boolean;
|
|
30
34
|
importNativeGrid?(snapshot: NativeGridCapture): boolean;
|
|
@@ -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,18 @@ export class TerminalReplicaInterpreter {
|
|
|
400
421
|
}
|
|
401
422
|
const previous = this.#backend;
|
|
402
423
|
this.#backend = replacement;
|
|
424
|
+
this.#attachKitty(replacement);
|
|
425
|
+
// A capture replay does not erase graphics already observed by this owner.
|
|
426
|
+
// Keep anchors only when both backends agree on their coordinate basis;
|
|
427
|
+
// reflow, history replacement and authoritative stream resets are ambiguous.
|
|
428
|
+
const sameGraphicsBasis =
|
|
429
|
+
operation.bootstrap === "painted-capture" &&
|
|
430
|
+
previous.cols === replacement.cols &&
|
|
431
|
+
previous.rows === replacement.rows &&
|
|
432
|
+
previous.graphicsViewportTop?.() !== undefined &&
|
|
433
|
+
previous.graphicsViewportTop?.() === replacement.graphicsViewportTop?.() &&
|
|
434
|
+
previous.modes().alternateScreen === replacement.modes().alternateScreen;
|
|
435
|
+
if (!sameGraphicsBasis) this.#kitty.dropCursorPlacements();
|
|
403
436
|
this.#nativeReseedRequested = false;
|
|
404
437
|
this.#scrollback = scrollback;
|
|
405
438
|
this.#bootstrap = {
|
|
@@ -421,6 +454,7 @@ export class TerminalReplicaInterpreter {
|
|
|
421
454
|
this.#nativeSeedBackingCandidate = undefined;
|
|
422
455
|
}
|
|
423
456
|
previous.dispose();
|
|
457
|
+
this.#publishGraphics();
|
|
424
458
|
return;
|
|
425
459
|
}
|
|
426
460
|
if (operation.type === "cursor") {
|
|
@@ -434,6 +468,8 @@ export class TerminalReplicaInterpreter {
|
|
|
434
468
|
return;
|
|
435
469
|
}
|
|
436
470
|
if (operation.type === "resize") {
|
|
471
|
+
if (operation.cols !== this.#backend.cols || operation.rows !== this.#backend.rows)
|
|
472
|
+
this.#kitty.dropCursorPlacements();
|
|
437
473
|
this.#causalCell?.fail("geometry-drift");
|
|
438
474
|
if (this.#backend.modes().synchronizedOutput) {
|
|
439
475
|
// Geometry is part of the admitted FIFO even while publication is
|
|
@@ -445,6 +481,7 @@ export class TerminalReplicaInterpreter {
|
|
|
445
481
|
}
|
|
446
482
|
this.#backend.resize(operation.cols, operation.rows);
|
|
447
483
|
this.#commit(false);
|
|
484
|
+
this.#publishGraphics();
|
|
448
485
|
return;
|
|
449
486
|
}
|
|
450
487
|
const baseRevision = this.#revision;
|
|
@@ -462,6 +499,7 @@ export class TerminalReplicaInterpreter {
|
|
|
462
499
|
};
|
|
463
500
|
this.#revision = revision;
|
|
464
501
|
this.#closed = true;
|
|
502
|
+
this.#kitty.dispose();
|
|
465
503
|
this.#causalCell?.fail("transport-closed");
|
|
466
504
|
this.#clearSyncRecovery();
|
|
467
505
|
if (this.#needsSeed)
|
|
@@ -479,8 +517,18 @@ export class TerminalReplicaInterpreter {
|
|
|
479
517
|
this.#admitRaw(data);
|
|
480
518
|
this.#stats.parseBatches += 1;
|
|
481
519
|
this.#observeMarkerBytes(data);
|
|
520
|
+
const scanned = this.#kittyScanner.scan(data);
|
|
521
|
+
for (const command of scanned.commands) this.#kittyPending.set(command.marker, command);
|
|
482
522
|
const parseStarted = this.#observability.enabled ? this.#observability.nowMicros() : 0;
|
|
483
|
-
|
|
523
|
+
// Always forward the scanner's output: tmux splits %output (~2 KiB) inside
|
|
524
|
+
// kitty APCs (~4 KiB chunks), so a chunk with no completed command can still
|
|
525
|
+
// be all image payload that must never reach the VT parser as text.
|
|
526
|
+
// A chunk entirely inside image payload changes nothing the parser can
|
|
527
|
+
// see. Skipping it matters: every parser write settles on a later timer
|
|
528
|
+
// turn, so a frame split into dozens of %output chunks would otherwise
|
|
529
|
+
// serialize dozens of turns and fall behind a 30 fps image stream.
|
|
530
|
+
if (scanned.forward.byteLength === 0) return;
|
|
531
|
+
await this.#writeToBackend(this.#backend, scanned.forward);
|
|
484
532
|
if (this.#observability.enabled)
|
|
485
533
|
this.#observability.recordSpan(
|
|
486
534
|
"parse",
|
|
@@ -519,6 +567,7 @@ export class TerminalReplicaInterpreter {
|
|
|
519
567
|
} else {
|
|
520
568
|
this.#commit(false, this.#backend.dirtyRange(), trace);
|
|
521
569
|
}
|
|
570
|
+
this.#publishGraphics();
|
|
522
571
|
}
|
|
523
572
|
|
|
524
573
|
#scheduleSyncRecovery(): void {
|
|
@@ -719,6 +768,55 @@ export class TerminalReplicaInterpreter {
|
|
|
719
768
|
});
|
|
720
769
|
}
|
|
721
770
|
|
|
771
|
+
/** The pane's kitty image store (bytes for the graphics side channel). */
|
|
772
|
+
kittyImages(): KittyImageStore {
|
|
773
|
+
return this.#kitty;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** Latest graphics state, or null while the pane has never held an image. */
|
|
777
|
+
currentGraphics(): TerminalGraphicsState | null {
|
|
778
|
+
return this.#graphics;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
onGraphics(listener: (state: TerminalGraphicsState) => void): () => void {
|
|
782
|
+
this.#graphicsListeners.add(listener);
|
|
783
|
+
return () => this.#graphicsListeners.delete(listener);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** Publish graphics state when the store or the viewport-relative placements moved. */
|
|
787
|
+
#publishGraphics(): void {
|
|
788
|
+
if (this.#graphics === null && this.#kitty.revision === 0) return;
|
|
789
|
+
const state = this.#kitty.state(
|
|
790
|
+
this.#backend.graphicsViewportTop?.() ?? 0,
|
|
791
|
+
this.#backend.rows,
|
|
792
|
+
this.#backend.cols,
|
|
793
|
+
this.#backend.modes().alternateScreen,
|
|
794
|
+
);
|
|
795
|
+
const key = JSON.stringify([state.revision, state.placements]);
|
|
796
|
+
if (key === this.#graphicsKey) return;
|
|
797
|
+
this.#graphicsKey = key;
|
|
798
|
+
this.#graphics = state;
|
|
799
|
+
for (const listener of this.#graphicsListeners) {
|
|
800
|
+
try {
|
|
801
|
+
listener(state);
|
|
802
|
+
} catch {
|
|
803
|
+
// Renderer callbacks never poison the authoritative parser FIFO.
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
#attachKitty(backend: TerminalInterpreterBackend): void {
|
|
809
|
+
// Minimal test backends omit the OSC seam; they simply never place images.
|
|
810
|
+
if (typeof backend.registerOscHandler !== "function") return;
|
|
811
|
+
backend.registerOscHandler(KITTY_PLACEMENT_OSC, (data) => {
|
|
812
|
+
const command = this.#kittyPending.get(Number(data));
|
|
813
|
+
if (!command) return true;
|
|
814
|
+
this.#kittyPending.delete(Number(data));
|
|
815
|
+
this.#kitty.apply(command, backend.graphicsCursor?.() ?? null);
|
|
816
|
+
return true;
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
722
820
|
#currentSnapshotHash(): string {
|
|
723
821
|
return (this.#snapshotHash ??= hashTerminalReplicaSnapshot(this.#snapshot));
|
|
724
822
|
}
|
|
@@ -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);
|