tmux-ide 2.9.0-beta.19 → 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 (90) hide show
  1. package/bin/cli.js +1979 -783
  2. package/package.json +4 -2
  3. package/packages/contracts/src/__tests__/daemon-wire.test.ts +32 -0
  4. package/packages/contracts/src/daemon-events.ts +12 -12
  5. package/packages/contracts/src/daemon-wire.ts +30 -0
  6. package/packages/daemon/dist/command-center/agent-status-watch.js +9 -2
  7. package/packages/daemon/dist/command-center/diagnostics.js +65 -0
  8. package/packages/daemon/dist/command-center/log-stream.js +9 -0
  9. package/packages/daemon/dist/command-center/resources/fleet-preview-route.js +22 -6
  10. package/packages/daemon/dist/command-center/server.js +7 -0
  11. package/packages/daemon/dist/doctor.js +62 -0
  12. package/packages/daemon/dist/lib/__tests__/installed-recovery-fixture.js +400 -0
  13. package/packages/daemon/dist/lib/app-config.js +4 -2
  14. package/packages/daemon/dist/lib/canonical-daemon.js +1 -0
  15. package/packages/daemon/dist/lib/daemon-embed.js +24 -0
  16. package/packages/daemon/dist/lib/daemon-provenance.js +575 -0
  17. package/packages/daemon/dist/lib/headless-daemon.js +1 -0
  18. package/packages/daemon/dist/lib/log-sanitize.js +199 -0
  19. package/packages/daemon/dist/lib/log.js +123 -13
  20. package/packages/daemon/dist/lib/soak-diagnostics.js +124 -0
  21. package/packages/daemon/dist/lib/soak-verdict.js +472 -0
  22. package/packages/daemon/dist/lib/terminal-host-color.js +33 -0
  23. package/packages/daemon/dist/lib/tmux-external-interaction-observer.js +187 -39
  24. package/packages/daemon/dist/lib/tmux-interaction-retention.js +21 -0
  25. package/packages/daemon/dist/lib/workspace-promotion.js +55 -37
  26. package/packages/daemon/dist/terminal/mirror/session-channel.js +2 -1
  27. package/packages/daemon/dist/tui/mirror/automatic-contrast.js +161 -0
  28. package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +9 -3
  29. package/packages/daemon/dist/tui/mirror/pane-surface.jsx +7 -5
  30. package/packages/daemon/dist/tui/mirror/resize-transaction.js +48 -20
  31. package/packages/daemon/dist/tui/mirror/runtime/application-appearance-owner.js +40 -6
  32. package/packages/daemon/dist/tui/mirror/runtime/application-fleet-preview.js +4 -1
  33. package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +63 -42
  34. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +138 -67
  35. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-palette-owner.js +44 -28
  36. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +49 -11
  37. package/packages/daemon/dist/tui/mirror/runtime/semantic-shell-viewport-resize.js +140 -2
  38. package/packages/daemon/dist/tui/mirror/runtime/workspace-terminal-fast-lane.js +3 -1
  39. package/packages/daemon/dist/tui/mirror/semantic-pane-render-source.js +2 -1
  40. package/packages/daemon/dist/tui/mirror/theme.js +4 -31
  41. package/packages/daemon/dist/tui/mirror/workspace/terminal-pane-header.jsx +15 -8
  42. package/packages/daemon/dist/tui/team/wait-receipts.js +112 -33
  43. package/packages/daemon/src/command-center/agent-status-watch.ts +8 -2
  44. package/packages/daemon/src/command-center/diagnostics.ts +75 -0
  45. package/packages/daemon/src/command-center/log-stream.ts +8 -0
  46. package/packages/daemon/src/command-center/resources/fleet-preview-route.ts +27 -5
  47. package/packages/daemon/src/command-center/server.ts +8 -0
  48. package/packages/daemon/src/doctor.ts +70 -0
  49. package/packages/daemon/src/lib/app-config.ts +11 -3
  50. package/packages/daemon/src/lib/canonical-daemon.ts +1 -0
  51. package/packages/daemon/src/lib/daemon-embed.ts +30 -0
  52. package/packages/daemon/src/lib/daemon-provenance.ts +769 -0
  53. package/packages/daemon/src/lib/fleet-preview-model.ts +1 -0
  54. package/packages/daemon/src/lib/headless-daemon.ts +1 -0
  55. package/packages/daemon/src/lib/log-sanitize.ts +248 -0
  56. package/packages/daemon/src/lib/log.ts +165 -14
  57. package/packages/daemon/src/lib/soak-diagnostics.ts +183 -0
  58. package/packages/daemon/src/lib/soak-verdict.ts +785 -0
  59. package/packages/daemon/src/lib/terminal-host-color.ts +43 -0
  60. package/packages/daemon/src/lib/tmux-external-interaction-observer.ts +233 -37
  61. package/packages/daemon/src/lib/tmux-interaction-retention.ts +24 -0
  62. package/packages/daemon/src/lib/workspace-promotion.ts +74 -45
  63. package/packages/daemon/src/terminal/mirror/session-channel.ts +2 -1
  64. package/packages/daemon/src/tui/mirror/automatic-contrast.ts +180 -0
  65. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +26 -5
  66. package/packages/daemon/src/tui/mirror/pane-surface.tsx +9 -8
  67. package/packages/daemon/src/tui/mirror/resize-transaction.ts +46 -22
  68. package/packages/daemon/src/tui/mirror/runtime/application-appearance-owner.ts +45 -6
  69. package/packages/daemon/src/tui/mirror/runtime/application-fleet-preview.ts +4 -0
  70. package/packages/daemon/src/tui/mirror/runtime/application-fleet-switcher.tsx +2 -1
  71. package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +99 -75
  72. package/packages/daemon/src/tui/mirror/runtime/application-palette-preview.tsx +13 -3
  73. package/packages/daemon/src/tui/mirror/runtime/application-reference-sheet.tsx +125 -0
  74. package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +2 -1
  75. package/packages/daemon/src/tui/mirror/runtime/application-shell-overlays.tsx +199 -157
  76. package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +2 -0
  77. package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +148 -69
  78. package/packages/daemon/src/tui/mirror/runtime/application-terminal-palette-owner.ts +55 -28
  79. package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +59 -17
  80. package/packages/daemon/src/tui/mirror/runtime/semantic-shell-viewport-resize.ts +151 -3
  81. package/packages/daemon/src/tui/mirror/runtime/workspace-terminal-fast-lane.ts +3 -1
  82. package/packages/daemon/src/tui/mirror/semantic-pane-render-source.ts +2 -1
  83. package/packages/daemon/src/tui/mirror/theme.ts +4 -39
  84. package/packages/daemon/src/tui/mirror/workspace/terminal-pane-header.tsx +21 -8
  85. package/packages/daemon/src/tui/team/wait-receipts.ts +115 -40
  86. package/packages/daemon-client/src/terminal-fast-lane.test.ts +18 -0
  87. package/packages/daemon-client/src/terminal-fast-lane.ts +7 -2
  88. package/packages/tmux-bridge/src/index.ts +2 -0
  89. package/packages/tmux-bridge/src/runner.test.ts +41 -0
  90. package/packages/tmux-bridge/src/runner.ts +41 -2
@@ -26,7 +26,7 @@
26
26
  import type { WorkspaceAdmissionSnapshot } from "@tmux-ide/contracts";
27
27
 
28
28
  import { createHash } from "node:crypto";
29
- import { realpathSync, statSync } from "node:fs";
29
+ import { realpath, stat } from "node:fs/promises";
30
30
 
31
31
  import {
32
32
  WorkspacePromoteMutationRequestSchemaZ,
@@ -36,10 +36,10 @@ import {
36
36
  type WorkspacePromoteMutationResult,
37
37
  type WorkspacePromotedResource,
38
38
  } from "@tmux-ide/contracts";
39
- import { TmuxError } from "@tmux-ide/tmux-bridge";
39
+ import { classifyTmuxError, TmuxError } from "@tmux-ide/tmux-bridge";
40
40
 
41
41
  import {
42
- createPinnedWorkspaceTmuxRunner,
42
+ createPinnedWorkspaceTmuxAsyncRunner,
43
43
  resolveWorkspacePaneTmuxAuthority,
44
44
  type WorkspacePaneTmuxAuthority,
45
45
  } from "./workspace-pane-creation.ts";
@@ -169,9 +169,16 @@ interface WorkspacePromotionRegistry {
169
169
  add(input: AddWorkspaceInput): Workspace;
170
170
  }
171
171
 
172
+ /**
173
+ * Promotion io. `runTmux` and `canonicalProjectDir` may answer synchronously
174
+ * (unit fixtures) or asynchronously (the daemon default): the authority awaits
175
+ * both so the many tmux round-trips a multi-pane promotion performs never run
176
+ * on the daemon event loop synchronously, which would stall every other
177
+ * connected client for the whole promotion.
178
+ */
172
179
  export interface WorkspacePromotionIo {
173
- readonly runTmux: (args: readonly string[]) => string;
174
- readonly canonicalProjectDir: (path: string) => string;
180
+ readonly runTmux: (args: readonly string[]) => string | Promise<string>;
181
+ readonly canonicalProjectDir: (path: string) => string | Promise<string>;
175
182
  readonly isMissingTmuxTarget: (error: unknown) => boolean;
176
183
  readonly isTmuxUnavailable: (error: unknown) => boolean;
177
184
  readonly now: () => number;
@@ -411,10 +418,29 @@ function requestFingerprint(request: WorkspacePromoteMutationRequest): string {
411
418
  return JSON.stringify(request);
412
419
  }
413
420
 
421
+ /**
422
+ * The daemon's pinned async runner keeps every promotion round-trip off the
423
+ * event loop; it surfaces raw child-process failures, so classify them here
424
+ * into the shared `TmuxError` codes the io predicates below inspect.
425
+ */
426
+ function classifiedAsyncRunner(
427
+ tmuxAuthority: WorkspacePaneTmuxAuthority | undefined,
428
+ ): (args: readonly string[]) => Promise<string> {
429
+ const run = createPinnedWorkspaceTmuxAsyncRunner(
430
+ tmuxAuthority ?? resolveWorkspacePaneTmuxAuthority(),
431
+ );
432
+ return (args) =>
433
+ run(args).catch((error: unknown) => {
434
+ throw error instanceof TmuxError ? error : classifyTmuxError(error);
435
+ });
436
+ }
437
+
414
438
  const DEFAULT_IO: Omit<WorkspacePromotionIo, "runTmux"> = {
415
- canonicalProjectDir: (path) => {
416
- const canonical = realpathSync(path);
417
- if (!statSync(canonical).isDirectory()) throw new Error("project root is not a directory");
439
+ canonicalProjectDir: async (path) => {
440
+ const canonical = await realpath(path);
441
+ if (!(await stat(canonical)).isDirectory()) {
442
+ throw new Error("project root is not a directory");
443
+ }
418
444
  return canonical;
419
445
  },
420
446
  isMissingTmuxTarget: (error) => error instanceof TmuxError && error.code === "SESSION_NOT_FOUND",
@@ -462,11 +488,7 @@ export class WorkspacePromotionAuthority {
462
488
  this.#io = {
463
489
  ...DEFAULT_IO,
464
490
  ...options.io,
465
- runTmux:
466
- options.io?.runTmux ??
467
- createPinnedWorkspaceTmuxRunner(
468
- options.tmuxAuthority ?? resolveWorkspacePaneTmuxAuthority(),
469
- ),
491
+ runTmux: options.io?.runTmux ?? classifiedAsyncRunner(options.tmuxAuthority),
470
492
  };
471
493
  this.#maxReplayOperations = boundedAuthorityLimit(options.maxOperations, MAX_OPERATIONS);
472
494
  this.#maxPendingOperations = boundedAuthorityLimit(
@@ -532,7 +554,7 @@ export class WorkspacePromotionAuthority {
532
554
  if (existing) return this.#replay(existing, request, fingerprint);
533
555
 
534
556
  try {
535
- const session = this.#resolveSession(request.intent.sessionId);
557
+ const session = await this.#resolveSession(request.intent.sessionId);
536
558
 
537
559
  // Already a registry workspace — including an app-created (m32) session —
538
560
  // is idempotent, not an error. It is NOT automatically attachable though:
@@ -551,10 +573,10 @@ export class WorkspacePromotionAuthority {
551
573
  workspaceName: alreadyRegistered.name,
552
574
  sessionName: session.sessionName,
553
575
  };
554
- this.#stampPaneInventory(request, session, registeredIdentity);
576
+ await this.#stampPaneInventory(request, session, registeredIdentity);
555
577
  this.#assertActive(request.operationId);
556
- this.#verifyPromotedInventory(session.sessionId, registeredIdentity);
557
- this.#publishFleetEnrollment(request, session, registeredIdentity);
578
+ await this.#verifyPromotedInventory(session.sessionId, registeredIdentity);
579
+ await this.#publishFleetEnrollment(request, session, registeredIdentity);
558
580
  return this.#succeed(request, fingerprint, alreadyRegistered.name, session.sessionName, {
559
581
  replayed: true,
560
582
  });
@@ -563,10 +585,10 @@ export class WorkspacePromotionAuthority {
563
585
  const identity = derivePromotionIdentity(session.sessionName);
564
586
  this.#assertConflictFreeIdentity(identity);
565
587
 
566
- const canonicalRoot = this.#stampSession(request, session, identity);
588
+ const canonicalRoot = await this.#stampSession(request, session, identity);
567
589
  this.#assertActive(request.operationId);
568
- this.#verifyPromotedInventory(session.sessionId, identity);
569
- this.#publishFleetEnrollment(request, session, identity);
590
+ await this.#verifyPromotedInventory(session.sessionId, identity);
591
+ await this.#publishFleetEnrollment(request, session, identity);
570
592
 
571
593
  let registered: Workspace;
572
594
  try {
@@ -605,8 +627,8 @@ export class WorkspacePromotionAuthority {
605
627
  }
606
628
  }
607
629
 
608
- #resolveSession(sessionId: string): SessionRecord {
609
- const records = this.#listSessions();
630
+ async #resolveSession(sessionId: string): Promise<SessionRecord> {
631
+ const records = await this.#listSessions();
610
632
  const matches = records.filter(
611
633
  (record) => fleetSessionIdForName(record.sessionName) === sessionId,
612
634
  );
@@ -620,9 +642,9 @@ export class WorkspacePromotionAuthority {
620
642
  return match;
621
643
  }
622
644
 
623
- #listSessions(): SessionRecord[] {
645
+ async #listSessions(): Promise<SessionRecord[]> {
624
646
  try {
625
- return parseSessionRecords(this.#io.runTmux(["list-sessions", "-F", SESSION_FORMAT]));
647
+ return parseSessionRecords(await this.#io.runTmux(["list-sessions", "-F", SESSION_FORMAT]));
626
648
  } catch (error) {
627
649
  if (this.#io.isTmuxUnavailable(error)) return [];
628
650
  throw error;
@@ -648,14 +670,14 @@ export class WorkspacePromotionAuthority {
648
670
  * a newly published marker makes the soon-to-be registered workspace visible
649
671
  * to the shared FleetCatalog in the same mutation transaction.
650
672
  */
651
- #publishFleetEnrollment(
673
+ async #publishFleetEnrollment(
652
674
  request: WorkspacePromoteMutationRequest,
653
675
  session: SessionRecord,
654
676
  identity: PromotionIdentity,
655
- ): void {
677
+ ): Promise<void> {
656
678
  if (session.adopted) return;
657
679
  try {
658
- this.#io.runTmux(["set-option", "-t", session.sessionId, ADOPTED_OPTION, "1"]);
680
+ await this.#io.runTmux(["set-option", "-t", session.sessionId, ADOPTED_OPTION, "1"]);
659
681
  } catch (error) {
660
682
  throw new WorkspacePromotionError(
661
683
  "stamp_failed",
@@ -671,19 +693,19 @@ export class WorkspacePromotionAuthority {
671
693
  * `set-option` failure maps to `stamp_failed`; the caller has not yet touched
672
694
  * the registry, so a failure here leaves the session harmless.
673
695
  */
674
- #stampSession(
696
+ async #stampSession(
675
697
  request: WorkspacePromoteMutationRequest,
676
698
  session: SessionRecord,
677
699
  identity: PromotionIdentity,
678
- ): string {
679
- const scanned = this.#stampPaneInventory(request, session, identity);
700
+ ): Promise<string> {
701
+ const scanned = await this.#stampPaneInventory(request, session, identity);
680
702
  try {
681
703
  for (const [option, value] of [
682
704
  [SESSION_OPERATION_OPTION, request.operationId],
683
705
  [SESSION_WORKSPACE_OPTION, identity.workspaceName],
684
706
  [SESSION_PROMOTED_MARKER_OPTION, "1"],
685
707
  ] as const) {
686
- this.#io.runTmux(["set-option", "-t", session.sessionId, option, value]);
708
+ await this.#io.runTmux(["set-option", "-t", session.sessionId, option, value]);
687
709
  }
688
710
  } catch (error) {
689
711
  throw new WorkspacePromotionError(
@@ -693,7 +715,7 @@ export class WorkspacePromotionAuthority {
693
715
  );
694
716
  }
695
717
 
696
- return this.#resolveProjectDir(session, scanned);
718
+ return await this.#resolveProjectDir(session, scanned);
697
719
  }
698
720
 
699
721
  /**
@@ -703,15 +725,22 @@ export class WorkspacePromotionAuthority {
703
725
  * session, which may be an m32-open workspace whose provenance must never
704
726
  * acquire the promotion marker.
705
727
  */
706
- #stampPaneInventory(
728
+ async #stampPaneInventory(
707
729
  request: WorkspacePromoteMutationRequest,
708
730
  session: SessionRecord,
709
731
  identity: PromotionIdentity,
710
- ): ScanPane[] {
732
+ ): Promise<ScanPane[]> {
711
733
  let scanned: ScanPane[];
712
734
  try {
713
735
  scanned = parseScanPanes(
714
- this.#io.runTmux(["list-panes", "-s", "-t", session.sessionId, "-F", PANE_SCAN_FORMAT]),
736
+ await this.#io.runTmux([
737
+ "list-panes",
738
+ "-s",
739
+ "-t",
740
+ session.sessionId,
741
+ "-F",
742
+ PANE_SCAN_FORMAT,
743
+ ]),
715
744
  );
716
745
  } catch (error) {
717
746
  if (error instanceof WorkspacePromotionError) throw error;
@@ -734,7 +763,7 @@ export class WorkspacePromotionAuthority {
734
763
  for (const pane of scanned) {
735
764
  if (!hasValidPaneStamp(pane.semanticPaneId)) {
736
765
  const paneStamp = `pane.promoted.${digest(`${session.sessionName}\0${pane.paneId}`)}`;
737
- this.#io.runTmux([
766
+ await this.#io.runTmux([
738
767
  "set-option",
739
768
  "-p",
740
769
  "-t",
@@ -745,14 +774,14 @@ export class WorkspacePromotionAuthority {
745
774
  for (const [option, value] of this.#ideDefaults(pane, nowSec)) {
746
775
  // Additive: only fill an empty `@ide_*`, never clobber existing intent.
747
776
  if (value === null) continue;
748
- this.#io.runTmux(["set-option", "-p", "-t", pane.paneId, option, value]);
777
+ await this.#io.runTmux(["set-option", "-p", "-t", pane.paneId, option, value]);
749
778
  }
750
779
  }
751
780
  if (!reconciledWindows.has(pane.windowId)) {
752
781
  reconciledWindows.add(pane.windowId);
753
782
  if (pane.semanticWindowId.length === 0) {
754
783
  const windowStamp = `window.promoted.${digest(`${session.sessionName}\0${pane.windowId}`)}`;
755
- this.#io.runTmux([
784
+ await this.#io.runTmux([
756
785
  "set-option",
757
786
  "-w",
758
787
  "-t",
@@ -763,7 +792,7 @@ export class WorkspacePromotionAuthority {
763
792
  // Initialize chrome only when adopting a previously unstamped
764
793
  // window. Reopening an existing workspace must preserve native
765
794
  // border choices and PTY dimensions, even while stamping new panes.
766
- this.#io.runTmux([
795
+ await this.#io.runTmux([
767
796
  "set-option",
768
797
  "-w",
769
798
  "-t",
@@ -771,7 +800,7 @@ export class WorkspacePromotionAuthority {
771
800
  "pane-border-status",
772
801
  "top",
773
802
  ]);
774
- this.#io.runTmux([
803
+ await this.#io.runTmux([
775
804
  "set-option",
776
805
  "-w",
777
806
  "-t",
@@ -803,7 +832,7 @@ export class WorkspacePromotionAuthority {
803
832
  * (b) then the active pane's cwd, then the remaining panes in scan order;
804
833
  * (c) only when NOTHING resolves does promotion fail.
805
834
  */
806
- #resolveProjectDir(session: SessionRecord, scanned: readonly ScanPane[]): string {
835
+ async #resolveProjectDir(session: SessionRecord, scanned: readonly ScanPane[]): Promise<string> {
807
836
  const active = scanned.find((pane) => pane.active);
808
837
  const candidates = [
809
838
  session.sessionPath,
@@ -813,7 +842,7 @@ export class WorkspacePromotionAuthority {
813
842
  for (const candidate of candidates) {
814
843
  if (candidate.length === 0) continue;
815
844
  try {
816
- return this.#io.canonicalProjectDir(candidate);
845
+ return await this.#io.canonicalProjectDir(candidate);
817
846
  } catch {
818
847
  // A dead or non-directory cwd (a pruned worktree) is expected; try the
819
848
  // next candidate rather than failing the whole promotion.
@@ -866,12 +895,12 @@ export class WorkspacePromotionAuthority {
866
895
  * initial-Terminal-window assertion — a promoted session may have any number
867
896
  * of windows and multi-pane windows.
868
897
  */
869
- #verifyPromotedInventory(sessionId: string, identity: PromotionIdentity): void {
898
+ async #verifyPromotedInventory(sessionId: string, identity: PromotionIdentity): Promise<void> {
870
899
  let panes: VerifyPane[];
871
900
  try {
872
901
  const args = ["list-panes", "-s", "-t", sessionId, "-F", PANE_VERIFY_FORMAT] as const;
873
- const before = boundedTmuxOutput(this.#io.runTmux(args));
874
- const after = boundedTmuxOutput(this.#io.runTmux(args));
902
+ const before = boundedTmuxOutput(await this.#io.runTmux(args));
903
+ const after = boundedTmuxOutput(await this.#io.runTmux(args));
875
904
  if (before !== after) {
876
905
  throw new WorkspacePromotionError("promotion_verification_failed", {
877
906
  reason: "inventory_changed_during_proof",
@@ -1,3 +1,4 @@
1
+ import { boundedTmuxInteractionAppendCommand } from "../../lib/tmux-interaction-retention.ts";
1
2
  import {
2
3
  decodeNativeGridCapture,
3
4
  isNativeBootstrapCapture,
@@ -1853,7 +1854,7 @@ export class SessionChannel {
1853
1854
  const sentinel = (kind: string): string =>
1854
1855
  `display-message -p -l -t ${pane.runtimeId} ` + `"%tmux-ide-atomic-v1 ${nonce} ${kind}"`;
1855
1856
  const observerCommands =
1856
- ` ; set-buffer -a -b ${observer!.bufferName} ${tmuxSingleQuote(observer!.record)}` +
1857
+ ` ; ${boundedTmuxInteractionAppendCommand(observer!.bufferName, observer!.record)}` +
1857
1858
  ` ; wait-for -S ${observer!.signalChannel}`;
1858
1859
  const body =
1859
1860
  `set-option -po -t ${pane.runtimeId} ${INTERNAL_READ_OPERATION_OPTION} ${internalReadMarker}` +
@@ -0,0 +1,180 @@
1
+ /** Quantized sRGB contrast; Oklab matrices: https://bottosson.github.io/posts/oklab/ . */
2
+ const LINEAR = Float64Array.from({ length: 256 }, (_, n) => {
3
+ const c = n / 255;
4
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
5
+ });
6
+ const luminance = (rgb: number): number =>
7
+ 0.2126 * LINEAR[(rgb >>> 16) & 255]! +
8
+ 0.7152 * LINEAR[(rgb >>> 8) & 255]! +
9
+ 0.0722 * LINEAR[rgb & 255]!;
10
+ export function packedContrastRatio(a: number, b: number): number {
11
+ const x = luminance(a) + 0.05,
12
+ y = luminance(b) + 0.05;
13
+ return Math.max(x, y) / Math.min(x, y);
14
+ }
15
+ function byte(c: number): number {
16
+ return Math.round(
17
+ Math.max(0, Math.min(1, c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055)) * 255,
18
+ );
19
+ }
20
+ function candidate(L: number, a: number, b: number): number {
21
+ // Reduce chroma along the same hue ray until inside sRGB, then quantize.
22
+ let lo = 0,
23
+ hi = 1,
24
+ result = -1;
25
+ for (let i = 0; i < 10; i++) {
26
+ const scale = i === 0 ? 1 : (lo + hi) / 2;
27
+ const l = (L + 0.3963377774 * a * scale + 0.2158037573 * b * scale) ** 3;
28
+ const m = (L - 0.1055613458 * a * scale - 0.0638541728 * b * scale) ** 3;
29
+ const s = (L - 0.0894841775 * a * scale - 1.291485548 * b * scale) ** 3;
30
+ const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
31
+ const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
32
+ const blue = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
33
+ if (Math.min(r, g, blue) >= 0 && Math.max(r, g, blue) <= 1) {
34
+ result = (byte(r) << 16) | (byte(g) << 8) | byte(blue);
35
+ if (i === 0) return result;
36
+ lo = scale;
37
+ } else hi = scale;
38
+ }
39
+ return result < 0 ? byte(L ** 3) * 0x10101 : result;
40
+ }
41
+ /** Leave passing pairs exact. Search both lightness directions with a verified endpoint fallback. */
42
+ export function correctContrastForeground(fg: number, bg: number): number {
43
+ if (packedContrastRatio(fg, bg) >= 4.5) return fg;
44
+ const r = LINEAR[(fg >>> 16) & 255]!,
45
+ g = LINEAR[(fg >>> 8) & 255]!,
46
+ b = LINEAR[fg & 255]!;
47
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
48
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
49
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
50
+ const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
51
+ const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
52
+ const bb = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
53
+ let best = 0,
54
+ distance = Infinity;
55
+ for (const end of [0, 1]) {
56
+ let passing = end,
57
+ failing = L;
58
+ let rgb = end === 0 ? 0 : 0xffffff;
59
+ if (packedContrastRatio(rgb, bg) < 4.5) continue;
60
+ for (let i = 0; i < 12; i++) {
61
+ const mid = (passing + failing) / 2;
62
+ const trial = candidate(mid, a, bb);
63
+ if (packedContrastRatio(trial, bg) >= 4.5) {
64
+ passing = mid;
65
+ rgb = trial;
66
+ } else failing = mid;
67
+ }
68
+ if (Math.abs(passing - L) < distance) {
69
+ best = rgb;
70
+ distance = Math.abs(passing - L);
71
+ }
72
+ }
73
+ return best;
74
+ }
75
+
76
+ /** Cheap high-color-churn fallback: linear-sRGB blend to the nearer passing endpoint. */
77
+ export function fastContrastForeground(fg: number, bg: number): number {
78
+ if (packedContrastRatio(fg, bg) >= 4.5) return fg;
79
+ const y = luminance(fg),
80
+ base = luminance(bg);
81
+ const darkTarget = (base + 0.05) / 4.5 - 0.05;
82
+ const lightTarget = (base + 0.05) * 4.5 - 0.05;
83
+ const darkMix = darkTarget >= 0 ? (y - darkTarget) / y : Infinity;
84
+ const lightMix = lightTarget <= 1 ? (lightTarget - y) / (1 - y) : Infinity;
85
+ const end = darkMix <= lightMix ? 0 : 1;
86
+ // A small quantization margin usually avoids endpoint fallback. Always verify.
87
+ const amount = Math.min(1, Math.max(0, Math.min(darkMix, lightMix)) + 0.012);
88
+ const r = LINEAR[(fg >>> 16) & 255]!,
89
+ g = LINEAR[(fg >>> 8) & 255]!,
90
+ b = LINEAR[fg & 255]!;
91
+ const result =
92
+ (byte(r + (end - r) * amount) << 16) |
93
+ (byte(g + (end - g) * amount) << 8) |
94
+ byte(b + (end - b) * amount);
95
+ return packedContrastRatio(result, bg) >= 4.5 ? result : end === 0 ? 0 : 0xffffff;
96
+ }
97
+
98
+ export function createContrastPairCache(limit = 4096) {
99
+ const pairs = new Map<number, number>();
100
+ let remaining = Infinity;
101
+ return {
102
+ beginFrame() {
103
+ remaining = 128;
104
+ },
105
+ get size() {
106
+ return pairs.size;
107
+ },
108
+ correct(fg: number, bg: number): number {
109
+ const key = fg * 0x1000000 + bg;
110
+ const cached = pairs.get(key);
111
+ if (cached !== undefined) return cached;
112
+ const corrected =
113
+ packedContrastRatio(fg, bg) >= 4.5
114
+ ? fg
115
+ : remaining-- > 0
116
+ ? correctContrastForeground(fg, bg)
117
+ : fastContrastForeground(fg, bg);
118
+ if (pairs.size >= limit) pairs.clear();
119
+ pairs.set(key, corrected);
120
+ return corrected;
121
+ },
122
+ };
123
+ }
124
+
125
+ export interface ContrastBuffer {
126
+ readonly buffers: {
127
+ char: Uint32Array;
128
+ fg: Uint16Array;
129
+ bg: Uint16Array;
130
+ attributes: Uint32Array;
131
+ };
132
+ }
133
+ const read = (c: Uint16Array, i: number) =>
134
+ ((c[i]! & 255) << 16) | ((c[i + 1]! & 255) << 8) | (c[i + 2]! & 255);
135
+ /** Final composed surface only: no canonical state or scheduling; allocation-free cache hits. */
136
+ export function createAutomaticContrastPass() {
137
+ const cache = createContrastPairCache();
138
+ return (buffer: ContrastBuffer): void => {
139
+ cache.beginFrame();
140
+ const { char, fg, bg, attributes } = buffer.buffers;
141
+ for (let cell = 0; cell < char.length; cell++) {
142
+ const cp = char[cell]!,
143
+ attr = attributes[cell]!,
144
+ i = cell * 4;
145
+ // Preserve concealment, whitespace and pixel-art coverage. Grapheme IDs and
146
+ // wide continuations are opaque and retained byte-for-byte.
147
+ if (
148
+ attr & 64 ||
149
+ cp === 0 ||
150
+ cp === 32 ||
151
+ cp === 160 ||
152
+ (cp >= 0x2580 && cp <= 0x259f) ||
153
+ (cp >= 0x2800 && cp <= 0x28ff)
154
+ )
155
+ continue;
156
+ if ((fg[i + 3]! & 255) !== 255 || (bg[i + 3]! & 255) !== 255) continue;
157
+ const foreground = attr & 32 ? bg : fg;
158
+ const background = attr & 32 ? fg : bg;
159
+ const original = read(foreground, i);
160
+ const base = read(background, i);
161
+ // DIM is emulator-dependent. Materialize a deterministic 50% sRGB blend
162
+ // before correction and clear only the final composed DIM bit.
163
+ let visible = original;
164
+ if (attr & 2) {
165
+ visible =
166
+ (Math.round(((original >>> 16) + (base >>> 16)) / 2) << 16) |
167
+ (Math.round((((original >>> 8) & 255) + ((base >>> 8) & 255)) / 2) << 8) |
168
+ Math.round(((original & 255) + (base & 255)) / 2);
169
+ attributes[cell] = attr & ~2;
170
+ }
171
+ const corrected = cache.correct(visible, base);
172
+ if (corrected === original && !(attr & 2)) continue;
173
+ // RGB intent is zero. Indexed/default metadata must not override correction.
174
+ foreground[i] = corrected >>> 16;
175
+ foreground[i + 1] = (corrected >>> 8) & 255;
176
+ foreground[i + 2] = corrected & 255;
177
+ foreground[i + 3] = 255;
178
+ }
179
+ };
180
+ }
@@ -155,7 +155,11 @@ export interface OpenTuiWorkspaceRuntimePort extends WorkspaceClientRuntimePort<
155
155
  getLayout(): OpenTuiWorkspaceLayout | null;
156
156
  getLayoutSnapshot(): OpenTuiWorkspaceLayoutSnapshot;
157
157
  onLayout(listener: (layout: OpenTuiWorkspaceLayoutSnapshot) => void): () => void;
158
- fitViewport(cols: number, rows: number): Promise<"ok" | "geometry-authority-conflict">;
158
+ fitViewport(
159
+ cols: number,
160
+ rows: number,
161
+ semanticWindowId?: string,
162
+ ): Promise<"ok" | "geometry-authority-conflict">;
159
163
  }
160
164
 
161
165
  export interface ConnectOpenTuiWorkspaceRuntimePortOptions {
@@ -1395,8 +1399,17 @@ export async function connectOpenTuiWorkspaceRuntimePort(
1395
1399
  }
1396
1400
  }) ?? null;
1397
1401
  type FitResult = "ok" | "geometry-authority-conflict";
1398
- let pendingInitialFit: { cols: number; rows: number; result: Promise<FitResult> } | null = null;
1399
- const fitViewport = (cols: number, rows: number): Promise<FitResult> => {
1402
+ let pendingInitialFit: {
1403
+ cols: number;
1404
+ rows: number;
1405
+ semanticWindowId?: string;
1406
+ result: Promise<FitResult>;
1407
+ } | null = null;
1408
+ const fitViewport = (
1409
+ cols: number,
1410
+ rows: number,
1411
+ semanticWindowId?: string,
1412
+ ): Promise<FitResult> => {
1400
1413
  if (closed) return Promise.resolve("geometry-authority-conflict");
1401
1414
  // Keep only the latest requested geometry while hidden seeds arrive. All
1402
1415
  // callers share one wait; resize storms cannot accumulate deferred work.
@@ -1404,23 +1417,31 @@ export async function connectOpenTuiWorkspaceRuntimePort(
1404
1417
  if (pendingInitialFit) {
1405
1418
  pendingInitialFit.cols = cols;
1406
1419
  pendingInitialFit.rows = rows;
1420
+ pendingInitialFit.semanticWindowId = semanticWindowId;
1407
1421
  return pendingInitialFit.result;
1408
1422
  }
1409
1423
  const request = {
1410
1424
  cols,
1411
1425
  rows,
1426
+ semanticWindowId,
1412
1427
  result: Promise.resolve<FitResult>("geometry-authority-conflict"),
1413
1428
  };
1414
1429
  pendingInitialFit = request;
1415
1430
  request.result = allSeeds.then((ready) => {
1416
1431
  pendingInitialFit = null;
1417
- return ready ? fitViewport(request.cols, request.rows) : "geometry-authority-conflict";
1432
+ return ready
1433
+ ? fitViewport(request.cols, request.rows, request.semanticWindowId)
1434
+ : "geometry-authority-conflict";
1418
1435
  });
1419
1436
  return request.result;
1420
1437
  }
1421
1438
  if ([...endpoints.values()].some((endpoint) => !endpoint.inputReady))
1422
1439
  return Promise.resolve("geometry-authority-conflict");
1423
- return opened.fitViewport(cols, rows).catch((error: unknown) => {
1440
+ return (
1441
+ semanticWindowId === undefined
1442
+ ? opened.fitViewport(cols, rows)
1443
+ : opened.fitViewport(cols, rows, semanticWindowId)
1444
+ ).catch((error: unknown) => {
1424
1445
  if (error instanceof PaneStreamOperationError && error.code === "authority-rejected")
1425
1446
  return "geometry-authority-conflict" as const;
1426
1447
  throw error;
@@ -164,9 +164,8 @@ export interface PaneSurfaceOptions extends RenderableOptions<FrameBufferRendera
164
164
  /** Bumps when canonical presentation changed without terminal cell damage. */
165
165
  presentationVersion?: number;
166
166
  /**
167
- * Renderer-owned framebuffer generation. Unlike `contentVersion`, this
168
- * changes when retained canonical cells are still current but the native
169
- * presentation may have been invalidated by composition or visibility.
167
+ * Renderer-owned composition generation. Retained framebuffer cells survive
168
+ * visibility and position changes; the hardware cursor must be re-presented.
170
169
  */
171
170
  presentationGeneration?: string;
172
171
  /** Retained-source generation; forces a full blit even when content version restarts equal. */
@@ -641,11 +640,11 @@ class PaneSurfaceRenderable extends FrameBufferRenderable {
641
640
  set presentationGeneration(v: string) {
642
641
  if (v === this._presentationGeneration) return;
643
642
  this._presentationGeneration = v;
644
- // Framebuffer validity is independent from terminal content freshness. A
645
- // quiet pane must be able to repaint its retained canonical snapshot after
646
- // visibility/composition changes without waiting for another terminal byte.
647
- this._forceFull = true;
648
- this.invalidate();
643
+ // Composition reuses the retained framebuffer. Geometry reallocation,
644
+ // source replacement and palette changes independently force a full walk.
645
+ // Reclaim the hardware cursor at the new absolute position on visibility.
646
+ this._needsCursorPresentation = true;
647
+ this.requestRender();
649
648
  }
650
649
  set sourceEpoch(v: number) {
651
650
  if (v === this._sourceEpoch) return;
@@ -668,6 +667,8 @@ class PaneSurfaceRenderable extends FrameBufferRenderable {
668
667
  // Detailed focus correlation never owns renderer replacement.
669
668
  }
670
669
  this._rendererEpoch = v;
670
+ this._forceFull = true;
671
+ this.invalidate();
671
672
  }
672
673
  set hostFocusTransitionOwner(v: PaneSurfaceHostFocusTransitionOwner | null | undefined) {
673
674
  if (v !== this._hostFocusTransitionOwner) this.cancelPendingFocusTransition();