tmux-ide 2.9.10 → 2.9.12
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 +4321 -2596
- package/package.json +1 -1
- package/packages/contracts/src/index.ts +4 -0
- package/packages/contracts/src/pane-stream.ts +72 -0
- package/packages/contracts/src/terminal-delivery-binary-chunk.ts +55 -0
- package/packages/contracts/src/terminal-delivery.ts +17 -2
- package/packages/contracts/src/terminal-graphics.ts +68 -0
- package/packages/contracts/src/terminal-projection.ts +153 -0
- package/packages/core/src/fixtures/terminal-projection-vectors.json +585 -0
- package/packages/core/src/index.ts +2 -0
- package/packages/core/src/terminal-delivery-binary.ts +160 -0
- package/packages/core/src/terminal-delivery.ts +371 -16
- package/packages/core/src/terminal-projection-vectors.mjs +82 -0
- package/packages/core/src/terminal-projection.ts +476 -0
- package/packages/core/src/terminal-replica-hash-cache.ts +8 -1
- package/packages/core/src/terminal-replica.ts +1 -1
- package/packages/daemon/dist/lib/daemon-embed.js +39 -3
- package/packages/daemon/dist/lib/embedded-tmux-server-owners.js +1 -0
- package/packages/daemon/dist/lib/tmux-server-owner.js +1 -0
- 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/graphics/local-image-owner.js +330 -0
- package/packages/daemon/dist/terminal/graphics/local-image-store.js +255 -0
- 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 +190 -19
- package/packages/daemon/dist/terminal/pane-stream/runtime.js +1 -0
- package/packages/daemon/dist/terminal/session-runtime/registry.js +19 -6
- package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +146 -16
- package/packages/daemon/dist/terminal/session-runtime/terminal-projection-delivery.js +32 -0
- package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +95 -36
- package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +5 -0
- package/packages/daemon/dist/terminal/session-runtime/transport-binding.js +2 -2
- package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +4 -0
- 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 +90 -5
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +13 -1
- package/packages/daemon/dist/tui/mirror/runtime/open-tui-generation-host.js +2 -2
- package/packages/daemon/dist/tui/mirror/runtime/renderer-probe.js +195 -0
- 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-fast-lane-renderer-adapter.js +6 -0
- package/packages/daemon/dist/tui/mirror/runtime/terminal-selection-waiter.js +36 -0
- package/packages/daemon/dist/tui/mirror/semantic-pane-render-source.js +4 -0
- package/packages/daemon/src/lib/daemon-embed.ts +38 -2
- package/packages/daemon/src/lib/embedded-tmux-server-owners.ts +3 -0
- package/packages/daemon/src/lib/tmux-server-owner.ts +3 -0
- package/packages/daemon/src/terminal/graphics/local-image-owner.ts +346 -0
- package/packages/daemon/src/terminal/graphics/local-image-store.ts +286 -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 +248 -16
- package/packages/daemon/src/terminal/pane-stream/runtime.ts +3 -0
- package/packages/daemon/src/terminal/session-runtime/registry.ts +25 -3
- package/packages/daemon/src/terminal/session-runtime/runtime-observability.ts +1 -1
- package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +216 -22
- package/packages/daemon/src/terminal/session-runtime/terminal-delivery-observation-identity.ts +7 -4
- package/packages/daemon/src/terminal/session-runtime/terminal-projection-delivery.ts +59 -0
- package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +102 -44
- package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +6 -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 +6 -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 +89 -6
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +20 -1
- package/packages/daemon/src/tui/mirror/runtime/open-tui-generation-host.ts +2 -2
- package/packages/daemon/src/tui/mirror/runtime/renderer-probe.ts +257 -0
- 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-fast-lane-renderer-adapter.ts +15 -0
- package/packages/daemon/src/tui/mirror/runtime/terminal-selection-waiter.ts +36 -0
- package/packages/daemon/src/tui/mirror/semantic-pane-render-source.ts +4 -0
- package/packages/daemon-client/src/pane-stream-client.ts +92 -1
|
@@ -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,12 @@ export class SessionRuntimeTerminalReplicaOwner {
|
|
|
259
259
|
return this.#disposed ? null : this.#publishedIncarnation;
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
/** Screen recapture does not replace the terminal lifetime. Keep its input fence
|
|
263
|
+
* while resizing; disposal or a new owner still invalidates held input. */
|
|
264
|
+
inputAdmissionIncarnation(): string | null {
|
|
265
|
+
return this.#disposed ? null : this.#publishedIncarnation;
|
|
266
|
+
}
|
|
267
|
+
|
|
262
268
|
qualificationSnapshot(): TerminalReplicaQualificationSnapshot {
|
|
263
269
|
const seed = this.#interpreter.currentSeed();
|
|
264
270
|
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;
|
|
@@ -530,6 +532,10 @@ class WireTerminalEndpoint {
|
|
|
530
532
|
return;
|
|
531
533
|
}
|
|
532
534
|
if (this.#unavailable) return;
|
|
535
|
+
if (message.type === "terminal.projection") {
|
|
536
|
+
this.#failConnection(new Error("Unsolicited alternate-screen projection"));
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
533
539
|
if (message.type === "terminal.delivery") {
|
|
534
540
|
this.#acceptEnvelope(message);
|
|
535
541
|
return;
|
|
@@ -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,5 @@
|
|
|
1
|
+
import { rendererProbe } from "./renderer-probe.ts";
|
|
2
|
+
import { createTerminalSelectionWaiter } from "./terminal-selection-waiter.ts";
|
|
1
3
|
import { WorkspaceWindowLinkSelectResultSchemaZ } from "@tmux-ide/contracts";
|
|
2
4
|
import { windowLinkForPane, windowLinkTarget } from "./application-terminal-workspace-policy.ts";
|
|
3
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
@@ -350,6 +352,10 @@ export function createApplicationTerminalInteractionController(
|
|
|
350
352
|
left.generation === right.generation &&
|
|
351
353
|
left.workspace === right.workspace;
|
|
352
354
|
let selectedControl: ControlIdentity | null = null;
|
|
355
|
+
const selectionWaiter = createTerminalSelectionWaiter();
|
|
356
|
+
let observedLayoutEpoch: number | null = null;
|
|
357
|
+
let selectionGeneration: OpenTuiGenerationHostSnapshot | null = null;
|
|
358
|
+
let selectionLayout = options.layout();
|
|
353
359
|
const waitingInput = new Set<{
|
|
354
360
|
control: ControlIdentity;
|
|
355
361
|
weight: number;
|
|
@@ -823,10 +829,30 @@ export function createApplicationTerminalInteractionController(
|
|
|
823
829
|
"semanticPaneId" | "generation" | "incarnation" | "revision" | "stateHash"
|
|
824
830
|
>;
|
|
825
831
|
}>({
|
|
826
|
-
select: async (paneId) => {
|
|
832
|
+
select: async (paneId): Promise<boolean> => {
|
|
827
833
|
finishLinkSelection(false);
|
|
834
|
+
const initial = options.generation();
|
|
835
|
+
const selectionControl = controlIdentity(initial);
|
|
836
|
+
const selectionVersion = paneInput.selectionVersion;
|
|
837
|
+
const selectionCurrent = (): boolean =>
|
|
838
|
+
paneInput.selectionVersion === selectionVersion &&
|
|
839
|
+
sameControl(selectionControl, controlIdentity(selectionGeneration));
|
|
840
|
+
const panePresent = () =>
|
|
841
|
+
selectionLayout.windows.some((window) => window.panes.some((pane) => pane.pane === paneId));
|
|
842
|
+
if (initial?.status === "rebinding") {
|
|
843
|
+
// Reserve the optimistic target now, but send its FIRST mutation only
|
|
844
|
+
// after this same control connection has a coherent terminal runtime.
|
|
845
|
+
const ready = await selectionWaiter.wait(() => {
|
|
846
|
+
if (!selectionCurrent()) return false;
|
|
847
|
+
const active = selectionGeneration;
|
|
848
|
+
if (active?.status !== "live" || observedLayoutEpoch !== active.rendererEpoch)
|
|
849
|
+
return "pending";
|
|
850
|
+
return panePresent();
|
|
851
|
+
});
|
|
852
|
+
if (!ready || !selectionCurrent()) return false;
|
|
853
|
+
}
|
|
828
854
|
const expected = liveSelectionTarget();
|
|
829
|
-
|
|
855
|
+
let dispatchedRendererEpoch: number | undefined;
|
|
830
856
|
const selecting = pendingWindowSwitch;
|
|
831
857
|
const operationId = selecting?.traceId;
|
|
832
858
|
const selectionFailure: { current: PaneSelectionFailure | null } = { current: null };
|
|
@@ -845,8 +871,37 @@ export function createApplicationTerminalInteractionController(
|
|
|
845
871
|
selectionFailure.current = failure;
|
|
846
872
|
},
|
|
847
873
|
windowLinkForPane(options.layout(), paneId) ?? undefined,
|
|
874
|
+
() => {
|
|
875
|
+
dispatchedRendererEpoch = options.generation()?.rendererEpoch;
|
|
876
|
+
},
|
|
848
877
|
)
|
|
849
878
|
: null;
|
|
879
|
+
if (
|
|
880
|
+
receipt === null &&
|
|
881
|
+
selectionFailure.current?.reason === "topology-changed" &&
|
|
882
|
+
selectionCurrent()
|
|
883
|
+
) {
|
|
884
|
+
// The daemon may have selected the pane before retiring its stream.
|
|
885
|
+
// Observe the replacement runtime; never resubmit an uncertain command
|
|
886
|
+
// or manufacture an operation receipt. Input still waits on this promise.
|
|
887
|
+
const confirmed = await selectionWaiter.wait(() => {
|
|
888
|
+
if (!selectionCurrent()) return false;
|
|
889
|
+
const active = selectionGeneration;
|
|
890
|
+
if (
|
|
891
|
+
active?.status !== "live" ||
|
|
892
|
+
active.rendererEpoch === dispatchedRendererEpoch ||
|
|
893
|
+
observedLayoutEpoch !== active.rendererEpoch
|
|
894
|
+
)
|
|
895
|
+
return "pending";
|
|
896
|
+
if (!panePresent()) return false;
|
|
897
|
+
const current = selectionLayout.current;
|
|
898
|
+
const activePanes = current?.panes.filter((pane) => pane.active) ?? [];
|
|
899
|
+
if (!current?.currentWindow || activePanes.length !== 1) return "pending";
|
|
900
|
+
return activePanes[0]?.pane === paneId ? true : "pending";
|
|
901
|
+
});
|
|
902
|
+
if (selecting && pendingWindowSwitch === selecting) pendingWindowSwitch = null;
|
|
903
|
+
return confirmed && selectionCurrent();
|
|
904
|
+
}
|
|
850
905
|
if (selecting && pendingWindowSwitch === selecting) {
|
|
851
906
|
const receiptAtMicros = options.detailedWindowSwitchTiming ? diagnosticNowMicros() : null;
|
|
852
907
|
if (selecting.timing) selecting.timing.receiptAtMicros = receiptAtMicros;
|
|
@@ -996,22 +1051,30 @@ export function createApplicationTerminalInteractionController(
|
|
|
996
1051
|
}
|
|
997
1052
|
return outcome.status === "sent";
|
|
998
1053
|
},
|
|
999
|
-
onFocusedPane:
|
|
1054
|
+
onFocusedPane: (pane) => {
|
|
1055
|
+
rendererProbe()?.cancel("selection-changed", nowMicros());
|
|
1056
|
+
options.setFocusedPane(pane);
|
|
1057
|
+
},
|
|
1000
1058
|
});
|
|
1001
1059
|
|
|
1002
1060
|
return {
|
|
1003
1061
|
cancelPendingInput() {
|
|
1062
|
+
rendererProbe()?.cancel("input-cancelled", nowMicros());
|
|
1063
|
+
selectionWaiter.cancel();
|
|
1004
1064
|
finishLinkSelection(false);
|
|
1005
1065
|
paneInput.invalidateSelection();
|
|
1006
1066
|
for (const entry of waitingInput) entry.settle(false);
|
|
1007
1067
|
},
|
|
1008
1068
|
adoptGeneration(snapshot) {
|
|
1069
|
+
selectionGeneration = snapshot;
|
|
1009
1070
|
const control = controlIdentity(snapshot);
|
|
1010
1071
|
if (!sameControl(selectedControl, control)) {
|
|
1072
|
+
rendererProbe()?.cancel("control-changed", nowMicros());
|
|
1011
1073
|
finishLinkSelection(false);
|
|
1012
1074
|
paneInput.invalidateSelection();
|
|
1013
1075
|
}
|
|
1014
1076
|
selectedControl = control;
|
|
1077
|
+
selectionWaiter.reconcile();
|
|
1015
1078
|
if (waitingInput.size > 0 && snapshot?.status === "live" && snapshot.fastLane) {
|
|
1016
1079
|
// Old waiters resume first; input admitted synchronously by the next
|
|
1017
1080
|
// layout publication must not overtake them in this microtask turn.
|
|
@@ -1061,6 +1124,7 @@ export function createApplicationTerminalInteractionController(
|
|
|
1061
1124
|
next.fastLane !== inputAuthorityIdentity.fastLane ||
|
|
1062
1125
|
next.adapter !== inputAuthorityIdentity.adapter);
|
|
1063
1126
|
if (next === null || replaced) {
|
|
1127
|
+
rendererProbe()?.cancel("generation-changed", nowMicros());
|
|
1064
1128
|
finishLinkSelection(false);
|
|
1065
1129
|
pendingWindowSwitch = null;
|
|
1066
1130
|
pendingWindowRename = null;
|
|
@@ -1074,6 +1138,9 @@ export function createApplicationTerminalInteractionController(
|
|
|
1074
1138
|
inputAuthorityIdentity = next;
|
|
1075
1139
|
},
|
|
1076
1140
|
adoptLayout(snapshot) {
|
|
1141
|
+
selectionLayout = snapshot;
|
|
1142
|
+
observedLayoutEpoch = snapshot.rendererEpoch ?? null;
|
|
1143
|
+
selectionWaiter.reconcile();
|
|
1077
1144
|
reconcileLinkSelection(snapshot);
|
|
1078
1145
|
paneInput.adoptCanonicalPane(snapshot.current ? paneForWindow(snapshot.current) : null);
|
|
1079
1146
|
const currentWindow = snapshot.current?.semanticWindowId ?? snapshot.current?.windowName;
|
|
@@ -1165,6 +1232,7 @@ export function createApplicationTerminalInteractionController(
|
|
|
1165
1232
|
selectPane: (paneId) => {
|
|
1166
1233
|
finishLinkSelection(false);
|
|
1167
1234
|
paneInput.selectPane(paneId);
|
|
1235
|
+
selectionWaiter.reconcile();
|
|
1168
1236
|
},
|
|
1169
1237
|
async unlinkWindowLink(target) {
|
|
1170
1238
|
const expected = liveSelectionTarget();
|
|
@@ -1303,10 +1371,25 @@ export function createApplicationTerminalInteractionController(
|
|
|
1303
1371
|
}
|
|
1304
1372
|
},
|
|
1305
1373
|
sendInput: async (input, parserOrigin) => {
|
|
1306
|
-
|
|
1374
|
+
const probe = rendererProbe();
|
|
1375
|
+
const state = probe
|
|
1376
|
+
? (options.generation()?.fastLane?.lane.paneState(paneInput.focusedPane ?? "") ?? null)
|
|
1377
|
+
: null;
|
|
1378
|
+
const measured = probe?.admit(input, state, nowMicros());
|
|
1379
|
+
const sent = await paneInput.sendInput({ input: measured ?? input, parserOrigin });
|
|
1380
|
+
if (measured && !sent) probe?.cancel("dispatch-rejected", nowMicros());
|
|
1381
|
+
},
|
|
1382
|
+
sendInputToPane: async (paneId, input, parserOrigin) => {
|
|
1383
|
+
const probe = rendererProbe();
|
|
1384
|
+
const state = probe ? (options.generation()?.fastLane?.lane.paneState(paneId) ?? null) : null;
|
|
1385
|
+
const measured = probe?.admit(input, state, nowMicros());
|
|
1386
|
+
const sent = await paneInput.sendInputToPane(paneId, {
|
|
1387
|
+
input: measured ?? input,
|
|
1388
|
+
parserOrigin,
|
|
1389
|
+
});
|
|
1390
|
+
if (measured && !sent) probe?.cancel("dispatch-rejected", nowMicros());
|
|
1391
|
+
return sent;
|
|
1307
1392
|
},
|
|
1308
|
-
sendInputToPane: (paneId, input, parserOrigin) =>
|
|
1309
|
-
paneInput.sendInputToPane(paneId, { input, parserOrigin }),
|
|
1310
1393
|
beginResizePointerIngress(input) {
|
|
1311
1394
|
if (!options.diagnosticsEnabled) return null;
|
|
1312
1395
|
const atMicros = diagnosticNowMicros();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { rendererProbe } from "./renderer-probe.ts";
|
|
1
2
|
import { paneInteractionDisplayDestination } from "../ui/pane-interaction-presentation.ts";
|
|
2
3
|
import type { InteractionObservationStatus, NativePaneIdentity } from "@tmux-ide/contracts";
|
|
3
4
|
import { interactionPaneEndpointKey } from "@tmux-ide/core";
|
|
@@ -1350,6 +1351,7 @@ export function ApplicationTerminalWorkspace(props: ApplicationTerminalWorkspace
|
|
|
1350
1351
|
return;
|
|
1351
1352
|
}
|
|
1352
1353
|
if (event.type === "scroll") {
|
|
1354
|
+
const probeAt = rendererProbe() ? Math.floor(performance.now() * 1000) : 0;
|
|
1353
1355
|
// A held selection owns the gesture even when the pointer crosses a sibling.
|
|
1354
1356
|
const activeSelection = selecting;
|
|
1355
1357
|
const selectionCell =
|
|
@@ -1359,7 +1361,10 @@ export function ApplicationTerminalWorkspace(props: ApplicationTerminalWorkspace
|
|
|
1359
1361
|
activeSelection && selectionCell
|
|
1360
1362
|
? { frame: activeSelection.frame, ...selectionCell }
|
|
1361
1363
|
: paneContentAt(point);
|
|
1362
|
-
const before =
|
|
1364
|
+
const before =
|
|
1365
|
+
(props.onWheelObservation || rendererProbe()) && hit
|
|
1366
|
+
? scrollback.offset(hit.frame.paneId)
|
|
1367
|
+
: null;
|
|
1363
1368
|
const observe = (route: string): void => {
|
|
1364
1369
|
props.onWheelObservation?.({
|
|
1365
1370
|
paneId: hit?.frame.paneId ?? null,
|
|
@@ -1457,6 +1462,20 @@ export function ApplicationTerminalWorkspace(props: ApplicationTerminalWorkspace
|
|
|
1457
1462
|
scrollback.seek(hit.frame.paneId, { ...origin, y: next.originY });
|
|
1458
1463
|
setKeyboardCopy({ ...keyboard, cursor: next.cursor });
|
|
1459
1464
|
} else scrollback.move(hit.frame.paneId, motion.lines);
|
|
1465
|
+
if (rendererProbe() && identity && before !== null)
|
|
1466
|
+
rendererProbe()?.scroll(
|
|
1467
|
+
{
|
|
1468
|
+
pane: hit.frame.paneId,
|
|
1469
|
+
generation: identity.generation,
|
|
1470
|
+
incarnation: identity.incarnation,
|
|
1471
|
+
cols: snapshot.cols,
|
|
1472
|
+
rows: snapshot.rows,
|
|
1473
|
+
},
|
|
1474
|
+
snapshot,
|
|
1475
|
+
before,
|
|
1476
|
+
scrollback.offset(hit.frame.paneId),
|
|
1477
|
+
probeAt,
|
|
1478
|
+
);
|
|
1460
1479
|
observe("local-history");
|
|
1461
1480
|
if (selecting?.paneId === hit.frame.paneId) updatePointerSelection(selecting.pointer);
|
|
1462
1481
|
else {
|
|
@@ -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,
|