@vibecook/ghosttea-react 0.11.0 → 0.12.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.
Files changed (64) hide show
  1. package/README.md +44 -0
  2. package/dist/TerminalSurface.d.ts.map +1 -1
  3. package/dist/TerminalSurface.js +31 -4
  4. package/dist/TerminalSurface.js.map +1 -1
  5. package/dist/appearance/AppearanceSettings.d.ts +3 -2
  6. package/dist/appearance/AppearanceSettings.d.ts.map +1 -1
  7. package/dist/appearance/AppearanceSettings.js +3 -2
  8. package/dist/appearance/AppearanceSettings.js.map +1 -1
  9. package/dist/appearance/BackdropBlurControl.d.ts +5 -0
  10. package/dist/appearance/BackdropBlurControl.d.ts.map +1 -0
  11. package/dist/appearance/BackdropBlurControl.js +42 -0
  12. package/dist/appearance/BackdropBlurControl.js.map +1 -0
  13. package/dist/appearance/types.d.ts +6 -0
  14. package/dist/appearance/types.d.ts.map +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/link-input.d.ts +18 -0
  19. package/dist/link-input.d.ts.map +1 -0
  20. package/dist/link-input.js +27 -0
  21. package/dist/link-input.js.map +1 -0
  22. package/dist/renderers/background-geometry.d.ts +16 -0
  23. package/dist/renderers/background-geometry.d.ts.map +1 -0
  24. package/dist/renderers/background-geometry.js +20 -0
  25. package/dist/renderers/background-geometry.js.map +1 -0
  26. package/dist/renderers/types.d.ts +2 -0
  27. package/dist/renderers/types.d.ts.map +1 -1
  28. package/dist/renderers/types.js.map +1 -1
  29. package/dist/renderers/webgpu-renderer.d.ts.map +1 -1
  30. package/dist/renderers/webgpu-renderer.js +10 -2
  31. package/dist/renderers/webgpu-renderer.js.map +1 -1
  32. package/dist/routed-control.d.ts +10 -0
  33. package/dist/routed-control.d.ts.map +1 -1
  34. package/dist/routed-control.js +14 -0
  35. package/dist/routed-control.js.map +1 -1
  36. package/dist/runtime.d.ts +30 -2
  37. package/dist/runtime.d.ts.map +1 -1
  38. package/dist/runtime.js +244 -54
  39. package/dist/runtime.js.map +1 -1
  40. package/dist/styles.css +13 -0
  41. package/dist/terminal-render.worker.js +71 -9
  42. package/dist/terminal-render.worker.js.map +3 -3
  43. package/dist/use-terminal-links.d.ts +17 -0
  44. package/dist/use-terminal-links.d.ts.map +1 -0
  45. package/dist/use-terminal-links.js +101 -0
  46. package/dist/use-terminal-links.js.map +1 -0
  47. package/dist/worker-messages.d.ts +5 -0
  48. package/dist/worker-messages.d.ts.map +1 -1
  49. package/dist/workspace/Workspace.d.ts +6 -3
  50. package/dist/workspace/Workspace.d.ts.map +1 -1
  51. package/dist/workspace/Workspace.js +18 -9
  52. package/dist/workspace/Workspace.js.map +1 -1
  53. package/dist/workspace/index.d.ts +2 -2
  54. package/dist/workspace/index.d.ts.map +1 -1
  55. package/dist/workspace/index.js.map +1 -1
  56. package/dist/workspace/pane-focus.d.ts +8 -0
  57. package/dist/workspace/pane-focus.d.ts.map +1 -0
  58. package/dist/workspace/pane-focus.js +24 -0
  59. package/dist/workspace/pane-focus.js.map +1 -0
  60. package/dist/workspace/pane-layout.d.ts +2 -1
  61. package/dist/workspace/pane-layout.d.ts.map +1 -1
  62. package/dist/workspace/pane-layout.js +29 -2
  63. package/dist/workspace/pane-layout.js.map +1 -1
  64. package/package.json +4 -4
package/dist/runtime.js CHANGED
@@ -1,9 +1,10 @@
1
+ import { terminalLinkUrl } from "@vibecook/ghosttea-protocol";
1
2
  import { ControlClient } from "@vibecook/ghosttea";
2
3
  import { DEFAULT_ROUTED_PROTOCOL_LIMITS, PROTOCOL_MAJOR, PROTOCOL_MINOR, SESSION_SCROLLBACK_PROTOCOL_MINOR, STRUCTURED_ERROR_PROTOCOL_MINOR, isRoutedSessionAttachGrant, isRoutedTerminalOpenTicket, isValidScrollbackBytes, } from "@vibecook/ghosttea-protocol";
3
4
  import { FRAME_MAGIC, FrameFlag } from "@vibecook/ghosttea-frame";
4
5
  import { FrameResyncController } from "./frame-resync.js";
5
6
  import { initialRoutedActivation, reduceRoutedActivation, } from "./routed-activation.js";
6
- import { RoutedControlTransport } from "./routed-control.js";
7
+ import { RoutedControlTransport, } from "./routed-control.js";
7
8
  const MAX_BROWSER_TIMEOUT_MS = 2_147_483_647;
8
9
  function routedConnectionRefusalIsRecoverable(refusal) {
9
10
  return refusal.retryable || refusal.code === "GRANT_GENERATION_ROLLBACK" || refusal.code === "GRANT_NONCE_REPLAYED";
@@ -101,10 +102,16 @@ export class GhostteaTerminalRuntime extends EventTarget {
101
102
  #configSnapshot;
102
103
  #configProtocolSupported = false;
103
104
  #metadataTimers = new Map();
105
+ #metadataRefreshes = new Map();
106
+ #metadataRefreshPending = new Set();
107
+ #sessionGenerationByHandle = new Map();
108
+ #appliedRoutedExitEvents = new Set();
109
+ #sessionGeneration = 0;
104
110
  #resync;
105
111
  #performanceRequestId = 1;
106
112
  #performanceRequests = new Map();
107
113
  #counterRequests = new Map();
114
+ #linksByHandle = new Map();
108
115
  #disposed = false;
109
116
  constructor(options) {
110
117
  super();
@@ -119,6 +126,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
119
126
  options.transport === "routed"
120
127
  ? new RoutedControlTransport({
121
128
  ...(options.websocketFactory === undefined ? {} : { socketFactory: options.websocketFactory }),
129
+ acceptExtensionMessages: options.host.onExtensionMessage !== undefined,
122
130
  emit: (event) => this.#handleRoutedControlEvent(event),
123
131
  })
124
132
  : undefined;
@@ -149,6 +157,10 @@ export class GhostteaTerminalRuntime extends EventTarget {
149
157
  else if (data.type === "clipboard-write") {
150
158
  this.#platform.writeClipboard(data.text);
151
159
  }
160
+ else if (data.type === "link-targets") {
161
+ this.#linksByHandle.set(data.sessionHandle, data.links.filter((link) => terminalLinkUrl(link.uri) !== null));
162
+ this.dispatchEvent(new CustomEvent("link-targets", { detail: { sessionHandle: data.sessionHandle } }));
163
+ }
152
164
  else if (data.type === "scrollbar-state") {
153
165
  this.#scrollbarByHandle.set(data.sessionHandle, data.scrollbar);
154
166
  this.dispatchEvent(new CustomEvent("scrollbar-state", {
@@ -162,6 +174,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
162
174
  this.#resync.complete(data.sessionHandle);
163
175
  }
164
176
  else if (data.type === "frame-committed") {
177
+ if (this.#routedHost?.getSession)
178
+ this.#scheduleMetadataRefresh(data.sessionHandle);
165
179
  this.#recordCommittedFrame(data.sessionHandle, data.fullSnapshot);
166
180
  }
167
181
  else if (data.type === "catalog-pressure") {
@@ -282,22 +296,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
282
296
  this.#control = new ControlClient(ports.control);
283
297
  this.#control.addEventListener("session-exited", (event) => {
284
298
  const detail = event.detail;
285
- const handle = this.#handleBySessionId.get(detail.sessionId);
286
- const session = handle ? this.#sessionByHandle.get(handle) : undefined;
287
- if (!handle || !session)
288
- return;
289
- this.#cancelMetadataRefresh(handle);
290
- const exited = {
291
- ...session,
292
- exited: true,
293
- exitCode: detail.exitCode,
294
- exitSignal: detail.exitSignal,
295
- requestedTermination: detail.requestedTermination,
296
- exitOutcome: detail.exitOutcome,
297
- };
298
- this.#sessionByHandle.set(handle, exited);
299
- this.dispatchEvent(new CustomEvent("session-metadata", { detail: exited }));
300
- this.dispatchEvent(new CustomEvent("session-exited", { detail }));
299
+ this.#applySessionExited(detail);
301
300
  });
302
301
  this.#control.addEventListener("events-lost", () => {
303
302
  // The daemon dropped events faster than this client drained them. Any
@@ -307,14 +306,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
307
306
  });
308
307
  this.#control.addEventListener("session-activity-changed", (event) => {
309
308
  const detail = event.detail;
310
- const handle = this.#handleBySessionId.get(detail.sessionId);
311
- const session = handle ? this.#sessionByHandle.get(handle) : undefined;
312
- if (!handle || !session || session.exited)
313
- return;
314
- const updated = { ...session, activity: detail.activity };
315
- this.#sessionByHandle.set(handle, updated);
316
- this.dispatchEvent(new CustomEvent("session-activity", { detail }));
317
- this.dispatchEvent(new CustomEvent("session-metadata", { detail: updated }));
309
+ this.#applySessionActivity(detail);
318
310
  });
319
311
  this.#control.addEventListener("control-changed", (event) => {
320
312
  const detail = event.detail;
@@ -464,54 +456,196 @@ export class GhostteaTerminalRuntime extends EventTarget {
464
456
  if (this.#frameFlowControlEnabled)
465
457
  this.#frames?.postMessage({ type: "frame-credit", bytes });
466
458
  }
459
+ #bumpSessionGeneration(sessionHandle) {
460
+ this.#sessionGeneration += 1;
461
+ this.#sessionGenerationByHandle.set(sessionHandle, this.#sessionGeneration);
462
+ return this.#sessionGeneration;
463
+ }
464
+ #applySessionExited(detail) {
465
+ const handle = this.#handleBySessionId.get(detail.sessionId);
466
+ const session = handle ? this.#sessionByHandle.get(handle) : undefined;
467
+ if (!handle || !session)
468
+ return;
469
+ this.#cancelMetadataRefresh(handle);
470
+ const exited = {
471
+ ...session,
472
+ exited: true,
473
+ exitCode: detail.exitCode,
474
+ exitSignal: detail.exitSignal,
475
+ requestedTermination: detail.requestedTermination,
476
+ exitOutcome: detail.exitOutcome,
477
+ };
478
+ this.#sessionByHandle.set(handle, exited);
479
+ this.#bumpSessionGeneration(handle);
480
+ this.dispatchEvent(new CustomEvent("session-metadata", { detail: exited }));
481
+ this.dispatchEvent(new CustomEvent("session-exited", { detail }));
482
+ }
483
+ #applySessionActivity(detail) {
484
+ const handle = this.#handleBySessionId.get(detail.sessionId);
485
+ const session = handle ? this.#sessionByHandle.get(handle) : undefined;
486
+ if (!handle || !session || session.exited)
487
+ return;
488
+ const updated = { ...session, activity: detail.activity };
489
+ this.#sessionByHandle.set(handle, updated);
490
+ this.#bumpSessionGeneration(handle);
491
+ this.dispatchEvent(new CustomEvent("session-activity", { detail }));
492
+ this.dispatchEvent(new CustomEvent("session-metadata", { detail: updated }));
493
+ }
494
+ #applyRoutedSessionSummary(sessionHandle, next, expectedGeneration) {
495
+ const previous = this.#sessionByHandle.get(sessionHandle);
496
+ if (!previous ||
497
+ previous.exited ||
498
+ previous.id !== next.id ||
499
+ next.handle !== sessionHandle ||
500
+ this.#handleBySessionId.get(next.id) !== sessionHandle ||
501
+ (expectedGeneration !== undefined && this.#sessionGenerationByHandle.get(sessionHandle) !== expectedGeneration)) {
502
+ return false;
503
+ }
504
+ this.#sessionByHandle.set(sessionHandle, next);
505
+ this.#bumpSessionGeneration(sessionHandle);
506
+ if (previous.title !== next.title ||
507
+ previous.cwd !== next.cwd ||
508
+ previous.exited !== next.exited ||
509
+ !sameSessionActivity(previous.activity, next.activity)) {
510
+ this.dispatchEvent(new CustomEvent("session-metadata", { detail: next }));
511
+ }
512
+ return true;
513
+ }
467
514
  #scheduleMetadataRefresh(sessionHandle) {
468
515
  const scheduledSession = this.#sessionByHandle.get(sessionHandle);
469
516
  if (!scheduledSession || scheduledSession.exited)
470
517
  return;
518
+ if (this.#routedHost?.getSession && this.#metadataRefreshes.has(sessionHandle)) {
519
+ this.#metadataRefreshPending.add(sessionHandle);
520
+ return;
521
+ }
471
522
  if (this.#metadataTimers.has(sessionHandle))
472
523
  return;
473
524
  const timer = window.setTimeout(() => {
474
525
  this.#metadataTimers.delete(sessionHandle);
475
526
  const session = this.#sessionByHandle.get(sessionHandle);
476
- if (!session || session.exited || !this.#control)
527
+ if (!session || session.exited)
477
528
  return;
478
- void this.#control
479
- .request({ type: "get-session", sessionId: session.id })
480
- .then((response) => {
481
- if (response.type !== "session")
482
- return;
483
- const previous = this.#sessionByHandle.get(sessionHandle);
484
- if (!previous || previous.id !== response.session.id || previous.exited)
485
- return;
486
- this.#sessionByHandle.set(sessionHandle, response.session);
487
- if (previous.title !== response.session.title ||
488
- previous.cwd !== response.session.cwd ||
489
- previous.exited !== response.session.exited ||
490
- !sameSessionActivity(previous.activity, response.session.activity)) {
491
- this.dispatchEvent(new CustomEvent("session-metadata", { detail: response.session }));
492
- }
493
- })
494
- .catch((error) => {
495
- const current = this.#sessionByHandle.get(sessionHandle);
496
- if (!current || current.exited || this.#disposed)
497
- return;
498
- console.warn("[terminal-runtime] session metadata refresh failed", error);
499
- });
529
+ if (this.#control) {
530
+ void this.#control
531
+ .request({ type: "get-session", sessionId: session.id })
532
+ .then((response) => {
533
+ if (response.type !== "session")
534
+ return;
535
+ const previous = this.#sessionByHandle.get(sessionHandle);
536
+ if (!previous || previous.id !== response.session.id || previous.exited)
537
+ return;
538
+ this.#sessionByHandle.set(sessionHandle, response.session);
539
+ if (previous.title !== response.session.title ||
540
+ previous.cwd !== response.session.cwd ||
541
+ previous.exited !== response.session.exited ||
542
+ !sameSessionActivity(previous.activity, response.session.activity)) {
543
+ this.dispatchEvent(new CustomEvent("session-metadata", { detail: response.session }));
544
+ }
545
+ })
546
+ .catch((error) => {
547
+ const current = this.#sessionByHandle.get(sessionHandle);
548
+ if (!current || current.exited || this.#disposed)
549
+ return;
550
+ console.warn("[terminal-runtime] session metadata refresh failed", error);
551
+ });
552
+ return;
553
+ }
554
+ if (this.#routedHost?.getSession)
555
+ this.#startRoutedMetadataRefresh(sessionHandle, session);
500
556
  }, 200);
501
557
  this.#metadataTimers.set(sessionHandle, timer);
502
558
  }
559
+ #startRoutedMetadataRefresh(sessionHandle, session) {
560
+ const host = this.#routedHost;
561
+ const getSession = host?.getSession;
562
+ if (!host || !getSession || this.#disposed)
563
+ return;
564
+ if (this.#metadataRefreshes.has(sessionHandle)) {
565
+ this.#metadataRefreshPending.add(sessionHandle);
566
+ return;
567
+ }
568
+ const expectedGeneration = this.#sessionGenerationByHandle.get(sessionHandle);
569
+ const refresh = Promise.resolve()
570
+ .then(() => getSession.call(host, session.id))
571
+ .then((next) => {
572
+ if (next === null)
573
+ return;
574
+ this.#applyRoutedSessionSummary(sessionHandle, next, expectedGeneration);
575
+ })
576
+ .catch((error) => {
577
+ const current = this.#sessionByHandle.get(sessionHandle);
578
+ if (!current || current.exited || this.#disposed)
579
+ return;
580
+ console.warn("[terminal-runtime] session metadata refresh failed", error);
581
+ })
582
+ .finally(() => {
583
+ if (this.#metadataRefreshes.get(sessionHandle) !== refresh)
584
+ return;
585
+ this.#metadataRefreshes.delete(sessionHandle);
586
+ if (!this.#metadataRefreshPending.delete(sessionHandle) || this.#disposed)
587
+ return;
588
+ const current = this.#sessionByHandle.get(sessionHandle);
589
+ if (current && !current.exited)
590
+ this.#scheduleMetadataRefresh(sessionHandle);
591
+ });
592
+ this.#metadataRefreshes.set(sessionHandle, refresh);
593
+ }
503
594
  #cancelMetadataRefresh(sessionHandle) {
504
595
  const timer = this.#metadataTimers.get(sessionHandle);
505
596
  if (timer !== undefined)
506
597
  window.clearTimeout(timer);
507
598
  this.#metadataTimers.delete(sessionHandle);
599
+ this.#metadataRefreshPending.delete(sessionHandle);
508
600
  }
509
601
  sessionMetadata(sessionHandle) {
510
602
  return this.#sessionByHandle.get(sessionHandle);
511
603
  }
512
604
  registerSession(session) {
605
+ const previous = this.#sessionByHandle.get(session.handle);
606
+ if (!previous || previous.id !== session.id || !previous.exited || !session.exited) {
607
+ this.#appliedRoutedExitEvents.delete(session.handle);
608
+ }
513
609
  this.#sessionByHandle.set(session.handle, session);
514
610
  this.#handleBySessionId.set(session.id, session.handle);
611
+ this.#bumpSessionGeneration(session.handle);
612
+ }
613
+ applySessionEvent(event) {
614
+ if (this.#disposed)
615
+ return;
616
+ if (event.type === "updated") {
617
+ const handle = this.#handleBySessionId.get(event.session.id);
618
+ if (handle)
619
+ this.#applyRoutedSessionSummary(handle, event.session);
620
+ return;
621
+ }
622
+ if (event.type === "activity-changed") {
623
+ this.#applySessionActivity({
624
+ requestId: 0,
625
+ type: "session-activity-changed",
626
+ sessionId: event.sessionId,
627
+ activity: event.activity,
628
+ });
629
+ return;
630
+ }
631
+ if (event.type === "exited") {
632
+ const handle = this.#handleBySessionId.get(event.sessionId);
633
+ const current = handle ? this.#sessionByHandle.get(handle) : undefined;
634
+ if (!handle || !current || this.#appliedRoutedExitEvents.has(handle))
635
+ return;
636
+ this.#appliedRoutedExitEvents.add(handle);
637
+ this.#applySessionExited({
638
+ requestId: 0,
639
+ type: "session-exited",
640
+ sessionId: event.sessionId,
641
+ exitCode: event.exitCode,
642
+ exitSignal: event.exitSignal,
643
+ requestedTermination: event.requestedTermination,
644
+ exitOutcome: event.exitOutcome,
645
+ });
646
+ return;
647
+ }
648
+ this.unregisterSession(event.sessionId);
515
649
  }
516
650
  #queueFrameSubscriptionSync() {
517
651
  const frames = this.#frames;
@@ -1186,6 +1320,18 @@ export class GhostteaTerminalRuntime extends EventTarget {
1186
1320
  #handleRoutedControlEvent(event) {
1187
1321
  if (this.#disposed)
1188
1322
  return;
1323
+ if (event.type === "extension-message") {
1324
+ const host = this.#routedHost;
1325
+ if (!host?.onExtensionMessage)
1326
+ return;
1327
+ try {
1328
+ host.onExtensionMessage(event.message, event.context);
1329
+ }
1330
+ catch (error) {
1331
+ console.error("[terminal-runtime] routed extension handler failed", error);
1332
+ }
1333
+ return;
1334
+ }
1189
1335
  if (event.type === "transport-ready") {
1190
1336
  const deadline = event.accepted.protocolLimits.activationAttachDeadlineMs;
1191
1337
  this.#routedAttachDeadlineByCell.set(event.cellBootId, deadline);
@@ -1664,6 +1810,29 @@ export class GhostteaTerminalRuntime extends EventTarget {
1664
1810
  this.#routedByActivation.delete(entry.state.activationId);
1665
1811
  this.#routedGeometry.delete(sessionId);
1666
1812
  }
1813
+ /**
1814
+ * Detach one of our views and let the seat go with it.
1815
+ *
1816
+ * The daemon clears the resize controller when its holder detaches, but a
1817
+ * local session announces control only through the legacy `control-changed`
1818
+ * frame, which cannot say "no controller": the clear never reaches this
1819
+ * client, and a pane that remounts (a split re-parents its surface) would
1820
+ * see its own previous incarnation on the record forever and never claim.
1821
+ * Our own detach is the one clear this client can predict, so the record is
1822
+ * cleared here at the same revision and the session's remaining views get
1823
+ * their one look at the empty seat (§4.2.3).
1824
+ */
1825
+ #detachView(sessionId, viewId) {
1826
+ this.#control?.notify({ type: "detach-session", sessionId, viewId });
1827
+ const control = this.#controlBySession.get(sessionId);
1828
+ if (!control?.controller || control.controller.viewId !== viewId)
1829
+ return;
1830
+ this.#controlBySession.set(sessionId, { controller: null, revision: control.revision });
1831
+ for (const other of this.#viewIdsForSession(sessionId)) {
1832
+ if (other !== viewId)
1833
+ this.#maybeReclaim(other);
1834
+ }
1835
+ }
1667
1836
  #createMountLease(mounted) {
1668
1837
  let disposed = false;
1669
1838
  return {
@@ -1689,7 +1858,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
1689
1858
  if (this.#routedHost)
1690
1859
  this.#releaseRoutedView(mounted.sessionId, mounted.viewId);
1691
1860
  else
1692
- this.#control?.notify({ type: "detach-session", sessionId: mounted.sessionId, viewId: mounted.viewId });
1861
+ this.#detachView(mounted.sessionId, mounted.viewId);
1693
1862
  this.#views.delete(mounted.viewId);
1694
1863
  this.#focusByView.delete(mounted.viewId);
1695
1864
  }
@@ -2129,6 +2298,16 @@ export class GhostteaTerminalRuntime extends EventTarget {
2129
2298
  setEffects(sessionHandle, effects, surfaceId) {
2130
2299
  this.#postWorker({ type: "effects", sessionHandle, ...(surfaceId ? { surfaceId } : {}), effects });
2131
2300
  }
2301
+ links(sessionHandle) {
2302
+ return this.#platform.openExternal
2303
+ ? (this.#linksByHandle.get(sessionHandle) ?? []).filter((link) => link.explicit || this.#configSnapshot?.renderer.linkUrl !== false)
2304
+ : [];
2305
+ }
2306
+ async openLink(uri) {
2307
+ const url = terminalLinkUrl(uri);
2308
+ if (url)
2309
+ await this.#platform.openExternal?.(url);
2310
+ }
2132
2311
  setSelection(sessionHandle, selection, surfaceId) {
2133
2312
  this.#postWorker({ type: "selection", sessionHandle, ...(surfaceId ? { surfaceId } : {}), selection });
2134
2313
  }
@@ -2229,9 +2408,12 @@ export class GhostteaTerminalRuntime extends EventTarget {
2229
2408
  }
2230
2409
  return;
2231
2410
  }
2232
- // The legacy protocol has no release verb. Clearing the local epoch still
2233
- // closes every resize path immediately; a later explicit claim can renew it.
2234
- view.controlEpoch = undefined;
2411
+ // The legacy protocol has no release verb, so the daemon keeps this view
2412
+ // seated. Dropping the request is what closes every resize path; the epoch
2413
+ // stays, because it is still the seat's truth and a later explicit claim
2414
+ // resumes on it. Clearing it here would leave the view with the record in
2415
+ // its own name and no epoch: the funnel's one-claim-per-attachment guard
2416
+ // refuses that claim, and a pane hidden by a zoom never resizes again.
2235
2417
  }
2236
2418
  setViewInputPolicy(viewId, readWrite) {
2237
2419
  const view = this.#views.get(viewId);
@@ -2340,6 +2522,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
2340
2522
  this.#sessionMountReferences.delete(handle);
2341
2523
  const subscriptionChanged = this.#subscribedSessionHandles.delete(handle);
2342
2524
  this.#cancelMetadataRefresh(handle);
2525
+ this.#linksByHandle.delete(handle);
2343
2526
  this.#mouseTrackingByHandle.delete(handle);
2344
2527
  this.#scrollbarByHandle.delete(handle);
2345
2528
  for (const mounted of [...this.#mountedEntries]) {
@@ -2354,7 +2537,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
2354
2537
  this.#postWorker({ type: "unmount", surfaceId: mounted.viewId });
2355
2538
  this.#mountGenerationBySurface.delete(mounted.viewId);
2356
2539
  if (detachViews)
2357
- this.#control?.notify({ type: "detach-session", sessionId: mounted.sessionId, viewId: mounted.viewId });
2540
+ this.#detachView(mounted.sessionId, mounted.viewId);
2358
2541
  this.#views.delete(mounted.viewId);
2359
2542
  this.#focusByView.delete(mounted.viewId);
2360
2543
  }
@@ -2365,12 +2548,14 @@ export class GhostteaTerminalRuntime extends EventTarget {
2365
2548
  if (view.sessionId === sessionId) {
2366
2549
  view.pendingInput.length = 0;
2367
2550
  if (detachViews)
2368
- this.#control?.notify({ type: "detach-session", sessionId, viewId });
2551
+ this.#detachView(sessionId, viewId);
2369
2552
  this.#views.delete(viewId);
2370
2553
  this.#focusByView.delete(viewId);
2371
2554
  }
2372
2555
  }
2373
2556
  this.#sessionByHandle.delete(handle);
2557
+ this.#sessionGenerationByHandle.delete(handle);
2558
+ this.#appliedRoutedExitEvents.delete(handle);
2374
2559
  this.#handleBySessionId.delete(sessionId);
2375
2560
  this.#remoteSessions.delete(sessionId);
2376
2561
  this.#controlBySession.delete(sessionId);
@@ -2452,6 +2637,10 @@ export class GhostteaTerminalRuntime extends EventTarget {
2452
2637
  for (const timer of this.#metadataTimers.values())
2453
2638
  window.clearTimeout(timer);
2454
2639
  this.#metadataTimers.clear();
2640
+ this.#metadataRefreshPending.clear();
2641
+ this.#metadataRefreshes.clear();
2642
+ this.#sessionGenerationByHandle.clear();
2643
+ this.#appliedRoutedExitEvents.clear();
2455
2644
  this.#resync.dispose();
2456
2645
  for (const request of this.#performanceRequests.values()) {
2457
2646
  window.clearTimeout(request.timer);
@@ -2469,6 +2658,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
2469
2658
  this.#recoveredSessions.clear();
2470
2659
  this.#focusByView.clear();
2471
2660
  this.#mountGenerationBySurface.clear();
2661
+ this.#linksByHandle.clear();
2472
2662
  this.#mouseTrackingByHandle.clear();
2473
2663
  this.#scrollbarByHandle.clear();
2474
2664
  this.#subscribedSessionHandles.clear();