@vibecook/ghosttea-react 0.12.0 → 0.13.1
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/README.md +59 -4
- package/dist/TerminalSurface.d.ts +53 -0
- package/dist/TerminalSurface.d.ts.map +1 -1
- package/dist/TerminalSurface.js +76 -9
- package/dist/TerminalSurface.js.map +1 -1
- package/dist/appearance/AppearanceSettings.d.ts +2 -0
- package/dist/appearance/AppearanceSettings.d.ts.map +1 -1
- package/dist/appearance/AppearanceSettings.js +12 -2
- package/dist/appearance/AppearanceSettings.js.map +1 -1
- package/dist/appearance/types.d.ts +4 -0
- package/dist/appearance/types.d.ts.map +1 -1
- package/dist/catalog-budget.d.ts +2 -2
- package/dist/catalog-budget.d.ts.map +1 -1
- package/dist/catalog-budget.js +2 -2
- package/dist/catalog-budget.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/renderers/glyph-atlas.d.ts +43 -0
- package/dist/renderers/glyph-atlas.d.ts.map +1 -0
- package/dist/renderers/glyph-atlas.js +175 -0
- package/dist/renderers/glyph-atlas.js.map +1 -0
- package/dist/renderers/light-adaptation.d.ts +36 -0
- package/dist/renderers/light-adaptation.d.ts.map +1 -0
- package/dist/renderers/light-adaptation.js +332 -0
- package/dist/renderers/light-adaptation.js.map +1 -0
- package/dist/renderers/types.d.ts +21 -0
- package/dist/renderers/types.d.ts.map +1 -1
- package/dist/renderers/types.js.map +1 -1
- package/dist/renderers/webgpu-renderer.d.ts +4 -0
- package/dist/renderers/webgpu-renderer.d.ts.map +1 -1
- package/dist/renderers/webgpu-renderer.js +321 -237
- package/dist/renderers/webgpu-renderer.js.map +1 -1
- package/dist/routed-control.d.ts +26 -2
- package/dist/routed-control.d.ts.map +1 -1
- package/dist/routed-control.js +145 -27
- package/dist/routed-control.js.map +1 -1
- package/dist/routed-frames.d.ts +1 -1
- package/dist/routed-frames.d.ts.map +1 -1
- package/dist/routed-frames.js +3 -7
- package/dist/routed-frames.js.map +1 -1
- package/dist/row-damage.d.ts +21 -0
- package/dist/row-damage.d.ts.map +1 -0
- package/dist/row-damage.js +76 -0
- package/dist/row-damage.js.map +1 -0
- package/dist/runtime.d.ts +92 -3
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +439 -43
- package/dist/runtime.js.map +1 -1
- package/dist/styles.css +11 -0
- package/dist/terminal-render.worker.js +908 -288
- package/dist/terminal-render.worker.js.map +4 -4
- package/dist/worker-messages.d.ts +22 -1
- package/dist/worker-messages.d.ts.map +1 -1
- package/dist/workspace/Workspace.d.ts +15 -2
- package/dist/workspace/Workspace.d.ts.map +1 -1
- package/dist/workspace/Workspace.js +36 -12
- package/dist/workspace/Workspace.js.map +1 -1
- package/dist/workspace/pane-layout.d.ts +3 -1
- package/dist/workspace/pane-layout.d.ts.map +1 -1
- package/dist/workspace/pane-layout.js +16 -4
- package/dist/workspace/pane-layout.js.map +1 -1
- package/package.json +4 -4
package/dist/runtime.js
CHANGED
|
@@ -25,6 +25,57 @@ export function showsStaleScreen(state) {
|
|
|
25
25
|
export function sessionIsFrozen(state) {
|
|
26
26
|
return showsStaleScreen(state.state) || state.awaitingRecoveryFrame;
|
|
27
27
|
}
|
|
28
|
+
function geometryHolderRecord(holder) {
|
|
29
|
+
return { clientId: holder.clientId, viewId: holder.viewId, holderGeneration: holder.holderGeneration };
|
|
30
|
+
}
|
|
31
|
+
function sameGeometryHolder(a, b) {
|
|
32
|
+
if (a === undefined || b === undefined)
|
|
33
|
+
return a === b;
|
|
34
|
+
return a.clientId === b.clientId && a.viewId === b.viewId && a.holderGeneration === b.holderGeneration;
|
|
35
|
+
}
|
|
36
|
+
/** A take's context on the control leg: which view and take it is, and the seat state it was built on. */
|
|
37
|
+
class RoutedGeometryTake {
|
|
38
|
+
viewId;
|
|
39
|
+
/** The view's request serial this take was sent under. */
|
|
40
|
+
viewSerial;
|
|
41
|
+
/** The session's take serial. */
|
|
42
|
+
serial;
|
|
43
|
+
attempt;
|
|
44
|
+
basisHolder;
|
|
45
|
+
basisRevision;
|
|
46
|
+
constructor(viewId, viewSerial, serial, attempt, basis) {
|
|
47
|
+
this.viewId = viewId;
|
|
48
|
+
this.viewSerial = viewSerial;
|
|
49
|
+
this.serial = serial;
|
|
50
|
+
this.attempt = attempt;
|
|
51
|
+
this.basisHolder = basis?.holder;
|
|
52
|
+
this.basisRevision = basis?.revision ?? 0;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** A plain claim's context: the view that sent it, under which serial, and whether it re-sends a grid that did not land. */
|
|
56
|
+
class RoutedGeometryClaim {
|
|
57
|
+
viewId;
|
|
58
|
+
viewSerial;
|
|
59
|
+
trailing;
|
|
60
|
+
constructor(viewId, viewSerial, trailing) {
|
|
61
|
+
this.viewId = viewId;
|
|
62
|
+
this.viewSerial = viewSerial;
|
|
63
|
+
this.trailing = trailing;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Refusals that describe the seat as it is, so a take built on older news can retry against them. */
|
|
67
|
+
const RETRYABLE_TAKE_REFUSALS = new Set(["SEAT_HELD", "STALE_REVISION", "NOT_HOLDER"]);
|
|
68
|
+
/**
|
|
69
|
+
* Whether a take's refusal names a seat other than the one the take was built
|
|
70
|
+
* on. A refusal that repeats the take's own view of the seat (a cell that does
|
|
71
|
+
* not allow the take at all) would only be refused again.
|
|
72
|
+
*/
|
|
73
|
+
function takeRefusalNamesNews(refused, take) {
|
|
74
|
+
if (!RETRYABLE_TAKE_REFUSALS.has(refused.code) || refused.geometryRevision === undefined)
|
|
75
|
+
return false;
|
|
76
|
+
const holder = refused.currentHolder === undefined ? undefined : geometryHolderRecord(refused.currentHolder);
|
|
77
|
+
return refused.geometryRevision !== take.basisRevision || !sameGeometryHolder(holder, take.basisHolder);
|
|
78
|
+
}
|
|
28
79
|
const FRAME_SUBSCRIPTION_ACK_TIMEOUT_MS = 10_000;
|
|
29
80
|
const FRAME_SUBSCRIPTION_ACK_PROTOCOL_MINOR = 7;
|
|
30
81
|
const FRAME_BRIDGE_CAPABILITY_VERSION = 1;
|
|
@@ -53,6 +104,9 @@ export function waitForGhostteaRendererPorts(timeoutMs = 10_000) {
|
|
|
53
104
|
}
|
|
54
105
|
export class GhostteaTerminalRuntime extends EventTarget {
|
|
55
106
|
#worker;
|
|
107
|
+
#sessionsCreatedHere = new Set();
|
|
108
|
+
#sessionsWithInput = new Set();
|
|
109
|
+
#sessionsDrawn = new Set();
|
|
56
110
|
#ports;
|
|
57
111
|
#routedHost;
|
|
58
112
|
#routedReceiverCapacities;
|
|
@@ -178,6 +232,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
178
232
|
this.#scheduleMetadataRefresh(data.sessionHandle);
|
|
179
233
|
this.#recordCommittedFrame(data.sessionHandle, data.fullSnapshot);
|
|
180
234
|
}
|
|
235
|
+
else if (data.type === "session-drawn") {
|
|
236
|
+
this.#recordSessionDrawn(data.sessionHandle);
|
|
237
|
+
}
|
|
181
238
|
else if (data.type === "catalog-pressure") {
|
|
182
239
|
console.warn(`[terminal-runtime] native text catalog budget exceeded for ${data.sessionHandle}; using bounded fallback text`);
|
|
183
240
|
this.dispatchEvent(new CustomEvent("catalog-pressure", { detail: data }));
|
|
@@ -202,6 +259,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
202
259
|
else if (data.type === "routed-frames-event") {
|
|
203
260
|
this.#handleRoutedFramesEvent(data.event);
|
|
204
261
|
}
|
|
262
|
+
else if (data.type === "terminal-modes") {
|
|
263
|
+
this.#noteMouseTracking(data.sessionHandle, data.mouseTracking);
|
|
264
|
+
}
|
|
205
265
|
else if (data.type === "renderer-reload-required") {
|
|
206
266
|
console.error(`[terminal-runtime] renderer requested reload: ${String(data.reason ?? "unknown")}`);
|
|
207
267
|
this.#platform.setForceCanvasFallback(true);
|
|
@@ -218,6 +278,26 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
218
278
|
return;
|
|
219
279
|
this.#worker.postMessage(message, transfer);
|
|
220
280
|
}
|
|
281
|
+
/** Whether this renderer has attempted terminal input during this session's lifetime.
|
|
282
|
+
* Restored or externally created sessions must never be treated as unused from this alone. */
|
|
283
|
+
sessionHasInput(sessionId) {
|
|
284
|
+
return this.#sessionsWithInput.has(sessionId);
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Whether a committed frame of this session has put something on screen in
|
|
288
|
+
* this renderer: what a host that just started a program waits for before it
|
|
289
|
+
* reveals the terminal. Once true it stays true while the session is
|
|
290
|
+
* registered here (a restart in place is the same session and does not draw
|
|
291
|
+
* "first" again); a `session-drawn` event (`detail: { sessionId }`) announces
|
|
292
|
+
* the change, and a session registered again announces again.
|
|
293
|
+
*/
|
|
294
|
+
sessionHasDrawn(sessionId) {
|
|
295
|
+
return this.#sessionsDrawn.has(sessionId);
|
|
296
|
+
}
|
|
297
|
+
/** A local birth receipt, never inferred from registration or an empty input history. */
|
|
298
|
+
sessionCreatedHere(sessionId) {
|
|
299
|
+
return this.#sessionsCreatedHere.has(sessionId);
|
|
300
|
+
}
|
|
221
301
|
get rendererBackend() {
|
|
222
302
|
return this.#rendererBackend;
|
|
223
303
|
}
|
|
@@ -231,6 +311,48 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
231
311
|
return false;
|
|
232
312
|
return this.#routedBySession.get(view.sessionId)?.state.inputAllowed ?? false;
|
|
233
313
|
}
|
|
314
|
+
/**
|
|
315
|
+
* The routed session's geometry seat as this runtime last saw it, or
|
|
316
|
+
* `undefined` before anything was seen (and always on the port-pair
|
|
317
|
+
* transport). A change of holder also fires `geometry-holder-changed`; a
|
|
318
|
+
* resize by the same holder only moves `revision`, without an event.
|
|
319
|
+
*/
|
|
320
|
+
geometryHolder(sessionHandle) {
|
|
321
|
+
const session = this.#sessionByHandle.get(sessionHandle);
|
|
322
|
+
const entry = session ? this.#routedBySession.get(session.id) : undefined;
|
|
323
|
+
const geometry = entry ? this.#routedGeometry.get(entry.sessionId) : undefined;
|
|
324
|
+
return entry && geometry ? this.#geometryHolderState(entry, geometry) : undefined;
|
|
325
|
+
}
|
|
326
|
+
#geometryHolderState(entry, geometry) {
|
|
327
|
+
const holder = geometry.holder;
|
|
328
|
+
return {
|
|
329
|
+
holder: holder === undefined ? null : { ...holder },
|
|
330
|
+
revision: geometry.revision,
|
|
331
|
+
local: holder !== undefined && holder.clientId === entry.clientId,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
/** Whether this view, under this runtime's client, holds the seat as last seen. */
|
|
335
|
+
#routedViewHolds(entry, geometry, viewId) {
|
|
336
|
+
const holder = geometry?.holder;
|
|
337
|
+
return holder !== undefined && holder.viewId === viewId && holder.clientId === entry.clientId;
|
|
338
|
+
}
|
|
339
|
+
/** Every write of a session's seat state goes through here, so a holder change always fires its event. */
|
|
340
|
+
#setRoutedGeometry(entry, next) {
|
|
341
|
+
const previous = this.#routedGeometry.get(entry.sessionId);
|
|
342
|
+
if (next === undefined)
|
|
343
|
+
this.#routedGeometry.delete(entry.sessionId);
|
|
344
|
+
else
|
|
345
|
+
this.#routedGeometry.set(entry.sessionId, next);
|
|
346
|
+
if ((previous === undefined) === (next === undefined) && sameGeometryHolder(previous?.holder, next?.holder))
|
|
347
|
+
return;
|
|
348
|
+
const detail = {
|
|
349
|
+
sessionId: entry.sessionId,
|
|
350
|
+
sessionHandle: entry.sessionHandle,
|
|
351
|
+
previous: previous === undefined ? undefined : this.#geometryHolderState(entry, previous),
|
|
352
|
+
current: next === undefined ? undefined : this.#geometryHolderState(entry, next),
|
|
353
|
+
};
|
|
354
|
+
this.dispatchEvent(new CustomEvent("geometry-holder-changed", { detail }));
|
|
355
|
+
}
|
|
234
356
|
#resolvePerformanceRequest(requestId, value) {
|
|
235
357
|
const pending = this.#performanceRequests.get(requestId);
|
|
236
358
|
if (!pending)
|
|
@@ -358,11 +480,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
358
480
|
this.#returnFrameCredit(data.byteLength);
|
|
359
481
|
return;
|
|
360
482
|
}
|
|
361
|
-
|
|
362
|
-
if (this.#mouseTrackingByHandle.get(sessionHandle) !== tracking) {
|
|
363
|
-
this.#mouseTrackingByHandle.set(sessionHandle, tracking);
|
|
364
|
-
this.dispatchEvent(new CustomEvent("terminal-modes", { detail: { sessionHandle, mouseTracking: tracking } }));
|
|
365
|
-
}
|
|
483
|
+
this.#noteMouseTracking(sessionHandle, (view.getUint16(6, true) & FrameFlag.MouseTracking) !== 0);
|
|
366
484
|
this.#scheduleMetadataRefresh(sessionHandle);
|
|
367
485
|
this.#postWorker({ type: "frame", packet: data }, [data]);
|
|
368
486
|
};
|
|
@@ -810,6 +928,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
810
928
|
if (!this.#routedHost.createSession)
|
|
811
929
|
throw new Error("The routed host does not provide session creation");
|
|
812
930
|
const session = await this.#routedHost.createSession(options);
|
|
931
|
+
this.#sessionsCreatedHere.add(session.id);
|
|
813
932
|
this.registerSession(session);
|
|
814
933
|
return session;
|
|
815
934
|
}
|
|
@@ -827,6 +946,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
827
946
|
});
|
|
828
947
|
if (response.type !== "session-created")
|
|
829
948
|
throw new Error("ghosttead returned an unexpected response");
|
|
949
|
+
this.#sessionsCreatedHere.add(response.session.id);
|
|
830
950
|
this.registerSession(response.session);
|
|
831
951
|
console.info(`[terminal-runtime] created session ${response.session.id}`);
|
|
832
952
|
return response.session;
|
|
@@ -983,11 +1103,11 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
983
1103
|
if (response.type !== "ok")
|
|
984
1104
|
throw new Error("ghosttead rejected frame resynchronization");
|
|
985
1105
|
}
|
|
986
|
-
mount(sessionId, sessionHandle, viewId, canvas) {
|
|
1106
|
+
mount(sessionId, sessionHandle, viewId, canvas, initialSize) {
|
|
987
1107
|
if (this.#disposed)
|
|
988
1108
|
throw new Error("Cannot mount a disposed terminal runtime");
|
|
989
1109
|
if (this.#routedHost)
|
|
990
|
-
return this.#mountRouted(sessionId, sessionHandle, viewId, canvas);
|
|
1110
|
+
return this.#mountRouted(sessionId, sessionHandle, viewId, canvas, initialSize);
|
|
991
1111
|
const mounted = this.#mountedCanvases.get(canvas);
|
|
992
1112
|
if (mounted) {
|
|
993
1113
|
if (!mounted.active)
|
|
@@ -1002,11 +1122,20 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1002
1122
|
}
|
|
1003
1123
|
return this.#createMountLease(mounted);
|
|
1004
1124
|
}
|
|
1125
|
+
const size = initialSize ?? {
|
|
1126
|
+
width: canvas.clientWidth || 0,
|
|
1127
|
+
height: canvas.clientHeight || 0,
|
|
1128
|
+
dpr: window.devicePixelRatio || 1,
|
|
1129
|
+
};
|
|
1130
|
+
// Allocate the first render target at its actual size, too: the backend
|
|
1131
|
+
// must not build a default 300x150 scene texture and immediately replace it.
|
|
1132
|
+
canvas.width = Math.max(1, Math.round(size.width * size.dpr));
|
|
1133
|
+
canvas.height = Math.max(1, Math.round(size.height * size.dpr));
|
|
1005
1134
|
const offscreen = canvas.transferControlToOffscreen();
|
|
1006
1135
|
const subscription = this.#retainFrameSubscription(sessionHandle);
|
|
1007
1136
|
const generation = (this.#mountGenerationBySurface.get(viewId) ?? 0) + 1;
|
|
1008
1137
|
this.#mountGenerationBySurface.set(viewId, generation);
|
|
1009
|
-
this.#postWorker({ type: "mount", surfaceId: viewId, sessionHandle, canvas: offscreen }, [offscreen]);
|
|
1138
|
+
this.#postWorker({ type: "mount", surfaceId: viewId, sessionHandle, canvas: offscreen, size }, [offscreen]);
|
|
1010
1139
|
const entry = {
|
|
1011
1140
|
canvas,
|
|
1012
1141
|
sessionHandle,
|
|
@@ -1035,6 +1164,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1035
1164
|
lastAttachmentEpoch: undefined,
|
|
1036
1165
|
claimedEpoch: undefined,
|
|
1037
1166
|
claimedRevision: 0,
|
|
1167
|
+
routedGeometrySerial: 0,
|
|
1038
1168
|
};
|
|
1039
1169
|
this.#views.set(viewId, view);
|
|
1040
1170
|
const remote = this.#remoteSessions.get(sessionId);
|
|
@@ -1093,7 +1223,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1093
1223
|
.catch((error) => console.error(`[terminal-runtime] failed to attach view ${viewId}`, error));
|
|
1094
1224
|
return this.#createMountLease(entry);
|
|
1095
1225
|
}
|
|
1096
|
-
#mountRouted(sessionId, sessionHandle, viewId, canvas) {
|
|
1226
|
+
#mountRouted(sessionId, sessionHandle, viewId, canvas, initialSize) {
|
|
1097
1227
|
const mounted = this.#mountedCanvases.get(canvas);
|
|
1098
1228
|
if (mounted) {
|
|
1099
1229
|
if (!mounted.active)
|
|
@@ -1108,10 +1238,19 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1108
1238
|
}
|
|
1109
1239
|
return this.#createMountLease(mounted);
|
|
1110
1240
|
}
|
|
1241
|
+
const size = initialSize ?? {
|
|
1242
|
+
width: canvas.clientWidth || 0,
|
|
1243
|
+
height: canvas.clientHeight || 0,
|
|
1244
|
+
dpr: window.devicePixelRatio || 1,
|
|
1245
|
+
};
|
|
1246
|
+
// Allocate the first render target at its actual size, too: the backend
|
|
1247
|
+
// must not build a default 300x150 scene texture and immediately replace it.
|
|
1248
|
+
canvas.width = Math.max(1, Math.round(size.width * size.dpr));
|
|
1249
|
+
canvas.height = Math.max(1, Math.round(size.height * size.dpr));
|
|
1111
1250
|
const offscreen = canvas.transferControlToOffscreen();
|
|
1112
1251
|
const generation = (this.#mountGenerationBySurface.get(viewId) ?? 0) + 1;
|
|
1113
1252
|
this.#mountGenerationBySurface.set(viewId, generation);
|
|
1114
|
-
this.#postWorker({ type: "mount", surfaceId: viewId, sessionHandle, canvas: offscreen }, [offscreen]);
|
|
1253
|
+
this.#postWorker({ type: "mount", surfaceId: viewId, sessionHandle, canvas: offscreen, size }, [offscreen]);
|
|
1115
1254
|
const entry = {
|
|
1116
1255
|
canvas,
|
|
1117
1256
|
sessionHandle,
|
|
@@ -1142,6 +1281,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1142
1281
|
lastAttachmentEpoch: undefined,
|
|
1143
1282
|
claimedEpoch: undefined,
|
|
1144
1283
|
claimedRevision: 0,
|
|
1284
|
+
routedGeometrySerial: 0,
|
|
1145
1285
|
});
|
|
1146
1286
|
const activation = this.#routedBySession.get(sessionId);
|
|
1147
1287
|
if (activation)
|
|
@@ -1178,6 +1318,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1178
1318
|
sessionHandle,
|
|
1179
1319
|
state: initialRoutedActivation(sessionId, activationId, inputPolicy),
|
|
1180
1320
|
viewIds: new Set([viewId]),
|
|
1321
|
+
takeSerial: 0,
|
|
1181
1322
|
recoveryAttempts: 0,
|
|
1182
1323
|
preAuthRemints: { control: 0, frames: 0 },
|
|
1183
1324
|
protocolFailures: { control: 0, frames: 0 },
|
|
@@ -1223,15 +1364,22 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1223
1364
|
const host = this.#routedHost;
|
|
1224
1365
|
if (!host || this.#disposed || entry.viewIds.size === 0)
|
|
1225
1366
|
return;
|
|
1367
|
+
const activationId = entry.state.activationId;
|
|
1368
|
+
const isCurrent = () => !this.#disposed &&
|
|
1369
|
+
entry.viewIds.size > 0 &&
|
|
1370
|
+
entry.state.activationId === activationId &&
|
|
1371
|
+
this.#routedByActivation.get(activationId) === entry &&
|
|
1372
|
+
this.#routedBySession.get(entry.sessionId) === entry;
|
|
1226
1373
|
let ticket;
|
|
1227
1374
|
try {
|
|
1228
1375
|
ticket = await host.openTicket(entry.sessionId, { reason });
|
|
1229
1376
|
}
|
|
1230
1377
|
catch (error) {
|
|
1231
|
-
|
|
1378
|
+
if (isCurrent())
|
|
1379
|
+
this.#transitionRouted(entry, { type: "no-route", reason: String(error) });
|
|
1232
1380
|
return;
|
|
1233
1381
|
}
|
|
1234
|
-
if (
|
|
1382
|
+
if (!isCurrent())
|
|
1235
1383
|
return;
|
|
1236
1384
|
if (!this.#routedTicketMatches(entry, ticket)) {
|
|
1237
1385
|
this.#transitionRouted(entry, { type: "no-route", reason: "ticket-binding-mismatch" });
|
|
@@ -1241,6 +1389,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1241
1389
|
if (!ticket.endpoints)
|
|
1242
1390
|
return;
|
|
1243
1391
|
entry.ticket = ticket;
|
|
1392
|
+
entry.clientId = ticket.attachGrant.claims.clientId;
|
|
1244
1393
|
this.#transitionRouted(entry, { type: "transport-ready" });
|
|
1245
1394
|
this.#routedControl.attach({
|
|
1246
1395
|
cellBootId: ticket.route.cellBootId,
|
|
@@ -1248,10 +1397,22 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1248
1397
|
transportGrant: ticket.transportGrant,
|
|
1249
1398
|
attachGrant: ticket.attachGrant,
|
|
1250
1399
|
activationId: entry.state.activationId,
|
|
1400
|
+
replacePreviousActivation: true,
|
|
1251
1401
|
...(entry.replacesActivationId === undefined ? {} : { replacesActivationId: entry.replacesActivationId }),
|
|
1252
1402
|
initialDemand: this.#routedDemand(entry),
|
|
1253
1403
|
capabilities: this.#routedCapabilities,
|
|
1254
1404
|
});
|
|
1405
|
+
this.#armRoutedAttachDeadline(entry, this.#routedAttachDeadlineByCell.get(ticket.route.cellBootId) ??
|
|
1406
|
+
DEFAULT_ROUTED_PROTOCOL_LIMITS.activationAttachDeadlineMs);
|
|
1407
|
+
this.#scheduleRoutedRenewal(entry);
|
|
1408
|
+
}
|
|
1409
|
+
#startRoutedFrames(entry) {
|
|
1410
|
+
const ticket = entry.ticket;
|
|
1411
|
+
if (!ticket?.endpoints || !entry.state.controlAttached || entry.framesAttachStarted || entry.viewIds.size === 0)
|
|
1412
|
+
return;
|
|
1413
|
+
// Control creates the native activation. The frames socket may already be
|
|
1414
|
+
// warm; sending on both legs at once races that creation on every remount.
|
|
1415
|
+
entry.framesAttachStarted = true;
|
|
1255
1416
|
this.#postWorker({
|
|
1256
1417
|
type: "routed-frames-attach",
|
|
1257
1418
|
request: {
|
|
@@ -1266,19 +1427,23 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1266
1427
|
capabilities: this.#routedCapabilities,
|
|
1267
1428
|
},
|
|
1268
1429
|
});
|
|
1269
|
-
this.#armRoutedAttachDeadline(entry, this.#routedAttachDeadlineByCell.get(ticket.route.cellBootId) ??
|
|
1270
|
-
DEFAULT_ROUTED_PROTOCOL_LIMITS.activationAttachDeadlineMs);
|
|
1271
|
-
this.#scheduleRoutedRenewal(entry);
|
|
1272
1430
|
}
|
|
1273
1431
|
#stopRoutedActivation(entry) {
|
|
1274
1432
|
this.#routedControl?.detach(entry.state.activationId);
|
|
1275
1433
|
this.#postWorker({ type: "routed-frames-detach", activationId: entry.state.activationId });
|
|
1434
|
+
delete entry.framesAttachStarted;
|
|
1276
1435
|
if (entry.attachTimer !== undefined)
|
|
1277
1436
|
window.clearTimeout(entry.attachTimer);
|
|
1278
1437
|
delete entry.attachTimer;
|
|
1279
1438
|
if (entry.renewalTimer !== undefined)
|
|
1280
1439
|
window.clearTimeout(entry.renewalTimer);
|
|
1281
1440
|
delete entry.renewalTimer;
|
|
1441
|
+
if (entry.cellLeaseTimer !== undefined)
|
|
1442
|
+
window.clearTimeout(entry.cellLeaseTimer);
|
|
1443
|
+
delete entry.cellLeaseTimer;
|
|
1444
|
+
if (entry.workerLeaseTimer !== undefined)
|
|
1445
|
+
window.clearTimeout(entry.workerLeaseTimer);
|
|
1446
|
+
delete entry.workerLeaseTimer;
|
|
1282
1447
|
delete entry.ticket;
|
|
1283
1448
|
}
|
|
1284
1449
|
#transitionRouted(entry, event) {
|
|
@@ -1352,23 +1517,36 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1352
1517
|
grantGeneration: event.attached.grantGenerationAccepted,
|
|
1353
1518
|
rights: event.attached.rights,
|
|
1354
1519
|
});
|
|
1520
|
+
this.#startRoutedFrames(entry);
|
|
1355
1521
|
const previousGeometry = this.#routedGeometry.get(entry.sessionId);
|
|
1522
|
+
const previousHolder = previousGeometry?.holder;
|
|
1356
1523
|
if (!event.attached.rights.includes("geometry") &&
|
|
1357
|
-
previousGeometry
|
|
1358
|
-
|
|
1524
|
+
previousGeometry !== undefined &&
|
|
1525
|
+
previousHolder !== undefined &&
|
|
1526
|
+
this.#routedViewHolds(entry, previousGeometry, previousHolder.viewId) &&
|
|
1527
|
+
entry.viewIds.has(previousHolder.viewId)) {
|
|
1359
1528
|
// The cell auto-releases a holder when renewal drops the geometry
|
|
1360
1529
|
// right. Preserve its next CAS revision without retaining authority.
|
|
1361
|
-
this.#
|
|
1530
|
+
this.#setRoutedGeometry(entry, {
|
|
1362
1531
|
revision: previousGeometry.revision + 1,
|
|
1363
1532
|
...(previousGeometry.cols === undefined ? {} : { cols: previousGeometry.cols }),
|
|
1364
1533
|
...(previousGeometry.rows === undefined ? {} : { rows: previousGeometry.rows }),
|
|
1365
1534
|
});
|
|
1366
1535
|
}
|
|
1536
|
+
// A take asked before the leg could carry it goes out now. It decides
|
|
1537
|
+
// the seat, so the plain claims below would only draw refusals.
|
|
1538
|
+
const queuedTake = entry.queuedTake;
|
|
1539
|
+
delete entry.queuedTake;
|
|
1540
|
+
if (queuedTake !== undefined &&
|
|
1541
|
+
queuedTake.serial === entry.takeSerial &&
|
|
1542
|
+
this.#sendRoutedTake(entry, queuedTake.viewId, queuedTake.serial, 0)) {
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1367
1545
|
const geometry = this.#routedGeometry.get(entry.sessionId);
|
|
1368
1546
|
for (const viewId of entry.viewIds) {
|
|
1369
1547
|
const view = this.#views.get(viewId);
|
|
1370
1548
|
if (view?.resizeControlRequested &&
|
|
1371
|
-
geometry
|
|
1549
|
+
!this.#routedViewHolds(entry, geometry, viewId) &&
|
|
1372
1550
|
view.desiredCols !== undefined &&
|
|
1373
1551
|
view.desiredRows !== undefined) {
|
|
1374
1552
|
this.#claimRoutedGeometry(entry, viewId, view.desiredCols, view.desiredRows);
|
|
@@ -1390,9 +1568,15 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1390
1568
|
const entry = this.#routedByActivation.get(event.status.activationId);
|
|
1391
1569
|
if (!entry || event.status.sessionId !== entry.sessionId)
|
|
1392
1570
|
return;
|
|
1571
|
+
const previousSequence = entry.state.lastCellStatusSequence;
|
|
1393
1572
|
this.#transitionRouted(entry, { type: "cell-status", status: event.status, now: performance.now() });
|
|
1394
1573
|
const sequence = entry.state.lastCellStatusSequence;
|
|
1395
|
-
|
|
1574
|
+
if (sequence === previousSequence)
|
|
1575
|
+
return;
|
|
1576
|
+
if (entry.cellLeaseTimer !== undefined)
|
|
1577
|
+
window.clearTimeout(entry.cellLeaseTimer);
|
|
1578
|
+
entry.cellLeaseTimer = window.setTimeout(() => {
|
|
1579
|
+
delete entry.cellLeaseTimer;
|
|
1396
1580
|
if (entry.state.lastCellStatusSequence !== sequence)
|
|
1397
1581
|
return;
|
|
1398
1582
|
this.#transitionRouted(entry, { type: "cell-lease-expired" });
|
|
@@ -1407,14 +1591,21 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1407
1591
|
const entry = event.activationId ? this.#routedByActivation.get(event.activationId) : undefined;
|
|
1408
1592
|
if (!entry)
|
|
1409
1593
|
return;
|
|
1410
|
-
this.#
|
|
1411
|
-
|
|
1412
|
-
holderGeneration: event.committed.holder.holderGeneration,
|
|
1594
|
+
this.#setRoutedGeometry(entry, {
|
|
1595
|
+
holder: geometryHolderRecord(event.committed.holder),
|
|
1413
1596
|
revision: event.committed.geometryRevision,
|
|
1414
1597
|
cols: event.committed.cols,
|
|
1415
1598
|
rows: event.committed.rows,
|
|
1416
1599
|
});
|
|
1417
|
-
this
|
|
1600
|
+
this.#trailRoutedResize(entry, event.request?.context, undefined);
|
|
1601
|
+
this.dispatchEvent(new CustomEvent("routed-geometry", {
|
|
1602
|
+
detail: {
|
|
1603
|
+
sessionId: entry.sessionId,
|
|
1604
|
+
type: event.type,
|
|
1605
|
+
...(event.activationId === undefined ? {} : { activationId: event.activationId }),
|
|
1606
|
+
committed: event.committed,
|
|
1607
|
+
},
|
|
1608
|
+
}));
|
|
1418
1609
|
return;
|
|
1419
1610
|
}
|
|
1420
1611
|
if (event.type === "geometry-refused") {
|
|
@@ -1422,14 +1613,32 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1422
1613
|
if (entry && event.refused.geometryRevision !== undefined) {
|
|
1423
1614
|
const previous = this.#routedGeometry.get(entry.sessionId);
|
|
1424
1615
|
const holder = event.refused.currentHolder;
|
|
1425
|
-
this.#
|
|
1616
|
+
this.#setRoutedGeometry(entry, {
|
|
1426
1617
|
revision: event.refused.geometryRevision,
|
|
1427
|
-
...(holder === undefined ? {} : {
|
|
1618
|
+
...(holder === undefined ? {} : { holder: geometryHolderRecord(holder) }),
|
|
1428
1619
|
...(previous?.cols === undefined ? {} : { cols: previous.cols }),
|
|
1429
1620
|
...(previous?.rows === undefined ? {} : { rows: previous.rows }),
|
|
1430
1621
|
});
|
|
1431
1622
|
}
|
|
1432
|
-
|
|
1623
|
+
// A take refused on stale news retries once against the seat the
|
|
1624
|
+
// refusal names; only the session's latest take does.
|
|
1625
|
+
const take = event.request?.context instanceof RoutedGeometryTake ? event.request.context : undefined;
|
|
1626
|
+
if (entry &&
|
|
1627
|
+
take &&
|
|
1628
|
+
take.attempt === 0 &&
|
|
1629
|
+
take.serial === entry.takeSerial &&
|
|
1630
|
+
takeRefusalNamesNews(event.refused, take)) {
|
|
1631
|
+
this.#sendRoutedTake(entry, take.viewId, take.serial, 1);
|
|
1632
|
+
}
|
|
1633
|
+
if (entry)
|
|
1634
|
+
this.#trailRoutedResize(entry, event.request?.context, event.refused.code);
|
|
1635
|
+
this.dispatchEvent(new CustomEvent("routed-geometry-refused", {
|
|
1636
|
+
detail: {
|
|
1637
|
+
type: event.type,
|
|
1638
|
+
...(event.activationId === undefined ? {} : { activationId: event.activationId }),
|
|
1639
|
+
refused: event.refused,
|
|
1640
|
+
},
|
|
1641
|
+
}));
|
|
1433
1642
|
return;
|
|
1434
1643
|
}
|
|
1435
1644
|
if (event.type === "transport-closed") {
|
|
@@ -1528,9 +1737,15 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1528
1737
|
const entry = this.#routedByActivation.get(event.status.activationId);
|
|
1529
1738
|
if (!entry)
|
|
1530
1739
|
return;
|
|
1740
|
+
const previousSequence = entry.state.lastWorkerStatusSequence;
|
|
1531
1741
|
this.#transitionRouted(entry, { type: "presentation-status", status: event.status, now: performance.now() });
|
|
1532
1742
|
const sequence = entry.state.lastWorkerStatusSequence;
|
|
1533
|
-
|
|
1743
|
+
if (sequence === previousSequence)
|
|
1744
|
+
return;
|
|
1745
|
+
if (entry.workerLeaseTimer !== undefined)
|
|
1746
|
+
window.clearTimeout(entry.workerLeaseTimer);
|
|
1747
|
+
entry.workerLeaseTimer = window.setTimeout(() => {
|
|
1748
|
+
delete entry.workerLeaseTimer;
|
|
1534
1749
|
if (entry.state.lastWorkerStatusSequence !== sequence)
|
|
1535
1750
|
return;
|
|
1536
1751
|
this.#transitionRouted(entry, { type: "worker-lease-expired" });
|
|
@@ -1644,6 +1859,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1644
1859
|
return;
|
|
1645
1860
|
}
|
|
1646
1861
|
entry.ticket = ticket;
|
|
1862
|
+
entry.clientId = ticket.attachGrant.claims.clientId;
|
|
1647
1863
|
this.#routedControl?.renew(activationId, ticket.attachGrant);
|
|
1648
1864
|
this.#scheduleRoutedRenewal(entry);
|
|
1649
1865
|
this.#postWorker({
|
|
@@ -1685,14 +1901,10 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1685
1901
|
this.#transitionRouted(entry, { type: "transport-failed", recoveryExhausted: true });
|
|
1686
1902
|
return;
|
|
1687
1903
|
}
|
|
1688
|
-
// Geometry belongs to the cell-side attach/client/view scope. Preserve it
|
|
1689
|
-
// across a same-route leg replacement, but never carry its revision or
|
|
1690
|
-
// holder generation to a newly routed cell.
|
|
1691
|
-
if (reason === "route-stale")
|
|
1692
|
-
this.#routedGeometry.delete(entry.sessionId);
|
|
1693
1904
|
const previousActivationId = entry.state.activationId;
|
|
1694
1905
|
this.#routedControl?.detach(previousActivationId);
|
|
1695
1906
|
this.#postWorker({ type: "routed-frames-detach", activationId: previousActivationId });
|
|
1907
|
+
delete entry.framesAttachStarted;
|
|
1696
1908
|
this.#routedByActivation.delete(previousActivationId);
|
|
1697
1909
|
const nextActivationId = crypto.randomUUID();
|
|
1698
1910
|
entry.replacesActivationId = previousActivationId;
|
|
@@ -1716,7 +1928,20 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1716
1928
|
if (entry.renewalTimer !== undefined)
|
|
1717
1929
|
window.clearTimeout(entry.renewalTimer);
|
|
1718
1930
|
delete entry.renewalTimer;
|
|
1931
|
+
if (entry.cellLeaseTimer !== undefined)
|
|
1932
|
+
window.clearTimeout(entry.cellLeaseTimer);
|
|
1933
|
+
delete entry.cellLeaseTimer;
|
|
1934
|
+
if (entry.workerLeaseTimer !== undefined)
|
|
1935
|
+
window.clearTimeout(entry.workerLeaseTimer);
|
|
1936
|
+
delete entry.workerLeaseTimer;
|
|
1719
1937
|
delete entry.ticket;
|
|
1938
|
+
// Geometry belongs to the cell-side attach/client/view scope. Preserve it
|
|
1939
|
+
// across a same-route leg replacement, but never carry its revision or
|
|
1940
|
+
// holder generation to a newly routed cell. Forgotten only now, once the
|
|
1941
|
+
// old activation is gone, so a listener of the holder event cannot act
|
|
1942
|
+
// on it.
|
|
1943
|
+
if (reason === "route-stale")
|
|
1944
|
+
this.#setRoutedGeometry(entry, undefined);
|
|
1720
1945
|
const delay = Math.min(2_000, 100 * 2 ** Math.max(0, entry.recoveryAttempts - 1));
|
|
1721
1946
|
window.setTimeout(() => {
|
|
1722
1947
|
if (this.#routedByActivation.get(nextActivationId) !== entry)
|
|
@@ -1795,6 +2020,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1795
2020
|
if (!entry)
|
|
1796
2021
|
return;
|
|
1797
2022
|
entry.viewIds.delete(viewId);
|
|
2023
|
+
if (entry.queuedTake?.viewId === viewId)
|
|
2024
|
+
delete entry.queuedTake;
|
|
1798
2025
|
if (entry.viewIds.size > 0) {
|
|
1799
2026
|
const anyWritable = this.#routedHost?.encodeInput !== undefined &&
|
|
1800
2027
|
[...entry.viewIds].some((candidate) => {
|
|
@@ -1808,7 +2035,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
1808
2035
|
this.#transitionRouted(entry, { type: "detach" });
|
|
1809
2036
|
this.#routedBySession.delete(sessionId);
|
|
1810
2037
|
this.#routedByActivation.delete(entry.state.activationId);
|
|
1811
|
-
this.#
|
|
2038
|
+
this.#setRoutedGeometry(entry, undefined);
|
|
1812
2039
|
}
|
|
1813
2040
|
/**
|
|
1814
2041
|
* Detach one of our views and let the seat go with it.
|
|
@@ -2072,6 +2299,13 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2072
2299
|
* screen: a partial update repaints part of the stale one, which is exactly
|
|
2073
2300
|
* what would thaw a pane too early.
|
|
2074
2301
|
*/
|
|
2302
|
+
#recordSessionDrawn(sessionHandle) {
|
|
2303
|
+
const sessionId = this.#sessionByHandle.get(sessionHandle)?.id;
|
|
2304
|
+
if (sessionId === undefined || this.#sessionsDrawn.has(sessionId))
|
|
2305
|
+
return;
|
|
2306
|
+
this.#sessionsDrawn.add(sessionId);
|
|
2307
|
+
this.dispatchEvent(new CustomEvent("session-drawn", { detail: { sessionId } }));
|
|
2308
|
+
}
|
|
2075
2309
|
#recordCommittedFrame(sessionHandle, fullSnapshot) {
|
|
2076
2310
|
if (!fullSnapshot)
|
|
2077
2311
|
return;
|
|
@@ -2212,6 +2446,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2212
2446
|
return message !== null && this.#routedControl.sendExtension(activation.state.activationId, message);
|
|
2213
2447
|
}
|
|
2214
2448
|
sendText(sessionId, viewId, text) {
|
|
2449
|
+
if (text.length > 0)
|
|
2450
|
+
this.#sessionsWithInput.add(sessionId);
|
|
2215
2451
|
if (this.#routedHost) {
|
|
2216
2452
|
this.#sendRoutedInput(sessionId, viewId, { kind: "text", text });
|
|
2217
2453
|
return;
|
|
@@ -2222,6 +2458,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2222
2458
|
this.#postWorker({ type: "cursor-activity", sessionHandle: handle });
|
|
2223
2459
|
}
|
|
2224
2460
|
paste(sessionId, viewId, text) {
|
|
2461
|
+
if (text.length > 0)
|
|
2462
|
+
this.#sessionsWithInput.add(sessionId);
|
|
2225
2463
|
if (this.#routedHost) {
|
|
2226
2464
|
this.#sendRoutedInput(sessionId, viewId, { kind: "paste", text });
|
|
2227
2465
|
return;
|
|
@@ -2232,6 +2470,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2232
2470
|
this.#postWorker({ type: "cursor-activity", sessionHandle: handle });
|
|
2233
2471
|
}
|
|
2234
2472
|
sendKey(sessionId, viewId, event) {
|
|
2473
|
+
if (event.type === "down" && !["Shift", "Control", "Alt", "Meta"].includes(event.key))
|
|
2474
|
+
this.#sessionsWithInput.add(sessionId);
|
|
2235
2475
|
if (this.#routedHost) {
|
|
2236
2476
|
this.#sendRoutedInput(sessionId, viewId, { kind: "key", event });
|
|
2237
2477
|
return;
|
|
@@ -2273,6 +2513,14 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2273
2513
|
isMouseTracking(sessionHandle) {
|
|
2274
2514
|
return this.#mouseTrackingByHandle.get(sessionHandle) ?? false;
|
|
2275
2515
|
}
|
|
2516
|
+
/** A session's mouse modes, from whichever path carried its frame: the legacy
|
|
2517
|
+
* bridge's header, read here, or the worker's word for a routed frame. */
|
|
2518
|
+
#noteMouseTracking(sessionHandle, tracking) {
|
|
2519
|
+
if (this.#mouseTrackingByHandle.get(sessionHandle) === tracking)
|
|
2520
|
+
return;
|
|
2521
|
+
this.#mouseTrackingByHandle.set(sessionHandle, tracking);
|
|
2522
|
+
this.dispatchEvent(new CustomEvent("terminal-modes", { detail: { sessionHandle, mouseTracking: tracking } }));
|
|
2523
|
+
}
|
|
2276
2524
|
setTheme(sessionHandle, theme, surfaceId) {
|
|
2277
2525
|
this.#postWorker({ type: "theme", sessionHandle, ...(surfaceId ? { surfaceId } : {}), theme });
|
|
2278
2526
|
// A surface-scoped theme is renderer-local. Updating the daemon's
|
|
@@ -2342,13 +2590,14 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2342
2590
|
setPartialRenderingEnabled(enabled) {
|
|
2343
2591
|
this.#postWorker({ type: "partial-rendering", enabled });
|
|
2344
2592
|
}
|
|
2345
|
-
#claimRoutedGeometry(entry, viewId, cols, rows) {
|
|
2593
|
+
#claimRoutedGeometry(entry, viewId, cols, rows, trailing = false) {
|
|
2346
2594
|
const ticket = entry.ticket;
|
|
2347
2595
|
const view = this.#views.get(viewId);
|
|
2348
2596
|
if (!ticket || !view?.resizeControlRequested || !entry.state.rights.includes("geometry"))
|
|
2349
2597
|
return;
|
|
2350
2598
|
const geometry = this.#routedGeometry.get(entry.sessionId);
|
|
2351
|
-
|
|
2599
|
+
const viewSerial = view.routedGeometrySerial + 1;
|
|
2600
|
+
const sent = this.#routedControl?.claimGeometry(entry.state.activationId, {
|
|
2352
2601
|
sessionId: entry.sessionId,
|
|
2353
2602
|
activationId: entry.state.activationId,
|
|
2354
2603
|
leaseEpoch: ticket.attachGrant.claims.leaseEpoch ?? 0,
|
|
@@ -2356,7 +2605,139 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2356
2605
|
cols,
|
|
2357
2606
|
rows,
|
|
2358
2607
|
expectRevision: geometry?.revision ?? 0,
|
|
2359
|
-
});
|
|
2608
|
+
}, new RoutedGeometryClaim(viewId, viewSerial, trailing));
|
|
2609
|
+
if (sent)
|
|
2610
|
+
view.routedGeometrySerial = viewSerial;
|
|
2611
|
+
}
|
|
2612
|
+
/**
|
|
2613
|
+
* Overlapping claims from one view name the same revision, so the cell
|
|
2614
|
+
* commits the first and refuses the rest as stale: on a slow control leg
|
|
2615
|
+
* the last grid of a resize would never land. When the reply to a view's
|
|
2616
|
+
* latest claim or take (a commit, or a STALE_REVISION refusal) leaves the
|
|
2617
|
+
* seat with this view at a grid other than the one it shows, that grid goes
|
|
2618
|
+
* once more. Only for the holder, and never after a re-sent claim is
|
|
2619
|
+
* refused.
|
|
2620
|
+
*/
|
|
2621
|
+
#trailRoutedResize(entry, context, refusal) {
|
|
2622
|
+
const request = context instanceof RoutedGeometryClaim || context instanceof RoutedGeometryTake ? context : undefined;
|
|
2623
|
+
if (request === undefined)
|
|
2624
|
+
return;
|
|
2625
|
+
if (refusal !== undefined) {
|
|
2626
|
+
if (refusal !== "STALE_REVISION")
|
|
2627
|
+
return;
|
|
2628
|
+
if (request instanceof RoutedGeometryClaim && request.trailing)
|
|
2629
|
+
return;
|
|
2630
|
+
}
|
|
2631
|
+
const view = this.#views.get(request.viewId);
|
|
2632
|
+
if (!view?.resizeControlRequested || view.routedGeometrySerial !== request.viewSerial)
|
|
2633
|
+
return;
|
|
2634
|
+
if (view.desiredCols === undefined || view.desiredRows === undefined)
|
|
2635
|
+
return;
|
|
2636
|
+
const geometry = this.#routedGeometry.get(entry.sessionId);
|
|
2637
|
+
if (!geometry || !this.#routedViewHolds(entry, geometry, request.viewId))
|
|
2638
|
+
return;
|
|
2639
|
+
if (geometry.cols === view.desiredCols && geometry.rows === view.desiredRows)
|
|
2640
|
+
return;
|
|
2641
|
+
this.#claimRoutedGeometry(entry, request.viewId, view.desiredCols, view.desiredRows, true);
|
|
2642
|
+
}
|
|
2643
|
+
/**
|
|
2644
|
+
* Send a take of the seat for `viewId`, built on the holder this runtime
|
|
2645
|
+
* last saw. False when it cannot go out: no ticket or attached control leg,
|
|
2646
|
+
* no `geometry` right, or the view no longer asks for resize control.
|
|
2647
|
+
*/
|
|
2648
|
+
#sendRoutedTake(entry, viewId, serial, attempt) {
|
|
2649
|
+
const ticket = entry.ticket;
|
|
2650
|
+
const view = this.#views.get(viewId);
|
|
2651
|
+
if (!ticket ||
|
|
2652
|
+
!entry.state.controlAttached ||
|
|
2653
|
+
!entry.state.rights.includes("geometry") ||
|
|
2654
|
+
!entry.viewIds.has(viewId) ||
|
|
2655
|
+
!view?.resizeControlRequested ||
|
|
2656
|
+
view.desiredCols === undefined ||
|
|
2657
|
+
view.desiredRows === undefined) {
|
|
2658
|
+
return false;
|
|
2659
|
+
}
|
|
2660
|
+
const clientId = ticket.attachGrant.claims.clientId;
|
|
2661
|
+
const geometry = this.#routedGeometry.get(entry.sessionId);
|
|
2662
|
+
const holder = geometry?.holder;
|
|
2663
|
+
const viewSerial = view.routedGeometrySerial + 1;
|
|
2664
|
+
const context = new RoutedGeometryTake(viewId, viewSerial, serial, attempt, geometry);
|
|
2665
|
+
const lease = {
|
|
2666
|
+
sessionId: entry.sessionId,
|
|
2667
|
+
activationId: entry.state.activationId,
|
|
2668
|
+
leaseEpoch: ticket.attachGrant.claims.leaseEpoch ?? 0,
|
|
2669
|
+
};
|
|
2670
|
+
const expectRevision = geometry?.revision ?? 0;
|
|
2671
|
+
const cols = view.desiredCols;
|
|
2672
|
+
const rows = view.desiredRows;
|
|
2673
|
+
// An empty seat is claimed; a claim of the view's own seat is committed
|
|
2674
|
+
// by the cell as a resize (at the same size, the PTY is left alone).
|
|
2675
|
+
const sent = holder === undefined || (holder.clientId === clientId && holder.viewId === viewId)
|
|
2676
|
+
? this.#routedControl?.claimGeometry(entry.state.activationId, { ...lease, claimant: { clientId, viewId }, cols, rows, expectRevision }, context)
|
|
2677
|
+
: this.#routedControl?.transferGeometry(entry.state.activationId, { ...lease, from: { ...holder }, to: { clientId, viewId }, expectRevision, cols, rows }, context);
|
|
2678
|
+
if (sent)
|
|
2679
|
+
view.routedGeometrySerial = viewSerial;
|
|
2680
|
+
return sent ?? false;
|
|
2681
|
+
}
|
|
2682
|
+
/**
|
|
2683
|
+
* Take the session's geometry seat for `viewId`: the host's policy verb.
|
|
2684
|
+
*
|
|
2685
|
+
* Focus is a scheduling fact. The runtime never turns it into geometry;
|
|
2686
|
+
* the host decides when a view takes the seat. VibeField's rule (DS-D5) is
|
|
2687
|
+
* that the pane focused last holds the PTY's cols×rows, on any device, last
|
|
2688
|
+
* write wins, and `TerminalSurface`'s `takeGeometryOnFocus` applies it.
|
|
2689
|
+
*
|
|
2690
|
+
* On the routed transport the take is built on the holder this runtime last
|
|
2691
|
+
* saw (`geometryHolder`):
|
|
2692
|
+
* - an empty seat draws a `ClaimGeometry`;
|
|
2693
|
+
* - a seat this view holds draws a `ClaimGeometry` of its own seat, which
|
|
2694
|
+
* the cell commits as a resize;
|
|
2695
|
+
* - a seat held by another view, of this client or another, draws one
|
|
2696
|
+
* `TransferGeometry {from: holder, to: {clientId, viewId}, expectRevision,
|
|
2697
|
+
* cols, rows}`, with `clientId` from this session's attach grant.
|
|
2698
|
+
*
|
|
2699
|
+
* A refusal is the only announcement a take gets. The cell answers only the
|
|
2700
|
+
* caller's control leg, so a pane whose seat another client took still
|
|
2701
|
+
* believes it holds it, and its take is a resize that the cell refuses. A
|
|
2702
|
+
* `SEAT_HELD`, `STALE_REVISION` or `NOT_HOLDER` refusal that names a holder
|
|
2703
|
+
* or revision other than the one the take was built on is retried once,
|
|
2704
|
+
* against what it names: one `TransferGeometry` from the named holder at
|
|
2705
|
+
* the named revision; a claim of the view's own seat if it names this
|
|
2706
|
+
* view; a `ClaimGeometry` if it names an empty seat. A refusal that repeats
|
|
2707
|
+
* the take's own view of the seat, or names no seat, is not retried, and
|
|
2708
|
+
* neither is a second refusal. The runtime owns that one retry: a host
|
|
2709
|
+
* calls this once per focus gain of a controller view and never retries
|
|
2710
|
+
* it. Only the session's latest take retries. A take asked before the
|
|
2711
|
+
* control leg attaches is sent when it attaches, unless the view's focus
|
|
2712
|
+
* ends first. Without the grant's `geometry` right nothing is sent.
|
|
2713
|
+
*
|
|
2714
|
+
* On the port-pair transport this is `claimResizeControl`, which never
|
|
2715
|
+
* displaces another view.
|
|
2716
|
+
*
|
|
2717
|
+
* Like `claimResizeControl`, it leaves the view asking for resize control:
|
|
2718
|
+
* its later grid changes resize the PTY while it holds the seat, until
|
|
2719
|
+
* `releaseResizeControl`.
|
|
2720
|
+
*/
|
|
2721
|
+
takeResizeControl(sessionHandle, viewId, cols, rows) {
|
|
2722
|
+
if (!this.#routedHost) {
|
|
2723
|
+
this.claimResizeControl(sessionHandle, viewId, cols, rows);
|
|
2724
|
+
return;
|
|
2725
|
+
}
|
|
2726
|
+
const view = this.#views.get(viewId);
|
|
2727
|
+
const session = this.#sessionByHandle.get(sessionHandle);
|
|
2728
|
+
const entry = session ? this.#routedBySession.get(session.id) : undefined;
|
|
2729
|
+
if (!view || !entry || view.sessionId !== entry.sessionId || !entry.viewIds.has(viewId))
|
|
2730
|
+
return;
|
|
2731
|
+
view.desiredCols = cols;
|
|
2732
|
+
view.desiredRows = rows;
|
|
2733
|
+
view.resizeControlRequested = true;
|
|
2734
|
+
entry.takeSerial += 1;
|
|
2735
|
+
const serial = entry.takeSerial;
|
|
2736
|
+
delete entry.queuedTake;
|
|
2737
|
+
if (this.#sendRoutedTake(entry, viewId, serial, 0))
|
|
2738
|
+
return;
|
|
2739
|
+
if (!entry.state.controlAttached)
|
|
2740
|
+
entry.queuedTake = { viewId, serial };
|
|
2360
2741
|
}
|
|
2361
2742
|
claimResizeControl(sessionHandle, viewId, cols, rows) {
|
|
2362
2743
|
const view = this.#views.get(viewId);
|
|
@@ -2383,9 +2764,12 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2383
2764
|
view.resizeControlRequested = false;
|
|
2384
2765
|
if (this.#routedHost) {
|
|
2385
2766
|
const entry = this.#routedBySession.get(view.sessionId);
|
|
2767
|
+
if (entry?.queuedTake?.viewId === viewId)
|
|
2768
|
+
delete entry.queuedTake;
|
|
2386
2769
|
const geometry = this.#routedGeometry.get(view.sessionId);
|
|
2387
2770
|
const ticket = entry?.ticket;
|
|
2388
|
-
|
|
2771
|
+
const holder = geometry?.holder;
|
|
2772
|
+
if (entry && ticket && geometry && holder && this.#routedViewHolds(entry, geometry, viewId)) {
|
|
2389
2773
|
const sent = this.#routedControl?.releaseGeometry(entry.state.activationId, {
|
|
2390
2774
|
sessionId: view.sessionId,
|
|
2391
2775
|
activationId: entry.state.activationId,
|
|
@@ -2393,13 +2777,14 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2393
2777
|
holder: {
|
|
2394
2778
|
clientId: ticket.attachGrant.claims.clientId,
|
|
2395
2779
|
viewId,
|
|
2396
|
-
holderGeneration:
|
|
2780
|
+
holderGeneration: holder.holderGeneration,
|
|
2397
2781
|
},
|
|
2398
2782
|
});
|
|
2399
2783
|
if (sent) {
|
|
2400
2784
|
// T1 sends no success body for release. The ordered control leg and
|
|
2401
|
-
// cell state machine make the next revision deterministic
|
|
2402
|
-
|
|
2785
|
+
// cell state machine make the next revision deterministic; if the
|
|
2786
|
+
// seat had moved on, the cell's refusal names the holder instead.
|
|
2787
|
+
this.#setRoutedGeometry(entry, {
|
|
2403
2788
|
revision: geometry.revision + 1,
|
|
2404
2789
|
...(geometry.cols === undefined ? {} : { cols: geometry.cols }),
|
|
2405
2790
|
...(geometry.rows === undefined ? {} : { rows: geometry.rows }),
|
|
@@ -2448,8 +2833,12 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2448
2833
|
return;
|
|
2449
2834
|
if (this.#routedHost) {
|
|
2450
2835
|
const entry = this.#routedBySession.get(session.id);
|
|
2451
|
-
if (entry)
|
|
2452
|
-
|
|
2836
|
+
if (!entry)
|
|
2837
|
+
return;
|
|
2838
|
+
// The focus that asked for a take still waiting on the leg has ended.
|
|
2839
|
+
if (!focused && entry.queuedTake?.viewId === viewId)
|
|
2840
|
+
delete entry.queuedTake;
|
|
2841
|
+
this.#declareRoutedDemand(entry);
|
|
2453
2842
|
return;
|
|
2454
2843
|
}
|
|
2455
2844
|
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => {
|
|
@@ -2501,6 +2890,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2501
2890
|
return response.text;
|
|
2502
2891
|
}
|
|
2503
2892
|
interrupt(sessionId, viewId) {
|
|
2893
|
+
this.#sessionsWithInput.add(sessionId);
|
|
2504
2894
|
if (this.#routedHost) {
|
|
2505
2895
|
this.#sendRoutedInput(sessionId, viewId, { kind: "interrupt" });
|
|
2506
2896
|
return;
|
|
@@ -2560,6 +2950,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2560
2950
|
this.#remoteSessions.delete(sessionId);
|
|
2561
2951
|
this.#controlBySession.delete(sessionId);
|
|
2562
2952
|
this.#recoveredSessions.delete(sessionId);
|
|
2953
|
+
// The worker forgets the handle's first frame with its snapshot (`drop-session`).
|
|
2954
|
+
this.#sessionsDrawn.delete(sessionId);
|
|
2563
2955
|
if (subscriptionChanged) {
|
|
2564
2956
|
this.#frameSubscriptionVersion += 1;
|
|
2565
2957
|
this.#syncFrameSubscriptionsInBackground();
|
|
@@ -2679,6 +3071,10 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
2679
3071
|
window.clearTimeout(entry.attachTimer);
|
|
2680
3072
|
if (entry.renewalTimer !== undefined)
|
|
2681
3073
|
window.clearTimeout(entry.renewalTimer);
|
|
3074
|
+
if (entry.cellLeaseTimer !== undefined)
|
|
3075
|
+
window.clearTimeout(entry.cellLeaseTimer);
|
|
3076
|
+
if (entry.workerLeaseTimer !== undefined)
|
|
3077
|
+
window.clearTimeout(entry.workerLeaseTimer);
|
|
2682
3078
|
}
|
|
2683
3079
|
this.#routedBySession.clear();
|
|
2684
3080
|
this.#routedByActivation.clear();
|