tmux-ide 2.9.0-beta.20 → 2.9.0-beta.21

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 (49) hide show
  1. package/bin/cli.js +100 -8
  2. package/package.json +3 -2
  3. package/packages/daemon/dist/command-center/diagnostics.js +65 -0
  4. package/packages/daemon/dist/command-center/log-stream.js +9 -0
  5. package/packages/daemon/dist/command-center/server.js +7 -0
  6. package/packages/daemon/dist/lib/app-config.js +4 -2
  7. package/packages/daemon/dist/lib/soak-diagnostics.js +124 -0
  8. package/packages/daemon/dist/lib/soak-verdict.js +185 -35
  9. package/packages/daemon/dist/lib/terminal-host-color.js +33 -0
  10. package/packages/daemon/dist/tui/mirror/automatic-contrast.js +161 -0
  11. package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +9 -3
  12. package/packages/daemon/dist/tui/mirror/pane-surface.jsx +7 -5
  13. package/packages/daemon/dist/tui/mirror/resize-transaction.js +48 -20
  14. package/packages/daemon/dist/tui/mirror/runtime/application-appearance-owner.js +40 -6
  15. package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +63 -42
  16. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +138 -67
  17. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-palette-owner.js +44 -28
  18. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +49 -11
  19. package/packages/daemon/dist/tui/mirror/runtime/semantic-shell-viewport-resize.js +140 -2
  20. package/packages/daemon/dist/tui/mirror/runtime/workspace-terminal-fast-lane.js +3 -1
  21. package/packages/daemon/dist/tui/mirror/semantic-pane-render-source.js +2 -1
  22. package/packages/daemon/dist/tui/mirror/theme.js +4 -31
  23. package/packages/daemon/dist/tui/mirror/workspace/terminal-pane-header.jsx +15 -8
  24. package/packages/daemon/src/command-center/diagnostics.ts +75 -0
  25. package/packages/daemon/src/command-center/log-stream.ts +8 -0
  26. package/packages/daemon/src/command-center/server.ts +8 -0
  27. package/packages/daemon/src/lib/app-config.ts +11 -3
  28. package/packages/daemon/src/lib/soak-diagnostics.ts +183 -0
  29. package/packages/daemon/src/lib/soak-verdict.ts +314 -23
  30. package/packages/daemon/src/lib/terminal-host-color.ts +43 -0
  31. package/packages/daemon/src/tui/mirror/automatic-contrast.ts +180 -0
  32. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +26 -5
  33. package/packages/daemon/src/tui/mirror/pane-surface.tsx +9 -8
  34. package/packages/daemon/src/tui/mirror/resize-transaction.ts +46 -22
  35. package/packages/daemon/src/tui/mirror/runtime/application-appearance-owner.ts +45 -6
  36. package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +99 -75
  37. package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +1 -0
  38. package/packages/daemon/src/tui/mirror/runtime/application-shell-overlays.tsx +9 -2
  39. package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +2 -0
  40. package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +148 -69
  41. package/packages/daemon/src/tui/mirror/runtime/application-terminal-palette-owner.ts +55 -28
  42. package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +59 -17
  43. package/packages/daemon/src/tui/mirror/runtime/semantic-shell-viewport-resize.ts +151 -3
  44. package/packages/daemon/src/tui/mirror/runtime/workspace-terminal-fast-lane.ts +3 -1
  45. package/packages/daemon/src/tui/mirror/semantic-pane-render-source.ts +2 -1
  46. package/packages/daemon/src/tui/mirror/theme.ts +4 -39
  47. package/packages/daemon/src/tui/mirror/workspace/terminal-pane-header.tsx +21 -8
  48. package/packages/daemon-client/src/terminal-fast-lane.test.ts +18 -0
  49. package/packages/daemon-client/src/terminal-fast-lane.ts +7 -2
@@ -102,6 +102,7 @@ export interface ApplicationTerminalInteractionController {
102
102
  readonly gestureId: string | null;
103
103
  }): ApplicationResizePointerIngress | null;
104
104
  resizePane(preview: ApplicationPaneResizePreview): void;
105
+ cancelPaneResize(): void;
105
106
  keyboardResize(axis: "cols" | "rows", direction: -1 | 1): void;
106
107
  routeWorkspaceKey(
107
108
  event: Readonly<{ name: string; meta: boolean; ctrl: boolean; shift: boolean }>,
@@ -222,6 +223,7 @@ export function createApplicationTerminalInteractionController(
222
223
  startedAtMicros: number;
223
224
  preview: ApplicationPaneResizePreview;
224
225
  guideDigest: string;
226
+ readonly workspaceName: string;
225
227
  readonly daemonGeneration: string;
226
228
  readonly clientGeneration: number;
227
229
  readonly rendererEpoch: number;
@@ -236,7 +238,7 @@ export function createApplicationTerminalInteractionController(
236
238
  readonly presentationBeforeDigest: string;
237
239
  pointerIngress: ApplicationResizePointerIngress | null;
238
240
  } | null = null;
239
- let pendingPaneResize: {
241
+ type PendingPaneResize = {
240
242
  readonly source: "keyboard" | "pointer";
241
243
  readonly operationId: string;
242
244
  readonly semanticPaneId: string;
@@ -261,7 +263,35 @@ export function createApplicationTerminalInteractionController(
261
263
  receiptOutcome: "applied" | "unchanged" | null;
262
264
  layoutCells: number | null;
263
265
  frameRequested: boolean;
264
- } | null = null;
266
+ };
267
+ let pendingPaneResize: PendingPaneResize | null = null;
268
+ let paneResizeFrame: PendingPaneResize | null = null;
269
+ let resizePaneIdentity: Pick<
270
+ PendingPaneResize,
271
+ "semanticPaneId" | "incarnation" | "generation" | "sourceEpoch"
272
+ > | null = null;
273
+ const captureResizePaneIdentity = (semanticPaneId: string): void => {
274
+ const identity = options.generation()?.adapter?.paneCanonicalIdentity(semanticPaneId);
275
+ resizePaneIdentity = identity
276
+ ? {
277
+ semanticPaneId,
278
+ incarnation: identity.incarnation,
279
+ generation: identity.generation,
280
+ sourceEpoch: identity.sourceEpoch,
281
+ }
282
+ : null;
283
+ };
284
+ const resizePaneIdentityCurrent = (): boolean => {
285
+ if (!resizePaneIdentity) return false;
286
+ const current = options
287
+ .generation()
288
+ ?.adapter?.paneCanonicalIdentity(resizePaneIdentity.semanticPaneId);
289
+ return (
290
+ current?.incarnation === resizePaneIdentity.incarnation &&
291
+ current?.generation === resizePaneIdentity.generation &&
292
+ current?.sourceEpoch === resizePaneIdentity.sourceEpoch
293
+ );
294
+ };
265
295
  let lastResizeGuidePresentationDigest: string | null = null;
266
296
  let lastWindowPresentationDigest: string | null = null;
267
297
  let diagnosticWindowFrame: DiagnosticWindowFrameContext | null = null;
@@ -398,23 +428,34 @@ export function createApplicationTerminalInteractionController(
398
428
  };
399
429
  const maybeRequestPaneResizeFrame = (): void => {
400
430
  const pending = pendingPaneResize;
401
- if (
402
- !pending ||
403
- pending.frameRequested ||
404
- pending.receiptCells === null ||
405
- pending.layoutCells !== pending.receiptCells
406
- )
431
+ if (!pending || pending.receiptCells === null || pending.layoutCells !== pending.receiptCells)
407
432
  return;
408
- if (!options.diagnosticsEnabled) {
433
+ if (!resizePaneIdentityCurrent()) {
409
434
  pendingPaneResize = null;
435
+ resizeTransaction.retire();
410
436
  return;
411
437
  }
412
- pending.frameRequested = true;
413
- try {
414
- options.requestRender?.();
415
- } catch {
416
- pendingPaneResize = null;
438
+ // Detach before settlement: it can synchronously dispatch the latest target.
439
+ pendingPaneResize = null;
440
+ if (options.diagnosticsEnabled && pending.receiptOutcome !== "unchanged") {
441
+ if (paneResizeFrame)
442
+ diagnose("pane-resize-frame-superseded", { operationId: paneResizeFrame.operationId });
443
+ paneResizeFrame = pending;
444
+ pending.frameRequested = true;
445
+ try {
446
+ options.requestRender?.();
447
+ } catch {
448
+ paneResizeFrame = null;
449
+ }
417
450
  }
451
+ resizeTransaction.observeLayout({
452
+ operationId: pending.operationId,
453
+ authorityGeneration: pending.daemonGeneration,
454
+ workspaceName: pending.workspaceName,
455
+ semanticPaneId: pending.semanticPaneId,
456
+ axis: pending.axis,
457
+ cells: pending.receiptCells,
458
+ });
418
459
  };
419
460
  const dispatchPaneResize = (
420
461
  preview: ApplicationPaneResizePreview,
@@ -462,6 +503,7 @@ export function createApplicationTerminalInteractionController(
462
503
  layoutCells: null,
463
504
  frameRequested: false,
464
505
  };
506
+ diagnose("pane-resize-dispatch", resizeDiagnosticDetails(pendingPaneResize));
465
507
  const failure: { current: PaneResizeFailure | null } = { current: null };
466
508
  void resizeTerminalPane(
467
509
  expected,
@@ -499,32 +541,14 @@ export function createApplicationTerminalInteractionController(
499
541
  ...resizeDiagnosticDetails(pending),
500
542
  verb: "workspace.pane.resize",
501
543
  });
502
- if (receipt.outcome === "unchanged" && receipt.cells === pending.beforeCells) {
503
- pending.layoutCells = receipt.cells;
504
- resizeTransaction.observeLayout({
505
- operationId,
506
- authorityGeneration: pending.daemonGeneration,
507
- workspaceName: pending.workspaceName,
508
- semanticPaneId: pending.semanticPaneId,
509
- axis: pending.axis,
510
- cells: receipt.cells,
511
- });
544
+ // A clamp/no-op still needs current canonical truth, not the guide.
545
+ const observed = paneCells(options.layout(), pending.semanticPaneId, pending.axis);
546
+ if (observed === receipt.cells) pending.layoutCells = observed;
547
+ if (receipt.outcome === "unchanged")
512
548
  diagnoseCritical(`pane-resize:${operationId}:unchanged`, "pane-resize-unchanged", {
513
549
  ...resizeDiagnosticDetails(pending),
514
550
  changed: false,
515
551
  });
516
- pendingPaneResize = null;
517
- return;
518
- }
519
- if (pending.layoutCells === receipt.cells)
520
- resizeTransaction.observeLayout({
521
- operationId,
522
- authorityGeneration: pending.daemonGeneration,
523
- workspaceName: pending.workspaceName,
524
- semanticPaneId: pending.semanticPaneId,
525
- axis: pending.axis,
526
- cells: receipt.cells,
527
- });
528
552
  maybeRequestPaneResizeFrame();
529
553
  })
530
554
  .catch(() => {
@@ -551,7 +575,17 @@ export function createApplicationTerminalInteractionController(
551
575
  submit: ({ operationId, intent }) => {
552
576
  const state = resizeTransaction.state();
553
577
  const context = resizeCommitContext;
554
- if (state.phase !== "pending" || state.operationId !== operationId || !context) return;
578
+ const live = liveResizeTarget();
579
+ if (
580
+ state.phase !== "pending" ||
581
+ state.operationId !== operationId ||
582
+ !context ||
583
+ !resizePaneIdentityCurrent() ||
584
+ live?.daemonGeneration !== state.authorityGeneration ||
585
+ live.workspaceName !== state.workspaceName ||
586
+ !options.layout().current?.panes.some((pane) => pane.pane === intent.semanticPaneId)
587
+ )
588
+ throw new Error("resize target retired");
555
589
  dispatchPaneResize(
556
590
  {
557
591
  semanticPaneId: intent.semanticPaneId,
@@ -566,6 +600,7 @@ export function createApplicationTerminalInteractionController(
566
600
  );
567
601
  },
568
602
  onState: (state) => {
603
+ if (state.phase === "idle" && state.outcome?.kind === "settled") resizeCommitContext = null;
569
604
  if (state.phase === "idle" && state.outcome?.kind === "reverted") {
570
605
  diagnose("pane-resize-transaction-reverted", {
571
606
  operationId: state.outcome.operationId,
@@ -803,6 +838,7 @@ export function createApplicationTerminalInteractionController(
803
838
  pendingWindowRename = null;
804
839
  diagnosticWindowFrame = null;
805
840
  pendingPaneResize = null;
841
+ paneResizeFrame = null;
806
842
  pendingResizeGuide = null;
807
843
  resizeCommitContext = null;
808
844
  resizeTransaction.retire();
@@ -874,20 +910,24 @@ export function createApplicationTerminalInteractionController(
874
910
  });
875
911
  }
876
912
  }
913
+ const resizeState = resizeTransaction.state();
914
+ if (
915
+ resizeState.phase !== "idle" &&
916
+ !snapshot.current?.panes.some((pane) => pane.pane === resizeState.semanticPaneId)
917
+ ) {
918
+ resizeTransaction.retire();
919
+ pendingPaneResize = null;
920
+ pendingResizeGuide = null;
921
+ }
877
922
  if (pendingPaneResize) {
878
923
  const cells = paneCells(snapshot, pendingPaneResize.semanticPaneId, pendingPaneResize.axis);
879
- if (cells !== null && cells !== pendingPaneResize.beforeCells) {
924
+ if (
925
+ cells !== null &&
926
+ cells !== pendingPaneResize.layoutCells &&
927
+ cells !== pendingPaneResize.beforeCells
928
+ ) {
880
929
  pendingPaneResize.layoutCells = cells;
881
930
  diagnose("pane-resize-layout", resizeDiagnosticDetails(pendingPaneResize));
882
- if (pendingPaneResize.receiptCells === cells)
883
- resizeTransaction.observeLayout({
884
- operationId: pendingPaneResize.operationId,
885
- authorityGeneration: pendingPaneResize.daemonGeneration,
886
- workspaceName: pendingPaneResize.workspaceName,
887
- semanticPaneId: pendingPaneResize.semanticPaneId,
888
- axis: pendingPaneResize.axis,
889
- cells,
890
- });
891
931
  }
892
932
  }
893
933
  maybeRequestWindowSwitchFrame();
@@ -928,7 +968,9 @@ export function createApplicationTerminalInteractionController(
928
968
  const live = liveResizeTarget();
929
969
  const canonicalCells = paneCells(options.layout(), preview.semanticPaneId, preview.axis);
930
970
  const state = resizeTransaction.state();
931
- if (live && canonicalCells !== null && state.phase === "idle")
971
+ if (!resizeCommitContext && live && canonicalCells !== null && state.phase === "idle") {
972
+ resizeCommitContext = { source: "pointer", pointerIngress: preview.pointerIngress ?? null };
973
+ captureResizePaneIdentity(preview.semanticPaneId);
932
974
  resizeTransaction.begin({
933
975
  authorityGeneration: live.daemonGeneration,
934
976
  workspaceName: live.workspaceName,
@@ -936,6 +978,9 @@ export function createApplicationTerminalInteractionController(
936
978
  axis: preview.axis,
937
979
  canonicalCells,
938
980
  });
981
+ }
982
+ if (resizeCommitContext?.source === "pointer")
983
+ resizeCommitContext = { source: "pointer", pointerIngress: preview.pointerIngress ?? null };
939
984
  resizeTransaction.move(preview.cells);
940
985
  if (!options.diagnosticsEnabled) return;
941
986
  if (pendingResizeGuide) {
@@ -983,6 +1028,7 @@ export function createApplicationTerminalInteractionController(
983
1028
  startedAtMicros,
984
1029
  preview,
985
1030
  guideDigest,
1031
+ workspaceName: active.connection.workspaceName,
986
1032
  daemonGeneration: active.daemonGeneration,
987
1033
  clientGeneration: clientGeneration!,
988
1034
  rendererEpoch: active.rendererEpoch,
@@ -1014,16 +1060,38 @@ export function createApplicationTerminalInteractionController(
1014
1060
  resizePane(preview) {
1015
1061
  pendingResizeGuide = null;
1016
1062
  lastResizeGuidePresentationDigest = null;
1063
+ if (!resizeCommitContext || resizeCommitContext.source !== "pointer") return;
1064
+ resizeCommitContext = { source: "pointer", pointerIngress: preview.pointerIngress ?? null };
1017
1065
  resizeTransaction.move(preview.cells);
1018
- resizeCommitContext = {
1019
- source: "pointer",
1020
- pointerIngress: preview.pointerIngress?.action === "up" ? preview.pointerIngress : null,
1021
- };
1022
- try {
1023
- resizeTransaction.release();
1024
- } finally {
1025
- resizeCommitContext = null;
1026
- }
1066
+ resizeTransaction.release();
1067
+ if (options.diagnosticsEnabled)
1068
+ try {
1069
+ const active = options.generation();
1070
+ const identity = active?.adapter?.paneCanonicalIdentity(preview.semanticPaneId);
1071
+ diagnose("pane-resize-release", {
1072
+ semanticPaneId: preview.semanticPaneId,
1073
+ axis: preview.axis,
1074
+ requestedCells: preview.cells,
1075
+ pointerIngress: preview.pointerIngress ?? null,
1076
+ transactionPhase: resizeTransaction.state().phase,
1077
+ workspaceName: active?.connection?.workspaceName,
1078
+ daemonGeneration: active?.daemonGeneration,
1079
+ clientGeneration: active?.client?.getSnapshot().generation,
1080
+ rendererEpoch: active?.rendererEpoch,
1081
+ sourceEpoch: identity?.sourceEpoch,
1082
+ generation: identity?.generation,
1083
+ incarnation: identity?.incarnation,
1084
+ });
1085
+ } catch {
1086
+ /* Diagnostics never own gesture completion. */
1087
+ }
1088
+ // Retain context while a coalesced final target awaits dispatch.
1089
+ if (resizeTransaction.state().phase === "idle") resizeCommitContext = null;
1090
+ },
1091
+ cancelPaneResize() {
1092
+ resizeTransaction.cancelDrag();
1093
+ pendingResizeGuide = null;
1094
+ if (resizeTransaction.state().phase === "idle") resizeCommitContext = null;
1027
1095
  },
1028
1096
  keyboardResize(axis, direction) {
1029
1097
  const paneId = options.focusedPane?.() ?? null;
@@ -1042,12 +1110,13 @@ export function createApplicationTerminalInteractionController(
1042
1110
  })
1043
1111
  )
1044
1112
  return;
1045
- resizeTransaction.move(Math.max(1, beforeCells + direction));
1046
1113
  resizeCommitContext = { source: "keyboard", pointerIngress: null };
1114
+ captureResizePaneIdentity(paneId);
1115
+ resizeTransaction.move(Math.max(1, beforeCells + direction));
1047
1116
  try {
1048
1117
  resizeTransaction.release();
1049
1118
  } finally {
1050
- resizeCommitContext = null;
1119
+ if (resizeTransaction.state().phase === "idle") resizeCommitContext = null;
1051
1120
  }
1052
1121
  },
1053
1122
  routeWorkspaceKey(event) {
@@ -1539,6 +1608,7 @@ export function createApplicationTerminalInteractionController(
1539
1608
  pendingResizeGuide = null;
1540
1609
  const settledAtMicros = diagnosticNowMicros();
1541
1610
  let identityExact: boolean;
1611
+ let canonicalAfter = null;
1542
1612
  try {
1543
1613
  const active = options.generation();
1544
1614
  const identity = active?.adapter?.paneCanonicalIdentity(settled.preview.semanticPaneId);
@@ -1547,6 +1617,7 @@ export function createApplicationTerminalInteractionController(
1547
1617
  const layoutPane = layoutWindow?.panes.find(
1548
1618
  ({ pane }) => pane === settled.preview.semanticPaneId,
1549
1619
  );
1620
+ canonicalAfter = identity ?? null;
1550
1621
  identityExact =
1551
1622
  active?.status === "live" &&
1552
1623
  active.daemonGeneration === settled.daemonGeneration &&
@@ -1556,11 +1627,8 @@ export function createApplicationTerminalInteractionController(
1556
1627
  identity?.sourceEpoch === settled.sourceEpoch &&
1557
1628
  identity.generation === settled.generation &&
1558
1629
  identity.incarnation === settled.incarnation &&
1559
- identity.revision === settled.revision &&
1560
- identity.stateHash === settled.stateHash &&
1561
- identity.cols === settled.cols &&
1562
- identity.rows === settled.rows &&
1563
- layoutPane?.width === settled.cols &&
1630
+ identity.revision >= settled.revision &&
1631
+ layoutPane?.width === identity.cols &&
1564
1632
  layoutWindow !== undefined &&
1565
1633
  layoutWindow !== null &&
1566
1634
  nativePaneResizeCells(
@@ -1568,7 +1636,7 @@ export function createApplicationTerminalInteractionController(
1568
1636
  "rows",
1569
1637
  layoutWindow.paneBorderStatus,
1570
1638
  layoutWindow.rows,
1571
- ) === settled.rows;
1639
+ ) === identity.rows;
1572
1640
  } catch {
1573
1641
  identityExact = false;
1574
1642
  }
@@ -1588,6 +1656,7 @@ export function createApplicationTerminalInteractionController(
1588
1656
  settled.presentationBeforeDigest.length === 64 &&
1589
1657
  presentationDigest !== settled.presentationBeforeDigest,
1590
1658
  identityExact,
1659
+ canonicalAfter,
1591
1660
  durationMicros:
1592
1661
  settledAtMicros === null ? null : settledAtMicros - settled.startedAtMicros,
1593
1662
  };
@@ -1605,9 +1674,8 @@ export function createApplicationTerminalInteractionController(
1605
1674
  writerHealth,
1606
1675
  });
1607
1676
  }
1608
- const pending = pendingPaneResize;
1677
+ const pending = paneResizeFrame;
1609
1678
  if (!pending?.frameRequested) return;
1610
- pendingPaneResize = null;
1611
1679
  let canonicalAfter = null;
1612
1680
  let identityLineageExact = false;
1613
1681
  try {
@@ -1625,13 +1693,24 @@ export function createApplicationTerminalInteractionController(
1625
1693
  identity.sourceEpoch === pending.sourceEpoch &&
1626
1694
  identity.generation === pending.generation &&
1627
1695
  identity.incarnation === pending.incarnation &&
1628
- identity.revision >= pending.revision &&
1629
- layoutCells === pending.layoutCells;
1696
+ identity.revision >= pending.revision;
1697
+ // Layout publication can precede the canonical terminal snapshot. Keep
1698
+ // the bounded diagnostic slot until a coherent frame consumes both.
1699
+ // A newer operation explicitly supersedes it; diagnostics never hold
1700
+ // the resize transaction or request a polling render loop.
1701
+ if (
1702
+ identityLineageExact &&
1703
+ (identity.cols !== paneCells(options.layout(), pending.semanticPaneId, "cols") ||
1704
+ identity.rows !== paneCells(options.layout(), pending.semanticPaneId, "rows") ||
1705
+ layoutCells !== pending.layoutCells)
1706
+ )
1707
+ return;
1630
1708
  }
1631
1709
  } catch {
1632
1710
  canonicalAfter = null;
1633
1711
  identityLineageExact = false;
1634
1712
  }
1713
+ paneResizeFrame = null;
1635
1714
  const presentationDigest = resizePresentationDigest(null);
1636
1715
  const details = {
1637
1716
  ...resizeDiagnosticDetails(pending),
@@ -1,3 +1,4 @@
1
+ import { parseTerminalHostColor, terminalHostMode } from "../../../lib/terminal-host-color.ts";
1
2
  import type { ResolvedThemeMode } from "../../../lib/theme-mode.ts";
2
3
 
3
4
  export type ApplicationTerminalPaletteAvailability = "pending" | "available" | "unavailable";
@@ -62,6 +63,7 @@ export interface ApplicationTerminalPaletteOwner {
62
63
  readonly ready: Promise<void>;
63
64
  getSnapshot(): ApplicationTerminalPaletteSnapshot;
64
65
  subscribe(listener: () => void): () => void;
66
+ /** Query now with a bounded retry pair for terminals still changing theme. */
65
67
  refresh(): Promise<void>;
66
68
  dispose(): void;
67
69
  }
@@ -74,7 +76,7 @@ const EMPTY_PALETTE = Object.freeze(Array<string | null>(PALETTE_SIZE).fill(null
74
76
  function normalizeColor(value: unknown): string | null {
75
77
  if (typeof value !== "string") return null;
76
78
  const normalized = value.trim().toLowerCase();
77
- return normalized.length > 0 ? normalized : null;
79
+ return parseTerminalHostColor(normalized) ? normalized : null;
78
80
  }
79
81
 
80
82
  function normalizePalette(values: unknown): readonly (string | null)[] {
@@ -99,19 +101,6 @@ function immutableCapabilities(value: unknown): Readonly<Record<string, unknown>
99
101
  return immutableValue(value) as Readonly<Record<string, unknown>>;
100
102
  }
101
103
 
102
- function detectedMode(background: string | null, fallback: ResolvedThemeMode): ResolvedThemeMode {
103
- if (!background) return fallback;
104
- const match = /^#([\da-f]{3}|[\da-f]{6})$/iu.exec(background);
105
- if (!match) return fallback;
106
- const hex = match[1]!;
107
- const expanded =
108
- hex.length === 3 ? `${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}` : hex;
109
- const red = Number.parseInt(expanded.slice(0, 2), 16) / 255;
110
- const green = Number.parseInt(expanded.slice(2, 4), 16) / 255;
111
- const blue = Number.parseInt(expanded.slice(4, 6), 16) / 255;
112
- return 0.299 * red + 0.587 * green + 0.114 * blue > 0.5 ? "light" : "dark";
113
- }
114
-
115
104
  function snapshotSignature(input: Omit<ApplicationTerminalPaletteSnapshot, "signature">): string {
116
105
  return JSON.stringify(input);
117
106
  }
@@ -162,7 +151,7 @@ function availableSnapshot(
162
151
  tekBackground: normalizeColor(result.tekBackground),
163
152
  highlightBackground: normalizeColor(result.highlightBackground),
164
153
  highlightForeground: normalizeColor(result.highlightForeground),
165
- detectedMode: detectedMode(defaultBackground, fallbackMode),
154
+ detectedMode: terminalHostMode(defaultBackground) ?? fallbackMode,
166
155
  capabilities,
167
156
  });
168
157
  }
@@ -191,6 +180,9 @@ export function createApplicationTerminalPaletteOwner(
191
180
  let lastValid: ApplicationTerminalPaletteSnapshot | null = null;
192
181
  let active: Promise<void> | null = null;
193
182
  let queued = false;
183
+ let modeHint: ResolvedThemeMode | null = null;
184
+ let modeGeneration = 0;
185
+ let awaitingModePalette = false;
194
186
  let disposed = false;
195
187
 
196
188
  const publish = (next: ApplicationTerminalPaletteSnapshot): void => {
@@ -206,22 +198,40 @@ export function createApplicationTerminalPaletteOwner(
206
198
  return active;
207
199
  }
208
200
  renderer.clearPaletteCache();
201
+ const requestedModeGeneration = modeGeneration;
202
+ const guardModeForQuery = awaitingModePalette;
209
203
  const generation = Promise.resolve()
210
204
  .then(() => renderer.getPalette({ size: PALETTE_SIZE, timeout: queryTimeoutMs }))
211
205
  .then((result) => {
212
- if (disposed) return;
206
+ if (disposed || requestedModeGeneration !== modeGeneration) return;
207
+ const reportedMode = terminalHostMode(result.defaultBackground);
208
+ // A terminal can still answer with its previous palette while switching.
209
+ // Queries issued during the transition wait for a matching reply.
210
+ if (guardModeForQuery && modeHint && reportedMode && reportedMode !== modeHint) return;
213
211
  if (!hasReportedColor(result)) {
214
212
  if (!lastValid)
215
- publish(fallbackSnapshot("unavailable", renderer.themeMode ?? "dark", capabilities));
213
+ publish(
214
+ fallbackSnapshot(
215
+ "unavailable",
216
+ modeHint ?? renderer.themeMode ?? "dark",
217
+ capabilities,
218
+ ),
219
+ );
216
220
  return;
217
221
  }
218
- const next = availableSnapshot(result, renderer.themeMode ?? "dark", capabilities);
222
+ const next = availableSnapshot(
223
+ result,
224
+ modeHint ?? renderer.themeMode ?? "dark",
225
+ capabilities,
226
+ );
219
227
  lastValid = next;
220
228
  publish(next);
221
229
  })
222
230
  .catch(() => {
223
- if (disposed || lastValid) return;
224
- publish(fallbackSnapshot("unavailable", renderer.themeMode ?? "dark", capabilities));
231
+ if (disposed || requestedModeGeneration !== modeGeneration || lastValid) return;
232
+ publish(
233
+ fallbackSnapshot("unavailable", modeHint ?? renderer.themeMode ?? "dark", capabilities),
234
+ );
225
235
  })
226
236
  .finally(() => {
227
237
  if (active !== generation) return;
@@ -234,20 +244,37 @@ export function createApplicationTerminalPaletteOwner(
234
244
  return generation;
235
245
  };
236
246
 
237
- const scheduleFollowUps = (): void => {
238
- if (disposed) return;
239
- void refresh();
247
+ const scheduleFollowUps = (): Promise<void> => {
248
+ if (disposed) return Promise.resolve();
249
+ const first = refresh();
250
+ // A burst owns only one bounded pair of retries, never one pair per event.
251
+ for (const timer of timers) cancelTimeout(timer);
252
+ timers.clear();
240
253
  for (const delay of FOLLOW_UP_DELAYS_MS) {
241
254
  const timer = scheduleTimeout(() => {
242
255
  timers.delete(timer);
256
+ // After the bounded transition window, a fresh measured OSC 11 color
257
+ // wins even when a custom theme's luminance disagrees with its hint.
258
+ if (delay === FOLLOW_UP_DELAYS_MS[FOLLOW_UP_DELAYS_MS.length - 1])
259
+ awaitingModePalette = false;
243
260
  if (!disposed) void refresh();
244
261
  }, delay);
245
262
  timers.add(timer);
246
263
  }
264
+ return first;
247
265
  };
248
266
 
249
- const onThemeMode = (): void => {
250
- if (isThemeModeUnlocked()) scheduleFollowUps();
267
+ const onThemeMode = (mode: ResolvedThemeMode): void => {
268
+ const changed = modeHint !== mode;
269
+ if (changed) modeGeneration += 1;
270
+ modeHint = mode;
271
+ if (changed && snapshot.detectedMode !== mode) {
272
+ awaitingModePalette = true;
273
+ // Track hints even while locked, so unlocking cannot resurrect old colors.
274
+ lastValid = null;
275
+ publish(fallbackSnapshot("unavailable", mode, capabilities));
276
+ }
277
+ if (isThemeModeUnlocked()) void scheduleFollowUps();
251
278
  };
252
279
  const onCapabilities = (next: unknown): void => {
253
280
  capabilities = immutableCapabilities(next);
@@ -261,14 +288,14 @@ export function createApplicationTerminalPaletteOwner(
261
288
  publish(
262
289
  fallbackSnapshot(
263
290
  snapshot.availability === "pending" ? "pending" : "unavailable",
264
- renderer.themeMode ?? "dark",
291
+ modeHint ?? renderer.themeMode ?? "dark",
265
292
  capabilities,
266
293
  ),
267
294
  );
268
295
  };
269
296
  const onThemeNotification = (sequence: string): boolean => {
270
297
  if (sequence !== "\x1b[?997;1n" && sequence !== "\x1b[?997;2n") return false;
271
- if (isThemeModeUnlocked()) scheduleFollowUps();
298
+ onThemeMode(sequence === "\x1b[?997;1n" ? "dark" : "light");
272
299
  return false;
273
300
  };
274
301
 
@@ -285,7 +312,7 @@ export function createApplicationTerminalPaletteOwner(
285
312
  listeners.add(listener);
286
313
  return () => listeners.delete(listener);
287
314
  },
288
- refresh,
315
+ refresh: scheduleFollowUps,
289
316
  dispose() {
290
317
  if (disposed) return;
291
318
  disposed = true;