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.
- package/bin/cli.js +1979 -783
- package/package.json +4 -2
- package/packages/contracts/src/__tests__/daemon-wire.test.ts +32 -0
- package/packages/contracts/src/daemon-events.ts +12 -12
- package/packages/contracts/src/daemon-wire.ts +30 -0
- package/packages/daemon/dist/command-center/agent-status-watch.js +9 -2
- package/packages/daemon/dist/command-center/diagnostics.js +65 -0
- package/packages/daemon/dist/command-center/log-stream.js +9 -0
- package/packages/daemon/dist/command-center/resources/fleet-preview-route.js +22 -6
- package/packages/daemon/dist/command-center/server.js +7 -0
- package/packages/daemon/dist/doctor.js +62 -0
- package/packages/daemon/dist/lib/__tests__/installed-recovery-fixture.js +400 -0
- package/packages/daemon/dist/lib/app-config.js +4 -2
- package/packages/daemon/dist/lib/canonical-daemon.js +1 -0
- package/packages/daemon/dist/lib/daemon-embed.js +24 -0
- package/packages/daemon/dist/lib/daemon-provenance.js +575 -0
- package/packages/daemon/dist/lib/headless-daemon.js +1 -0
- package/packages/daemon/dist/lib/log-sanitize.js +199 -0
- package/packages/daemon/dist/lib/log.js +123 -13
- package/packages/daemon/dist/lib/soak-diagnostics.js +124 -0
- package/packages/daemon/dist/lib/soak-verdict.js +472 -0
- package/packages/daemon/dist/lib/terminal-host-color.js +33 -0
- package/packages/daemon/dist/lib/tmux-external-interaction-observer.js +187 -39
- package/packages/daemon/dist/lib/tmux-interaction-retention.js +21 -0
- package/packages/daemon/dist/lib/workspace-promotion.js +55 -37
- package/packages/daemon/dist/terminal/mirror/session-channel.js +2 -1
- package/packages/daemon/dist/tui/mirror/automatic-contrast.js +161 -0
- package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +9 -3
- package/packages/daemon/dist/tui/mirror/pane-surface.jsx +7 -5
- package/packages/daemon/dist/tui/mirror/resize-transaction.js +48 -20
- package/packages/daemon/dist/tui/mirror/runtime/application-appearance-owner.js +40 -6
- package/packages/daemon/dist/tui/mirror/runtime/application-fleet-preview.js +4 -1
- package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +63 -42
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +138 -67
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-palette-owner.js +44 -28
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +49 -11
- package/packages/daemon/dist/tui/mirror/runtime/semantic-shell-viewport-resize.js +140 -2
- package/packages/daemon/dist/tui/mirror/runtime/workspace-terminal-fast-lane.js +3 -1
- package/packages/daemon/dist/tui/mirror/semantic-pane-render-source.js +2 -1
- package/packages/daemon/dist/tui/mirror/theme.js +4 -31
- package/packages/daemon/dist/tui/mirror/workspace/terminal-pane-header.jsx +15 -8
- package/packages/daemon/dist/tui/team/wait-receipts.js +112 -33
- package/packages/daemon/src/command-center/agent-status-watch.ts +8 -2
- package/packages/daemon/src/command-center/diagnostics.ts +75 -0
- package/packages/daemon/src/command-center/log-stream.ts +8 -0
- package/packages/daemon/src/command-center/resources/fleet-preview-route.ts +27 -5
- package/packages/daemon/src/command-center/server.ts +8 -0
- package/packages/daemon/src/doctor.ts +70 -0
- package/packages/daemon/src/lib/app-config.ts +11 -3
- package/packages/daemon/src/lib/canonical-daemon.ts +1 -0
- package/packages/daemon/src/lib/daemon-embed.ts +30 -0
- package/packages/daemon/src/lib/daemon-provenance.ts +769 -0
- package/packages/daemon/src/lib/fleet-preview-model.ts +1 -0
- package/packages/daemon/src/lib/headless-daemon.ts +1 -0
- package/packages/daemon/src/lib/log-sanitize.ts +248 -0
- package/packages/daemon/src/lib/log.ts +165 -14
- package/packages/daemon/src/lib/soak-diagnostics.ts +183 -0
- package/packages/daemon/src/lib/soak-verdict.ts +785 -0
- package/packages/daemon/src/lib/terminal-host-color.ts +43 -0
- package/packages/daemon/src/lib/tmux-external-interaction-observer.ts +233 -37
- package/packages/daemon/src/lib/tmux-interaction-retention.ts +24 -0
- package/packages/daemon/src/lib/workspace-promotion.ts +74 -45
- package/packages/daemon/src/terminal/mirror/session-channel.ts +2 -1
- package/packages/daemon/src/tui/mirror/automatic-contrast.ts +180 -0
- package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +26 -5
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +9 -8
- package/packages/daemon/src/tui/mirror/resize-transaction.ts +46 -22
- package/packages/daemon/src/tui/mirror/runtime/application-appearance-owner.ts +45 -6
- package/packages/daemon/src/tui/mirror/runtime/application-fleet-preview.ts +4 -0
- package/packages/daemon/src/tui/mirror/runtime/application-fleet-switcher.tsx +2 -1
- package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +99 -75
- package/packages/daemon/src/tui/mirror/runtime/application-palette-preview.tsx +13 -3
- package/packages/daemon/src/tui/mirror/runtime/application-reference-sheet.tsx +125 -0
- package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +2 -1
- package/packages/daemon/src/tui/mirror/runtime/application-shell-overlays.tsx +199 -157
- package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +2 -0
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +148 -69
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-palette-owner.ts +55 -28
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +59 -17
- package/packages/daemon/src/tui/mirror/runtime/semantic-shell-viewport-resize.ts +151 -3
- package/packages/daemon/src/tui/mirror/runtime/workspace-terminal-fast-lane.ts +3 -1
- package/packages/daemon/src/tui/mirror/semantic-pane-render-source.ts +2 -1
- package/packages/daemon/src/tui/mirror/theme.ts +4 -39
- package/packages/daemon/src/tui/mirror/workspace/terminal-pane-header.tsx +21 -8
- package/packages/daemon/src/tui/team/wait-receipts.ts +115 -40
- package/packages/daemon-client/src/terminal-fast-lane.test.ts +18 -0
- package/packages/daemon-client/src/terminal-fast-lane.ts +7 -2
- package/packages/tmux-bridge/src/index.ts +2 -0
- package/packages/tmux-bridge/src/runner.test.ts +41 -0
- 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 {
|
|
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
|
-
|
|
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 =
|
|
417
|
-
if (!
|
|
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([
|
|
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
|
-
` ;
|
|
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(
|
|
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: {
|
|
1399
|
-
|
|
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
|
|
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
|
|
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
|
|
168
|
-
*
|
|
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
|
-
//
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
this.
|
|
648
|
-
this.
|
|
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();
|