@gajae-code/tui 0.13.3 → 0.14.0
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/CHANGELOG.md +16 -0
- package/dist/types/components/gajae-pet.d.ts +86 -5
- package/dist/types/terminal-capabilities.d.ts +22 -0
- package/dist/types/terminal.d.ts +9 -0
- package/dist/types/tui.d.ts +122 -4
- package/package.json +3 -3
- package/src/components/gajae-pet.ts +381 -26
- package/src/terminal-capabilities.ts +137 -0
- package/src/terminal.ts +33 -0
- package/src/tui.ts +989 -106
package/src/tui.ts
CHANGED
|
@@ -32,6 +32,67 @@ import {
|
|
|
32
32
|
visibleWidth,
|
|
33
33
|
visibleWidths,
|
|
34
34
|
} from "./utils";
|
|
35
|
+
export type CellRect = Readonly<{ column: number; row: number; width: number; height: number }>;
|
|
36
|
+
export type RasterLeaseToken = Readonly<{ ownerId: string; generation: number; rect: CellRect }>;
|
|
37
|
+
export type RasterLeaseInvalidatedNotification = Readonly<{
|
|
38
|
+
type: "raster-lease-invalidated";
|
|
39
|
+
queueId: number;
|
|
40
|
+
token: RasterLeaseToken;
|
|
41
|
+
cause:
|
|
42
|
+
| "intersecting-generic-output"
|
|
43
|
+
| "full-redraw"
|
|
44
|
+
| "resize"
|
|
45
|
+
| "terminal-loss"
|
|
46
|
+
| "capability-loss"
|
|
47
|
+
| "mode-off"
|
|
48
|
+
| "dispose"
|
|
49
|
+
| "explicit"
|
|
50
|
+
| "manual-viewport";
|
|
51
|
+
eraseAck: TerminalOutputAck;
|
|
52
|
+
}>;
|
|
53
|
+
export type RasterLeaseRequest = Readonly<{
|
|
54
|
+
ownerId: string;
|
|
55
|
+
rect: CellRect;
|
|
56
|
+
erase: Readonly<{ type: "raster-erase"; bytes: Uint8Array }>;
|
|
57
|
+
onInvalidated?: (notice: RasterLeaseInvalidatedNotification) => void;
|
|
58
|
+
}>;
|
|
59
|
+
export type TerminalOutputOperation =
|
|
60
|
+
| Readonly<{ type: "generic-render"; rect: CellRect; bytes: Uint8Array }>
|
|
61
|
+
| Readonly<{ type: "generic-full-redraw"; rect: CellRect; bytes: Uint8Array }>
|
|
62
|
+
| Readonly<{
|
|
63
|
+
type: "raster-multipart-batch";
|
|
64
|
+
records: readonly Uint8Array[];
|
|
65
|
+
prefix?: Uint8Array;
|
|
66
|
+
afterPrefix?: () => Promise<boolean>;
|
|
67
|
+
/** Synchronous freshness gate evaluated immediately before terminal output. */
|
|
68
|
+
shouldWrite?: () => boolean;
|
|
69
|
+
replayPrefix?: Uint8Array;
|
|
70
|
+
suffix?: Uint8Array;
|
|
71
|
+
abortSuffix?: Uint8Array;
|
|
72
|
+
restoreCursorVisibility?: boolean;
|
|
73
|
+
}>
|
|
74
|
+
| Readonly<{ type: "raster-erase"; bytes: Uint8Array }>
|
|
75
|
+
| Readonly<{ type: "raster-probe"; bytes: Uint8Array }>
|
|
76
|
+
| Readonly<{
|
|
77
|
+
type: "queued-output";
|
|
78
|
+
bytes: Uint8Array;
|
|
79
|
+
shouldWrite?: () => boolean;
|
|
80
|
+
/** Runs synchronously at the terminal write boundary after a successful write. */
|
|
81
|
+
onWritten?: () => void;
|
|
82
|
+
}>;
|
|
83
|
+
export type TerminalOutputAck = Readonly<{
|
|
84
|
+
queueId: number;
|
|
85
|
+
operation: TerminalOutputOperation["type"];
|
|
86
|
+
status: "written" | "stale-token" | "revoked" | "failed";
|
|
87
|
+
token?: RasterLeaseToken;
|
|
88
|
+
}>;
|
|
89
|
+
export type LifecycleCleanupAck = Readonly<{ attempted: number; written: number; stillPending: number }>;
|
|
90
|
+
export type RasterLeaseAcquireResult =
|
|
91
|
+
| Readonly<{ status: "acquired"; token: RasterLeaseToken }>
|
|
92
|
+
| Readonly<{
|
|
93
|
+
status: "rejected";
|
|
94
|
+
reason: "invalid-geometry" | "terminal-unavailable" | "owner-conflict" | "manual-viewport";
|
|
95
|
+
}>;
|
|
35
96
|
|
|
36
97
|
const SEGMENT_RESET = "\x1b[0m";
|
|
37
98
|
/**
|
|
@@ -44,6 +105,8 @@ const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x07";
|
|
|
44
105
|
const MOUSE_SELECTION_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
45
106
|
/** Discrete mouse-wheel notch size in terminal rows (xterm/less-style). */
|
|
46
107
|
export const DEFAULT_WHEEL_LINES = 3;
|
|
108
|
+
/** CSI 6 terminal-cell dimensions beyond this cannot produce a safe pet raster. */
|
|
109
|
+
const MAX_CELL_DIMENSION_PX = 512;
|
|
47
110
|
|
|
48
111
|
/** DA1 (`CSI ? … c`) and XTSMGRAPHICS (`CSI ? … S`) replies to the sixel probe. */
|
|
49
112
|
const DEVICE_REPORT_PATTERN = /^\x1b\[\?[\d;]*[cS]$/u;
|
|
@@ -110,6 +173,10 @@ function stripTerminalEraseControls(bytes: string): string {
|
|
|
110
173
|
}
|
|
111
174
|
type InputListenerResult = { consume?: boolean; data?: string } | undefined;
|
|
112
175
|
type InputListener = (data: string) => InputListenerResult;
|
|
176
|
+
type PostRenderEmission = {
|
|
177
|
+
payload: string;
|
|
178
|
+
onWritten?: () => void;
|
|
179
|
+
};
|
|
113
180
|
|
|
114
181
|
/**
|
|
115
182
|
* Component interface - all components must implement this
|
|
@@ -184,6 +251,12 @@ export interface Component {
|
|
|
184
251
|
* Default is false - release events are filtered out.
|
|
185
252
|
*/
|
|
186
253
|
wantsKeyRelease?: boolean;
|
|
254
|
+
/**
|
|
255
|
+
* Optional monotonic revision for renderer-level subtree reuse. Components that
|
|
256
|
+
* expose this MUST advance it whenever render output can change without a
|
|
257
|
+
* width change. Unversioned components are always rendered normally.
|
|
258
|
+
*/
|
|
259
|
+
getRenderRevision?(): bigint;
|
|
187
260
|
|
|
188
261
|
/**
|
|
189
262
|
* Invalidate any cached rendering state.
|
|
@@ -522,9 +595,19 @@ export class Container implements ViewportAnchorProvider {
|
|
|
522
595
|
children: Component[] = [];
|
|
523
596
|
#disposed = false;
|
|
524
597
|
#viewportAnchorSources = new Map<Component, ViewportAnchorSource>();
|
|
598
|
+
#renderRevision = 0n;
|
|
599
|
+
|
|
600
|
+
#advanceRenderRevision(): void {
|
|
601
|
+
this.#renderRevision += 1n;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
getRenderRevision(): bigint {
|
|
605
|
+
return this.#renderRevision;
|
|
606
|
+
}
|
|
525
607
|
|
|
526
608
|
addChild(component: Component): void {
|
|
527
609
|
this.children.push(component);
|
|
610
|
+
this.#advanceRenderRevision();
|
|
528
611
|
}
|
|
529
612
|
|
|
530
613
|
removeChild(component: Component): void {
|
|
@@ -533,6 +616,7 @@ export class Container implements ViewportAnchorProvider {
|
|
|
533
616
|
this.children.splice(index, 1);
|
|
534
617
|
this.#viewportAnchorSources.delete(component);
|
|
535
618
|
component.dispose?.();
|
|
619
|
+
this.#advanceRenderRevision();
|
|
536
620
|
}
|
|
537
621
|
}
|
|
538
622
|
|
|
@@ -542,6 +626,7 @@ export class Container implements ViewportAnchorProvider {
|
|
|
542
626
|
if (index !== -1) {
|
|
543
627
|
this.children.splice(index, 1);
|
|
544
628
|
this.#viewportAnchorSources.delete(component);
|
|
629
|
+
this.#advanceRenderRevision();
|
|
545
630
|
}
|
|
546
631
|
}
|
|
547
632
|
|
|
@@ -563,12 +648,21 @@ export class Container implements ViewportAnchorProvider {
|
|
|
563
648
|
for (const child of this.children) child.dispose?.();
|
|
564
649
|
this.children = [];
|
|
565
650
|
this.#viewportAnchorSources.clear();
|
|
651
|
+
this.#advanceRenderRevision();
|
|
566
652
|
}
|
|
567
653
|
|
|
568
654
|
/** Remove all children without disposing them (for detach-then-readd reuse). */
|
|
569
655
|
detachAll(): void {
|
|
570
656
|
this.children = [];
|
|
571
657
|
this.#viewportAnchorSources.clear();
|
|
658
|
+
this.#advanceRenderRevision();
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Replace direct children without disposing reusable components. */
|
|
662
|
+
replaceChildren(children: Component[]): void {
|
|
663
|
+
this.children = children;
|
|
664
|
+
this.#viewportAnchorSources.clear();
|
|
665
|
+
this.#advanceRenderRevision();
|
|
572
666
|
}
|
|
573
667
|
|
|
574
668
|
/** Registers a direct child as eligible for semantic viewport anchoring. */
|
|
@@ -578,6 +672,7 @@ export class Container implements ViewportAnchorProvider {
|
|
|
578
672
|
}
|
|
579
673
|
if (source === null) this.#viewportAnchorSources.delete(component);
|
|
580
674
|
else this.#viewportAnchorSources.set(component, source);
|
|
675
|
+
this.#advanceRenderRevision();
|
|
581
676
|
}
|
|
582
677
|
|
|
583
678
|
dispose(): void {
|
|
@@ -585,10 +680,12 @@ export class Container implements ViewportAnchorProvider {
|
|
|
585
680
|
this.#disposed = true;
|
|
586
681
|
for (const child of this.children) child.dispose?.();
|
|
587
682
|
this.#viewportAnchorSources.clear();
|
|
683
|
+
this.#advanceRenderRevision();
|
|
588
684
|
}
|
|
589
685
|
|
|
590
686
|
invalidate(): void {
|
|
591
687
|
for (const child of this.children) child.invalidate?.();
|
|
688
|
+
this.#advanceRenderRevision();
|
|
592
689
|
}
|
|
593
690
|
|
|
594
691
|
render(width: number): string[] {
|
|
@@ -882,6 +979,18 @@ export class TUI extends Container {
|
|
|
882
979
|
#previousHeight = 0;
|
|
883
980
|
#focusedComponent: Component | null = null;
|
|
884
981
|
#inputListeners = new Set<InputListener>();
|
|
982
|
+
#viewportAnchorRenderCache:
|
|
983
|
+
| {
|
|
984
|
+
component: Component;
|
|
985
|
+
width: number;
|
|
986
|
+
componentRevision: bigint;
|
|
987
|
+
sourceIdentity: string;
|
|
988
|
+
sourceRevision: bigint;
|
|
989
|
+
rendered: ViewportAnchorRender;
|
|
990
|
+
safeLines: string[];
|
|
991
|
+
kittyPlacements: KittyPlacementReference[][];
|
|
992
|
+
}
|
|
993
|
+
| undefined;
|
|
885
994
|
|
|
886
995
|
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
|
|
887
996
|
onDebug?: () => void;
|
|
@@ -891,9 +1000,12 @@ export class TUI extends Container {
|
|
|
891
1000
|
#committedRenderGeneration = 0;
|
|
892
1001
|
#renderCommitWaiters = new Map<number, Set<RenderCommitWaiter>>();
|
|
893
1002
|
#lastRenderWriteSucceeded = false;
|
|
1003
|
+
/** Generation whose render path is currently capturing terminal output. */
|
|
1004
|
+
#renderGenerationInProgress = 0;
|
|
894
1005
|
#resizeRenderQueued = false;
|
|
895
1006
|
#resizeRenderMutationQueued = false;
|
|
896
1007
|
#renderMutationQueued = false;
|
|
1008
|
+
#renderScope: "full" | "layout" = "full";
|
|
897
1009
|
#renderTimer: NodeJS.Timeout | undefined;
|
|
898
1010
|
#widthSettleTimer: NodeJS.Timeout | undefined;
|
|
899
1011
|
#widthSettleRepairPending = false;
|
|
@@ -935,6 +1047,7 @@ export class TUI extends Container {
|
|
|
935
1047
|
#manualViewportAnchor: ManualViewportAnchor | null = null;
|
|
936
1048
|
#manualViewportFallbackAnchors: ManualViewportAnchor[] = [];
|
|
937
1049
|
#reconcileMissingViewportAnchor = false;
|
|
1050
|
+
#manualViewportLeaseSuspendPending = false;
|
|
938
1051
|
#lastCursorPosition: { row: number; col: number } | null = null;
|
|
939
1052
|
#latestViewportObservation: TuiViewportObservation | null = null;
|
|
940
1053
|
#sixelProbePendingDa = false;
|
|
@@ -972,6 +1085,39 @@ export class TUI extends Container {
|
|
|
972
1085
|
#manualSuffixLineCount = 0;
|
|
973
1086
|
#committedTranscriptRows: Array<number | null> = [];
|
|
974
1087
|
#paintedManualOutputNotice = false;
|
|
1088
|
+
#rasterGeneration = 0;
|
|
1089
|
+
#rasterQueueId = 0;
|
|
1090
|
+
#rasterLeases = new Map<
|
|
1091
|
+
string,
|
|
1092
|
+
{
|
|
1093
|
+
token: RasterLeaseToken;
|
|
1094
|
+
erase: Uint8Array;
|
|
1095
|
+
callback?: (n: RasterLeaseInvalidatedNotification) => void;
|
|
1096
|
+
revoked: boolean;
|
|
1097
|
+
}
|
|
1098
|
+
>();
|
|
1099
|
+
#rasterCleanup = new Map<
|
|
1100
|
+
string,
|
|
1101
|
+
{
|
|
1102
|
+
token: RasterLeaseToken;
|
|
1103
|
+
erase: Uint8Array;
|
|
1104
|
+
callback?: (n: RasterLeaseInvalidatedNotification) => void;
|
|
1105
|
+
queueId: number;
|
|
1106
|
+
cause: RasterLeaseInvalidatedNotification["cause"];
|
|
1107
|
+
terminalGeneration: number;
|
|
1108
|
+
}
|
|
1109
|
+
>();
|
|
1110
|
+
#rasterIngress: Promise<unknown> = Promise.resolve();
|
|
1111
|
+
#rasterPending = 0;
|
|
1112
|
+
#pendingDependentGenericBytes: Array<{ bytes: Uint8Array; rect: CellRect; blockedBy: string[] }> = [];
|
|
1113
|
+
#terminalGeneration = 0;
|
|
1114
|
+
/**
|
|
1115
|
+
* Raster lifecycle epoch. `stop()` increments it before lease cleanup so a
|
|
1116
|
+
* queue body captured under a prior running epoch can never write to the
|
|
1117
|
+
* terminal after restoration; `start()` does not reset it, so work enqueued
|
|
1118
|
+
* in the new lifecycle carries the new epoch.
|
|
1119
|
+
*/
|
|
1120
|
+
#rasterLifecycle = 0;
|
|
975
1121
|
|
|
976
1122
|
#unsubscribeTabWidthChange?: () => void;
|
|
977
1123
|
static #renderCounters: TuiRenderCounterSnapshot = {
|
|
@@ -1068,8 +1214,33 @@ export class TUI extends Container {
|
|
|
1068
1214
|
override dispose(): void {
|
|
1069
1215
|
this.#unsubscribeTabWidthChange?.();
|
|
1070
1216
|
this.#unsubscribeTabWidthChange = undefined;
|
|
1217
|
+
this.#finalizeRasterLeases("terminal-loss");
|
|
1071
1218
|
super.dispose();
|
|
1072
1219
|
}
|
|
1220
|
+
#finalizeRasterLeases(cause: RasterLeaseInvalidatedNotification["cause"]): void {
|
|
1221
|
+
this.#revokeRasterLeases(cause);
|
|
1222
|
+
void this.notifyTerminalLifecycle({
|
|
1223
|
+
kind: "explicit-cleanup",
|
|
1224
|
+
source: "tui",
|
|
1225
|
+
terminalGeneration: this.#terminalGeneration,
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
#flushRasterLeasesBeforeStop(cause: RasterLeaseInvalidatedNotification["cause"]): void {
|
|
1229
|
+
this.#revokeRasterLeases(cause);
|
|
1230
|
+
for (const [owner, record] of this.#rasterCleanup) {
|
|
1231
|
+
const erase = this.#cursorGuardedRasterSequence(new TextDecoder().decode(record.erase));
|
|
1232
|
+
if (!this.#writeTerminal(erase)) return;
|
|
1233
|
+
this.#rasterCleanup.delete(owner);
|
|
1234
|
+
record.callback?.({
|
|
1235
|
+
type: "raster-lease-invalidated",
|
|
1236
|
+
queueId: record.queueId,
|
|
1237
|
+
token: record.token,
|
|
1238
|
+
cause: record.cause,
|
|
1239
|
+
eraseAck: { queueId: record.queueId, operation: "raster-erase", status: "written", token: record.token },
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
this.flushTerminalCleanup();
|
|
1243
|
+
}
|
|
1073
1244
|
|
|
1074
1245
|
get fullRedraws(): number {
|
|
1075
1246
|
return this.#fullRedrawCount;
|
|
@@ -1168,11 +1339,13 @@ export class TUI extends Container {
|
|
|
1168
1339
|
override removeChild(component: Component): void {
|
|
1169
1340
|
this.#invalidateFocusForRemovedTree(component);
|
|
1170
1341
|
super.removeChild(component);
|
|
1342
|
+
if (component === this.#viewportAnchorComponent) this.#viewportAnchorRenderCache = undefined;
|
|
1171
1343
|
}
|
|
1172
1344
|
|
|
1173
1345
|
override clear(): void {
|
|
1174
1346
|
for (const child of this.children) this.#invalidateFocusForRemovedTree(child);
|
|
1175
1347
|
super.clear();
|
|
1348
|
+
this.#viewportAnchorRenderCache = undefined;
|
|
1176
1349
|
}
|
|
1177
1350
|
|
|
1178
1351
|
#invalidateFocusForRemovedTree(component: Component): void {
|
|
@@ -1214,6 +1387,7 @@ export class TUI extends Container {
|
|
|
1214
1387
|
}
|
|
1215
1388
|
if (identityReset || this.#manualViewportTop === undefined) this.#manualOutputNotice = false;
|
|
1216
1389
|
this.#viewportOutputSource = source;
|
|
1390
|
+
if (identityReset) this.#viewportAnchorRenderCache = undefined;
|
|
1217
1391
|
this.requestRender();
|
|
1218
1392
|
}
|
|
1219
1393
|
|
|
@@ -1224,6 +1398,7 @@ export class TUI extends Container {
|
|
|
1224
1398
|
}
|
|
1225
1399
|
if (this.#viewportAnchorComponent === component) return;
|
|
1226
1400
|
this.#viewportAnchorComponent = component;
|
|
1401
|
+
this.#viewportAnchorRenderCache = undefined;
|
|
1227
1402
|
this.#viewportAnchorFrame = null;
|
|
1228
1403
|
}
|
|
1229
1404
|
/** Returns the direct component registered as the semantic viewport anchor source. */
|
|
@@ -1266,6 +1441,28 @@ export class TUI extends Container {
|
|
|
1266
1441
|
if (this.#manualViewportAnchor !== null) this.#reconcileMissingViewportAnchor = true;
|
|
1267
1442
|
}
|
|
1268
1443
|
|
|
1444
|
+
#suspendRasterLeasesForManualViewport(resume: () => void): boolean {
|
|
1445
|
+
if (this.#manualViewportTop !== undefined) return false;
|
|
1446
|
+
if (this.#rasterLeases.size === 0 && this.#rasterCleanup.size === 0) return false;
|
|
1447
|
+
if (this.#manualViewportLeaseSuspendPending) return true;
|
|
1448
|
+
|
|
1449
|
+
this.#manualViewportLeaseSuspendPending = true;
|
|
1450
|
+
this.#revokeRasterLeases("manual-viewport");
|
|
1451
|
+
void this.notifyTerminalLifecycle({
|
|
1452
|
+
kind: "explicit-cleanup",
|
|
1453
|
+
source: "tui",
|
|
1454
|
+
terminalGeneration: this.#terminalGeneration,
|
|
1455
|
+
})
|
|
1456
|
+
.then(result => {
|
|
1457
|
+
this.#manualViewportLeaseSuspendPending = false;
|
|
1458
|
+
if (result.stillPending === 0) resume();
|
|
1459
|
+
})
|
|
1460
|
+
.catch(() => {
|
|
1461
|
+
this.#manualViewportLeaseSuspendPending = false;
|
|
1462
|
+
});
|
|
1463
|
+
return true;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1269
1466
|
/** Reveal a semantic viewport anchor without changing the rendered content width. */
|
|
1270
1467
|
revealViewportAnchor(id: ViewportAnchorId, alignment: "top" | "center" | "bottom"): boolean {
|
|
1271
1468
|
const height = this.terminal.rows;
|
|
@@ -1290,6 +1487,11 @@ export class TUI extends Container {
|
|
|
1290
1487
|
const desiredScreenRow =
|
|
1291
1488
|
alignment === "top" ? 0 : alignment === "center" ? Math.floor(transcriptCapacity / 2) : transcriptCapacity - 1;
|
|
1292
1489
|
const targetViewportTop = Math.max(0, frame.startRow + selectedRow - desiredScreenRow);
|
|
1490
|
+
if (
|
|
1491
|
+
this.#manualViewportTop === undefined &&
|
|
1492
|
+
this.#suspendRasterLeasesForManualViewport(() => this.revealViewportAnchor(id, alignment))
|
|
1493
|
+
)
|
|
1494
|
+
return true;
|
|
1293
1495
|
this.#manualViewportAnchor = {
|
|
1294
1496
|
id: selected.id,
|
|
1295
1497
|
graphemeIndex:
|
|
@@ -1362,6 +1564,11 @@ export class TUI extends Container {
|
|
|
1362
1564
|
if (this.#manualViewportTop === undefined && currentViewportTop === maxViewportTop) return true;
|
|
1363
1565
|
if (this.#manualViewportTop !== undefined) return this.followLiveViewport();
|
|
1364
1566
|
}
|
|
1567
|
+
if (
|
|
1568
|
+
this.#manualViewportTop === undefined &&
|
|
1569
|
+
this.#suspendRasterLeasesForManualViewport(() => this.scrollViewportBy(delta, options))
|
|
1570
|
+
)
|
|
1571
|
+
return true;
|
|
1365
1572
|
if (frame !== null) {
|
|
1366
1573
|
const desiredScreenRow =
|
|
1367
1574
|
this.#manualViewportAnchor?.desiredScreenRow ??
|
|
@@ -1626,10 +1833,447 @@ export class TUI extends Container {
|
|
|
1626
1833
|
|
|
1627
1834
|
override invalidate(): void {
|
|
1628
1835
|
super.invalidate();
|
|
1836
|
+
this.#viewportAnchorRenderCache = undefined;
|
|
1629
1837
|
for (const overlay of this.overlayStack) overlay.component.invalidate?.();
|
|
1630
1838
|
for (const overlay of this.overlayStack) overlay.mouseBounds = undefined;
|
|
1631
1839
|
}
|
|
1632
1840
|
|
|
1841
|
+
acquireRasterLease(request: RasterLeaseRequest): Promise<RasterLeaseAcquireResult> {
|
|
1842
|
+
return this.#enqueueRaster(isCurrentLifecycle => {
|
|
1843
|
+
if (!isCurrentLifecycle()) return { status: "rejected", reason: "terminal-unavailable" } as const;
|
|
1844
|
+
if (
|
|
1845
|
+
!request ||
|
|
1846
|
+
typeof request.ownerId !== "string" ||
|
|
1847
|
+
!request.rect ||
|
|
1848
|
+
typeof request.erase !== "object" ||
|
|
1849
|
+
request.erase.type !== "raster-erase" ||
|
|
1850
|
+
!(request.erase.bytes instanceof Uint8Array) ||
|
|
1851
|
+
(request.onInvalidated !== undefined && typeof request.onInvalidated !== "function")
|
|
1852
|
+
)
|
|
1853
|
+
return { status: "rejected", reason: "invalid-geometry" };
|
|
1854
|
+
if (!this.#validRect(request.rect)) return { status: "rejected", reason: "invalid-geometry" };
|
|
1855
|
+
if (!this.terminalAvailable) return { status: "rejected", reason: "terminal-unavailable" };
|
|
1856
|
+
if (this.manualViewportActive) return { status: "rejected", reason: "manual-viewport" };
|
|
1857
|
+
if (
|
|
1858
|
+
this.#rasterLeases.has(request.ownerId) ||
|
|
1859
|
+
this.#rasterCleanup.has(request.ownerId) ||
|
|
1860
|
+
[...this.#rasterLeases.values()].some(l => this.#intersects(l.token.rect, request.rect)) ||
|
|
1861
|
+
[...this.#rasterCleanup.values()].some(l => this.#intersects(l.token.rect, request.rect))
|
|
1862
|
+
)
|
|
1863
|
+
return { status: "rejected", reason: "owner-conflict" };
|
|
1864
|
+
const token = Object.freeze({
|
|
1865
|
+
ownerId: request.ownerId,
|
|
1866
|
+
generation: ++this.#rasterGeneration,
|
|
1867
|
+
rect: Object.freeze({ ...request.rect }),
|
|
1868
|
+
});
|
|
1869
|
+
this.#rasterLeases.set(request.ownerId, {
|
|
1870
|
+
token,
|
|
1871
|
+
erase: new Uint8Array(request.erase.bytes),
|
|
1872
|
+
callback: request.onInvalidated,
|
|
1873
|
+
revoked: false,
|
|
1874
|
+
});
|
|
1875
|
+
return { status: "acquired", token };
|
|
1876
|
+
});
|
|
1877
|
+
}
|
|
1878
|
+
submitTerminalOutput(
|
|
1879
|
+
request: Readonly<{ operation: TerminalOutputOperation; token?: RasterLeaseToken }>,
|
|
1880
|
+
): Promise<TerminalOutputAck> {
|
|
1881
|
+
return this.#enqueueRaster(async isCurrentLifecycle => {
|
|
1882
|
+
const id = ++this.#rasterQueueId;
|
|
1883
|
+
const rawOperation0 =
|
|
1884
|
+
request && typeof request === "object" ? (request as { operation?: unknown }).operation : undefined;
|
|
1885
|
+
const operation0 =
|
|
1886
|
+
rawOperation0 &&
|
|
1887
|
+
typeof rawOperation0 === "object" &&
|
|
1888
|
+
typeof (rawOperation0 as { type?: unknown }).type === "string"
|
|
1889
|
+
? (rawOperation0 as { type: string }).type
|
|
1890
|
+
: "queued-output";
|
|
1891
|
+
if (!isCurrentLifecycle())
|
|
1892
|
+
return { queueId: id, operation: operation0 as TerminalOutputOperation["type"], status: "failed" as const };
|
|
1893
|
+
const rawOperation =
|
|
1894
|
+
request && typeof request === "object" ? (request as { operation?: unknown }).operation : undefined;
|
|
1895
|
+
const operation =
|
|
1896
|
+
rawOperation &&
|
|
1897
|
+
typeof rawOperation === "object" &&
|
|
1898
|
+
typeof (rawOperation as { type?: unknown }).type === "string"
|
|
1899
|
+
? (rawOperation as { type: string }).type
|
|
1900
|
+
: "queued-output";
|
|
1901
|
+
const failed = () => ({
|
|
1902
|
+
queueId: id,
|
|
1903
|
+
operation: operation as TerminalOutputOperation["type"],
|
|
1904
|
+
status: "failed" as const,
|
|
1905
|
+
});
|
|
1906
|
+
if (!rawOperation || typeof rawOperation !== "object") return failed();
|
|
1907
|
+
const op = rawOperation as unknown as TerminalOutputOperation;
|
|
1908
|
+
if (
|
|
1909
|
+
![
|
|
1910
|
+
"generic-render",
|
|
1911
|
+
"generic-full-redraw",
|
|
1912
|
+
"raster-multipart-batch",
|
|
1913
|
+
"raster-erase",
|
|
1914
|
+
"raster-probe",
|
|
1915
|
+
"queued-output",
|
|
1916
|
+
].includes(op.type as string)
|
|
1917
|
+
)
|
|
1918
|
+
return failed();
|
|
1919
|
+
if (
|
|
1920
|
+
(op.type === "generic-render" || op.type === "generic-full-redraw") &&
|
|
1921
|
+
(!this.#validRect(op.rect as CellRect) || !(op.bytes instanceof Uint8Array))
|
|
1922
|
+
)
|
|
1923
|
+
return failed();
|
|
1924
|
+
if (
|
|
1925
|
+
op.type === "raster-multipart-batch" &&
|
|
1926
|
+
(!Array.isArray(op.records) ||
|
|
1927
|
+
!op.records.every((b: unknown) => b instanceof Uint8Array) ||
|
|
1928
|
+
(op.prefix !== undefined && !(op.prefix instanceof Uint8Array)) ||
|
|
1929
|
+
(op.afterPrefix !== undefined && typeof op.afterPrefix !== "function") ||
|
|
1930
|
+
(op.shouldWrite !== undefined && typeof op.shouldWrite !== "function") ||
|
|
1931
|
+
(op.replayPrefix !== undefined && !(op.replayPrefix instanceof Uint8Array)) ||
|
|
1932
|
+
(op.suffix !== undefined && !(op.suffix instanceof Uint8Array)) ||
|
|
1933
|
+
(op.abortSuffix !== undefined && !(op.abortSuffix instanceof Uint8Array)) ||
|
|
1934
|
+
(op.restoreCursorVisibility !== undefined && typeof op.restoreCursorVisibility !== "boolean") ||
|
|
1935
|
+
((op.replayPrefix !== undefined || op.abortSuffix !== undefined) &&
|
|
1936
|
+
(op.prefix === undefined || op.afterPrefix === undefined)))
|
|
1937
|
+
)
|
|
1938
|
+
return failed();
|
|
1939
|
+
if (
|
|
1940
|
+
(op.type === "raster-erase" || op.type === "raster-probe" || op.type === "queued-output") &&
|
|
1941
|
+
(!(op.bytes instanceof Uint8Array) ||
|
|
1942
|
+
(op.type === "queued-output" &&
|
|
1943
|
+
((op.shouldWrite !== undefined && typeof op.shouldWrite !== "function") ||
|
|
1944
|
+
(op.onWritten !== undefined && typeof op.onWritten !== "function"))))
|
|
1945
|
+
)
|
|
1946
|
+
return failed();
|
|
1947
|
+
if (op.type.startsWith("raster-") && op.type !== "raster-probe") {
|
|
1948
|
+
const lease = request?.token && this.#rasterLeases.get(request.token.ownerId);
|
|
1949
|
+
if (!lease || lease.revoked || lease.token !== request?.token)
|
|
1950
|
+
return { queueId: id, operation: op.type, status: lease?.revoked ? "revoked" : "stale-token" };
|
|
1951
|
+
}
|
|
1952
|
+
if ((op.type === "raster-multipart-batch" || op.type === "queued-output") && op.shouldWrite !== undefined) {
|
|
1953
|
+
let shouldWrite: boolean;
|
|
1954
|
+
try {
|
|
1955
|
+
shouldWrite = op.shouldWrite();
|
|
1956
|
+
} catch {
|
|
1957
|
+
return failed();
|
|
1958
|
+
}
|
|
1959
|
+
if (!shouldWrite) return { queueId: id, operation: op.type, status: "stale-token" };
|
|
1960
|
+
}
|
|
1961
|
+
if (op.type === "raster-multipart-batch" && op.prefix !== undefined && op.afterPrefix !== undefined) {
|
|
1962
|
+
const prefixWritten = this.#guardTerminalOperation(() =>
|
|
1963
|
+
this.terminal.write(new TextDecoder().decode(op.prefix)),
|
|
1964
|
+
);
|
|
1965
|
+
if (!prefixWritten) return failed();
|
|
1966
|
+
const abortBarrier = () => {
|
|
1967
|
+
// Abort/cursor-restoration bytes are terminal writes: never emit
|
|
1968
|
+
// them once the running epoch ended (e.g. a user predicate that
|
|
1969
|
+
// itself stops the terminal before throwing or returning false).
|
|
1970
|
+
if (!isCurrentLifecycle()) return;
|
|
1971
|
+
const abortSuffix = op.abortSuffix === undefined ? "" : new TextDecoder().decode(op.abortSuffix);
|
|
1972
|
+
const cursorVisibility = op.restoreCursorVisibility ? this.#cursorVisibilitySequence() : "";
|
|
1973
|
+
if (abortSuffix || cursorVisibility)
|
|
1974
|
+
this.#guardTerminalOperation(() => this.terminal.write(abortSuffix + cursorVisibility));
|
|
1975
|
+
};
|
|
1976
|
+
const flushed = await (this.terminal as Terminal & { flush?: () => Promise<boolean> }).flush?.();
|
|
1977
|
+
// Async boundary: the terminal may have stopped while we awaited.
|
|
1978
|
+
if (!isCurrentLifecycle()) return failed();
|
|
1979
|
+
if (flushed === false) {
|
|
1980
|
+
if (isCurrentLifecycle()) abortBarrier();
|
|
1981
|
+
return failed();
|
|
1982
|
+
}
|
|
1983
|
+
let afterPrefixSucceeded: boolean;
|
|
1984
|
+
try {
|
|
1985
|
+
afterPrefixSucceeded = await op.afterPrefix();
|
|
1986
|
+
} catch {
|
|
1987
|
+
if (isCurrentLifecycle()) abortBarrier();
|
|
1988
|
+
return failed();
|
|
1989
|
+
}
|
|
1990
|
+
if (afterPrefixSucceeded !== true) {
|
|
1991
|
+
if (isCurrentLifecycle()) abortBarrier();
|
|
1992
|
+
return failed();
|
|
1993
|
+
}
|
|
1994
|
+
// Async boundary: afterPrefix awaited external work; re-check epoch.
|
|
1995
|
+
if (!isCurrentLifecycle()) return failed();
|
|
1996
|
+
const currentLease = this.#rasterLeases.get(request.token?.ownerId ?? "");
|
|
1997
|
+
if (!currentLease || currentLease.revoked || currentLease.token !== request.token) {
|
|
1998
|
+
if (isCurrentLifecycle()) abortBarrier();
|
|
1999
|
+
return { queueId: id, operation: op.type, status: currentLease?.revoked ? "revoked" : "stale-token" };
|
|
2000
|
+
}
|
|
2001
|
+
if (op.shouldWrite !== undefined) {
|
|
2002
|
+
let shouldWrite: boolean;
|
|
2003
|
+
try {
|
|
2004
|
+
shouldWrite = op.shouldWrite();
|
|
2005
|
+
} catch {
|
|
2006
|
+
if (isCurrentLifecycle()) abortBarrier();
|
|
2007
|
+
return failed();
|
|
2008
|
+
}
|
|
2009
|
+
if (!shouldWrite) {
|
|
2010
|
+
if (isCurrentLifecycle()) abortBarrier();
|
|
2011
|
+
return { queueId: id, operation: op.type, status: "stale-token" };
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
// Final pre-write gate: an async body may have resumed after stop().
|
|
2016
|
+
if (!isCurrentLifecycle()) return failed();
|
|
2017
|
+
const bytes =
|
|
2018
|
+
op.type === "raster-multipart-batch"
|
|
2019
|
+
? op.records.map((b: Uint8Array) => new TextDecoder().decode(b)).join("")
|
|
2020
|
+
: new TextDecoder().decode(op.bytes);
|
|
2021
|
+
const finalBytes =
|
|
2022
|
+
op.type === "raster-multipart-batch"
|
|
2023
|
+
? `${op.afterPrefix === undefined && op.prefix !== undefined ? new TextDecoder().decode(op.prefix) : ""}${op.replayPrefix !== undefined ? new TextDecoder().decode(op.replayPrefix) : ""}${bytes}${op.suffix !== undefined ? new TextDecoder().decode(op.suffix) : ""}${op.restoreCursorVisibility ? this.#cursorVisibilitySequence() : ""}`
|
|
2024
|
+
: bytes;
|
|
2025
|
+
const dependent = op.type === "generic-render" || op.type === "generic-full-redraw";
|
|
2026
|
+
const ok = dependent
|
|
2027
|
+
? this.#writeProtectedRenderIngress(finalBytes)
|
|
2028
|
+
: this.#guardTerminalOperation(() => this.terminal.write(finalBytes));
|
|
2029
|
+
if (!ok && dependent) {
|
|
2030
|
+
const rect = (op as { rect: CellRect }).rect;
|
|
2031
|
+
const blockedBy = [...this.#rasterCleanup.entries()]
|
|
2032
|
+
.filter(([, record]) => this.#intersects(record.token.rect, rect))
|
|
2033
|
+
.map(([owner]) => owner);
|
|
2034
|
+
this.#pendingDependentGenericBytes.push({ bytes: new Uint8Array(op.bytes), rect, blockedBy });
|
|
2035
|
+
}
|
|
2036
|
+
if (ok && op.type === "queued-output") op.onWritten?.();
|
|
2037
|
+
return { queueId: id, operation: op.type, status: ok ? "written" : "failed", token: request.token };
|
|
2038
|
+
});
|
|
2039
|
+
}
|
|
2040
|
+
invalidateRasterLease(
|
|
2041
|
+
request: Readonly<{ token: RasterLeaseToken; cause: RasterLeaseInvalidatedNotification["cause"] }>,
|
|
2042
|
+
): Promise<TerminalOutputAck> {
|
|
2043
|
+
return this.#enqueueRaster(isCurrentLifecycle => {
|
|
2044
|
+
const id = ++this.#rasterQueueId,
|
|
2045
|
+
lease = this.#rasterLeases.get(request.token.ownerId);
|
|
2046
|
+
if (!isCurrentLifecycle())
|
|
2047
|
+
return { queueId: id, operation: "raster-erase" as const, status: "stale-token" as const };
|
|
2048
|
+
if (!lease || lease.token !== request.token)
|
|
2049
|
+
return { queueId: id, operation: "raster-erase", status: "stale-token" as const };
|
|
2050
|
+
lease.revoked = true;
|
|
2051
|
+
this.#rasterLeases.delete(request.token.ownerId);
|
|
2052
|
+
const erase = this.#cursorGuardedRasterSequence(new TextDecoder().decode(lease.erase));
|
|
2053
|
+
const ok = this.#guardTerminalOperation(() => this.terminal.write(erase));
|
|
2054
|
+
if (!ok)
|
|
2055
|
+
this.#rasterCleanup.set(request.token.ownerId, {
|
|
2056
|
+
token: lease.token,
|
|
2057
|
+
erase: lease.erase,
|
|
2058
|
+
callback: lease.callback,
|
|
2059
|
+
queueId: id,
|
|
2060
|
+
cause: request.cause,
|
|
2061
|
+
terminalGeneration: this.#terminalGeneration,
|
|
2062
|
+
});
|
|
2063
|
+
else
|
|
2064
|
+
lease.callback?.({
|
|
2065
|
+
type: "raster-lease-invalidated",
|
|
2066
|
+
queueId: id,
|
|
2067
|
+
token: lease.token,
|
|
2068
|
+
cause: request.cause,
|
|
2069
|
+
eraseAck: { queueId: id, operation: "raster-erase", status: "written", token: lease.token },
|
|
2070
|
+
});
|
|
2071
|
+
return {
|
|
2072
|
+
queueId: id,
|
|
2073
|
+
operation: "raster-erase" as const,
|
|
2074
|
+
status: ok ? ("written" as const) : ("failed" as const),
|
|
2075
|
+
token: lease.token,
|
|
2076
|
+
};
|
|
2077
|
+
});
|
|
2078
|
+
}
|
|
2079
|
+
notifyTerminalLifecycle(event: {
|
|
2080
|
+
kind: "availability-restored" | "explicit-cleanup";
|
|
2081
|
+
source: "tui" | "interactive-mode" | "transport";
|
|
2082
|
+
terminalGeneration: number;
|
|
2083
|
+
}): Promise<LifecycleCleanupAck> {
|
|
2084
|
+
if (
|
|
2085
|
+
!event ||
|
|
2086
|
+
(event.kind !== "availability-restored" && event.kind !== "explicit-cleanup") ||
|
|
2087
|
+
(event.source !== "tui" && event.source !== "interactive-mode" && event.source !== "transport") ||
|
|
2088
|
+
!Number.isSafeInteger(event.terminalGeneration) ||
|
|
2089
|
+
event.terminalGeneration < 0
|
|
2090
|
+
) {
|
|
2091
|
+
return Promise.reject(new TypeError("invalid terminal lifecycle event"));
|
|
2092
|
+
}
|
|
2093
|
+
return this.#enqueueRaster(isCurrentLifecycle => {
|
|
2094
|
+
if (!isCurrentLifecycle()) return { attempted: 0, written: 0, stillPending: 0 };
|
|
2095
|
+
if (event.terminalGeneration !== this.#terminalGeneration)
|
|
2096
|
+
return { attempted: 0, written: 0, stillPending: 0 };
|
|
2097
|
+
this.flushTerminalCleanup(true);
|
|
2098
|
+
if (this.#pendingTerminalCleanup.length > 0) {
|
|
2099
|
+
return {
|
|
2100
|
+
attempted: 0,
|
|
2101
|
+
written: 0,
|
|
2102
|
+
stillPending: this.#rasterCleanup.size + this.#pendingTerminalCleanup.length,
|
|
2103
|
+
};
|
|
2104
|
+
}
|
|
2105
|
+
let attempted = 0,
|
|
2106
|
+
written = 0;
|
|
2107
|
+
for (const [owner, r] of this.#rasterCleanup) {
|
|
2108
|
+
r.terminalGeneration = this.#terminalGeneration;
|
|
2109
|
+
attempted++;
|
|
2110
|
+
if (this.#writeLifecycleCleanup(this.#cursorGuardedRasterSequence(new TextDecoder().decode(r.erase)))) {
|
|
2111
|
+
written++;
|
|
2112
|
+
this.#rasterCleanup.delete(owner);
|
|
2113
|
+
const ack: TerminalOutputAck = {
|
|
2114
|
+
queueId: r.queueId,
|
|
2115
|
+
operation: "raster-erase",
|
|
2116
|
+
status: "written",
|
|
2117
|
+
token: r.token,
|
|
2118
|
+
};
|
|
2119
|
+
r.callback?.({
|
|
2120
|
+
type: "raster-lease-invalidated",
|
|
2121
|
+
queueId: r.queueId,
|
|
2122
|
+
token: r.token,
|
|
2123
|
+
cause: r.cause,
|
|
2124
|
+
eraseAck: ack,
|
|
2125
|
+
});
|
|
2126
|
+
const index = this.#pendingDependentGenericBytes.findIndex(item => item.blockedBy.includes(owner));
|
|
2127
|
+
if (index >= 0) {
|
|
2128
|
+
const item = this.#pendingDependentGenericBytes[index];
|
|
2129
|
+
const blocked = [...this.#rasterLeases.values(), ...this.#rasterCleanup.values()].some(record =>
|
|
2130
|
+
this.#intersects(record.token.rect, item.rect),
|
|
2131
|
+
);
|
|
2132
|
+
if (!blocked && this.#writeDisjointDependentIngress(new TextDecoder().decode(item.bytes), item.rect))
|
|
2133
|
+
this.#pendingDependentGenericBytes.splice(index, 1);
|
|
2134
|
+
}
|
|
2135
|
+
} else {
|
|
2136
|
+
r.terminalGeneration = this.#terminalGeneration;
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
if (written > 0) this.requestRender(true);
|
|
2140
|
+
return {
|
|
2141
|
+
attempted,
|
|
2142
|
+
written,
|
|
2143
|
+
stillPending: this.#rasterCleanup.size + this.#pendingTerminalCleanup.length,
|
|
2144
|
+
};
|
|
2145
|
+
});
|
|
2146
|
+
}
|
|
2147
|
+
#enqueueRaster<T>(fn: (isCurrentLifecycle: () => boolean) => T | Promise<T>): Promise<T> {
|
|
2148
|
+
const lifecycle = this.#rasterLifecycle;
|
|
2149
|
+
this.#rasterPending++;
|
|
2150
|
+
// A queue body captured under a prior running epoch must not write to the
|
|
2151
|
+
// terminal after stop() restored it — captured-epoch equality only,
|
|
2152
|
+
// evaluated lazily at entry AND after every await inside async bodies.
|
|
2153
|
+
// Synchronous stop cleanup writes directly, never through a stale body.
|
|
2154
|
+
const isCurrentLifecycle = () => lifecycle === this.#rasterLifecycle;
|
|
2155
|
+
const next: Promise<T> = this.#rasterIngress.then(() => fn(isCurrentLifecycle)) as Promise<T>;
|
|
2156
|
+
this.#rasterIngress = next.catch(() => undefined);
|
|
2157
|
+
void next.then(
|
|
2158
|
+
() => {
|
|
2159
|
+
this.#rasterPending--;
|
|
2160
|
+
},
|
|
2161
|
+
() => {
|
|
2162
|
+
this.#rasterPending--;
|
|
2163
|
+
},
|
|
2164
|
+
);
|
|
2165
|
+
return next;
|
|
2166
|
+
}
|
|
2167
|
+
#validRect(r: CellRect): boolean {
|
|
2168
|
+
return (
|
|
2169
|
+
Object.values(r).every(Number.isSafeInteger) &&
|
|
2170
|
+
r.column >= 0 &&
|
|
2171
|
+
r.row >= 0 &&
|
|
2172
|
+
r.width > 0 &&
|
|
2173
|
+
r.height > 0 &&
|
|
2174
|
+
r.column + r.width <= this.terminal.columns &&
|
|
2175
|
+
r.row + r.height <= this.terminal.rows
|
|
2176
|
+
);
|
|
2177
|
+
}
|
|
2178
|
+
#unleasedRowSegments(row: number, width: number): Array<{ column: number; width: number }> {
|
|
2179
|
+
let segments = [{ column: 0, width }];
|
|
2180
|
+
for (const lease of this.#rasterLeases.values()) {
|
|
2181
|
+
const rect = lease.token.rect;
|
|
2182
|
+
if (row < rect.row || row >= rect.row + rect.height) continue;
|
|
2183
|
+
const protectedStart = rect.column;
|
|
2184
|
+
const protectedEnd = rect.column + rect.width;
|
|
2185
|
+
const next: Array<{ column: number; width: number }> = [];
|
|
2186
|
+
for (const segment of segments) {
|
|
2187
|
+
const segmentEnd = segment.column + segment.width;
|
|
2188
|
+
if (protectedEnd <= segment.column || protectedStart >= segmentEnd) {
|
|
2189
|
+
next.push(segment);
|
|
2190
|
+
continue;
|
|
2191
|
+
}
|
|
2192
|
+
if (protectedStart > segment.column)
|
|
2193
|
+
next.push({ column: segment.column, width: protectedStart - segment.column });
|
|
2194
|
+
if (protectedEnd < segmentEnd) next.push({ column: protectedEnd, width: segmentEnd - protectedEnd });
|
|
2195
|
+
}
|
|
2196
|
+
segments = next;
|
|
2197
|
+
}
|
|
2198
|
+
return segments;
|
|
2199
|
+
}
|
|
2200
|
+
#intersects(a: CellRect, b: CellRect): boolean {
|
|
2201
|
+
return (
|
|
2202
|
+
a.column < b.column + b.width &&
|
|
2203
|
+
b.column < a.column + a.width &&
|
|
2204
|
+
a.row < b.row + b.height &&
|
|
2205
|
+
b.row < a.row + a.height
|
|
2206
|
+
);
|
|
2207
|
+
}
|
|
2208
|
+
#writeRasterPreservingRenderIngress(buffer: string): boolean {
|
|
2209
|
+
if (this.#rasterCleanup.size > 0) return this.#writeProtectedRenderIngress(buffer);
|
|
2210
|
+
return this.#writeTerminal(buffer);
|
|
2211
|
+
}
|
|
2212
|
+
#writeDisjointDependentIngress(buffer: string, rect: CellRect): boolean {
|
|
2213
|
+
if (
|
|
2214
|
+
[...this.#rasterLeases.values(), ...this.#rasterCleanup.values()].some(record =>
|
|
2215
|
+
this.#intersects(record.token.rect, rect),
|
|
2216
|
+
)
|
|
2217
|
+
)
|
|
2218
|
+
return false;
|
|
2219
|
+
return this.#guardTerminalOperation(() => this.terminal.write(buffer));
|
|
2220
|
+
}
|
|
2221
|
+
#writeProtectedRenderIngress(buffer: string): boolean {
|
|
2222
|
+
const affected = [...this.#rasterLeases.values()];
|
|
2223
|
+
const pending = [...this.#rasterCleanup.values()];
|
|
2224
|
+
if (pending.length > 0) return false;
|
|
2225
|
+
if (affected.length === 0 && pending.length === 0) return this.#writeTerminal(buffer);
|
|
2226
|
+
const queueId = ++this.#rasterQueueId;
|
|
2227
|
+
const cleanup = this.#cursorGuardedRasterSequence(
|
|
2228
|
+
[
|
|
2229
|
+
...pending.map(r => new TextDecoder().decode(r.erase)),
|
|
2230
|
+
...affected.map(lease => new TextDecoder().decode(lease.erase)),
|
|
2231
|
+
].join(""),
|
|
2232
|
+
);
|
|
2233
|
+
const ok = this.#guardTerminalOperation(() => this.terminal.write(cleanup + buffer));
|
|
2234
|
+
if (!ok) {
|
|
2235
|
+
this.#terminalUnavailable = true;
|
|
2236
|
+
this.#previousLines = [];
|
|
2237
|
+
this.#renderRequested = true;
|
|
2238
|
+
for (const lease of affected) {
|
|
2239
|
+
this.#rasterLeases.delete(lease.token.ownerId);
|
|
2240
|
+
this.#rasterCleanup.set(lease.token.ownerId, {
|
|
2241
|
+
token: lease.token,
|
|
2242
|
+
erase: lease.erase,
|
|
2243
|
+
callback: lease.callback,
|
|
2244
|
+
queueId,
|
|
2245
|
+
cause: "intersecting-generic-output",
|
|
2246
|
+
terminalGeneration: this.#terminalGeneration,
|
|
2247
|
+
});
|
|
2248
|
+
}
|
|
2249
|
+
return false;
|
|
2250
|
+
}
|
|
2251
|
+
this.#rasterCleanup.clear();
|
|
2252
|
+
for (const lease of affected) {
|
|
2253
|
+
this.#rasterLeases.delete(lease.token.ownerId);
|
|
2254
|
+
}
|
|
2255
|
+
for (const record of pending) {
|
|
2256
|
+
const ack: TerminalOutputAck = { queueId, operation: "raster-erase", status: "written", token: record.token };
|
|
2257
|
+
record.callback?.({
|
|
2258
|
+
type: "raster-lease-invalidated",
|
|
2259
|
+
queueId,
|
|
2260
|
+
token: record.token,
|
|
2261
|
+
cause: record.cause,
|
|
2262
|
+
eraseAck: ack,
|
|
2263
|
+
});
|
|
2264
|
+
}
|
|
2265
|
+
for (const lease of affected) {
|
|
2266
|
+
const ack: TerminalOutputAck = { queueId, operation: "raster-erase", status: "written", token: lease.token };
|
|
2267
|
+
lease.callback?.({
|
|
2268
|
+
type: "raster-lease-invalidated",
|
|
2269
|
+
queueId,
|
|
2270
|
+
token: lease.token,
|
|
2271
|
+
cause: "intersecting-generic-output",
|
|
2272
|
+
eraseAck: ack,
|
|
2273
|
+
});
|
|
2274
|
+
}
|
|
2275
|
+
return true;
|
|
2276
|
+
}
|
|
1633
2277
|
start(): void {
|
|
1634
2278
|
this.#stopped = false;
|
|
1635
2279
|
this.#terminalUnavailable = false;
|
|
@@ -1640,11 +2284,34 @@ export class TUI extends Container {
|
|
|
1640
2284
|
this.terminal.start(
|
|
1641
2285
|
data => this.#handleInput(data),
|
|
1642
2286
|
() => {
|
|
2287
|
+
const hadRasterLease = this.#rasterLeases.size > 0;
|
|
2288
|
+
this.#revokeRasterLeases("resize");
|
|
2289
|
+
// Only a pet raster lease needs refreshed cell metrics on resize; a
|
|
2290
|
+
// plain resize keeps the historical byte stream (no cell query).
|
|
2291
|
+
if (hadRasterLease) this.#queryCellSize(true);
|
|
1643
2292
|
this.invalidate();
|
|
1644
|
-
this.
|
|
2293
|
+
if (this.#pendingTerminalCleanup.length > 0 || this.#rasterCleanup.size > 0) {
|
|
2294
|
+
// Retained pet/cleanup output must be flushed before the resize
|
|
2295
|
+
// repaint so it cannot interleave behind the new frame.
|
|
2296
|
+
this.notifyTerminalLifecycle({
|
|
2297
|
+
kind: "explicit-cleanup",
|
|
2298
|
+
source: "tui",
|
|
2299
|
+
terminalGeneration: this.#terminalGeneration,
|
|
2300
|
+
}).then(result => {
|
|
2301
|
+
if (result.stillPending === 0) this.requestResizeRender();
|
|
2302
|
+
});
|
|
2303
|
+
} else {
|
|
2304
|
+
this.requestResizeRender();
|
|
2305
|
+
}
|
|
1645
2306
|
},
|
|
1646
2307
|
);
|
|
1647
|
-
this.
|
|
2308
|
+
if (this.#pendingTerminalCleanup.length > 0 || this.#rasterCleanup.size > 0) {
|
|
2309
|
+
void this.notifyTerminalLifecycle({
|
|
2310
|
+
kind: "availability-restored",
|
|
2311
|
+
source: "tui",
|
|
2312
|
+
terminalGeneration: this.#terminalGeneration,
|
|
2313
|
+
});
|
|
2314
|
+
}
|
|
1648
2315
|
this.#hideCursor();
|
|
1649
2316
|
this.#querySixelSupport();
|
|
1650
2317
|
this.#queryCellSize();
|
|
@@ -1706,7 +2373,35 @@ export class TUI extends Container {
|
|
|
1706
2373
|
return !this.#terminalUnavailable && this.terminal.available;
|
|
1707
2374
|
}
|
|
1708
2375
|
|
|
2376
|
+
get isRunning(): boolean {
|
|
2377
|
+
return !this.#stopped;
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
get terminalGeneration(): number {
|
|
2381
|
+
return this.#terminalGeneration;
|
|
2382
|
+
}
|
|
2383
|
+
get manualViewportActive(): boolean {
|
|
2384
|
+
return this.#manualViewportTop !== undefined || this.#manualViewportLeaseSuspendPending;
|
|
2385
|
+
}
|
|
2386
|
+
#revokeRasterLeases(cause: RasterLeaseInvalidatedNotification["cause"]): void {
|
|
2387
|
+
const queueId = ++this.#rasterQueueId;
|
|
2388
|
+
for (const lease of this.#rasterLeases.values()) {
|
|
2389
|
+
lease.revoked = true;
|
|
2390
|
+
this.#rasterCleanup.set(lease.token.ownerId, {
|
|
2391
|
+
token: lease.token,
|
|
2392
|
+
erase: lease.erase,
|
|
2393
|
+
callback: lease.callback,
|
|
2394
|
+
queueId,
|
|
2395
|
+
cause,
|
|
2396
|
+
terminalGeneration: this.#terminalGeneration,
|
|
2397
|
+
});
|
|
2398
|
+
}
|
|
2399
|
+
this.#rasterLeases.clear();
|
|
2400
|
+
}
|
|
1709
2401
|
#markTerminalUnavailable(settleRenderWaiters = true): void {
|
|
2402
|
+
this.#terminalGeneration++;
|
|
2403
|
+
for (const record of this.#rasterCleanup.values()) record.terminalGeneration = this.#terminalGeneration;
|
|
2404
|
+
this.#revokeRasterLeases("terminal-loss");
|
|
1710
2405
|
this.#terminalUnavailable = true;
|
|
1711
2406
|
this.#stopped = true;
|
|
1712
2407
|
this.#renderRequested = false;
|
|
@@ -1753,12 +2448,37 @@ export class TUI extends Container {
|
|
|
1753
2448
|
return true;
|
|
1754
2449
|
}
|
|
1755
2450
|
|
|
2451
|
+
#writeLifecycleCleanup(data: string): boolean {
|
|
2452
|
+
if (!this.terminal.available) {
|
|
2453
|
+
this.#markTerminalUnavailable();
|
|
2454
|
+
return false;
|
|
2455
|
+
}
|
|
2456
|
+
try {
|
|
2457
|
+
this.terminal.write(data);
|
|
2458
|
+
} catch {
|
|
2459
|
+
this.#markTerminalUnavailable();
|
|
2460
|
+
return false;
|
|
2461
|
+
}
|
|
2462
|
+
if (!this.terminal.available) {
|
|
2463
|
+
this.#markTerminalUnavailable();
|
|
2464
|
+
return false;
|
|
2465
|
+
}
|
|
2466
|
+
this.#terminalUnavailable = false;
|
|
2467
|
+
return true;
|
|
2468
|
+
}
|
|
2469
|
+
|
|
1756
2470
|
addInputListener(listener: InputListener): () => void {
|
|
1757
2471
|
this.#inputListeners.add(listener);
|
|
1758
2472
|
return () => {
|
|
1759
2473
|
this.#inputListeners.delete(listener);
|
|
1760
2474
|
};
|
|
1761
2475
|
}
|
|
2476
|
+
drainInput(maxMs: number, quiescenceMs: number): Promise<void> {
|
|
2477
|
+
return this.terminal.drainInput(maxMs, quiescenceMs);
|
|
2478
|
+
}
|
|
2479
|
+
drainPetProbeInput(maxMs: number, quiescenceMs: number): Promise<void> {
|
|
2480
|
+
return this.terminal.drainPendingInput?.(maxMs, quiescenceMs) ?? Promise.resolve();
|
|
2481
|
+
}
|
|
1762
2482
|
|
|
1763
2483
|
removeInputListener(listener: InputListener): void {
|
|
1764
2484
|
this.#inputListeners.delete(listener);
|
|
@@ -1899,9 +2619,14 @@ export class TUI extends Container {
|
|
|
1899
2619
|
this.invalidate();
|
|
1900
2620
|
this.requestRender(true);
|
|
1901
2621
|
}
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
2622
|
+
/** Refresh terminal cell metrics for a verified external image transport. */
|
|
2623
|
+
refreshImageCellSize(): void {
|
|
2624
|
+
this.#queryCellSize(true);
|
|
2625
|
+
}
|
|
2626
|
+
#queryCellSize(force = false): void {
|
|
2627
|
+
// Cell dimensions are also needed by the explicitly verified iTerm transport,
|
|
2628
|
+
// which intentionally does not claim a general TUI image protocol.
|
|
2629
|
+
if (!force && !TERMINAL.imageProtocol) {
|
|
1905
2630
|
return;
|
|
1906
2631
|
}
|
|
1907
2632
|
// Query terminal for cell size in pixels: CSI 16 t
|
|
@@ -1910,7 +2635,11 @@ export class TUI extends Container {
|
|
|
1910
2635
|
}
|
|
1911
2636
|
|
|
1912
2637
|
stop(): void {
|
|
1913
|
-
|
|
2638
|
+
// Invalidate every raster-queue body captured under the running epoch
|
|
2639
|
+
// before any teardown: nothing queued before stop may write after
|
|
2640
|
+
// restoration. Synchronous stop cleanup below writes directly.
|
|
2641
|
+
this.#rasterLifecycle++;
|
|
2642
|
+
this.#flushRasterLeasesBeforeStop("terminal-loss");
|
|
1914
2643
|
const placementCleanup = this.#kittyPlacementDeletePlan(this.#kittyPlacementSpans, [], [], true).output;
|
|
1915
2644
|
if (placementCleanup.length > 0 && this.#writeTerminal(placementCleanup)) this.#kittyPlacementSpans = [];
|
|
1916
2645
|
this.#clearSixelProbeState();
|
|
@@ -2072,13 +2801,24 @@ export class TUI extends Container {
|
|
|
2072
2801
|
this.requestRenderWithGeneration(force, source);
|
|
2073
2802
|
}
|
|
2074
2803
|
|
|
2075
|
-
|
|
2804
|
+
#requestRenderWithScope(force: boolean, source: string, scope: "full" | "layout"): number {
|
|
2076
2805
|
const generation = ++this.#nextRenderGeneration;
|
|
2077
2806
|
this.#renderRequestedGeneration = Math.max(this.#renderRequestedGeneration, generation);
|
|
2807
|
+
if (scope === "full") this.#renderScope = "full";
|
|
2078
2808
|
this.#requestRenderCore(force, source, generation);
|
|
2079
2809
|
return generation;
|
|
2080
2810
|
}
|
|
2081
2811
|
|
|
2812
|
+
/** Request a frame whose mutation is known to be outside the viewport-anchor subtree. */
|
|
2813
|
+
requestLayoutRender(source = "layout"): void {
|
|
2814
|
+
if (!this.#renderRequested) this.#renderScope = "layout";
|
|
2815
|
+
this.#requestRenderWithScope(false, source, "layout");
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
requestRenderWithGeneration(force = false, source = "unknown"): number {
|
|
2819
|
+
return this.#requestRenderWithScope(force, source, "full");
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2082
2822
|
#requestRenderCore(force: boolean, source: string, generation: number): void {
|
|
2083
2823
|
if (!this.terminalAvailable) {
|
|
2084
2824
|
this.#markTerminalUnavailable();
|
|
@@ -2139,7 +2879,9 @@ export class TUI extends Container {
|
|
|
2139
2879
|
this.#lastRenderAt = performance.now();
|
|
2140
2880
|
this.#lastRenderWriteSucceeded = false;
|
|
2141
2881
|
const t0 = renderMetrics.now();
|
|
2882
|
+
this.#renderGenerationInProgress = requestedGeneration;
|
|
2142
2883
|
this.#doRender();
|
|
2884
|
+
this.#renderGenerationInProgress = 0;
|
|
2143
2885
|
this.#commitRenderGeneration(requestedGeneration);
|
|
2144
2886
|
if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
|
|
2145
2887
|
});
|
|
@@ -2182,7 +2924,9 @@ export class TUI extends Container {
|
|
|
2182
2924
|
this.#lastRenderAt = performance.now();
|
|
2183
2925
|
this.#lastRenderWriteSucceeded = false;
|
|
2184
2926
|
const t0 = renderMetrics.now();
|
|
2927
|
+
this.#renderGenerationInProgress = requestedGeneration;
|
|
2185
2928
|
this.#doRender();
|
|
2929
|
+
this.#renderGenerationInProgress = 0;
|
|
2186
2930
|
this.#commitRenderGeneration(requestedGeneration);
|
|
2187
2931
|
if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
|
|
2188
2932
|
if (this.#renderRequested) {
|
|
@@ -2212,7 +2956,9 @@ export class TUI extends Container {
|
|
|
2212
2956
|
this.#lastRenderAt = performance.now();
|
|
2213
2957
|
this.#lastRenderWriteSucceeded = false;
|
|
2214
2958
|
const t0 = renderMetrics.now();
|
|
2959
|
+
this.#renderGenerationInProgress = requestedGeneration;
|
|
2215
2960
|
this.#doRender();
|
|
2961
|
+
this.#renderGenerationInProgress = 0;
|
|
2216
2962
|
this.#commitRenderGeneration(requestedGeneration);
|
|
2217
2963
|
if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
|
|
2218
2964
|
}
|
|
@@ -2463,7 +3209,14 @@ export class TUI extends Container {
|
|
|
2463
3209
|
|
|
2464
3210
|
const heightPx = parseInt(match[1], 10);
|
|
2465
3211
|
const widthPx = parseInt(match[2], 10);
|
|
2466
|
-
if (
|
|
3212
|
+
if (
|
|
3213
|
+
!Number.isSafeInteger(heightPx) ||
|
|
3214
|
+
!Number.isSafeInteger(widthPx) ||
|
|
3215
|
+
heightPx <= 0 ||
|
|
3216
|
+
widthPx <= 0 ||
|
|
3217
|
+
heightPx > MAX_CELL_DIMENSION_PX ||
|
|
3218
|
+
widthPx > MAX_CELL_DIMENSION_PX
|
|
3219
|
+
) {
|
|
2467
3220
|
return true;
|
|
2468
3221
|
}
|
|
2469
3222
|
|
|
@@ -3202,34 +3955,55 @@ export class TUI extends Container {
|
|
|
3202
3955
|
{ top: transcriptLineCount, bottom: transcriptLineCount + suffixLineCount },
|
|
3203
3956
|
]
|
|
3204
3957
|
: [{ top: nextViewportTop, bottom: nextViewportTop + height }];
|
|
3205
|
-
|
|
3206
|
-
|
|
3958
|
+
const lineForScreenRow = (screenRow: number): string => {
|
|
3959
|
+
const lineIndex = nextViewportTop + screenRow;
|
|
3960
|
+
const suffixRow = screenRow - transcriptCapacity - noticeRows;
|
|
3961
|
+
return paintManual && screenRow === transcriptCapacity && noticeRows > 0
|
|
3962
|
+
? "New output — type to follow"
|
|
3963
|
+
: paintManual && suffixRow >= 0
|
|
3964
|
+
? (lines[transcriptLineCount + suffixRow] ?? "")
|
|
3965
|
+
: paintManual && lineIndex >= transcriptLineCount
|
|
3966
|
+
? ""
|
|
3967
|
+
: (lines[lineIndex] ?? "");
|
|
3968
|
+
};
|
|
3969
|
+
const visibleLines = Array.from({ length: height }, (_, screenRow) => lineForScreenRow(screenRow));
|
|
3970
|
+
const preserveRasterLeases =
|
|
3971
|
+
this.#rasterLeases.size > 0 &&
|
|
3972
|
+
this.#rasterCleanup.size === 0 &&
|
|
3973
|
+
!visibleLines.some(line => TERMINAL.isImageLine(line));
|
|
3974
|
+
let buffer = `${preserveRasterLeases ? "\x1b[?2026h" : ""}${deletePlan.output}${preserveRasterLeases ? "\x1b[?25l" : ""}`;
|
|
3975
|
+
if (!preserveRasterLeases) buffer += "\x1b[H";
|
|
3207
3976
|
const committedTranscriptRows: Array<number | null> = [];
|
|
3208
3977
|
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
3209
|
-
if (
|
|
3978
|
+
if (preserveRasterLeases) buffer += `\x1b[${screenRow + 1};1H`;
|
|
3979
|
+
else if (screenRow > 0) buffer += avoidScrollback ? "\r\x1b[1B" : "\r\n";
|
|
3210
3980
|
const lineIndex = nextViewportTop + screenRow;
|
|
3211
|
-
const
|
|
3212
|
-
const line =
|
|
3213
|
-
paintManual && screenRow === transcriptCapacity && noticeRows > 0
|
|
3214
|
-
? "New output — type to follow"
|
|
3215
|
-
: paintManual && suffixRow >= 0
|
|
3216
|
-
? (lines[transcriptLineCount + suffixRow] ?? "")
|
|
3217
|
-
: paintManual && lineIndex >= transcriptLineCount
|
|
3218
|
-
? ""
|
|
3219
|
-
: (lines[lineIndex] ?? "");
|
|
3981
|
+
const line = visibleLines[screenRow]!;
|
|
3220
3982
|
committedTranscriptRows.push(
|
|
3221
3983
|
screenRow < transcriptCapacity && lineIndex < transcriptLineCount ? lineIndex : null,
|
|
3222
3984
|
);
|
|
3223
3985
|
const isImage = TERMINAL.isImageLine(line);
|
|
3224
|
-
if (avoidScrollback && isImage) buffer += "\x1b7\x1b[2K";
|
|
3986
|
+
if (!preserveRasterLeases && avoidScrollback && isImage) buffer += "\x1b7\x1b[2K";
|
|
3225
3987
|
if (!isImage && this.#visibleWidthForDifferentialGuard(line) > width) {
|
|
3226
3988
|
let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
|
|
3227
3989
|
truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
|
|
3228
|
-
|
|
3990
|
+
if (preserveRasterLeases) {
|
|
3991
|
+
for (const segment of this.#unleasedRowSegments(screenRow, width)) {
|
|
3992
|
+
buffer += `\x1b[${segment.column + 1}G\x1b[${segment.width}X`;
|
|
3993
|
+
buffer += `${sliceByColumn(truncatedLine, segment.column, segment.width, true)}${SEGMENT_RESET}`;
|
|
3994
|
+
}
|
|
3995
|
+
} else {
|
|
3996
|
+
buffer += this.#padLineToWidth(truncatedLine, width);
|
|
3997
|
+
}
|
|
3998
|
+
} else if (preserveRasterLeases) {
|
|
3999
|
+
for (const segment of this.#unleasedRowSegments(screenRow, width)) {
|
|
4000
|
+
buffer += `\x1b[${segment.column + 1}G\x1b[${segment.width}X`;
|
|
4001
|
+
buffer += `${sliceByColumn(line, segment.column, segment.width, true)}${SEGMENT_RESET}`;
|
|
4002
|
+
}
|
|
3229
4003
|
} else {
|
|
3230
4004
|
buffer += this.#padLineToWidth(line, width);
|
|
3231
4005
|
}
|
|
3232
|
-
if (avoidScrollback && isImage) buffer += "\x1b8";
|
|
4006
|
+
if (!preserveRasterLeases && avoidScrollback && isImage) buffer += "\x1b8";
|
|
3233
4007
|
}
|
|
3234
4008
|
if (avoidScrollback) buffer += "\r";
|
|
3235
4009
|
|
|
@@ -3244,25 +4018,31 @@ export class TUI extends Container {
|
|
|
3244
4018
|
buffer += cursorSeq;
|
|
3245
4019
|
buffer = this.#frameSynchronizedOutput(buffer);
|
|
3246
4020
|
let contentWritten = false;
|
|
3247
|
-
const writeSucceeded = this.#writeRenderBufferAndReanchorImeCursor(
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
4021
|
+
const writeSucceeded = this.#writeRenderBufferAndReanchorImeCursor(
|
|
4022
|
+
buffer,
|
|
4023
|
+
cursorPos,
|
|
4024
|
+
lines.length,
|
|
4025
|
+
() => {
|
|
4026
|
+
contentWritten = true;
|
|
4027
|
+
this.#hardwareCursorRow = cursorToRow;
|
|
4028
|
+
this.#committedTranscriptRows = committedTranscriptRows;
|
|
4029
|
+
this.#cursorRow = Math.max(0, lines.length - 1);
|
|
4030
|
+
this.#maxLinesRendered = lines.length;
|
|
4031
|
+
this.#viewportTopRow = nextViewportTop;
|
|
4032
|
+
if (paintManual) this.#manualViewportTop = nextViewportTop;
|
|
4033
|
+
this.#kittyPlacementSpans = this.#kittyCommittedPlacementsAfterPaint(
|
|
4034
|
+
placementsToClear,
|
|
4035
|
+
placementsToPaint,
|
|
4036
|
+
deletePlan,
|
|
4037
|
+
emittedRegions,
|
|
4038
|
+
);
|
|
4039
|
+
onPainted?.();
|
|
4040
|
+
this.#paintedManualOutputNotice = paintManual && this.#manualOutputNotice;
|
|
4041
|
+
this.#recordPaintedViewportObservation(nextViewportTop, height, paintManual);
|
|
4042
|
+
},
|
|
4043
|
+
preserveRasterLeases,
|
|
4044
|
+
);
|
|
4045
|
+
if (!writeSucceeded || !contentWritten) return false;
|
|
3266
4046
|
|
|
3267
4047
|
if (this.#debugRedraw) {
|
|
3268
4048
|
const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (lines=${lines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
|
|
@@ -3354,6 +4134,8 @@ export class TUI extends Container {
|
|
|
3354
4134
|
};
|
|
3355
4135
|
|
|
3356
4136
|
const renderTreeStart = renderMetrics.now();
|
|
4137
|
+
const renderScope = this.#renderScope;
|
|
4138
|
+
this.#renderScope = "full";
|
|
3357
4139
|
const renderedLines: string[] = [];
|
|
3358
4140
|
const renderedChildren = new Map<Component, string[]>();
|
|
3359
4141
|
let anchorFrame: ViewportAnchorFrame | null = null;
|
|
@@ -3365,8 +4147,37 @@ export class TUI extends Container {
|
|
|
3365
4147
|
const anchorRenderFailureCountBefore = viewportAnchorRenderFailureCount;
|
|
3366
4148
|
for (let childIndex = 0; childIndex < this.children.length; childIndex++) {
|
|
3367
4149
|
const child = this.children[childIndex];
|
|
3368
|
-
const
|
|
3369
|
-
const
|
|
4150
|
+
const componentRevision = child.getRenderRevision?.();
|
|
4151
|
+
const source = child === this.#viewportAnchorComponent ? this.#viewportOutputSource : null;
|
|
4152
|
+
const cached = this.#viewportAnchorRenderCache;
|
|
4153
|
+
const reuseCached =
|
|
4154
|
+
renderScope === "layout" &&
|
|
4155
|
+
componentRevision !== undefined &&
|
|
4156
|
+
source !== null &&
|
|
4157
|
+
cached?.component === child &&
|
|
4158
|
+
cached.width === width &&
|
|
4159
|
+
cached.componentRevision === componentRevision &&
|
|
4160
|
+
cached.sourceIdentity === source.identity &&
|
|
4161
|
+
cached.sourceRevision === source.revision;
|
|
4162
|
+
const rendered = reuseCached
|
|
4163
|
+
? cached.rendered
|
|
4164
|
+
: safeRenderComponentWithViewportAnchors(child, width, "tui-child");
|
|
4165
|
+
const safeLines = reuseCached ? cached.safeLines : rendered.lines.map(stripTerminalEraseControls);
|
|
4166
|
+
const kittyPlacements = reuseCached
|
|
4167
|
+
? cached.kittyPlacements
|
|
4168
|
+
: rendered.lines.map(line => [...extractKittyPlacementReferences(line)]);
|
|
4169
|
+
if (!reuseCached && componentRevision !== undefined && source !== null) {
|
|
4170
|
+
this.#viewportAnchorRenderCache = {
|
|
4171
|
+
component: child,
|
|
4172
|
+
width,
|
|
4173
|
+
componentRevision,
|
|
4174
|
+
sourceIdentity: source.identity,
|
|
4175
|
+
sourceRevision: source.revision,
|
|
4176
|
+
rendered,
|
|
4177
|
+
safeLines,
|
|
4178
|
+
kittyPlacements,
|
|
4179
|
+
};
|
|
4180
|
+
}
|
|
3370
4181
|
renderedChildren.set(child, safeLines);
|
|
3371
4182
|
const childStart = renderedLines.length;
|
|
3372
4183
|
if (child === this.#viewportAnchorComponent && rendered.anchors.some(anchor => anchor !== null)) {
|
|
@@ -3374,11 +4185,10 @@ export class TUI extends Container {
|
|
|
3374
4185
|
}
|
|
3375
4186
|
const owner: KittyPlacementOwner = hasStickySuffix && childIndex >= pinnedChildIndex ? "suffix" : "transcript";
|
|
3376
4187
|
for (let lineIndex = 0; lineIndex < rendered.lines.length; lineIndex++) {
|
|
3377
|
-
const
|
|
3378
|
-
for (const placement of extractKittyPlacementReferences(line)) {
|
|
4188
|
+
for (const placement of kittyPlacements[lineIndex] ?? []) {
|
|
3379
4189
|
placementOwners.set(this.#kittyPlacementKey(placement), owner);
|
|
3380
4190
|
}
|
|
3381
|
-
renderedLines.push(safeLines[lineIndex] ??
|
|
4191
|
+
renderedLines.push(safeLines[lineIndex] ?? rendered.lines[lineIndex]!);
|
|
3382
4192
|
}
|
|
3383
4193
|
}
|
|
3384
4194
|
const sourceTranscriptLineCount = hasStickySuffix
|
|
@@ -4057,8 +4867,15 @@ export class TUI extends Container {
|
|
|
4057
4867
|
if (this.#writeCursorPosition(cursorPos, newLines.length)) this.#refreshPaintedLiveViewportObservation(height);
|
|
4058
4868
|
return;
|
|
4059
4869
|
}
|
|
4060
|
-
|
|
4061
4870
|
const nextLiveViewportTop = Math.max(0, newLines.length - height);
|
|
4871
|
+
if (
|
|
4872
|
+
this.#rasterLeases.size > 0 &&
|
|
4873
|
+
this.#rasterCleanup.size === 0 &&
|
|
4874
|
+
!newLines.slice(Math.max(0, newLines.length - height)).some(line => TERMINAL.isImageLine(line))
|
|
4875
|
+
) {
|
|
4876
|
+
viewportRepaint("changed frame with active raster lease");
|
|
4877
|
+
return;
|
|
4878
|
+
}
|
|
4062
4879
|
if (newLines.length < this.#previousLines.length && nextLiveViewportTop !== prevViewportTop) {
|
|
4063
4880
|
viewportRepaint(`content contraction changed viewport top (${prevViewportTop} -> ${nextLiveViewportTop})`);
|
|
4064
4881
|
return;
|
|
@@ -4271,6 +5088,7 @@ export class TUI extends Container {
|
|
|
4271
5088
|
return;
|
|
4272
5089
|
}
|
|
4273
5090
|
// Render from first changed line to end
|
|
5091
|
+
const renderEnd = Math.min(lastChanged, newLines.length - 1);
|
|
4274
5092
|
// Build buffer with all updates wrapped in synchronized output
|
|
4275
5093
|
const deletePlan = this.#kittyPlacementDeletePlan(previousKittyPlacementSpans, nextKittyPlacementSpans, [
|
|
4276
5094
|
{ top: firstChanged, bottom: lastChanged + 1 },
|
|
@@ -4298,6 +5116,16 @@ export class TUI extends Container {
|
|
|
4298
5116
|
: appendStart
|
|
4299
5117
|
? firstChanged - 1
|
|
4300
5118
|
: firstChanged;
|
|
5119
|
+
const appendWillScroll = appendStart && moveTargetRow >= prevViewportBottom;
|
|
5120
|
+
if (
|
|
5121
|
+
(moveTargetRow > prevViewportBottom || appendWillScroll || renderEnd > prevViewportBottom) &&
|
|
5122
|
+
this.#rasterLeases.size > 0 &&
|
|
5123
|
+
this.#rasterCleanup.size === 0 &&
|
|
5124
|
+
!newLines.slice(Math.max(0, newLines.length - height)).some(line => TERMINAL.isImageLine(line))
|
|
5125
|
+
) {
|
|
5126
|
+
viewportRepaint("streaming append with active raster lease");
|
|
5127
|
+
return;
|
|
5128
|
+
}
|
|
4301
5129
|
if (moveTargetRow > prevViewportBottom) {
|
|
4302
5130
|
if (nativeScrollbackAdmission) {
|
|
4303
5131
|
// The logical cursor row can be one row ahead of the physical xterm
|
|
@@ -4333,12 +5161,16 @@ export class TUI extends Container {
|
|
|
4333
5161
|
|
|
4334
5162
|
buffer += appendStart ? "\r\n" : "\r"; // Move to column 0
|
|
4335
5163
|
|
|
4336
|
-
// Only render changed lines (firstChanged to lastChanged), not all lines to end
|
|
4337
|
-
// This reduces flicker when only a single line changes (e.g., spinner animation)
|
|
4338
|
-
const
|
|
5164
|
+
// Only render changed lines (firstChanged to lastChanged), not all lines to end.
|
|
5165
|
+
// This reduces flicker when only a single line changes (e.g., spinner animation).
|
|
5166
|
+
const preserveRasterLeases =
|
|
5167
|
+
this.#rasterLeases.size > 0 &&
|
|
5168
|
+
this.#rasterCleanup.size === 0 &&
|
|
5169
|
+
moveTargetRow <= prevViewportBottom &&
|
|
5170
|
+
!newLines.slice(firstChanged, renderEnd + 1).some(line => TERMINAL.isImageLine(line));
|
|
4339
5171
|
for (let i = firstChanged; i <= renderEnd; i++) {
|
|
4340
5172
|
if (i > firstChanged) buffer += "\r\n";
|
|
4341
|
-
buffer += "\x1b[2K";
|
|
5173
|
+
if (!preserveRasterLeases) buffer += "\x1b[2K";
|
|
4342
5174
|
const line = newLines[i];
|
|
4343
5175
|
let truncatedLine = line;
|
|
4344
5176
|
const isImage = TERMINAL.isImageLine(line);
|
|
@@ -4365,8 +5197,18 @@ export class TUI extends Container {
|
|
|
4365
5197
|
truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
|
|
4366
5198
|
}
|
|
4367
5199
|
// Non-image lines are pre-terminated/normalized by #applyLineResets;
|
|
4368
|
-
// truncated lines re-append LINE_TERMINATOR above.
|
|
4369
|
-
|
|
5200
|
+
// truncated lines re-append LINE_TERMINATOR above. While a raster lease
|
|
5201
|
+
// occupies this screen row, clear and redraw only the complementary cell
|
|
5202
|
+
// spans so ordinary input cannot erase the inline image.
|
|
5203
|
+
if (preserveRasterLeases) {
|
|
5204
|
+
const screenRow = i - viewportTop;
|
|
5205
|
+
for (const segment of this.#unleasedRowSegments(screenRow, width)) {
|
|
5206
|
+
buffer += `\x1b[${segment.column + 1}G\x1b[${segment.width}X`;
|
|
5207
|
+
buffer += `${sliceByColumn(truncatedLine, segment.column, segment.width, true)}${SEGMENT_RESET}`;
|
|
5208
|
+
}
|
|
5209
|
+
} else {
|
|
5210
|
+
buffer += this.#padLineToWidth(truncatedLine, width);
|
|
5211
|
+
}
|
|
4370
5212
|
}
|
|
4371
5213
|
|
|
4372
5214
|
// Track where cursor ended up after rendering
|
|
@@ -4426,25 +5268,31 @@ export class TUI extends Container {
|
|
|
4426
5268
|
// frame and geometry are authoritative even when the optional IME cursor
|
|
4427
5269
|
// write subsequently detaches the terminal.
|
|
4428
5270
|
if (
|
|
4429
|
-
!this.#writeRenderBufferAndReanchorImeCursor(
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
5271
|
+
!this.#writeRenderBufferAndReanchorImeCursor(
|
|
5272
|
+
buffer,
|
|
5273
|
+
cursorPos,
|
|
5274
|
+
newLines.length,
|
|
5275
|
+
() => {
|
|
5276
|
+
this.#hardwareCursorRow = toRow;
|
|
5277
|
+
this.#cursorRow = Math.max(0, newLines.length - 1);
|
|
5278
|
+
this.#maxLinesRendered = newLines.length;
|
|
5279
|
+
this.#viewportTopRow = Math.max(0, newLines.length - height);
|
|
5280
|
+
this.#nativeScrollbackViewportTop = Math.max(this.#nativeScrollbackViewportTop, this.#viewportTopRow);
|
|
5281
|
+
this.#previousLines = newLines;
|
|
5282
|
+
this.#previousWidth = width;
|
|
5283
|
+
this.#previousHeight = height;
|
|
5284
|
+
this.#kittyPlacementSpans = this.#kittyCommittedPlacementsAfterPaint(
|
|
5285
|
+
previousKittyPlacementSpans,
|
|
5286
|
+
nextKittyPlacementSpans,
|
|
5287
|
+
deletePlan,
|
|
5288
|
+
[{ top: firstChanged, bottom: renderEnd + 1 }],
|
|
5289
|
+
);
|
|
5290
|
+
this.#manualTranscriptLineCount = nextTranscriptLineCount;
|
|
5291
|
+
this.#manualSuffixLineCount = nextSuffixLineCount;
|
|
5292
|
+
this.#refreshPaintedLiveViewportObservation(height);
|
|
5293
|
+
},
|
|
5294
|
+
preserveRasterLeases,
|
|
5295
|
+
)
|
|
4448
5296
|
)
|
|
4449
5297
|
return;
|
|
4450
5298
|
this.#latestRenderedLines = newLines;
|
|
@@ -4494,18 +5342,51 @@ export class TUI extends Container {
|
|
|
4494
5342
|
|
|
4495
5343
|
return { seq, toRow: targetRow };
|
|
4496
5344
|
}
|
|
5345
|
+
#cursorVisibilitySequence(): string {
|
|
5346
|
+
if (this.#showHardwareCursor || this.#imeCursorActive)
|
|
5347
|
+
return this.#useImeBlockCursor ? "\x1b[2 q\x1b[?25h" : "\x1b[?25h";
|
|
5348
|
+
return this.#useImeBlockCursor ? "\x1b[0 q\x1b[?25l" : "\x1b[?25l";
|
|
5349
|
+
}
|
|
5350
|
+
#cursorGuardedRasterSequence(payload: string): string {
|
|
5351
|
+
return `\x1b[?2026h\x1b7\x1b[?25l${payload}\x1b8${this.#cursorVisibilitySequence()}\x1b[?2026l`;
|
|
5352
|
+
}
|
|
4497
5353
|
|
|
4498
5354
|
/** Retain terminal cleanup until a write succeeds, even after its component is disposed. */
|
|
4499
|
-
queueTerminalCleanup(payload: string, onDelivered?: () => void): void {
|
|
4500
|
-
this.#
|
|
4501
|
-
|
|
5355
|
+
queueTerminalCleanup(payload: string, onDelivered?: () => void): Promise<void> {
|
|
5356
|
+
return this.#enqueueRaster(isCurrentLifecycle => {
|
|
5357
|
+
if (isCurrentLifecycle() && this.#writeTerminal(payload)) {
|
|
5358
|
+
onDelivered?.();
|
|
5359
|
+
return;
|
|
5360
|
+
}
|
|
5361
|
+
// Stale epoch or failed write: retain the payload for a future start()
|
|
5362
|
+
// instead of writing it after terminal restoration.
|
|
5363
|
+
this.#pendingTerminalCleanup.push({ payload, onDelivered });
|
|
5364
|
+
});
|
|
5365
|
+
}
|
|
5366
|
+
|
|
5367
|
+
/** Queue protocol-neutral output behind the same terminal ordering as renders. */
|
|
5368
|
+
queueTerminalOutput(
|
|
5369
|
+
payload: string,
|
|
5370
|
+
options?: { shouldWrite?: () => boolean; onWritten?: () => void },
|
|
5371
|
+
): Promise<TerminalOutputAck> {
|
|
5372
|
+
return this.submitTerminalOutput({
|
|
5373
|
+
operation: {
|
|
5374
|
+
type: "queued-output",
|
|
5375
|
+
bytes: new TextEncoder().encode(payload),
|
|
5376
|
+
...(options?.shouldWrite ? { shouldWrite: options.shouldWrite } : {}),
|
|
5377
|
+
...(options?.onWritten ? { onWritten: options.onWritten } : {}),
|
|
5378
|
+
},
|
|
5379
|
+
});
|
|
4502
5380
|
}
|
|
4503
5381
|
|
|
4504
|
-
/** Retry
|
|
4505
|
-
flushTerminalCleanup(): void {
|
|
5382
|
+
/** Retry retained cleanup after recovery or before shutdown. */
|
|
5383
|
+
flushTerminalCleanup(restoreTerminalAvailability = false): void {
|
|
4506
5384
|
while (this.#pendingTerminalCleanup.length > 0) {
|
|
4507
5385
|
const pending = this.#pendingTerminalCleanup[0];
|
|
4508
|
-
|
|
5386
|
+
const written = restoreTerminalAvailability
|
|
5387
|
+
? this.#writeLifecycleCleanup(pending.payload)
|
|
5388
|
+
: this.#writeTerminal(pending.payload);
|
|
5389
|
+
if (!written) return;
|
|
4509
5390
|
this.#pendingTerminalCleanup.shift();
|
|
4510
5391
|
pending.onDelivered?.();
|
|
4511
5392
|
}
|
|
@@ -4516,44 +5397,46 @@ export class TUI extends Container {
|
|
|
4516
5397
|
* transaction. The emitter is an exempt physical overlay: its bytes are
|
|
4517
5398
|
* deliberately kept out of the shared transcript write.
|
|
4518
5399
|
*/
|
|
4519
|
-
setPostRenderEmitter(emitter: (() => string | null) | undefined): void {
|
|
5400
|
+
setPostRenderEmitter(emitter: (() => string | PostRenderEmission | null) | undefined): void {
|
|
4520
5401
|
this.#postRenderEmitter = emitter;
|
|
4521
5402
|
}
|
|
4522
5403
|
|
|
4523
|
-
#postRenderEmitter: (() => string | null) | undefined;
|
|
5404
|
+
#postRenderEmitter: (() => string | PostRenderEmission | null) | undefined;
|
|
4524
5405
|
|
|
4525
5406
|
#writeRenderBufferAndReanchorImeCursor(
|
|
4526
5407
|
buffer: string,
|
|
4527
5408
|
cursorPos: { row: number; col: number } | null,
|
|
4528
5409
|
totalLines: number,
|
|
4529
5410
|
onBufferWritten?: () => void,
|
|
5411
|
+
preserveRasterLeases = false,
|
|
4530
5412
|
): boolean {
|
|
4531
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
|
|
4542
|
-
|
|
4543
|
-
|
|
4544
|
-
|
|
4545
|
-
// fails, so do not make callers retry the shared bytes.
|
|
4546
|
-
if (!this.#writeTerminal(overlayBuffer, true)) {
|
|
4547
|
-
return true;
|
|
5413
|
+
const writeIngress = preserveRasterLeases
|
|
5414
|
+
? (bytes: string) => this.#writeRasterPreservingRenderIngress(bytes)
|
|
5415
|
+
: (bytes: string) => this.#writeProtectedRenderIngress(bytes);
|
|
5416
|
+
const renderGeneration = this.#renderGenerationInProgress;
|
|
5417
|
+
const write = () => {
|
|
5418
|
+
if (!writeIngress(buffer)) return false;
|
|
5419
|
+
onBufferWritten?.();
|
|
5420
|
+
this.#lastRenderWriteSucceeded = true;
|
|
5421
|
+
if (renderGeneration > 0) this.#settleRenderCommitWaiters(true, renderGeneration);
|
|
5422
|
+
const emission = this.#postRenderEmitter?.();
|
|
5423
|
+
if (emission) {
|
|
5424
|
+
const overlay = typeof emission === "string" ? emission : emission.payload;
|
|
5425
|
+
const overlayBuffer = this.#frameSynchronizedOutput(`\x1b7${overlay}\x1b8`);
|
|
5426
|
+
if (this.#writeTerminal(overlayBuffer, true) && typeof emission !== "string") emission.onWritten?.();
|
|
4548
5427
|
}
|
|
4549
|
-
|
|
4550
|
-
|
|
4551
|
-
|
|
4552
|
-
|
|
4553
|
-
|
|
4554
|
-
|
|
4555
|
-
|
|
4556
|
-
|
|
5428
|
+
if (!this.#imeCursorActive) return true;
|
|
5429
|
+
const cursorWritten = this.#writeCursorPosition(cursorPos, totalLines, true);
|
|
5430
|
+
return cursorWritten;
|
|
5431
|
+
};
|
|
5432
|
+
if (
|
|
5433
|
+
this.#rasterPending === 0 &&
|
|
5434
|
+
this.#rasterCleanup.size === 0 &&
|
|
5435
|
+
(preserveRasterLeases || this.#rasterLeases.size === 0)
|
|
5436
|
+
)
|
|
5437
|
+
return write();
|
|
5438
|
+
this.#enqueueRaster(isCurrentLifecycle => isCurrentLifecycle() && write());
|
|
5439
|
+
return true;
|
|
4557
5440
|
}
|
|
4558
5441
|
|
|
4559
5442
|
/**
|