@vibecook/ghosttea-react 0.6.1 → 0.7.0
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 +8 -0
- package/dist/TerminalSurface.d.ts +4 -1
- package/dist/TerminalSurface.d.ts.map +1 -1
- package/dist/TerminalSurface.js +6 -3
- package/dist/TerminalSurface.js.map +1 -1
- package/dist/bindings/action-route.d.ts.map +1 -1
- package/dist/bindings/action-route.js +4 -0
- package/dist/bindings/action-route.js.map +1 -1
- package/dist/bindings/ghostty-bindings.d.ts +13 -0
- package/dist/bindings/ghostty-bindings.d.ts.map +1 -1
- package/dist/bindings/ghostty-bindings.js +57 -0
- package/dist/bindings/ghostty-bindings.js.map +1 -1
- package/dist/bindings/index.d.ts +1 -1
- package/dist/bindings/index.d.ts.map +1 -1
- package/dist/bindings/index.js +1 -1
- package/dist/bindings/index.js.map +1 -1
- package/dist/config.d.ts +7 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +21 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/renderers/types.d.ts +6 -0
- package/dist/renderers/types.d.ts.map +1 -1
- package/dist/renderers/types.js +3 -0
- package/dist/renderers/types.js.map +1 -1
- package/dist/renderers/webgpu-renderer.d.ts.map +1 -1
- package/dist/renderers/webgpu-renderer.js +33 -4
- package/dist/renderers/webgpu-renderer.js.map +1 -1
- package/dist/runtime.d.ts +40 -3
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +526 -30
- package/dist/runtime.js.map +1 -1
- package/dist/terminal-bindings.d.ts +2 -2
- package/dist/terminal-bindings.d.ts.map +1 -1
- package/dist/terminal-bindings.js +2 -1
- package/dist/terminal-bindings.js.map +1 -1
- package/dist/terminal-render.worker.js +93 -7
- package/dist/terminal-render.worker.js.map +2 -2
- package/dist/worker-messages.d.ts +26 -1
- package/dist/worker-messages.d.ts.map +1 -1
- package/dist/workspace/RemoteSessionBanner.d.ts +22 -0
- package/dist/workspace/RemoteSessionBanner.d.ts.map +1 -0
- package/dist/workspace/RemoteSessionBanner.js +108 -0
- package/dist/workspace/RemoteSessionBanner.js.map +1 -0
- package/dist/workspace/RemoteSessionPalette.d.ts +5 -1
- package/dist/workspace/RemoteSessionPalette.d.ts.map +1 -1
- package/dist/workspace/RemoteSessionPalette.js +19 -15
- package/dist/workspace/RemoteSessionPalette.js.map +1 -1
- package/dist/workspace/Workspace.d.ts.map +1 -1
- package/dist/workspace/Workspace.js +96 -18
- package/dist/workspace/Workspace.js.map +1 -1
- package/dist/workspace/index.d.ts +2 -0
- package/dist/workspace/index.d.ts.map +1 -1
- package/dist/workspace/index.js +2 -0
- package/dist/workspace/index.js.map +1 -1
- package/dist/workspace/remote-banner.d.ts +36 -0
- package/dist/workspace/remote-banner.d.ts.map +1 -0
- package/dist/workspace/remote-banner.js +108 -0
- package/dist/workspace/remote-banner.js.map +1 -0
- package/dist/workspace.css +93 -0
- package/package.json +4 -4
package/dist/runtime.js
CHANGED
|
@@ -10,10 +10,23 @@ function sameSessionActivity(left, right) {
|
|
|
10
10
|
left.foregroundProcessGroupId === right.foregroundProcessGroupId &&
|
|
11
11
|
left.observedAtMs === right.observedAtMs);
|
|
12
12
|
}
|
|
13
|
+
/** States that leave a screen on display which the host is no longer updating. */
|
|
14
|
+
export function showsStaleScreen(state) {
|
|
15
|
+
return state !== "live" && state !== "opening";
|
|
16
|
+
}
|
|
17
|
+
/** Whether a pane is showing stale content: frozen outright, or live but not yet redrawn. */
|
|
18
|
+
export function sessionIsFrozen(state) {
|
|
19
|
+
return showsStaleScreen(state.state) || state.awaitingRecoveryFrame;
|
|
20
|
+
}
|
|
13
21
|
const FRAME_SUBSCRIPTION_ACK_TIMEOUT_MS = 10_000;
|
|
14
22
|
const FRAME_SUBSCRIPTION_ACK_PROTOCOL_MINOR = 7;
|
|
15
23
|
const FRAME_BRIDGE_CAPABILITY_VERSION = 1;
|
|
24
|
+
const CONFIG_PROTOCOL_MINOR = 10;
|
|
25
|
+
const REMOTE_LIFECYCLE_PROTOCOL_MINOR = 12;
|
|
26
|
+
const CONTROL_REVISION_CAS_PROTOCOL_MINOR = 13;
|
|
16
27
|
const DEFAULT_FRAME_SUBSCRIPTION_GRACE_MS = 1_000;
|
|
28
|
+
/** A one-shot resume covers a 20 s dial plus the attach handshake. */
|
|
29
|
+
const RECONNECT_REQUEST_TIMEOUT_MS = 60_000;
|
|
17
30
|
export function waitForGhostteaRendererPorts(timeoutMs = 10_000) {
|
|
18
31
|
return new Promise((resolve, reject) => {
|
|
19
32
|
const timeout = window.setTimeout(() => {
|
|
@@ -61,7 +74,15 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
61
74
|
#scrollbarByHandle = new Map();
|
|
62
75
|
#focusByView = new Map();
|
|
63
76
|
#views = new Map();
|
|
77
|
+
#remoteSessions = new Map();
|
|
78
|
+
#controlBySession = new Map();
|
|
79
|
+
/** Sessions whose current outage episode has already produced a full snapshot. */
|
|
80
|
+
#recoveredSessions = new Set();
|
|
81
|
+
#remoteLifecycleSupported = false;
|
|
82
|
+
#controlRevisionCasSupported = false;
|
|
64
83
|
#rendererBackend = "starting";
|
|
84
|
+
#configSnapshot;
|
|
85
|
+
#configProtocolSupported = false;
|
|
65
86
|
#metadataTimers = new Map();
|
|
66
87
|
#resync;
|
|
67
88
|
#performanceRequestId = 1;
|
|
@@ -112,6 +133,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
112
133
|
else if (data.type === "frame-resync-complete") {
|
|
113
134
|
this.#resync.complete(data.sessionHandle);
|
|
114
135
|
}
|
|
136
|
+
else if (data.type === "frame-committed") {
|
|
137
|
+
this.#recordCommittedFrame(data.sessionHandle, data.fullSnapshot);
|
|
138
|
+
}
|
|
115
139
|
else if (data.type === "catalog-pressure") {
|
|
116
140
|
console.warn(`[terminal-runtime] native text catalog budget exceeded for ${data.sessionHandle}; using bounded fallback text`);
|
|
117
141
|
this.dispatchEvent(new CustomEvent("catalog-pressure", { detail: data }));
|
|
@@ -210,6 +234,12 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
210
234
|
this.dispatchEvent(new CustomEvent("session-metadata", { detail: exited }));
|
|
211
235
|
this.dispatchEvent(new CustomEvent("session-exited", { detail }));
|
|
212
236
|
});
|
|
237
|
+
this.#control.addEventListener("events-lost", () => {
|
|
238
|
+
// The daemon dropped events faster than this client drained them. Any
|
|
239
|
+
// of them could have been a session-exited, so reconcile against the
|
|
240
|
+
// daemon's authoritative session list.
|
|
241
|
+
void this.#resyncAfterLostEvents();
|
|
242
|
+
});
|
|
213
243
|
this.#control.addEventListener("session-activity-changed", (event) => {
|
|
214
244
|
const detail = event.detail;
|
|
215
245
|
const handle = this.#handleBySessionId.get(detail.sessionId);
|
|
@@ -223,20 +253,23 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
223
253
|
});
|
|
224
254
|
this.#control.addEventListener("control-changed", (event) => {
|
|
225
255
|
const detail = event.detail;
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
256
|
+
this.#applyController(detail.sessionId, { viewId: detail.controllerViewId, controlEpoch: detail.controlEpoch }, detail.cols, detail.rows);
|
|
257
|
+
});
|
|
258
|
+
this.#control.addEventListener("control-state", (event) => {
|
|
259
|
+
const detail = event.detail;
|
|
260
|
+
this.#applyController(detail.sessionId, detail.controller, detail.cols, detail.rows, detail.controlRevision);
|
|
261
|
+
});
|
|
262
|
+
this.#control.addEventListener("remote-session-state-changed", (event) => {
|
|
263
|
+
const detail = event.detail;
|
|
264
|
+
this.#applyRemoteSessionState(detail.sessionId, detail, false);
|
|
265
|
+
});
|
|
266
|
+
this.#control.addEventListener("view-state-changed", (event) => {
|
|
267
|
+
const detail = event.detail;
|
|
268
|
+
this.#applyViewState(detail.viewId, detail);
|
|
269
|
+
});
|
|
270
|
+
this.#control.addEventListener("config-changed", (event) => {
|
|
271
|
+
const detail = event.detail;
|
|
272
|
+
this.#installConfig(detail.config);
|
|
240
273
|
});
|
|
241
274
|
this.#frames = ports.frames;
|
|
242
275
|
this.#frames.onmessage = ({ data }) => {
|
|
@@ -286,9 +319,44 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
286
319
|
if (hello.type !== "hello" || hello.protocolMajor !== PROTOCOL_MAJOR)
|
|
287
320
|
throw new Error("ghosttead protocol mismatch");
|
|
288
321
|
this.#frameSubscriptionAcksSupported = hello.protocolMinor >= FRAME_SUBSCRIPTION_ACK_PROTOCOL_MINOR;
|
|
322
|
+
this.#configProtocolSupported = hello.protocolMinor >= CONFIG_PROTOCOL_MINOR;
|
|
323
|
+
this.#remoteLifecycleSupported = hello.protocolMinor >= REMOTE_LIFECYCLE_PROTOCOL_MINOR;
|
|
324
|
+
this.#controlRevisionCasSupported = hello.protocolMinor >= CONTROL_REVISION_CAS_PROTOCOL_MINOR;
|
|
325
|
+
if (this.#configProtocolSupported && hello.configRevision !== undefined) {
|
|
326
|
+
await this.#refreshConfig();
|
|
327
|
+
}
|
|
289
328
|
await this.#queueFrameSubscriptionSync();
|
|
290
329
|
console.info("[terminal-runtime] authenticated ghosttead protocol");
|
|
291
330
|
}
|
|
331
|
+
get configSnapshot() {
|
|
332
|
+
return this.#configSnapshot;
|
|
333
|
+
}
|
|
334
|
+
async getConfig() {
|
|
335
|
+
await this.connect();
|
|
336
|
+
return this.#configSnapshot;
|
|
337
|
+
}
|
|
338
|
+
async reloadConfig() {
|
|
339
|
+
await this.connect();
|
|
340
|
+
const response = await this.#control.request({ type: "reload-config" });
|
|
341
|
+
if (response.type !== "config")
|
|
342
|
+
throw new Error("ghosttead returned an unexpected configuration response");
|
|
343
|
+
this.#installConfig(response.config);
|
|
344
|
+
return response.config;
|
|
345
|
+
}
|
|
346
|
+
#installConfig(config) {
|
|
347
|
+
if (this.#configSnapshot?.revision === config.revision)
|
|
348
|
+
return;
|
|
349
|
+
this.#configSnapshot = config;
|
|
350
|
+
this.dispatchEvent(new CustomEvent("config-changed", { detail: config }));
|
|
351
|
+
}
|
|
352
|
+
async #refreshConfig() {
|
|
353
|
+
if (!this.#configProtocolSupported || !this.#control)
|
|
354
|
+
return;
|
|
355
|
+
const response = await this.#control.request({ type: "get-config" });
|
|
356
|
+
if (response.type !== "config")
|
|
357
|
+
throw new Error("ghosttead returned an unexpected configuration response");
|
|
358
|
+
this.#installConfig(response.config);
|
|
359
|
+
}
|
|
292
360
|
#handleFrameChannelControl(message) {
|
|
293
361
|
if (!message || typeof message !== "object")
|
|
294
362
|
return false;
|
|
@@ -555,6 +623,74 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
555
623
|
}
|
|
556
624
|
return response.sessions;
|
|
557
625
|
}
|
|
626
|
+
/**
|
|
627
|
+
* Reconcile tracked sessions against the daemon's authoritative list after
|
|
628
|
+
* an events-lost notice. A session still listed but now exited had its exit
|
|
629
|
+
* event lost; a session absent from the list entirely exited and left the
|
|
630
|
+
* registry, so its exit details are gone and are reported as unknown.
|
|
631
|
+
*/
|
|
632
|
+
async #resyncAfterLostEvents() {
|
|
633
|
+
try {
|
|
634
|
+
const before = new Map();
|
|
635
|
+
for (const session of this.#sessionByHandle.values()) {
|
|
636
|
+
before.set(session.id, session);
|
|
637
|
+
}
|
|
638
|
+
const [sessions] = await Promise.all([
|
|
639
|
+
this.listSessions(),
|
|
640
|
+
this.#refreshConfig(),
|
|
641
|
+
this.#reconcileRemoteSessions(),
|
|
642
|
+
]);
|
|
643
|
+
const known = new Set();
|
|
644
|
+
for (const session of sessions) {
|
|
645
|
+
known.add(session.id);
|
|
646
|
+
const previous = before.get(session.id);
|
|
647
|
+
if (!previous || previous.exited || !session.exited)
|
|
648
|
+
continue;
|
|
649
|
+
this.#cancelMetadataRefresh(session.handle);
|
|
650
|
+
this.dispatchEvent(new CustomEvent("session-metadata", { detail: session }));
|
|
651
|
+
this.dispatchEvent(new CustomEvent("session-exited", {
|
|
652
|
+
detail: {
|
|
653
|
+
requestId: 0,
|
|
654
|
+
type: "session-exited",
|
|
655
|
+
sessionId: session.id,
|
|
656
|
+
exitCode: session.exitCode,
|
|
657
|
+
exitSignal: session.exitSignal,
|
|
658
|
+
requestedTermination: session.requestedTermination,
|
|
659
|
+
exitOutcome: session.exitOutcome ?? "unknown",
|
|
660
|
+
},
|
|
661
|
+
}));
|
|
662
|
+
}
|
|
663
|
+
for (const [handle, session] of this.#sessionByHandle) {
|
|
664
|
+
if (session.exited || known.has(session.id))
|
|
665
|
+
continue;
|
|
666
|
+
const detail = {
|
|
667
|
+
requestId: 0,
|
|
668
|
+
type: "session-exited",
|
|
669
|
+
sessionId: session.id,
|
|
670
|
+
exitCode: null,
|
|
671
|
+
exitSignal: null,
|
|
672
|
+
requestedTermination: null,
|
|
673
|
+
exitOutcome: "unknown",
|
|
674
|
+
};
|
|
675
|
+
this.#cancelMetadataRefresh(handle);
|
|
676
|
+
const exited = {
|
|
677
|
+
...session,
|
|
678
|
+
exited: true,
|
|
679
|
+
exitCode: null,
|
|
680
|
+
exitSignal: null,
|
|
681
|
+
requestedTermination: null,
|
|
682
|
+
exitOutcome: "unknown",
|
|
683
|
+
};
|
|
684
|
+
this.#sessionByHandle.set(handle, exited);
|
|
685
|
+
this.dispatchEvent(new CustomEvent("session-metadata", { detail: exited }));
|
|
686
|
+
this.dispatchEvent(new CustomEvent("session-exited", { detail }));
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
catch (error) {
|
|
690
|
+
if (!this.#disposed)
|
|
691
|
+
console.error("[terminal-runtime] failed to resynchronize state after lost events", error);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
558
694
|
async listRemoteHosts() {
|
|
559
695
|
await this.connect();
|
|
560
696
|
const response = await this.#control.request({ type: "list-remote-hosts" });
|
|
@@ -569,7 +705,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
569
705
|
throw new Error("ghosttead returned an unexpected response");
|
|
570
706
|
return response.sessions;
|
|
571
707
|
}
|
|
572
|
-
async openRemoteSession(deviceId, remoteSessionId, cols, rows) {
|
|
708
|
+
async openRemoteSession(deviceId, remoteSessionId, cols, rows, deviceName = deviceId) {
|
|
573
709
|
await this.connect();
|
|
574
710
|
const response = await this.#control.request({
|
|
575
711
|
type: "open-remote-session",
|
|
@@ -582,6 +718,25 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
582
718
|
if (response.type !== "session-created")
|
|
583
719
|
throw new Error("ghosttead could not open the remote session");
|
|
584
720
|
this.registerSession(response.session);
|
|
721
|
+
// The session counts as remote from here, before any daemon event, so its
|
|
722
|
+
// input is never queued. A daemon on this minor reports `opening` until
|
|
723
|
+
// the first snapshot lands, so start there and let its events take over;
|
|
724
|
+
// an older daemon reports nothing at all, and seeding `opening` against it
|
|
725
|
+
// would block input forever. The sequence below is under every real event.
|
|
726
|
+
this.#remoteSessions.set(response.session.id, {
|
|
727
|
+
sessionId: response.session.id,
|
|
728
|
+
state: this.#remoteLifecycleSupported ? "opening" : "live",
|
|
729
|
+
reason: null,
|
|
730
|
+
exit: null,
|
|
731
|
+
lifecycleSeq: -1,
|
|
732
|
+
deviceId,
|
|
733
|
+
deviceName,
|
|
734
|
+
attempt: null,
|
|
735
|
+
nextRetryMs: null,
|
|
736
|
+
lastContactMs: null,
|
|
737
|
+
observedAt: performance.now(),
|
|
738
|
+
awaitingRecoveryFrame: false,
|
|
739
|
+
});
|
|
585
740
|
return response.session;
|
|
586
741
|
}
|
|
587
742
|
async #refreshSession(sessionHandle) {
|
|
@@ -590,7 +745,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
590
745
|
return;
|
|
591
746
|
if (!this.#control)
|
|
592
747
|
throw new Error(`Session ${sessionHandle} is not ready for frame resynchronization`);
|
|
593
|
-
|
|
748
|
+
// A refresh of a remote session re-attaches and resynchronizes over the
|
|
749
|
+
// network, so it needs the same budget as a one-shot reconnect.
|
|
750
|
+
const response = await this.#control.request({ type: "refresh-session", sessionId: session.id }, RECONNECT_REQUEST_TIMEOUT_MS);
|
|
594
751
|
if (response.type !== "ok")
|
|
595
752
|
throw new Error("ghosttead rejected frame resynchronization");
|
|
596
753
|
}
|
|
@@ -637,8 +794,15 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
637
794
|
desiredCols: undefined,
|
|
638
795
|
desiredRows: undefined,
|
|
639
796
|
pendingInput: [],
|
|
797
|
+
lastViewStateSeq: undefined,
|
|
798
|
+
lastAttachmentEpoch: undefined,
|
|
799
|
+
claimedEpoch: undefined,
|
|
800
|
+
claimedRevision: 0,
|
|
640
801
|
};
|
|
641
802
|
this.#views.set(viewId, view);
|
|
803
|
+
const remote = this.#remoteSessions.get(sessionId);
|
|
804
|
+
if (remote && remote.state !== "live")
|
|
805
|
+
this.#setCursorFrozen(sessionId, true);
|
|
642
806
|
void subscription.ready
|
|
643
807
|
.then(() => {
|
|
644
808
|
const current = this.#views.get(viewId);
|
|
@@ -657,8 +821,16 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
657
821
|
const current = this.#views.get(viewId);
|
|
658
822
|
if (current !== view)
|
|
659
823
|
return;
|
|
660
|
-
|
|
661
|
-
|
|
824
|
+
const applied = this.#applyViewState(viewId, {
|
|
825
|
+
...(response.viewStateSeq !== undefined ? { viewStateSeq: response.viewStateSeq } : {}),
|
|
826
|
+
viewState: "attached",
|
|
827
|
+
attachmentEpoch: response.attachmentEpoch,
|
|
828
|
+
readWrite: response.readWrite,
|
|
829
|
+
error: null,
|
|
830
|
+
retryable: null,
|
|
831
|
+
});
|
|
832
|
+
if (!applied)
|
|
833
|
+
return;
|
|
662
834
|
this.dispatchEvent(new CustomEvent("view-attached", {
|
|
663
835
|
detail: { sessionId, sessionHandle, viewId, readWrite: response.readWrite },
|
|
664
836
|
}));
|
|
@@ -712,17 +884,301 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
712
884
|
},
|
|
713
885
|
};
|
|
714
886
|
}
|
|
715
|
-
|
|
887
|
+
get remoteLifecycleSupported() {
|
|
888
|
+
return this.#remoteLifecycleSupported;
|
|
889
|
+
}
|
|
890
|
+
/** Last known lifecycle of a remote session; undefined for local sessions. */
|
|
891
|
+
remoteSession(sessionId) {
|
|
892
|
+
return this.#remoteSessions.get(sessionId);
|
|
893
|
+
}
|
|
894
|
+
#applyController(sessionId, controller, cols, rows, revision = 0) {
|
|
895
|
+
const previous = this.#controlBySession.get(sessionId);
|
|
896
|
+
// A revisioned announcement older than what is cached is a queued view of
|
|
897
|
+
// the past, and must be dropped whole. Keeping its payload while raising
|
|
898
|
+
// the revision would manufacture a state that never existed — "nobody
|
|
899
|
+
// holds control, as of the newest revision" — and that reads as an empty
|
|
900
|
+
// seat the funnel may claim. The compare-and-swap would not catch it
|
|
901
|
+
// either: the expectation would be genuinely current, so the host would
|
|
902
|
+
// accept a claim that takes control from the real holder.
|
|
903
|
+
if (revision >= 1 && previous !== undefined && revision < previous.revision)
|
|
904
|
+
return;
|
|
905
|
+
this.#controlBySession.set(sessionId, {
|
|
906
|
+
controller,
|
|
907
|
+
// A legacy `control-changed` carries no revision; keep the last one so a
|
|
908
|
+
// downgrade never looks like the controller was cleared at a newer one.
|
|
909
|
+
// Unrevisioned announcements cannot be ordered against each other, so
|
|
910
|
+
// they stay last-write-wins — the only semantics available for them.
|
|
911
|
+
revision: Math.max(revision, previous?.revision ?? 0),
|
|
912
|
+
});
|
|
913
|
+
for (const [viewId, view] of this.#views) {
|
|
914
|
+
if (view.sessionId !== sessionId)
|
|
915
|
+
continue;
|
|
916
|
+
if (!controller || controller.viewId !== viewId) {
|
|
917
|
+
view.controlEpoch = undefined;
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
view.controlEpoch = controller.controlEpoch;
|
|
921
|
+
if (view.desiredCols !== undefined &&
|
|
922
|
+
view.desiredRows !== undefined &&
|
|
923
|
+
(view.desiredCols !== cols || view.desiredRows !== rows)) {
|
|
924
|
+
this.#sendResize(viewId, view, view.desiredCols, view.desiredRows);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
// A cleared controller is the one case worth re-evaluating: the pane that
|
|
928
|
+
// still holds focus may now take control back.
|
|
929
|
+
for (const viewId of this.#viewIdsForSession(sessionId))
|
|
930
|
+
this.#maybeReclaim(viewId);
|
|
931
|
+
}
|
|
932
|
+
*#viewIdsForSession(sessionId) {
|
|
933
|
+
for (const [viewId, view] of this.#views) {
|
|
934
|
+
if (view.sessionId === sessionId)
|
|
935
|
+
yield viewId;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* The single funnel for taking resize control (§4.2.3). Every condition that
|
|
940
|
+
* gates a claim re-enters here when it changes, because no one event is
|
|
941
|
+
* enough: recovery marks a view attached before its session reaches live, and
|
|
942
|
+
* the focus setter suppresses repeat `true` updates, so a claim keyed on
|
|
943
|
+
* either alone would be skipped and never retried.
|
|
944
|
+
*
|
|
945
|
+
* At most one claim per attachment epoch, plus one more each time the
|
|
946
|
+
* controller is cleared at a newer revision.
|
|
947
|
+
*
|
|
948
|
+
* A host that reports real revisions gets a compare-and-swap, so a claim or
|
|
949
|
+
* a clear that landed since this client last looked rejects the attempt
|
|
950
|
+
* instead of silently overwriting it. The outcome is asymmetric and needs no
|
|
951
|
+
* loop of its own: the daemon announces the resulting controller state, this
|
|
952
|
+
* funnel re-runs on it, and another pane holding control simply fails the
|
|
953
|
+
* guard above while an empty seat at a newer revision passes the one below.
|
|
954
|
+
*/
|
|
955
|
+
#maybeReclaim(viewId) {
|
|
956
|
+
const view = this.#views.get(viewId);
|
|
957
|
+
if (!view || view.readWrite === false)
|
|
958
|
+
return;
|
|
959
|
+
const attachmentEpoch = view.attachmentEpoch;
|
|
960
|
+
if (attachmentEpoch === undefined)
|
|
961
|
+
return;
|
|
962
|
+
if (view.desiredCols === undefined || view.desiredRows === undefined)
|
|
963
|
+
return;
|
|
964
|
+
if (this.#focusByView.get(viewId) !== true)
|
|
965
|
+
return;
|
|
966
|
+
const remote = this.#remoteSessions.get(view.sessionId);
|
|
967
|
+
if (remote && (remote.state !== "live" || remote.awaitingRecoveryFrame))
|
|
968
|
+
return;
|
|
969
|
+
const control = this.#controlBySession.get(view.sessionId);
|
|
970
|
+
const controller = control?.controller ?? null;
|
|
971
|
+
// Never take control from another pane. Our own name on the record is not
|
|
972
|
+
// a reason to stop: after a resume it is the previous incarnation's, and
|
|
973
|
+
// the new attachment epoch below is what decides.
|
|
974
|
+
if (controller && controller.viewId !== viewId)
|
|
975
|
+
return;
|
|
976
|
+
const revision = control?.revision ?? 0;
|
|
977
|
+
// One claim per attachment epoch. A controller *cleared* at a newer
|
|
978
|
+
// revision earns one more; the seat being confirmed as ours does not.
|
|
979
|
+
if (view.claimedEpoch === attachmentEpoch && (controller !== null || view.claimedRevision >= revision))
|
|
980
|
+
return;
|
|
981
|
+
const session = this.#sessionByHandle.get(view.sessionHandle);
|
|
982
|
+
if (!session)
|
|
983
|
+
return;
|
|
984
|
+
view.claimedEpoch = attachmentEpoch;
|
|
985
|
+
view.claimedRevision = revision;
|
|
986
|
+
this.#control?.notify({
|
|
987
|
+
type: "focus-and-resize",
|
|
988
|
+
sessionId: session.id,
|
|
989
|
+
viewId,
|
|
990
|
+
attachmentEpoch,
|
|
991
|
+
cols: view.desiredCols,
|
|
992
|
+
rows: view.desiredRows,
|
|
993
|
+
// Revision 0 is the "legacy, unknown" sentinel, never a real observation
|
|
994
|
+
// to swap against: a host without revisions only understands the
|
|
995
|
+
// unconditional claim, so the field stays off. The negotiated minor is
|
|
996
|
+
// checked too, because a cached revision could outlive a daemon
|
|
997
|
+
// downgrade and a daemon that predates the field would ignore it —
|
|
998
|
+
// turning a swap the caller believes in into a silent overwrite.
|
|
999
|
+
...(this.#controlRevisionCasSupported && revision >= 1 ? { expectedControlRevision: revision } : {}),
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* The only path that mutates per-view attachment state, whether the update
|
|
1004
|
+
* arrived as an event, a reconciliation, or an attach response. A sequence
|
|
1005
|
+
* at or below the last applied one is a delayed transition from an abandoned
|
|
1006
|
+
* attempt and is dropped; an update without a sequence comes from a daemon
|
|
1007
|
+
* older than the fence and applies directly.
|
|
1008
|
+
*/
|
|
1009
|
+
#applyViewState(viewId, update) {
|
|
1010
|
+
const view = this.#views.get(viewId);
|
|
1011
|
+
if (!view)
|
|
1012
|
+
return false;
|
|
1013
|
+
if (update.viewStateSeq !== undefined) {
|
|
1014
|
+
if (view.lastViewStateSeq !== undefined && update.viewStateSeq <= view.lastViewStateSeq)
|
|
1015
|
+
return false;
|
|
1016
|
+
view.lastViewStateSeq = update.viewStateSeq;
|
|
1017
|
+
}
|
|
1018
|
+
const remote = this.#remoteSessions.has(view.sessionId);
|
|
1019
|
+
if (update.viewState === "attached" && update.attachmentEpoch !== null) {
|
|
1020
|
+
// Nothing typed against the previous incarnation may ride out on a
|
|
1021
|
+
// freshly armed epoch.
|
|
1022
|
+
if (remote)
|
|
1023
|
+
view.pendingInput.length = 0;
|
|
1024
|
+
view.attachmentEpoch = update.attachmentEpoch;
|
|
1025
|
+
view.lastAttachmentEpoch = update.attachmentEpoch;
|
|
1026
|
+
if (update.readWrite !== null)
|
|
1027
|
+
view.readWrite = update.readWrite;
|
|
1028
|
+
this.#maybeReclaim(viewId);
|
|
1029
|
+
return true;
|
|
1030
|
+
}
|
|
1031
|
+
view.pendingInput.length = 0;
|
|
1032
|
+
view.attachmentEpoch = undefined;
|
|
1033
|
+
return true;
|
|
1034
|
+
}
|
|
1035
|
+
#applyRemoteSessionState(sessionId, lifecycle, authoritative) {
|
|
1036
|
+
const previous = this.#remoteSessions.get(sessionId);
|
|
1037
|
+
if (previous) {
|
|
1038
|
+
// A reconciliation is the source of truth and may restate its sequence;
|
|
1039
|
+
// a pushed event at a sequence already seen is stale.
|
|
1040
|
+
const stale = authoritative
|
|
1041
|
+
? lifecycle.lifecycleSeq < previous.lifecycleSeq
|
|
1042
|
+
: lifecycle.lifecycleSeq <= previous.lifecycleSeq;
|
|
1043
|
+
if (stale)
|
|
1044
|
+
return false;
|
|
1045
|
+
}
|
|
1046
|
+
// Lifecycle events and frames travel independent channels, so a recovery
|
|
1047
|
+
// snapshot can commit either side of the live transition. An episode's
|
|
1048
|
+
// latch is cleared when it begins and set by any full snapshot, which lets
|
|
1049
|
+
// both arrival orders resolve without either one having to come first.
|
|
1050
|
+
if (showsStaleScreen(lifecycle.state) && (previous === undefined || !showsStaleScreen(previous.state))) {
|
|
1051
|
+
this.#recoveredSessions.delete(sessionId);
|
|
1052
|
+
}
|
|
1053
|
+
// Coming back from a frozen state, the screen on display is still the one
|
|
1054
|
+
// from before the outage. Keep it marked stale until the recovered stream
|
|
1055
|
+
// has committed a full frame; `opening` never had a screen to distrust.
|
|
1056
|
+
const awaitingRecoveryFrame = lifecycle.state === "live" &&
|
|
1057
|
+
previous !== undefined &&
|
|
1058
|
+
(previous.awaitingRecoveryFrame || showsStaleScreen(previous.state)) &&
|
|
1059
|
+
!this.#recoveredSessions.has(sessionId);
|
|
1060
|
+
const state = {
|
|
1061
|
+
...lifecycle,
|
|
1062
|
+
sessionId,
|
|
1063
|
+
observedAt: performance.now(),
|
|
1064
|
+
awaitingRecoveryFrame,
|
|
1065
|
+
};
|
|
1066
|
+
this.#remoteSessions.set(sessionId, state);
|
|
1067
|
+
if (state.state !== "live") {
|
|
1068
|
+
for (const view of this.#views.values()) {
|
|
1069
|
+
if (view.sessionId === sessionId)
|
|
1070
|
+
view.pendingInput.length = 0;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
this.#setCursorFrozen(sessionId, sessionIsFrozen(state));
|
|
1074
|
+
this.dispatchEvent(new CustomEvent("remote-session-state", { detail: state }));
|
|
1075
|
+
for (const viewId of this.#viewIdsForSession(sessionId))
|
|
1076
|
+
this.#maybeReclaim(viewId);
|
|
1077
|
+
return true;
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* A committed frame is only evidence of recovery if it carries a whole
|
|
1081
|
+
* screen: a partial update repaints part of the stale one, which is exactly
|
|
1082
|
+
* what would thaw a pane too early.
|
|
1083
|
+
*/
|
|
1084
|
+
#recordCommittedFrame(sessionHandle, fullSnapshot) {
|
|
1085
|
+
if (!fullSnapshot)
|
|
1086
|
+
return;
|
|
1087
|
+
const sessionId = this.#sessionByHandle.get(sessionHandle)?.id;
|
|
1088
|
+
if (sessionId === undefined || !this.#remoteSessions.has(sessionId))
|
|
1089
|
+
return;
|
|
1090
|
+
this.#recoveredSessions.add(sessionId);
|
|
1091
|
+
this.#thawRecoveredSession(sessionId);
|
|
1092
|
+
}
|
|
1093
|
+
/** What the pane shows is current again, so stop marking it stale. */
|
|
1094
|
+
#thawRecoveredSession(sessionId) {
|
|
1095
|
+
const state = this.#remoteSessions.get(sessionId);
|
|
1096
|
+
if (!state || !state.awaitingRecoveryFrame || !this.#recoveredSessions.has(sessionId))
|
|
1097
|
+
return;
|
|
1098
|
+
const thawed = { ...state, awaitingRecoveryFrame: false };
|
|
1099
|
+
this.#remoteSessions.set(sessionId, thawed);
|
|
1100
|
+
this.#setCursorFrozen(sessionId, sessionIsFrozen(thawed));
|
|
1101
|
+
this.dispatchEvent(new CustomEvent("remote-session-state", { detail: thawed }));
|
|
1102
|
+
for (const viewId of this.#viewIdsForSession(sessionId))
|
|
1103
|
+
this.#maybeReclaim(viewId);
|
|
1104
|
+
}
|
|
1105
|
+
/** Hold the cursor steady while the replica is frozen, without disturbing focus. */
|
|
1106
|
+
#setCursorFrozen(sessionId, frozen) {
|
|
1107
|
+
for (const [viewId, view] of this.#views) {
|
|
1108
|
+
if (view.sessionId === sessionId)
|
|
1109
|
+
this.#postWorker({ type: "cursor-frozen", surfaceId: viewId, frozen });
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
async #remoteSessionStateRequest(command, timeoutMs) {
|
|
1113
|
+
await this.connect();
|
|
1114
|
+
if (!this.#remoteLifecycleSupported)
|
|
1115
|
+
return undefined;
|
|
1116
|
+
const response = await this.#control.request(command, timeoutMs);
|
|
1117
|
+
if (response.type !== "remote-session-state") {
|
|
1118
|
+
throw new Error("ghosttead returned an unexpected remote session state");
|
|
1119
|
+
}
|
|
1120
|
+
this.#applyRemoteSessionState(command.sessionId, response, true);
|
|
1121
|
+
this.#applyController(command.sessionId, response.controller, response.cols, response.rows, response.controlRevision);
|
|
1122
|
+
for (const view of response.views)
|
|
1123
|
+
this.#applyViewState(view.viewId, view);
|
|
1124
|
+
return this.#remoteSessions.get(command.sessionId);
|
|
1125
|
+
}
|
|
1126
|
+
/** Rebuild this client's state for one remote session from the daemon. */
|
|
1127
|
+
async getRemoteSessionState(sessionId) {
|
|
1128
|
+
return this.#remoteSessionStateRequest({ type: "get-remote-session-state", sessionId }, 10_000);
|
|
1129
|
+
}
|
|
1130
|
+
async reconnectRemoteSession(sessionId) {
|
|
1131
|
+
return this.#remoteSessionStateRequest({ type: "reconnect-remote-session", sessionId }, RECONNECT_REQUEST_TIMEOUT_MS);
|
|
1132
|
+
}
|
|
1133
|
+
async retryRemoteView(sessionId, viewId) {
|
|
1134
|
+
await this.connect();
|
|
1135
|
+
if (!this.#remoteLifecycleSupported)
|
|
1136
|
+
return;
|
|
1137
|
+
const response = await this.#control.request({ type: "retry-remote-view", sessionId, viewId }, RECONNECT_REQUEST_TIMEOUT_MS);
|
|
1138
|
+
if (response.type !== "view-state")
|
|
1139
|
+
throw new Error("ghosttead returned an unexpected view state");
|
|
1140
|
+
this.#applyViewState(response.viewId, response);
|
|
1141
|
+
}
|
|
1142
|
+
#reconcileRemoteSessions() {
|
|
1143
|
+
if (!this.#remoteLifecycleSupported)
|
|
1144
|
+
return Promise.resolve();
|
|
1145
|
+
return Promise.all([...this.#remoteSessions.keys()].map((sessionId) => this.getRemoteSessionState(sessionId).catch((error) => {
|
|
1146
|
+
if (!this.#disposed) {
|
|
1147
|
+
console.warn(`[terminal-runtime] could not reconcile remote session ${sessionId}`, error);
|
|
1148
|
+
}
|
|
1149
|
+
})));
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* `silent` marks operations that drop without the keystroke hint: focus and
|
|
1153
|
+
* geometry updates are cosmetic, and pointer gestures are already answered
|
|
1154
|
+
* by the pane's frozen treatment.
|
|
1155
|
+
*/
|
|
1156
|
+
#sendViewInput(viewId, operation, silent = false) {
|
|
716
1157
|
const view = this.#views.get(viewId);
|
|
717
1158
|
if (!view || view.readWrite === false)
|
|
718
1159
|
return;
|
|
719
|
-
|
|
1160
|
+
const remote = this.#remoteSessions.get(view.sessionId);
|
|
1161
|
+
const attachmentEpoch = view.attachmentEpoch;
|
|
1162
|
+
// Input for a remote session is dropped with feedback rather than queued:
|
|
1163
|
+
// replaying a keystroke across an outage would deliver it to a screen the
|
|
1164
|
+
// user has never seen. The queue below survives only for a local session's
|
|
1165
|
+
// mount-to-attach gap, which is a local IPC round-trip.
|
|
1166
|
+
if (remote && (remote.state !== "live" || attachmentEpoch === undefined)) {
|
|
1167
|
+
view.pendingInput.length = 0;
|
|
1168
|
+
if (!silent)
|
|
1169
|
+
this.#reportSuppressedInput(view.sessionId, viewId, remote.state);
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
if (attachmentEpoch === undefined) {
|
|
720
1173
|
if (view.pendingInput.length < 256)
|
|
721
1174
|
view.pendingInput.push(operation);
|
|
722
1175
|
return;
|
|
723
1176
|
}
|
|
724
1177
|
view.inputSequence += 1;
|
|
725
|
-
operation(
|
|
1178
|
+
operation(attachmentEpoch, view.inputSequence);
|
|
1179
|
+
}
|
|
1180
|
+
#reportSuppressedInput(sessionId, viewId, state) {
|
|
1181
|
+
this.dispatchEvent(new CustomEvent("input-suppressed", { detail: { sessionId, viewId, state } }));
|
|
726
1182
|
}
|
|
727
1183
|
sendText(sessionId, viewId, text) {
|
|
728
1184
|
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "send-text", sessionId, viewId, attachmentEpoch, inputSequence, text }));
|
|
@@ -743,17 +1199,18 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
743
1199
|
this.#postWorker({ type: "cursor-activity", sessionHandle: handle });
|
|
744
1200
|
}
|
|
745
1201
|
sendMouse(sessionId, viewId, event) {
|
|
746
|
-
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "send-mouse", sessionId, viewId, attachmentEpoch, inputSequence, event }));
|
|
1202
|
+
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "send-mouse", sessionId, viewId, attachmentEpoch, inputSequence, event }), true);
|
|
747
1203
|
}
|
|
748
1204
|
scroll(sessionId, viewId, rows) {
|
|
749
1205
|
if (rows === 0)
|
|
750
1206
|
return;
|
|
751
|
-
|
|
1207
|
+
// Scrolling is host-side input, so it is simply inert while frozen.
|
|
1208
|
+
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "scroll", sessionId, viewId, attachmentEpoch, inputSequence, rows }), true);
|
|
752
1209
|
}
|
|
753
1210
|
scrollTo(sessionId, viewId, row) {
|
|
754
1211
|
if (!Number.isSafeInteger(row) || row < 0)
|
|
755
1212
|
return;
|
|
756
|
-
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "scroll-to", sessionId, viewId, attachmentEpoch, inputSequence, row }));
|
|
1213
|
+
this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "scroll-to", sessionId, viewId, attachmentEpoch, inputSequence, row }), true);
|
|
757
1214
|
}
|
|
758
1215
|
scrollbar(sessionHandle) {
|
|
759
1216
|
return this.#scrollbarByHandle.get(sessionHandle);
|
|
@@ -779,6 +1236,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
779
1236
|
cursor: rgb(theme.cursor),
|
|
780
1237
|
});
|
|
781
1238
|
}
|
|
1239
|
+
setEffects(sessionHandle, effects, surfaceId) {
|
|
1240
|
+
this.#postWorker({ type: "effects", sessionHandle, ...(surfaceId ? { surfaceId } : {}), effects });
|
|
1241
|
+
}
|
|
782
1242
|
setSelection(sessionHandle, selection, surfaceId) {
|
|
783
1243
|
this.#postWorker({ type: "selection", sessionHandle, ...(surfaceId ? { surfaceId } : {}), selection });
|
|
784
1244
|
}
|
|
@@ -799,6 +1259,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
799
1259
|
if (view) {
|
|
800
1260
|
view.desiredCols = cols;
|
|
801
1261
|
view.desiredRows = rows;
|
|
1262
|
+
// An explicit claim is the funnel's outcome, not a competing path.
|
|
1263
|
+
view.claimedEpoch = view.attachmentEpoch;
|
|
1264
|
+
view.claimedRevision = this.#controlBySession.get(view.sessionId)?.revision ?? 0;
|
|
802
1265
|
}
|
|
803
1266
|
const session = this.#sessionByHandle.get(sessionHandle);
|
|
804
1267
|
if (!session)
|
|
@@ -812,7 +1275,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
812
1275
|
cols,
|
|
813
1276
|
rows,
|
|
814
1277
|
});
|
|
815
|
-
});
|
|
1278
|
+
}, true);
|
|
816
1279
|
}
|
|
817
1280
|
setFocused(sessionHandle, viewId, focused, cols, rows) {
|
|
818
1281
|
const view = this.#views.get(viewId);
|
|
@@ -820,8 +1283,13 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
820
1283
|
view.desiredCols = cols;
|
|
821
1284
|
view.desiredRows = rows;
|
|
822
1285
|
}
|
|
823
|
-
if (this.#focusByView.get(viewId) === focused)
|
|
1286
|
+
if (this.#focusByView.get(viewId) === focused) {
|
|
1287
|
+
// Focus has not moved, so the claim below will not run — but an epoch or
|
|
1288
|
+
// controller change since the last update may have made one possible,
|
|
1289
|
+
// and nothing else would ever retry it after a resume.
|
|
1290
|
+
this.#maybeReclaim(viewId);
|
|
824
1291
|
return;
|
|
1292
|
+
}
|
|
825
1293
|
this.#focusByView.set(viewId, focused);
|
|
826
1294
|
this.#postWorker({ type: "focus", surfaceId: viewId, sessionHandle, focused });
|
|
827
1295
|
const session = this.#sessionByHandle.get(sessionHandle);
|
|
@@ -837,6 +1305,11 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
837
1305
|
focused,
|
|
838
1306
|
});
|
|
839
1307
|
if (focused) {
|
|
1308
|
+
// Taking focus is a deliberate claim, and counts as this epoch's.
|
|
1309
|
+
if (view) {
|
|
1310
|
+
view.claimedEpoch = attachmentEpoch;
|
|
1311
|
+
view.claimedRevision = this.#controlBySession.get(view.sessionId)?.revision ?? 0;
|
|
1312
|
+
}
|
|
840
1313
|
this.#control?.notify({
|
|
841
1314
|
type: "focus-and-resize",
|
|
842
1315
|
sessionId: session.id,
|
|
@@ -846,18 +1319,24 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
846
1319
|
rows,
|
|
847
1320
|
});
|
|
848
1321
|
}
|
|
849
|
-
});
|
|
1322
|
+
}, true);
|
|
850
1323
|
}
|
|
851
1324
|
async copySelection(sessionId, viewId, selection, selectAll = false) {
|
|
852
1325
|
await this.connect();
|
|
853
1326
|
const view = this.#views.get(viewId);
|
|
854
|
-
if (!view || view.sessionId !== sessionId
|
|
1327
|
+
if (!view || view.sessionId !== sessionId)
|
|
1328
|
+
return "";
|
|
1329
|
+
// A frozen replica stays copyable: offline the daemon answers from the
|
|
1330
|
+
// retained snapshot and authorizes by ownership, so the last epoch this
|
|
1331
|
+
// view held is enough to name the attachment.
|
|
1332
|
+
const attachmentEpoch = view.attachmentEpoch ?? view.lastAttachmentEpoch;
|
|
1333
|
+
if (attachmentEpoch === undefined)
|
|
855
1334
|
return "";
|
|
856
1335
|
const response = await this.#control.request({
|
|
857
1336
|
type: "selection-text",
|
|
858
1337
|
sessionId,
|
|
859
1338
|
viewId,
|
|
860
|
-
attachmentEpoch
|
|
1339
|
+
attachmentEpoch,
|
|
861
1340
|
startColumn: selection.anchor.column,
|
|
862
1341
|
startRow: selection.anchor.row,
|
|
863
1342
|
endColumn: selection.focus.column,
|
|
@@ -866,6 +1345,13 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
866
1345
|
});
|
|
867
1346
|
if (response.type !== "selection-text")
|
|
868
1347
|
throw new Error("ghosttead returned an unexpected selection response");
|
|
1348
|
+
// Offline the daemon can only reach the retained screen, so say so rather
|
|
1349
|
+
// than letting a short copy read as the whole scrollback.
|
|
1350
|
+
if (response.scope !== undefined) {
|
|
1351
|
+
this.dispatchEvent(new CustomEvent("selection-scope", {
|
|
1352
|
+
detail: { sessionId, viewId, scope: response.scope },
|
|
1353
|
+
}));
|
|
1354
|
+
}
|
|
869
1355
|
if (response.text)
|
|
870
1356
|
this.#platform.writeClipboard(response.text);
|
|
871
1357
|
return response.text;
|
|
@@ -920,6 +1406,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
920
1406
|
}
|
|
921
1407
|
this.#sessionByHandle.delete(handle);
|
|
922
1408
|
this.#handleBySessionId.delete(sessionId);
|
|
1409
|
+
this.#remoteSessions.delete(sessionId);
|
|
1410
|
+
this.#controlBySession.delete(sessionId);
|
|
1411
|
+
this.#recoveredSessions.delete(sessionId);
|
|
923
1412
|
if (subscriptionChanged) {
|
|
924
1413
|
this.#frameSubscriptionVersion += 1;
|
|
925
1414
|
this.#syncFrameSubscriptionsInBackground();
|
|
@@ -940,8 +1429,12 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
940
1429
|
return;
|
|
941
1430
|
view.desiredCols = cols;
|
|
942
1431
|
view.desiredRows = rows;
|
|
943
|
-
if (view.attachmentEpoch === undefined || view.controlEpoch === undefined)
|
|
1432
|
+
if (view.attachmentEpoch === undefined || view.controlEpoch === undefined) {
|
|
1433
|
+
// Dimensions are one of the funnel's conditions: a pane that measured
|
|
1434
|
+
// itself while uncontrolled may now be able to take control.
|
|
1435
|
+
this.#maybeReclaim(viewId);
|
|
944
1436
|
return;
|
|
1437
|
+
}
|
|
945
1438
|
this.#sendResize(viewId, view, cols, rows);
|
|
946
1439
|
}
|
|
947
1440
|
dispose() {
|
|
@@ -977,6 +1470,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
|
|
|
977
1470
|
}
|
|
978
1471
|
this.#performanceRequests.clear();
|
|
979
1472
|
this.#views.clear();
|
|
1473
|
+
this.#remoteSessions.clear();
|
|
1474
|
+
this.#controlBySession.clear();
|
|
1475
|
+
this.#recoveredSessions.clear();
|
|
980
1476
|
this.#focusByView.clear();
|
|
981
1477
|
this.#mountGenerationBySurface.clear();
|
|
982
1478
|
this.#mouseTrackingByHandle.clear();
|