tmux-ide 2.9.9 → 2.9.11
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 +173 -61
- package/package.json +1 -1
- package/packages/contracts/src/pane-stream.ts +12 -0
- package/packages/core/src/terminal-replica-hash-cache.ts +8 -1
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
- package/packages/daemon/dist/terminal/mirror/mirror-service.js +12 -2
- package/packages/daemon/dist/terminal/mirror/session-channel.js +46 -23
- package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +15 -4
- package/packages/daemon/dist/terminal/session-runtime/registry.js +17 -6
- package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +95 -36
- package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +6 -0
- package/packages/daemon/dist/terminal/session-runtime/transport-binding.js +2 -2
- package/packages/daemon/dist/tui/mirror/runtime/application-generation-starter.js +16 -1
- package/packages/daemon/dist/tui/mirror/runtime/application-machine-agent-navigation.js +13 -0
- package/packages/daemon/dist/tui/mirror/runtime/application-machine-navigation.js +12 -4
- package/packages/daemon/dist/tui/mirror/runtime/application-retained-agent.js +18 -0
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +62 -2
- package/packages/daemon/dist/tui/mirror/runtime/open-tui-generation-host.js +2 -2
- package/packages/daemon/dist/tui/mirror/runtime/runtime-layout-presentation.js +6 -3
- package/packages/daemon/dist/tui/mirror/runtime/select-terminal-pane.js +7 -2
- package/packages/daemon/dist/tui/mirror/runtime/terminal-selection-waiter.js +36 -0
- package/packages/daemon/src/terminal/mirror/mirror-service.ts +19 -2
- package/packages/daemon/src/terminal/mirror/session-channel.ts +57 -9
- package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +29 -1
- package/packages/daemon/src/terminal/session-runtime/registry.ts +23 -3
- package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +102 -44
- package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +7 -0
- package/packages/daemon/src/terminal/session-runtime/transport-binding.ts +3 -0
- package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +2 -0
- package/packages/daemon/src/tui/mirror/runtime/application-generation-starter.ts +17 -1
- package/packages/daemon/src/tui/mirror/runtime/application-machine-agent-navigation.ts +15 -0
- package/packages/daemon/src/tui/mirror/runtime/application-machine-navigation.ts +18 -4
- package/packages/daemon/src/tui/mirror/runtime/application-retained-agent.ts +29 -0
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +63 -2
- package/packages/daemon/src/tui/mirror/runtime/open-tui-generation-host.ts +2 -2
- package/packages/daemon/src/tui/mirror/runtime/runtime-layout-presentation.ts +7 -4
- package/packages/daemon/src/tui/mirror/runtime/select-terminal-pane.ts +9 -1
- package/packages/daemon/src/tui/mirror/runtime/terminal-selection-waiter.ts +36 -0
- package/packages/daemon-client/src/pane-stream-client.ts +17 -0
|
@@ -53,6 +53,23 @@ import {
|
|
|
53
53
|
} from "../graphics/kitty-graphics.ts";
|
|
54
54
|
import type { TerminalGraphicsState } from "@tmux-ide/contracts";
|
|
55
55
|
|
|
56
|
+
// Bound parser work per turn without changing byte-stream semantics. This is a
|
|
57
|
+
// batch bound, not a producer queue cap: upstream pressure must preserve bytes.
|
|
58
|
+
const MAX_INGEST_BATCH_BYTES = 256 * 1024;
|
|
59
|
+
type PendingWrite = {
|
|
60
|
+
data: Uint8Array;
|
|
61
|
+
offset: number;
|
|
62
|
+
resolve: () => void;
|
|
63
|
+
reject: (error: unknown) => void;
|
|
64
|
+
};
|
|
65
|
+
type WriteGroup = {
|
|
66
|
+
entries: PendingWrite[];
|
|
67
|
+
bytes: number;
|
|
68
|
+
scheduled: boolean;
|
|
69
|
+
flushQueued: boolean;
|
|
70
|
+
trace: SessionRuntimeTraceContext | null;
|
|
71
|
+
};
|
|
72
|
+
|
|
56
73
|
type CloseReason = "pane-closed" | "session-restarted" | "runtime-disposed";
|
|
57
74
|
export type TerminalReplicaInterpreterOperation =
|
|
58
75
|
| {
|
|
@@ -167,13 +184,7 @@ export class TerminalReplicaInterpreter {
|
|
|
167
184
|
#needsSeed = true;
|
|
168
185
|
#closed = false;
|
|
169
186
|
#walkCount = 0;
|
|
170
|
-
#pendingWrites:
|
|
171
|
-
data: Uint8Array;
|
|
172
|
-
trace: SessionRuntimeTraceContext | null;
|
|
173
|
-
resolve: () => void;
|
|
174
|
-
reject: (error: unknown) => void;
|
|
175
|
-
}> = [];
|
|
176
|
-
#writeFlushScheduled = false;
|
|
187
|
+
#pendingWrites: WriteGroup | null = null;
|
|
177
188
|
#widgetGate = false;
|
|
178
189
|
// kitty graphics: images outlive reseeds; the scanner owns split APCs.
|
|
179
190
|
readonly #kittyScanner = new KittyApcScanner();
|
|
@@ -252,18 +263,27 @@ export class TerminalReplicaInterpreter {
|
|
|
252
263
|
if (operation.type === "write") {
|
|
253
264
|
const data = operation.data.slice();
|
|
254
265
|
const trace = operation.trace ?? null;
|
|
255
|
-
const pendingTrace = this.#pendingWrites[0]?.trace ?? null;
|
|
256
266
|
if (
|
|
257
|
-
this.#pendingWrites
|
|
258
|
-
(
|
|
267
|
+
this.#pendingWrites &&
|
|
268
|
+
(this.#pendingWrites.trace?.traceId ?? null) !== (trace?.traceId ?? null)
|
|
259
269
|
)
|
|
260
270
|
this.#flushWrites();
|
|
271
|
+
const group = (this.#pendingWrites ??= {
|
|
272
|
+
entries: [],
|
|
273
|
+
bytes: 0,
|
|
274
|
+
scheduled: false,
|
|
275
|
+
flushQueued: false,
|
|
276
|
+
trace,
|
|
277
|
+
});
|
|
261
278
|
const promise = new Promise<void>((resolve, reject) => {
|
|
262
|
-
|
|
279
|
+
group.entries.push({ data, offset: 0, resolve, reject });
|
|
280
|
+
group.bytes += data.length;
|
|
263
281
|
});
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
282
|
+
// Leave this group open while the parser awaits its callback. Scheduling
|
|
283
|
+
// it once on the FIFO lets later arrivals join the next bounded batch.
|
|
284
|
+
if (!group.scheduled && !group.flushQueued) {
|
|
285
|
+
group.flushQueued = true;
|
|
286
|
+
this.#scheduler.microtask(() => this.#scheduleWriteGroup(group));
|
|
267
287
|
}
|
|
268
288
|
return promise;
|
|
269
289
|
}
|
|
@@ -528,7 +548,27 @@ export class TerminalReplicaInterpreter {
|
|
|
528
548
|
// turn, so a frame split into dozens of %output chunks would otherwise
|
|
529
549
|
// serialize dozens of turns and fall behind a 30 fps image stream.
|
|
530
550
|
if (scanned.forward.byteLength === 0) return;
|
|
531
|
-
|
|
551
|
+
// Scanning can prepend an escape retained from the previous chunk. Bound
|
|
552
|
+
// the actual parser calls too, and union damage across every fragment.
|
|
553
|
+
for (let offset = 0; offset < scanned.forward.length; offset += MAX_INGEST_BATCH_BYTES) {
|
|
554
|
+
await this.#writeToBackend(
|
|
555
|
+
this.#backend,
|
|
556
|
+
scanned.forward.subarray(offset, offset + MAX_INGEST_BATCH_BYTES),
|
|
557
|
+
);
|
|
558
|
+
// The parser resets its dirty tracker per write. Synchronized output and
|
|
559
|
+
// bounded fragments can both span writes before canonical publication.
|
|
560
|
+
const dirty = this.#backend.dirtyRange();
|
|
561
|
+
const pending = this.#pendingWriteDirty;
|
|
562
|
+
this.#pendingWriteDirty =
|
|
563
|
+
pending === null
|
|
564
|
+
? dirty
|
|
565
|
+
: pending === undefined || dirty === undefined
|
|
566
|
+
? undefined
|
|
567
|
+
: {
|
|
568
|
+
start: Math.min(pending.start, dirty.start),
|
|
569
|
+
end: Math.max(pending.end, dirty.end),
|
|
570
|
+
};
|
|
571
|
+
}
|
|
532
572
|
if (this.#observability.enabled)
|
|
533
573
|
this.#observability.recordSpan(
|
|
534
574
|
"parse",
|
|
@@ -537,19 +577,6 @@ export class TerminalReplicaInterpreter {
|
|
|
537
577
|
this.#observability.nowMicros(),
|
|
538
578
|
trace,
|
|
539
579
|
);
|
|
540
|
-
// The parser resets its dirty tracker per write. Synchronized output may
|
|
541
|
-
// span many writes before publication, so retain their union until commit.
|
|
542
|
-
const dirty = this.#backend.dirtyRange();
|
|
543
|
-
const pending = this.#pendingWriteDirty;
|
|
544
|
-
this.#pendingWriteDirty =
|
|
545
|
-
pending === null
|
|
546
|
-
? dirty
|
|
547
|
-
: pending === undefined || dirty === undefined
|
|
548
|
-
? undefined
|
|
549
|
-
: {
|
|
550
|
-
start: Math.min(pending.start, dirty.start),
|
|
551
|
-
end: Math.max(pending.end, dirty.end),
|
|
552
|
-
};
|
|
553
580
|
// DEC synchronized-output is atomic: no intermediate frame leaks.
|
|
554
581
|
if (this.#backend.requiresNativeReseed()) {
|
|
555
582
|
this.#commit(false);
|
|
@@ -574,6 +601,7 @@ export class TerminalReplicaInterpreter {
|
|
|
574
601
|
if (this.#syncRecovery || this.#closed) return;
|
|
575
602
|
this.#syncRecovery = this.#scheduler.timer(() => {
|
|
576
603
|
this.#syncRecovery = null;
|
|
604
|
+
this.#flushWrites();
|
|
577
605
|
const run = this.#tail.then(async () => {
|
|
578
606
|
if (this.#closed || !this.#backend.modes().synchronizedOutput) return;
|
|
579
607
|
// Recovery is synthetic parser maintenance, not pane output caused by
|
|
@@ -859,24 +887,54 @@ export class TerminalReplicaInterpreter {
|
|
|
859
887
|
}
|
|
860
888
|
}
|
|
861
889
|
|
|
890
|
+
/** Seal the current group at every trace, control and idle barrier. */
|
|
862
891
|
#flushWrites(): void {
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
892
|
+
const group = this.#pendingWrites;
|
|
893
|
+
this.#pendingWrites = null;
|
|
894
|
+
if (group) this.#scheduleWriteGroup(group);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
#scheduleWriteGroup(group: WriteGroup): void {
|
|
898
|
+
if (group.scheduled) return;
|
|
899
|
+
group.scheduled = true;
|
|
900
|
+
const run = this.#tail.then(async () => {
|
|
901
|
+
try {
|
|
902
|
+
while (group.entries.length > 0) {
|
|
903
|
+
const length = Math.min(group.bytes, MAX_INGEST_BATCH_BYTES);
|
|
904
|
+
const data = new Uint8Array(length);
|
|
905
|
+
const completed: PendingWrite[] = [];
|
|
906
|
+
let offset = 0;
|
|
907
|
+
for (const entry of group.entries) {
|
|
908
|
+
const take = Math.min(entry.data.length - entry.offset, length - offset);
|
|
909
|
+
data.set(entry.data.subarray(entry.offset, entry.offset + take), offset);
|
|
910
|
+
offset += take;
|
|
911
|
+
entry.offset += take;
|
|
912
|
+
if (entry.offset === entry.data.length) completed.push(entry);
|
|
913
|
+
else break;
|
|
914
|
+
if (offset === length && length > 0) break;
|
|
915
|
+
}
|
|
916
|
+
group.entries.splice(0, completed.length);
|
|
917
|
+
group.bytes -= length;
|
|
918
|
+
try {
|
|
919
|
+
await this.#write(data, group.trace);
|
|
920
|
+
} catch (error) {
|
|
921
|
+
for (const entry of completed) entry.reject(error);
|
|
922
|
+
throw error;
|
|
923
|
+
}
|
|
924
|
+
for (const entry of completed) entry.resolve();
|
|
925
|
+
}
|
|
926
|
+
} catch (error) {
|
|
927
|
+
// Reject every remaining fragment in the failed group; later FIFO
|
|
928
|
+
// controls (including recovery reseeds and close) still execute.
|
|
929
|
+
for (const entry of group.entries) entry.reject(error);
|
|
930
|
+
group.entries.length = 0;
|
|
931
|
+
group.bytes = 0;
|
|
932
|
+
throw error;
|
|
933
|
+
} finally {
|
|
934
|
+
if (this.#pendingWrites === group) this.#pendingWrites = null;
|
|
935
|
+
}
|
|
936
|
+
});
|
|
875
937
|
this.#tail = run.catch(() => undefined);
|
|
876
|
-
void run.then(
|
|
877
|
-
() => pending.forEach((entry) => entry.resolve()),
|
|
878
|
-
(error) => pending.forEach((entry) => entry.reject(error)),
|
|
879
|
-
);
|
|
880
938
|
}
|
|
881
939
|
|
|
882
940
|
#observeMarkerBytes(data: Uint8Array): void {
|
|
@@ -259,6 +259,13 @@ export class SessionRuntimeTerminalReplicaOwner {
|
|
|
259
259
|
return this.#disposed ? null : this.#publishedIncarnation;
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
/** Scalar admission fence; unavailable while canonical replacement is pending. */
|
|
263
|
+
inputAdmissionIncarnation(): string | null {
|
|
264
|
+
return this.#disposed || this.#reseed || this.#waitingForGeometryCapture
|
|
265
|
+
? null
|
|
266
|
+
: this.#publishedIncarnation;
|
|
267
|
+
}
|
|
268
|
+
|
|
262
269
|
qualificationSnapshot(): TerminalReplicaQualificationSnapshot {
|
|
263
270
|
const seed = this.#interpreter.currentSeed();
|
|
264
271
|
return Object.freeze({
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type SessionRuntimeSemanticIntent,
|
|
14
14
|
type SessionRuntimeTerminalInput,
|
|
15
15
|
type CausalCellProbeV1,
|
|
16
|
+
type PaneStreamInputLifetime,
|
|
16
17
|
type TerminalDeliveryOffer,
|
|
17
18
|
type TerminalDeliveryServerMessage,
|
|
18
19
|
} from "@tmux-ide/contracts";
|
|
@@ -428,6 +429,7 @@ export class SessionRuntimeTransportBinding {
|
|
|
428
429
|
performanceTraceId?: string,
|
|
429
430
|
causalProbe?: CausalCellProbeV1,
|
|
430
431
|
onCausalResult?: (result: CausalCellLedgerResult) => void,
|
|
432
|
+
expectedLifetime?: PaneStreamInputLifetime,
|
|
431
433
|
): void {
|
|
432
434
|
this.assertController(semanticPaneId);
|
|
433
435
|
const lease = this.#shared.lease;
|
|
@@ -451,6 +453,7 @@ export class SessionRuntimeTransportBinding {
|
|
|
451
453
|
onCausalResult?.(result);
|
|
452
454
|
}
|
|
453
455
|
: onCausalResult,
|
|
456
|
+
expectedLifetime,
|
|
454
457
|
);
|
|
455
458
|
} catch (error) {
|
|
456
459
|
if (causalProbe) this.#causalCellProbes.delete(causalProbe.traceId);
|
|
@@ -144,6 +144,8 @@ export const OPEN_TUI_HOST_CLIENT_ID = `opentui:${process.pid}`;
|
|
|
144
144
|
export type OpenTuiWorkspaceLayout = Extract<PaneStreamServerFrame, { type: "layout" }>;
|
|
145
145
|
|
|
146
146
|
export interface OpenTuiWorkspaceLayoutSnapshot {
|
|
147
|
+
/** Renderer-local provenance, stamped only by the host presentation adapter. */
|
|
148
|
+
readonly rendererEpoch?: number;
|
|
147
149
|
/** Live link authority; absent until an atomic stream snapshot arrives. */
|
|
148
150
|
readonly windowLinks?: WindowLinkTopology | null;
|
|
149
151
|
readonly current: OpenTuiWorkspaceLayout | null;
|
|
@@ -104,9 +104,25 @@ export function createApplicationAgentNavigator(options: {
|
|
|
104
104
|
return async (sessionName: string, paneId: string, source: Source = "mouse") => {
|
|
105
105
|
const token = ++navigationToken;
|
|
106
106
|
const currentOwner = options.sessionOwner();
|
|
107
|
+
const snapshot = currentOwner.snapshot();
|
|
108
|
+
// A renderer rebind keeps its semantic control connection. Reserve this
|
|
109
|
+
// exact target synchronously; the controller fences and bounds first send.
|
|
110
|
+
const retainedControl = (() => {
|
|
111
|
+
try {
|
|
112
|
+
return (
|
|
113
|
+
snapshot?.status === "rebinding" &&
|
|
114
|
+
snapshot.connection?.workspaceName === sessionName &&
|
|
115
|
+
snapshot.daemonGeneration &&
|
|
116
|
+
snapshot.client &&
|
|
117
|
+
Number.isSafeInteger(snapshot.client.getSnapshot().generation)
|
|
118
|
+
);
|
|
119
|
+
} catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
})();
|
|
107
123
|
if (
|
|
108
124
|
currentOwner.sessionName() === sessionName &&
|
|
109
|
-
applicationGenerationNavigationKey(
|
|
125
|
+
(applicationGenerationNavigationKey(snapshot) !== null || retainedControl)
|
|
110
126
|
) {
|
|
111
127
|
// Sidebar agents belong to the already-live workspace. Select their pane
|
|
112
128
|
// before returning control to OpenTUI so the very next key cannot leak to
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { retainsAgentControl } from "./application-retained-agent.ts";
|
|
1
2
|
import {
|
|
2
3
|
createApplicationHomeAgentNavigator,
|
|
3
4
|
type HomeAgentNavigationSource,
|
|
@@ -39,6 +40,20 @@ export function createApplicationMachineAgentNavigator(options: {
|
|
|
39
40
|
const selected = () => options.selectedMachineId() === machineId;
|
|
40
41
|
const generation = () =>
|
|
41
42
|
selected() && options.generationMachineId() === machineId ? options.generation() : null;
|
|
43
|
+
if (
|
|
44
|
+
selected() &&
|
|
45
|
+
options.isCurrentTarget(machineId, target) &&
|
|
46
|
+
options.sessionName() === target.sessionName &&
|
|
47
|
+
retainsAgentControl(generation(), target)
|
|
48
|
+
) {
|
|
49
|
+
// Reserve before the next key; the terminal controller waits for a fresh
|
|
50
|
+
// coherent runtime and remains the only owner of physical input.
|
|
51
|
+
options.selectPane(target.paneId!, source);
|
|
52
|
+
options.showTerminals(source);
|
|
53
|
+
options.setNote(null);
|
|
54
|
+
opening = false;
|
|
55
|
+
return Promise.resolve({ opened: true });
|
|
56
|
+
}
|
|
42
57
|
current = createApplicationHomeAgentNavigator({
|
|
43
58
|
isCurrentTarget: (row) => selected() && options.isCurrentTarget(machineId, row),
|
|
44
59
|
currentGeneration() {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { retainsAgentControl } from "./application-retained-agent.ts";
|
|
1
2
|
import type { OpenTuiGenerationHostSnapshot } from "./open-tui-generation-host.ts";
|
|
2
3
|
import type { TmuxServerScope } from "@tmux-ide/contracts";
|
|
3
4
|
import { fleetHostColor } from "./fleet-presentation.ts";
|
|
@@ -90,7 +91,12 @@ export function createApplicationMachineNavigation(options: {
|
|
|
90
91
|
const [tabRevision, setTabRevision] = createSignal(0);
|
|
91
92
|
const history: string[] = [...saved().recent].reverse();
|
|
92
93
|
let historyIndex = history.length - 1;
|
|
93
|
-
const select = (
|
|
94
|
+
const select = (
|
|
95
|
+
id: string,
|
|
96
|
+
expectedLiveSessionId?: string,
|
|
97
|
+
server?: TmuxServerScope,
|
|
98
|
+
retainedAgent?: ApplicationMachineAgent,
|
|
99
|
+
) => {
|
|
94
100
|
if (!manager.getMachine(id)) return false;
|
|
95
101
|
if (manager.snapshot().selectedMachineId !== id) {
|
|
96
102
|
navigation++;
|
|
@@ -100,9 +106,17 @@ export function createApplicationMachineNavigation(options: {
|
|
|
100
106
|
else options.resetWorkspace(id, expectedLiveSessionId);
|
|
101
107
|
manager.select(id);
|
|
102
108
|
} else if (expectedLiveSessionId) {
|
|
109
|
+
const active = options.attachedGeneration?.() ?? null;
|
|
110
|
+
const retained =
|
|
111
|
+
retainedAgent && retainsAgentControl(active, retainedAgent)
|
|
112
|
+
? {
|
|
113
|
+
liveSessionId: active!.connection!.liveSessionId,
|
|
114
|
+
daemonGeneration: active!.daemonGeneration,
|
|
115
|
+
...(active!.connection!.server ? { server: active!.connection!.server } : {}),
|
|
116
|
+
}
|
|
117
|
+
: null;
|
|
103
118
|
const attached =
|
|
104
|
-
options.attachedTarget?.() ??
|
|
105
|
-
attachedMachineNavigationTarget(options.attachedGeneration?.() ?? null);
|
|
119
|
+
options.attachedTarget?.() ?? attachedMachineNavigationTarget(active) ?? retained;
|
|
106
120
|
const sameAuthority =
|
|
107
121
|
attached?.liveSessionId === expectedLiveSessionId &&
|
|
108
122
|
(server
|
|
@@ -357,7 +371,7 @@ export function createApplicationMachineNavigation(options: {
|
|
|
357
371
|
return;
|
|
358
372
|
}
|
|
359
373
|
options.cancelOpen();
|
|
360
|
-
if (!select(id, selectedSession?.liveSessionId, selectedSession?.server)) return;
|
|
374
|
+
if (!select(id, selectedSession?.liveSessionId, selectedSession?.server, row)) return;
|
|
361
375
|
if (selectedSession) setActiveSessionKey(selectedSession.id);
|
|
362
376
|
navigation++;
|
|
363
377
|
setFocused(false);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { OpenTuiGenerationHostSnapshot } from "./open-tui-generation-host.ts";
|
|
2
|
+
import type { HomeAgentNavigationTarget } from "./application-home-agent-navigation.ts";
|
|
3
|
+
|
|
4
|
+
/** Selection intent may wait on this exact retained control; input cannot use it yet. */
|
|
5
|
+
export function retainsAgentControl(
|
|
6
|
+
active: OpenTuiGenerationHostSnapshot | null,
|
|
7
|
+
target: HomeAgentNavigationTarget,
|
|
8
|
+
): boolean {
|
|
9
|
+
if (
|
|
10
|
+
active?.status !== "rebinding" ||
|
|
11
|
+
!active.client ||
|
|
12
|
+
!target.paneId ||
|
|
13
|
+
active.daemonGeneration !== target.daemonInstanceId ||
|
|
14
|
+
active.connection?.workspaceName !== target.sessionName ||
|
|
15
|
+
active.connection.liveSessionId !== target.liveSessionId
|
|
16
|
+
)
|
|
17
|
+
return false;
|
|
18
|
+
try {
|
|
19
|
+
const state = active.client.getSnapshot();
|
|
20
|
+
return (
|
|
21
|
+
Number.isSafeInteger(state.generation) &&
|
|
22
|
+
state.semantic?.sidebar.agents.some(
|
|
23
|
+
(agent) => agent.id === target.agentId && agent.paneId === target.paneId,
|
|
24
|
+
) === true
|
|
25
|
+
);
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createTerminalSelectionWaiter } from "./terminal-selection-waiter.ts";
|
|
1
2
|
import { WorkspaceWindowLinkSelectResultSchemaZ } from "@tmux-ide/contracts";
|
|
2
3
|
import { windowLinkForPane, windowLinkTarget } from "./application-terminal-workspace-policy.ts";
|
|
3
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
@@ -350,6 +351,10 @@ export function createApplicationTerminalInteractionController(
|
|
|
350
351
|
left.generation === right.generation &&
|
|
351
352
|
left.workspace === right.workspace;
|
|
352
353
|
let selectedControl: ControlIdentity | null = null;
|
|
354
|
+
const selectionWaiter = createTerminalSelectionWaiter();
|
|
355
|
+
let observedLayoutEpoch: number | null = null;
|
|
356
|
+
let selectionGeneration: OpenTuiGenerationHostSnapshot | null = null;
|
|
357
|
+
let selectionLayout = options.layout();
|
|
353
358
|
const waitingInput = new Set<{
|
|
354
359
|
control: ControlIdentity;
|
|
355
360
|
weight: number;
|
|
@@ -823,10 +828,30 @@ export function createApplicationTerminalInteractionController(
|
|
|
823
828
|
"semanticPaneId" | "generation" | "incarnation" | "revision" | "stateHash"
|
|
824
829
|
>;
|
|
825
830
|
}>({
|
|
826
|
-
select: async (paneId) => {
|
|
831
|
+
select: async (paneId): Promise<boolean> => {
|
|
827
832
|
finishLinkSelection(false);
|
|
833
|
+
const initial = options.generation();
|
|
834
|
+
const selectionControl = controlIdentity(initial);
|
|
835
|
+
const selectionVersion = paneInput.selectionVersion;
|
|
836
|
+
const selectionCurrent = (): boolean =>
|
|
837
|
+
paneInput.selectionVersion === selectionVersion &&
|
|
838
|
+
sameControl(selectionControl, controlIdentity(selectionGeneration));
|
|
839
|
+
const panePresent = () =>
|
|
840
|
+
selectionLayout.windows.some((window) => window.panes.some((pane) => pane.pane === paneId));
|
|
841
|
+
if (initial?.status === "rebinding") {
|
|
842
|
+
// Reserve the optimistic target now, but send its FIRST mutation only
|
|
843
|
+
// after this same control connection has a coherent terminal runtime.
|
|
844
|
+
const ready = await selectionWaiter.wait(() => {
|
|
845
|
+
if (!selectionCurrent()) return false;
|
|
846
|
+
const active = selectionGeneration;
|
|
847
|
+
if (active?.status !== "live" || observedLayoutEpoch !== active.rendererEpoch)
|
|
848
|
+
return "pending";
|
|
849
|
+
return panePresent();
|
|
850
|
+
});
|
|
851
|
+
if (!ready || !selectionCurrent()) return false;
|
|
852
|
+
}
|
|
828
853
|
const expected = liveSelectionTarget();
|
|
829
|
-
|
|
854
|
+
let dispatchedRendererEpoch: number | undefined;
|
|
830
855
|
const selecting = pendingWindowSwitch;
|
|
831
856
|
const operationId = selecting?.traceId;
|
|
832
857
|
const selectionFailure: { current: PaneSelectionFailure | null } = { current: null };
|
|
@@ -845,8 +870,37 @@ export function createApplicationTerminalInteractionController(
|
|
|
845
870
|
selectionFailure.current = failure;
|
|
846
871
|
},
|
|
847
872
|
windowLinkForPane(options.layout(), paneId) ?? undefined,
|
|
873
|
+
() => {
|
|
874
|
+
dispatchedRendererEpoch = options.generation()?.rendererEpoch;
|
|
875
|
+
},
|
|
848
876
|
)
|
|
849
877
|
: null;
|
|
878
|
+
if (
|
|
879
|
+
receipt === null &&
|
|
880
|
+
selectionFailure.current?.reason === "topology-changed" &&
|
|
881
|
+
selectionCurrent()
|
|
882
|
+
) {
|
|
883
|
+
// The daemon may have selected the pane before retiring its stream.
|
|
884
|
+
// Observe the replacement runtime; never resubmit an uncertain command
|
|
885
|
+
// or manufacture an operation receipt. Input still waits on this promise.
|
|
886
|
+
const confirmed = await selectionWaiter.wait(() => {
|
|
887
|
+
if (!selectionCurrent()) return false;
|
|
888
|
+
const active = selectionGeneration;
|
|
889
|
+
if (
|
|
890
|
+
active?.status !== "live" ||
|
|
891
|
+
active.rendererEpoch === dispatchedRendererEpoch ||
|
|
892
|
+
observedLayoutEpoch !== active.rendererEpoch
|
|
893
|
+
)
|
|
894
|
+
return "pending";
|
|
895
|
+
if (!panePresent()) return false;
|
|
896
|
+
const current = selectionLayout.current;
|
|
897
|
+
const activePanes = current?.panes.filter((pane) => pane.active) ?? [];
|
|
898
|
+
if (!current?.currentWindow || activePanes.length !== 1) return "pending";
|
|
899
|
+
return activePanes[0]?.pane === paneId ? true : "pending";
|
|
900
|
+
});
|
|
901
|
+
if (selecting && pendingWindowSwitch === selecting) pendingWindowSwitch = null;
|
|
902
|
+
return confirmed && selectionCurrent();
|
|
903
|
+
}
|
|
850
904
|
if (selecting && pendingWindowSwitch === selecting) {
|
|
851
905
|
const receiptAtMicros = options.detailedWindowSwitchTiming ? diagnosticNowMicros() : null;
|
|
852
906
|
if (selecting.timing) selecting.timing.receiptAtMicros = receiptAtMicros;
|
|
@@ -1001,17 +1055,20 @@ export function createApplicationTerminalInteractionController(
|
|
|
1001
1055
|
|
|
1002
1056
|
return {
|
|
1003
1057
|
cancelPendingInput() {
|
|
1058
|
+
selectionWaiter.cancel();
|
|
1004
1059
|
finishLinkSelection(false);
|
|
1005
1060
|
paneInput.invalidateSelection();
|
|
1006
1061
|
for (const entry of waitingInput) entry.settle(false);
|
|
1007
1062
|
},
|
|
1008
1063
|
adoptGeneration(snapshot) {
|
|
1064
|
+
selectionGeneration = snapshot;
|
|
1009
1065
|
const control = controlIdentity(snapshot);
|
|
1010
1066
|
if (!sameControl(selectedControl, control)) {
|
|
1011
1067
|
finishLinkSelection(false);
|
|
1012
1068
|
paneInput.invalidateSelection();
|
|
1013
1069
|
}
|
|
1014
1070
|
selectedControl = control;
|
|
1071
|
+
selectionWaiter.reconcile();
|
|
1015
1072
|
if (waitingInput.size > 0 && snapshot?.status === "live" && snapshot.fastLane) {
|
|
1016
1073
|
// Old waiters resume first; input admitted synchronously by the next
|
|
1017
1074
|
// layout publication must not overtake them in this microtask turn.
|
|
@@ -1074,6 +1131,9 @@ export function createApplicationTerminalInteractionController(
|
|
|
1074
1131
|
inputAuthorityIdentity = next;
|
|
1075
1132
|
},
|
|
1076
1133
|
adoptLayout(snapshot) {
|
|
1134
|
+
selectionLayout = snapshot;
|
|
1135
|
+
observedLayoutEpoch = snapshot.rendererEpoch ?? null;
|
|
1136
|
+
selectionWaiter.reconcile();
|
|
1077
1137
|
reconcileLinkSelection(snapshot);
|
|
1078
1138
|
paneInput.adoptCanonicalPane(snapshot.current ? paneForWindow(snapshot.current) : null);
|
|
1079
1139
|
const currentWindow = snapshot.current?.semanticWindowId ?? snapshot.current?.windowName;
|
|
@@ -1165,6 +1225,7 @@ export function createApplicationTerminalInteractionController(
|
|
|
1165
1225
|
selectPane: (paneId) => {
|
|
1166
1226
|
finishLinkSelection(false);
|
|
1167
1227
|
paneInput.selectPane(paneId);
|
|
1228
|
+
selectionWaiter.reconcile();
|
|
1168
1229
|
},
|
|
1169
1230
|
async unlinkWindowLink(target) {
|
|
1170
1231
|
const expected = liveSelectionTarget();
|
|
@@ -560,7 +560,7 @@ export function createOpenTuiGenerationHost(
|
|
|
560
560
|
// desired state against the fresh socket on the following live publish.
|
|
561
561
|
publish({ ...snapshot, status: "rebinding" });
|
|
562
562
|
owner.stopPresentation?.();
|
|
563
|
-
owner.stopPresentation = runtime ? presentation.adopt(runtime) : null;
|
|
563
|
+
owner.stopPresentation = runtime ? presentation.adopt(runtime, rendererEpoch + 1) : null;
|
|
564
564
|
owner.runtime = runtime;
|
|
565
565
|
owner.authorityClient = runtime ? runtimeAuthorityClient(owner.bundle, runtime) : null;
|
|
566
566
|
if (!runtime) presentation.clear();
|
|
@@ -580,7 +580,7 @@ export function createOpenTuiGenerationHost(
|
|
|
580
580
|
}
|
|
581
581
|
if (candidate !== owner) return;
|
|
582
582
|
if (runtime) {
|
|
583
|
-
owner.stopPresentation = presentation.adopt(runtime);
|
|
583
|
+
owner.stopPresentation = presentation.adopt(runtime, rendererEpoch + 1);
|
|
584
584
|
diagnose?.("host-internal-snapshot-publication", {
|
|
585
585
|
publicationPhase: "presentation-adopted",
|
|
586
586
|
daemonGeneration: owner.bundle.connection.target.daemon.instanceId,
|
|
@@ -11,7 +11,7 @@ export interface OpenTuiRuntimeLayoutPresentation {
|
|
|
11
11
|
getWindowSnapshot(): OpenTuiWorkspaceLayoutSnapshot;
|
|
12
12
|
subscribe(listener: (layout: OpenTuiTerminalLayout | null) => void): () => void;
|
|
13
13
|
subscribeWindows(listener: (layout: OpenTuiWorkspaceLayoutSnapshot) => void): () => void;
|
|
14
|
-
adopt(port: OpenTuiLayoutPort): () => void;
|
|
14
|
+
adopt(port: OpenTuiLayoutPort, rendererEpoch: number): () => void;
|
|
15
15
|
clear(): void;
|
|
16
16
|
dispose(): void;
|
|
17
17
|
}
|
|
@@ -66,21 +66,24 @@ export function createOpenTuiRuntimeLayoutPresentation(): OpenTuiRuntimeLayoutPr
|
|
|
66
66
|
listener(windows);
|
|
67
67
|
return () => windowListeners.delete(listener);
|
|
68
68
|
},
|
|
69
|
-
adopt(port) {
|
|
69
|
+
adopt(port, rendererEpoch) {
|
|
70
70
|
if (disposed) return () => undefined;
|
|
71
71
|
let candidate = true;
|
|
72
72
|
let candidateStop: (() => void) | null = null;
|
|
73
73
|
candidateStop = port.onLayout((layout) => {
|
|
74
74
|
if (disposed || !candidate || !layout.current) return;
|
|
75
|
+
// A port may reuse its snapshot object. Own the provenance wrapper so
|
|
76
|
+
// a preparing runtime cannot relabel the retained coherent frame.
|
|
77
|
+
const presented = Object.freeze({ ...layout, rendererEpoch });
|
|
75
78
|
if (currentPort !== port) {
|
|
76
79
|
const previousStop = stopLayout;
|
|
77
80
|
currentPort = port;
|
|
78
81
|
stopLayout = candidateStop;
|
|
79
|
-
publish(
|
|
82
|
+
publish(presented);
|
|
80
83
|
previousStop?.();
|
|
81
84
|
return;
|
|
82
85
|
}
|
|
83
|
-
publish(
|
|
86
|
+
publish(presented);
|
|
84
87
|
});
|
|
85
88
|
// `onLayout` intentionally seeds synchronously. Capture the disposer
|
|
86
89
|
// after that first callback has promoted this candidate.
|
|
@@ -20,6 +20,7 @@ export type PaneSelectionFailure = Readonly<{
|
|
|
20
20
|
| "generation-replaced"
|
|
21
21
|
| "operation-timeout"
|
|
22
22
|
| "transport-rejected"
|
|
23
|
+
| "topology-changed"
|
|
23
24
|
| "receipt-invalid";
|
|
24
25
|
backendReason?:
|
|
25
26
|
| "pane_inventory_not_ready"
|
|
@@ -42,6 +43,7 @@ export async function selectTerminalPane(
|
|
|
42
43
|
operationId?: string,
|
|
43
44
|
onFailure?: (failure: PaneSelectionFailure) => void,
|
|
44
45
|
windowLink?: WindowLinkTarget,
|
|
46
|
+
onDispatch?: () => void,
|
|
45
47
|
): Promise<PaneSelectionReceipt | null> {
|
|
46
48
|
const fail = (failure: PaneSelectionFailure): null => {
|
|
47
49
|
try {
|
|
@@ -62,7 +64,12 @@ export async function selectTerminalPane(
|
|
|
62
64
|
: undefined;
|
|
63
65
|
return fail({
|
|
64
66
|
stage,
|
|
65
|
-
reason:
|
|
67
|
+
reason:
|
|
68
|
+
code === "operation-timeout"
|
|
69
|
+
? "operation-timeout"
|
|
70
|
+
: code === "topology-changed" && stage === "dispatch"
|
|
71
|
+
? "topology-changed"
|
|
72
|
+
: "transport-rejected",
|
|
66
73
|
...(backendReason ? { backendReason } : {}),
|
|
67
74
|
});
|
|
68
75
|
};
|
|
@@ -90,6 +97,7 @@ export async function selectTerminalPane(
|
|
|
90
97
|
if (!isCurrent()) return fail({ stage: "pre-dispatch", reason: "generation-replaced" });
|
|
91
98
|
let result: unknown;
|
|
92
99
|
try {
|
|
100
|
+
onDispatch?.();
|
|
93
101
|
result = await expected.client.dispatch({
|
|
94
102
|
kind: "semantic-intent",
|
|
95
103
|
...(operationId ? { operationId } : {}),
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** One bounded selection observation; never submits or repeats a mutation. */
|
|
2
|
+
export function createTerminalSelectionWaiter() {
|
|
3
|
+
let pending: {
|
|
4
|
+
check: () => boolean | "pending";
|
|
5
|
+
finish: (selected: boolean) => void;
|
|
6
|
+
} | null = null;
|
|
7
|
+
const cancel = () => pending?.finish(false);
|
|
8
|
+
const reconcile = () => {
|
|
9
|
+
const waiting = pending;
|
|
10
|
+
if (!waiting) return;
|
|
11
|
+
const result = waiting.check();
|
|
12
|
+
if (result !== "pending") waiting.finish(result);
|
|
13
|
+
};
|
|
14
|
+
return {
|
|
15
|
+
cancel,
|
|
16
|
+
reconcile,
|
|
17
|
+
wait(check: () => boolean | "pending"): Promise<boolean> {
|
|
18
|
+
cancel();
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
const timer = setTimeout(() => waiting.finish(false), 5000);
|
|
21
|
+
timer.unref?.();
|
|
22
|
+
const waiting = {
|
|
23
|
+
check,
|
|
24
|
+
finish(selected: boolean) {
|
|
25
|
+
if (pending !== waiting) return;
|
|
26
|
+
pending = null;
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
resolve(selected);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
pending = waiting;
|
|
32
|
+
reconcile();
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|