@gajae-code/tui 0.13.2 → 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 +25 -1
- package/dist/types/components/gajae-pet.d.ts +121 -22
- package/dist/types/components/ouroboros-pet.d.ts +23 -0
- package/dist/types/components/select-list.d.ts +3 -2
- 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 +645 -49
- package/src/components/ouroboros-pet-frames.json +488 -0
- package/src/components/ouroboros-pet.ts +150 -0
- package/src/components/select-list.ts +17 -11
- package/src/terminal-capabilities.ts +137 -0
- package/src/terminal.ts +33 -0
- package/src/tui.ts +1026 -135
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.
|
|
@@ -349,15 +422,26 @@ export type SizeValue = number | `${number}%`;
|
|
|
349
422
|
/** Parse a SizeValue into absolute value given a reference size */
|
|
350
423
|
function parseSizeValue(value: SizeValue | undefined, referenceSize: number): number | undefined {
|
|
351
424
|
if (value === undefined) return undefined;
|
|
352
|
-
if (typeof value === "number") return value;
|
|
425
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
|
|
353
426
|
// Parse percentage string like "50%"
|
|
354
427
|
const match = value.match(/^(\d+(?:\.\d+)?)%$/);
|
|
355
428
|
if (match) {
|
|
356
|
-
|
|
429
|
+
const percent = Number.parseFloat(match[1]);
|
|
430
|
+
if (!Number.isFinite(percent)) return undefined;
|
|
431
|
+
const parsed = Math.floor((referenceSize * percent) / 100);
|
|
432
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
357
433
|
}
|
|
358
434
|
return undefined;
|
|
359
435
|
}
|
|
360
436
|
|
|
437
|
+
function finiteNumber(value: number | undefined, fallback: number): number {
|
|
438
|
+
return value !== undefined && Number.isFinite(value) ? value : fallback;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function finiteNonNegative(value: number | undefined, fallback = 0): number {
|
|
442
|
+
return Math.max(0, finiteNumber(value, fallback));
|
|
443
|
+
}
|
|
444
|
+
|
|
361
445
|
const DISABLED_ENV_VALUES = new Set(["0", "false", "off", "no"]);
|
|
362
446
|
|
|
363
447
|
function envIsEnabled(value: string | undefined): boolean {
|
|
@@ -511,9 +595,19 @@ export class Container implements ViewportAnchorProvider {
|
|
|
511
595
|
children: Component[] = [];
|
|
512
596
|
#disposed = false;
|
|
513
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
|
+
}
|
|
514
607
|
|
|
515
608
|
addChild(component: Component): void {
|
|
516
609
|
this.children.push(component);
|
|
610
|
+
this.#advanceRenderRevision();
|
|
517
611
|
}
|
|
518
612
|
|
|
519
613
|
removeChild(component: Component): void {
|
|
@@ -522,6 +616,7 @@ export class Container implements ViewportAnchorProvider {
|
|
|
522
616
|
this.children.splice(index, 1);
|
|
523
617
|
this.#viewportAnchorSources.delete(component);
|
|
524
618
|
component.dispose?.();
|
|
619
|
+
this.#advanceRenderRevision();
|
|
525
620
|
}
|
|
526
621
|
}
|
|
527
622
|
|
|
@@ -531,6 +626,7 @@ export class Container implements ViewportAnchorProvider {
|
|
|
531
626
|
if (index !== -1) {
|
|
532
627
|
this.children.splice(index, 1);
|
|
533
628
|
this.#viewportAnchorSources.delete(component);
|
|
629
|
+
this.#advanceRenderRevision();
|
|
534
630
|
}
|
|
535
631
|
}
|
|
536
632
|
|
|
@@ -552,12 +648,21 @@ export class Container implements ViewportAnchorProvider {
|
|
|
552
648
|
for (const child of this.children) child.dispose?.();
|
|
553
649
|
this.children = [];
|
|
554
650
|
this.#viewportAnchorSources.clear();
|
|
651
|
+
this.#advanceRenderRevision();
|
|
555
652
|
}
|
|
556
653
|
|
|
557
654
|
/** Remove all children without disposing them (for detach-then-readd reuse). */
|
|
558
655
|
detachAll(): void {
|
|
559
656
|
this.children = [];
|
|
560
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();
|
|
561
666
|
}
|
|
562
667
|
|
|
563
668
|
/** Registers a direct child as eligible for semantic viewport anchoring. */
|
|
@@ -567,6 +672,7 @@ export class Container implements ViewportAnchorProvider {
|
|
|
567
672
|
}
|
|
568
673
|
if (source === null) this.#viewportAnchorSources.delete(component);
|
|
569
674
|
else this.#viewportAnchorSources.set(component, source);
|
|
675
|
+
this.#advanceRenderRevision();
|
|
570
676
|
}
|
|
571
677
|
|
|
572
678
|
dispose(): void {
|
|
@@ -574,10 +680,12 @@ export class Container implements ViewportAnchorProvider {
|
|
|
574
680
|
this.#disposed = true;
|
|
575
681
|
for (const child of this.children) child.dispose?.();
|
|
576
682
|
this.#viewportAnchorSources.clear();
|
|
683
|
+
this.#advanceRenderRevision();
|
|
577
684
|
}
|
|
578
685
|
|
|
579
686
|
invalidate(): void {
|
|
580
687
|
for (const child of this.children) child.invalidate?.();
|
|
688
|
+
this.#advanceRenderRevision();
|
|
581
689
|
}
|
|
582
690
|
|
|
583
691
|
render(width: number): string[] {
|
|
@@ -871,6 +979,18 @@ export class TUI extends Container {
|
|
|
871
979
|
#previousHeight = 0;
|
|
872
980
|
#focusedComponent: Component | null = null;
|
|
873
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;
|
|
874
994
|
|
|
875
995
|
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
|
|
876
996
|
onDebug?: () => void;
|
|
@@ -880,9 +1000,12 @@ export class TUI extends Container {
|
|
|
880
1000
|
#committedRenderGeneration = 0;
|
|
881
1001
|
#renderCommitWaiters = new Map<number, Set<RenderCommitWaiter>>();
|
|
882
1002
|
#lastRenderWriteSucceeded = false;
|
|
1003
|
+
/** Generation whose render path is currently capturing terminal output. */
|
|
1004
|
+
#renderGenerationInProgress = 0;
|
|
883
1005
|
#resizeRenderQueued = false;
|
|
884
1006
|
#resizeRenderMutationQueued = false;
|
|
885
1007
|
#renderMutationQueued = false;
|
|
1008
|
+
#renderScope: "full" | "layout" = "full";
|
|
886
1009
|
#renderTimer: NodeJS.Timeout | undefined;
|
|
887
1010
|
#widthSettleTimer: NodeJS.Timeout | undefined;
|
|
888
1011
|
#widthSettleRepairPending = false;
|
|
@@ -924,6 +1047,7 @@ export class TUI extends Container {
|
|
|
924
1047
|
#manualViewportAnchor: ManualViewportAnchor | null = null;
|
|
925
1048
|
#manualViewportFallbackAnchors: ManualViewportAnchor[] = [];
|
|
926
1049
|
#reconcileMissingViewportAnchor = false;
|
|
1050
|
+
#manualViewportLeaseSuspendPending = false;
|
|
927
1051
|
#lastCursorPosition: { row: number; col: number } | null = null;
|
|
928
1052
|
#latestViewportObservation: TuiViewportObservation | null = null;
|
|
929
1053
|
#sixelProbePendingDa = false;
|
|
@@ -961,6 +1085,39 @@ export class TUI extends Container {
|
|
|
961
1085
|
#manualSuffixLineCount = 0;
|
|
962
1086
|
#committedTranscriptRows: Array<number | null> = [];
|
|
963
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;
|
|
964
1121
|
|
|
965
1122
|
#unsubscribeTabWidthChange?: () => void;
|
|
966
1123
|
static #renderCounters: TuiRenderCounterSnapshot = {
|
|
@@ -1057,8 +1214,33 @@ export class TUI extends Container {
|
|
|
1057
1214
|
override dispose(): void {
|
|
1058
1215
|
this.#unsubscribeTabWidthChange?.();
|
|
1059
1216
|
this.#unsubscribeTabWidthChange = undefined;
|
|
1217
|
+
this.#finalizeRasterLeases("terminal-loss");
|
|
1060
1218
|
super.dispose();
|
|
1061
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
|
+
}
|
|
1062
1244
|
|
|
1063
1245
|
get fullRedraws(): number {
|
|
1064
1246
|
return this.#fullRedrawCount;
|
|
@@ -1157,11 +1339,13 @@ export class TUI extends Container {
|
|
|
1157
1339
|
override removeChild(component: Component): void {
|
|
1158
1340
|
this.#invalidateFocusForRemovedTree(component);
|
|
1159
1341
|
super.removeChild(component);
|
|
1342
|
+
if (component === this.#viewportAnchorComponent) this.#viewportAnchorRenderCache = undefined;
|
|
1160
1343
|
}
|
|
1161
1344
|
|
|
1162
1345
|
override clear(): void {
|
|
1163
1346
|
for (const child of this.children) this.#invalidateFocusForRemovedTree(child);
|
|
1164
1347
|
super.clear();
|
|
1348
|
+
this.#viewportAnchorRenderCache = undefined;
|
|
1165
1349
|
}
|
|
1166
1350
|
|
|
1167
1351
|
#invalidateFocusForRemovedTree(component: Component): void {
|
|
@@ -1203,6 +1387,7 @@ export class TUI extends Container {
|
|
|
1203
1387
|
}
|
|
1204
1388
|
if (identityReset || this.#manualViewportTop === undefined) this.#manualOutputNotice = false;
|
|
1205
1389
|
this.#viewportOutputSource = source;
|
|
1390
|
+
if (identityReset) this.#viewportAnchorRenderCache = undefined;
|
|
1206
1391
|
this.requestRender();
|
|
1207
1392
|
}
|
|
1208
1393
|
|
|
@@ -1213,6 +1398,7 @@ export class TUI extends Container {
|
|
|
1213
1398
|
}
|
|
1214
1399
|
if (this.#viewportAnchorComponent === component) return;
|
|
1215
1400
|
this.#viewportAnchorComponent = component;
|
|
1401
|
+
this.#viewportAnchorRenderCache = undefined;
|
|
1216
1402
|
this.#viewportAnchorFrame = null;
|
|
1217
1403
|
}
|
|
1218
1404
|
/** Returns the direct component registered as the semantic viewport anchor source. */
|
|
@@ -1255,6 +1441,28 @@ export class TUI extends Container {
|
|
|
1255
1441
|
if (this.#manualViewportAnchor !== null) this.#reconcileMissingViewportAnchor = true;
|
|
1256
1442
|
}
|
|
1257
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
|
+
|
|
1258
1466
|
/** Reveal a semantic viewport anchor without changing the rendered content width. */
|
|
1259
1467
|
revealViewportAnchor(id: ViewportAnchorId, alignment: "top" | "center" | "bottom"): boolean {
|
|
1260
1468
|
const height = this.terminal.rows;
|
|
@@ -1279,6 +1487,11 @@ export class TUI extends Container {
|
|
|
1279
1487
|
const desiredScreenRow =
|
|
1280
1488
|
alignment === "top" ? 0 : alignment === "center" ? Math.floor(transcriptCapacity / 2) : transcriptCapacity - 1;
|
|
1281
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;
|
|
1282
1495
|
this.#manualViewportAnchor = {
|
|
1283
1496
|
id: selected.id,
|
|
1284
1497
|
graphemeIndex:
|
|
@@ -1351,6 +1564,11 @@ export class TUI extends Container {
|
|
|
1351
1564
|
if (this.#manualViewportTop === undefined && currentViewportTop === maxViewportTop) return true;
|
|
1352
1565
|
if (this.#manualViewportTop !== undefined) return this.followLiveViewport();
|
|
1353
1566
|
}
|
|
1567
|
+
if (
|
|
1568
|
+
this.#manualViewportTop === undefined &&
|
|
1569
|
+
this.#suspendRasterLeasesForManualViewport(() => this.scrollViewportBy(delta, options))
|
|
1570
|
+
)
|
|
1571
|
+
return true;
|
|
1354
1572
|
if (frame !== null) {
|
|
1355
1573
|
const desiredScreenRow =
|
|
1356
1574
|
this.#manualViewportAnchor?.desiredScreenRow ??
|
|
@@ -1615,10 +1833,447 @@ export class TUI extends Container {
|
|
|
1615
1833
|
|
|
1616
1834
|
override invalidate(): void {
|
|
1617
1835
|
super.invalidate();
|
|
1836
|
+
this.#viewportAnchorRenderCache = undefined;
|
|
1618
1837
|
for (const overlay of this.overlayStack) overlay.component.invalidate?.();
|
|
1619
1838
|
for (const overlay of this.overlayStack) overlay.mouseBounds = undefined;
|
|
1620
1839
|
}
|
|
1621
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
|
+
}
|
|
1622
2277
|
start(): void {
|
|
1623
2278
|
this.#stopped = false;
|
|
1624
2279
|
this.#terminalUnavailable = false;
|
|
@@ -1629,11 +2284,34 @@ export class TUI extends Container {
|
|
|
1629
2284
|
this.terminal.start(
|
|
1630
2285
|
data => this.#handleInput(data),
|
|
1631
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);
|
|
1632
2292
|
this.invalidate();
|
|
1633
|
-
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
|
+
}
|
|
1634
2306
|
},
|
|
1635
2307
|
);
|
|
1636
|
-
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
|
+
}
|
|
1637
2315
|
this.#hideCursor();
|
|
1638
2316
|
this.#querySixelSupport();
|
|
1639
2317
|
this.#queryCellSize();
|
|
@@ -1695,7 +2373,35 @@ export class TUI extends Container {
|
|
|
1695
2373
|
return !this.#terminalUnavailable && this.terminal.available;
|
|
1696
2374
|
}
|
|
1697
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
|
+
}
|
|
1698
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");
|
|
1699
2405
|
this.#terminalUnavailable = true;
|
|
1700
2406
|
this.#stopped = true;
|
|
1701
2407
|
this.#renderRequested = false;
|
|
@@ -1742,12 +2448,37 @@ export class TUI extends Container {
|
|
|
1742
2448
|
return true;
|
|
1743
2449
|
}
|
|
1744
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
|
+
|
|
1745
2470
|
addInputListener(listener: InputListener): () => void {
|
|
1746
2471
|
this.#inputListeners.add(listener);
|
|
1747
2472
|
return () => {
|
|
1748
2473
|
this.#inputListeners.delete(listener);
|
|
1749
2474
|
};
|
|
1750
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
|
+
}
|
|
1751
2482
|
|
|
1752
2483
|
removeInputListener(listener: InputListener): void {
|
|
1753
2484
|
this.#inputListeners.delete(listener);
|
|
@@ -1888,9 +2619,14 @@ export class TUI extends Container {
|
|
|
1888
2619
|
this.invalidate();
|
|
1889
2620
|
this.requestRender(true);
|
|
1890
2621
|
}
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
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) {
|
|
1894
2630
|
return;
|
|
1895
2631
|
}
|
|
1896
2632
|
// Query terminal for cell size in pixels: CSI 16 t
|
|
@@ -1899,7 +2635,11 @@ export class TUI extends Container {
|
|
|
1899
2635
|
}
|
|
1900
2636
|
|
|
1901
2637
|
stop(): void {
|
|
1902
|
-
|
|
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");
|
|
1903
2643
|
const placementCleanup = this.#kittyPlacementDeletePlan(this.#kittyPlacementSpans, [], [], true).output;
|
|
1904
2644
|
if (placementCleanup.length > 0 && this.#writeTerminal(placementCleanup)) this.#kittyPlacementSpans = [];
|
|
1905
2645
|
this.#clearSixelProbeState();
|
|
@@ -2061,13 +2801,24 @@ export class TUI extends Container {
|
|
|
2061
2801
|
this.requestRenderWithGeneration(force, source);
|
|
2062
2802
|
}
|
|
2063
2803
|
|
|
2064
|
-
|
|
2804
|
+
#requestRenderWithScope(force: boolean, source: string, scope: "full" | "layout"): number {
|
|
2065
2805
|
const generation = ++this.#nextRenderGeneration;
|
|
2066
2806
|
this.#renderRequestedGeneration = Math.max(this.#renderRequestedGeneration, generation);
|
|
2807
|
+
if (scope === "full") this.#renderScope = "full";
|
|
2067
2808
|
this.#requestRenderCore(force, source, generation);
|
|
2068
2809
|
return generation;
|
|
2069
2810
|
}
|
|
2070
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
|
+
|
|
2071
2822
|
#requestRenderCore(force: boolean, source: string, generation: number): void {
|
|
2072
2823
|
if (!this.terminalAvailable) {
|
|
2073
2824
|
this.#markTerminalUnavailable();
|
|
@@ -2128,7 +2879,9 @@ export class TUI extends Container {
|
|
|
2128
2879
|
this.#lastRenderAt = performance.now();
|
|
2129
2880
|
this.#lastRenderWriteSucceeded = false;
|
|
2130
2881
|
const t0 = renderMetrics.now();
|
|
2882
|
+
this.#renderGenerationInProgress = requestedGeneration;
|
|
2131
2883
|
this.#doRender();
|
|
2884
|
+
this.#renderGenerationInProgress = 0;
|
|
2132
2885
|
this.#commitRenderGeneration(requestedGeneration);
|
|
2133
2886
|
if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
|
|
2134
2887
|
});
|
|
@@ -2171,7 +2924,9 @@ export class TUI extends Container {
|
|
|
2171
2924
|
this.#lastRenderAt = performance.now();
|
|
2172
2925
|
this.#lastRenderWriteSucceeded = false;
|
|
2173
2926
|
const t0 = renderMetrics.now();
|
|
2927
|
+
this.#renderGenerationInProgress = requestedGeneration;
|
|
2174
2928
|
this.#doRender();
|
|
2929
|
+
this.#renderGenerationInProgress = 0;
|
|
2175
2930
|
this.#commitRenderGeneration(requestedGeneration);
|
|
2176
2931
|
if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
|
|
2177
2932
|
if (this.#renderRequested) {
|
|
@@ -2201,7 +2956,9 @@ export class TUI extends Container {
|
|
|
2201
2956
|
this.#lastRenderAt = performance.now();
|
|
2202
2957
|
this.#lastRenderWriteSucceeded = false;
|
|
2203
2958
|
const t0 = renderMetrics.now();
|
|
2959
|
+
this.#renderGenerationInProgress = requestedGeneration;
|
|
2204
2960
|
this.#doRender();
|
|
2961
|
+
this.#renderGenerationInProgress = 0;
|
|
2205
2962
|
this.#commitRenderGeneration(requestedGeneration);
|
|
2206
2963
|
if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
|
|
2207
2964
|
}
|
|
@@ -2452,7 +3209,14 @@ export class TUI extends Container {
|
|
|
2452
3209
|
|
|
2453
3210
|
const heightPx = parseInt(match[1], 10);
|
|
2454
3211
|
const widthPx = parseInt(match[2], 10);
|
|
2455
|
-
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
|
+
) {
|
|
2456
3220
|
return true;
|
|
2457
3221
|
}
|
|
2458
3222
|
|
|
@@ -2480,10 +3244,10 @@ export class TUI extends Container {
|
|
|
2480
3244
|
typeof opt.margin === "number"
|
|
2481
3245
|
? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin }
|
|
2482
3246
|
: (opt.margin ?? {});
|
|
2483
|
-
const marginTop = Math.max(0,
|
|
2484
|
-
const marginRight = Math.max(0,
|
|
2485
|
-
const marginBottom = Math.max(0,
|
|
2486
|
-
const marginLeft = Math.max(0,
|
|
3247
|
+
const marginTop = Math.min(finiteNonNegative(margin.top), Math.max(0, termHeight - 1));
|
|
3248
|
+
const marginRight = Math.min(finiteNonNegative(margin.right), Math.max(0, termWidth - 1));
|
|
3249
|
+
const marginBottom = Math.min(finiteNonNegative(margin.bottom), Math.max(0, termHeight - 1 - marginTop));
|
|
3250
|
+
const marginLeft = Math.min(finiteNonNegative(margin.left), Math.max(0, termWidth - 1 - marginRight));
|
|
2487
3251
|
|
|
2488
3252
|
// Available space after margins
|
|
2489
3253
|
const availWidth = Math.max(1, termWidth - marginLeft - marginRight);
|
|
@@ -2492,14 +3256,15 @@ export class TUI extends Container {
|
|
|
2492
3256
|
// === Resolve width ===
|
|
2493
3257
|
let width = parseSizeValue(opt.width, termWidth) ?? Math.min(80, availWidth);
|
|
2494
3258
|
// Apply minWidth
|
|
2495
|
-
if (opt.minWidth !== undefined) {
|
|
3259
|
+
if (opt.minWidth !== undefined && Number.isFinite(opt.minWidth)) {
|
|
2496
3260
|
width = Math.max(width, opt.minWidth);
|
|
2497
3261
|
}
|
|
2498
3262
|
// Clamp to available space
|
|
2499
3263
|
width = Math.max(1, Math.min(width, availWidth));
|
|
2500
3264
|
|
|
2501
3265
|
// === Resolve maxHeight ===
|
|
2502
|
-
|
|
3266
|
+
const parsedMaxHeight = parseSizeValue(opt.maxHeight, termHeight);
|
|
3267
|
+
let maxHeight = opt.maxHeight !== undefined && parsedMaxHeight === undefined ? availHeight : parsedMaxHeight;
|
|
2503
3268
|
// Clamp to available space
|
|
2504
3269
|
if (maxHeight !== undefined) {
|
|
2505
3270
|
maxHeight = Math.max(1, Math.min(maxHeight, availHeight));
|
|
@@ -2519,14 +3284,18 @@ export class TUI extends Container {
|
|
|
2519
3284
|
if (match) {
|
|
2520
3285
|
const maxRow = Math.max(0, availHeight - effectiveHeight);
|
|
2521
3286
|
const percent = parseFloat(match[1]) / 100;
|
|
2522
|
-
row =
|
|
3287
|
+
row = Number.isFinite(percent)
|
|
3288
|
+
? marginTop + Math.floor(maxRow * percent)
|
|
3289
|
+
: this.#resolveAnchorRow(opt.anchor ?? "center", effectiveHeight, availHeight, marginTop);
|
|
2523
3290
|
} else {
|
|
2524
3291
|
// Invalid format, fall back to center
|
|
2525
3292
|
row = this.#resolveAnchorRow("center", effectiveHeight, availHeight, marginTop);
|
|
2526
3293
|
}
|
|
2527
|
-
} else {
|
|
3294
|
+
} else if (Number.isFinite(opt.row)) {
|
|
2528
3295
|
// Absolute row position
|
|
2529
3296
|
row = opt.row;
|
|
3297
|
+
} else {
|
|
3298
|
+
row = this.#resolveAnchorRow(opt.anchor ?? "center", effectiveHeight, availHeight, marginTop);
|
|
2530
3299
|
}
|
|
2531
3300
|
} else {
|
|
2532
3301
|
// Anchor-based (default: center)
|
|
@@ -2541,14 +3310,18 @@ export class TUI extends Container {
|
|
|
2541
3310
|
if (match) {
|
|
2542
3311
|
const maxCol = Math.max(0, availWidth - width);
|
|
2543
3312
|
const percent = parseFloat(match[1]) / 100;
|
|
2544
|
-
col =
|
|
3313
|
+
col = Number.isFinite(percent)
|
|
3314
|
+
? marginLeft + Math.floor(maxCol * percent)
|
|
3315
|
+
: this.#resolveAnchorCol(opt.anchor ?? "center", width, availWidth, marginLeft);
|
|
2545
3316
|
} else {
|
|
2546
3317
|
// Invalid format, fall back to center
|
|
2547
3318
|
col = this.#resolveAnchorCol("center", width, availWidth, marginLeft);
|
|
2548
3319
|
}
|
|
2549
|
-
} else {
|
|
3320
|
+
} else if (Number.isFinite(opt.col)) {
|
|
2550
3321
|
// Absolute column position
|
|
2551
3322
|
col = opt.col;
|
|
3323
|
+
} else {
|
|
3324
|
+
col = this.#resolveAnchorCol(opt.anchor ?? "center", width, availWidth, marginLeft);
|
|
2552
3325
|
}
|
|
2553
3326
|
} else {
|
|
2554
3327
|
// Anchor-based (default: center)
|
|
@@ -2557,8 +3330,8 @@ export class TUI extends Container {
|
|
|
2557
3330
|
}
|
|
2558
3331
|
|
|
2559
3332
|
// Apply offsets
|
|
2560
|
-
|
|
2561
|
-
|
|
3333
|
+
row += finiteNumber(opt.offsetY, 0);
|
|
3334
|
+
col += finiteNumber(opt.offsetX, 0);
|
|
2562
3335
|
|
|
2563
3336
|
// Clamp to terminal bounds (respecting margins)
|
|
2564
3337
|
row = Math.max(marginTop, Math.min(row, termHeight - marginBottom - effectiveHeight));
|
|
@@ -2648,6 +3421,9 @@ export class TUI extends Container {
|
|
|
2648
3421
|
// than the current content. Padding to it can cause the renderer to output hundreds/thousands of blank
|
|
2649
3422
|
// lines, effectively scrolling the terminal when an overlay is shown.
|
|
2650
3423
|
const workingHeight = Math.max(result.length, minLinesNeeded);
|
|
3424
|
+
if (!Number.isFinite(workingHeight)) {
|
|
3425
|
+
throw new Error("Overlay layout produced a non-finite working height");
|
|
3426
|
+
}
|
|
2651
3427
|
|
|
2652
3428
|
// Extend result with empty lines if content is too short for overlay placement
|
|
2653
3429
|
while (result.length < workingHeight) {
|
|
@@ -3179,34 +3955,55 @@ export class TUI extends Container {
|
|
|
3179
3955
|
{ top: transcriptLineCount, bottom: transcriptLineCount + suffixLineCount },
|
|
3180
3956
|
]
|
|
3181
3957
|
: [{ top: nextViewportTop, bottom: nextViewportTop + height }];
|
|
3182
|
-
|
|
3183
|
-
|
|
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";
|
|
3184
3976
|
const committedTranscriptRows: Array<number | null> = [];
|
|
3185
3977
|
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
3186
|
-
if (
|
|
3978
|
+
if (preserveRasterLeases) buffer += `\x1b[${screenRow + 1};1H`;
|
|
3979
|
+
else if (screenRow > 0) buffer += avoidScrollback ? "\r\x1b[1B" : "\r\n";
|
|
3187
3980
|
const lineIndex = nextViewportTop + screenRow;
|
|
3188
|
-
const
|
|
3189
|
-
const line =
|
|
3190
|
-
paintManual && screenRow === transcriptCapacity && noticeRows > 0
|
|
3191
|
-
? "New output — type to follow"
|
|
3192
|
-
: paintManual && suffixRow >= 0
|
|
3193
|
-
? (lines[transcriptLineCount + suffixRow] ?? "")
|
|
3194
|
-
: paintManual && lineIndex >= transcriptLineCount
|
|
3195
|
-
? ""
|
|
3196
|
-
: (lines[lineIndex] ?? "");
|
|
3981
|
+
const line = visibleLines[screenRow]!;
|
|
3197
3982
|
committedTranscriptRows.push(
|
|
3198
3983
|
screenRow < transcriptCapacity && lineIndex < transcriptLineCount ? lineIndex : null,
|
|
3199
3984
|
);
|
|
3200
3985
|
const isImage = TERMINAL.isImageLine(line);
|
|
3201
|
-
if (avoidScrollback && isImage) buffer += "\x1b7\x1b[2K";
|
|
3986
|
+
if (!preserveRasterLeases && avoidScrollback && isImage) buffer += "\x1b7\x1b[2K";
|
|
3202
3987
|
if (!isImage && this.#visibleWidthForDifferentialGuard(line) > width) {
|
|
3203
3988
|
let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
|
|
3204
3989
|
truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
|
|
3205
|
-
|
|
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
|
+
}
|
|
3206
4003
|
} else {
|
|
3207
4004
|
buffer += this.#padLineToWidth(line, width);
|
|
3208
4005
|
}
|
|
3209
|
-
if (avoidScrollback && isImage) buffer += "\x1b8";
|
|
4006
|
+
if (!preserveRasterLeases && avoidScrollback && isImage) buffer += "\x1b8";
|
|
3210
4007
|
}
|
|
3211
4008
|
if (avoidScrollback) buffer += "\r";
|
|
3212
4009
|
|
|
@@ -3221,25 +4018,31 @@ export class TUI extends Container {
|
|
|
3221
4018
|
buffer += cursorSeq;
|
|
3222
4019
|
buffer = this.#frameSynchronizedOutput(buffer);
|
|
3223
4020
|
let contentWritten = false;
|
|
3224
|
-
const writeSucceeded = this.#writeRenderBufferAndReanchorImeCursor(
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
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;
|
|
3243
4046
|
|
|
3244
4047
|
if (this.#debugRedraw) {
|
|
3245
4048
|
const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (lines=${lines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
|
|
@@ -3331,6 +4134,8 @@ export class TUI extends Container {
|
|
|
3331
4134
|
};
|
|
3332
4135
|
|
|
3333
4136
|
const renderTreeStart = renderMetrics.now();
|
|
4137
|
+
const renderScope = this.#renderScope;
|
|
4138
|
+
this.#renderScope = "full";
|
|
3334
4139
|
const renderedLines: string[] = [];
|
|
3335
4140
|
const renderedChildren = new Map<Component, string[]>();
|
|
3336
4141
|
let anchorFrame: ViewportAnchorFrame | null = null;
|
|
@@ -3342,8 +4147,37 @@ export class TUI extends Container {
|
|
|
3342
4147
|
const anchorRenderFailureCountBefore = viewportAnchorRenderFailureCount;
|
|
3343
4148
|
for (let childIndex = 0; childIndex < this.children.length; childIndex++) {
|
|
3344
4149
|
const child = this.children[childIndex];
|
|
3345
|
-
const
|
|
3346
|
-
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
|
+
}
|
|
3347
4181
|
renderedChildren.set(child, safeLines);
|
|
3348
4182
|
const childStart = renderedLines.length;
|
|
3349
4183
|
if (child === this.#viewportAnchorComponent && rendered.anchors.some(anchor => anchor !== null)) {
|
|
@@ -3351,11 +4185,10 @@ export class TUI extends Container {
|
|
|
3351
4185
|
}
|
|
3352
4186
|
const owner: KittyPlacementOwner = hasStickySuffix && childIndex >= pinnedChildIndex ? "suffix" : "transcript";
|
|
3353
4187
|
for (let lineIndex = 0; lineIndex < rendered.lines.length; lineIndex++) {
|
|
3354
|
-
const
|
|
3355
|
-
for (const placement of extractKittyPlacementReferences(line)) {
|
|
4188
|
+
for (const placement of kittyPlacements[lineIndex] ?? []) {
|
|
3356
4189
|
placementOwners.set(this.#kittyPlacementKey(placement), owner);
|
|
3357
4190
|
}
|
|
3358
|
-
renderedLines.push(safeLines[lineIndex] ??
|
|
4191
|
+
renderedLines.push(safeLines[lineIndex] ?? rendered.lines[lineIndex]!);
|
|
3359
4192
|
}
|
|
3360
4193
|
}
|
|
3361
4194
|
const sourceTranscriptLineCount = hasStickySuffix
|
|
@@ -4034,25 +4867,17 @@ export class TUI extends Container {
|
|
|
4034
4867
|
if (this.#writeCursorPosition(cursorPos, newLines.length)) this.#refreshPaintedLiveViewportObservation(height);
|
|
4035
4868
|
return;
|
|
4036
4869
|
}
|
|
4037
|
-
|
|
4038
4870
|
const nextLiveViewportTop = Math.max(0, newLines.length - height);
|
|
4039
|
-
if (newLines.length < this.#previousLines.length && nextLiveViewportTop !== prevViewportTop) {
|
|
4040
|
-
viewportRepaint(`content contraction changed viewport top (${prevViewportTop} -> ${nextLiveViewportTop})`);
|
|
4041
|
-
return;
|
|
4042
|
-
}
|
|
4043
4871
|
if (
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
this.#kittyPlacementIntersectsRegion(placement, {
|
|
4048
|
-
top: prevViewportTop,
|
|
4049
|
-
bottom: prevViewportTop + height,
|
|
4050
|
-
}),
|
|
4051
|
-
)
|
|
4872
|
+
this.#rasterLeases.size > 0 &&
|
|
4873
|
+
this.#rasterCleanup.size === 0 &&
|
|
4874
|
+
!newLines.slice(Math.max(0, newLines.length - height)).some(line => TERMINAL.isImageLine(line))
|
|
4052
4875
|
) {
|
|
4053
|
-
viewportRepaint(
|
|
4054
|
-
|
|
4055
|
-
|
|
4876
|
+
viewportRepaint("changed frame with active raster lease");
|
|
4877
|
+
return;
|
|
4878
|
+
}
|
|
4879
|
+
if (newLines.length < this.#previousLines.length && nextLiveViewportTop !== prevViewportTop) {
|
|
4880
|
+
viewportRepaint(`content contraction changed viewport top (${prevViewportTop} -> ${nextLiveViewportTop})`);
|
|
4056
4881
|
return;
|
|
4057
4882
|
}
|
|
4058
4883
|
if (distinctPostContractionRows) this.#scrollbackResumeViewportTop = undefined;
|
|
@@ -4263,6 +5088,7 @@ export class TUI extends Container {
|
|
|
4263
5088
|
return;
|
|
4264
5089
|
}
|
|
4265
5090
|
// Render from first changed line to end
|
|
5091
|
+
const renderEnd = Math.min(lastChanged, newLines.length - 1);
|
|
4266
5092
|
// Build buffer with all updates wrapped in synchronized output
|
|
4267
5093
|
const deletePlan = this.#kittyPlacementDeletePlan(previousKittyPlacementSpans, nextKittyPlacementSpans, [
|
|
4268
5094
|
{ top: firstChanged, bottom: lastChanged + 1 },
|
|
@@ -4290,6 +5116,16 @@ export class TUI extends Container {
|
|
|
4290
5116
|
: appendStart
|
|
4291
5117
|
? firstChanged - 1
|
|
4292
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
|
+
}
|
|
4293
5129
|
if (moveTargetRow > prevViewportBottom) {
|
|
4294
5130
|
if (nativeScrollbackAdmission) {
|
|
4295
5131
|
// The logical cursor row can be one row ahead of the physical xterm
|
|
@@ -4325,12 +5161,16 @@ export class TUI extends Container {
|
|
|
4325
5161
|
|
|
4326
5162
|
buffer += appendStart ? "\r\n" : "\r"; // Move to column 0
|
|
4327
5163
|
|
|
4328
|
-
// Only render changed lines (firstChanged to lastChanged), not all lines to end
|
|
4329
|
-
// This reduces flicker when only a single line changes (e.g., spinner animation)
|
|
4330
|
-
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));
|
|
4331
5171
|
for (let i = firstChanged; i <= renderEnd; i++) {
|
|
4332
5172
|
if (i > firstChanged) buffer += "\r\n";
|
|
4333
|
-
buffer += "\x1b[2K";
|
|
5173
|
+
if (!preserveRasterLeases) buffer += "\x1b[2K";
|
|
4334
5174
|
const line = newLines[i];
|
|
4335
5175
|
let truncatedLine = line;
|
|
4336
5176
|
const isImage = TERMINAL.isImageLine(line);
|
|
@@ -4357,8 +5197,18 @@ export class TUI extends Container {
|
|
|
4357
5197
|
truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
|
|
4358
5198
|
}
|
|
4359
5199
|
// Non-image lines are pre-terminated/normalized by #applyLineResets;
|
|
4360
|
-
// truncated lines re-append LINE_TERMINATOR above.
|
|
4361
|
-
|
|
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
|
+
}
|
|
4362
5212
|
}
|
|
4363
5213
|
|
|
4364
5214
|
// Track where cursor ended up after rendering
|
|
@@ -4418,25 +5268,31 @@ export class TUI extends Container {
|
|
|
4418
5268
|
// frame and geometry are authoritative even when the optional IME cursor
|
|
4419
5269
|
// write subsequently detaches the terminal.
|
|
4420
5270
|
if (
|
|
4421
|
-
!this.#writeRenderBufferAndReanchorImeCursor(
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
|
|
4428
|
-
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
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
|
+
)
|
|
4440
5296
|
)
|
|
4441
5297
|
return;
|
|
4442
5298
|
this.#latestRenderedLines = newLines;
|
|
@@ -4486,18 +5342,51 @@ export class TUI extends Container {
|
|
|
4486
5342
|
|
|
4487
5343
|
return { seq, toRow: targetRow };
|
|
4488
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
|
+
}
|
|
4489
5353
|
|
|
4490
5354
|
/** Retain terminal cleanup until a write succeeds, even after its component is disposed. */
|
|
4491
|
-
queueTerminalCleanup(payload: string, onDelivered?: () => void): void {
|
|
4492
|
-
this.#
|
|
4493
|
-
|
|
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
|
+
});
|
|
4494
5380
|
}
|
|
4495
5381
|
|
|
4496
|
-
/** Retry
|
|
4497
|
-
flushTerminalCleanup(): void {
|
|
5382
|
+
/** Retry retained cleanup after recovery or before shutdown. */
|
|
5383
|
+
flushTerminalCleanup(restoreTerminalAvailability = false): void {
|
|
4498
5384
|
while (this.#pendingTerminalCleanup.length > 0) {
|
|
4499
5385
|
const pending = this.#pendingTerminalCleanup[0];
|
|
4500
|
-
|
|
5386
|
+
const written = restoreTerminalAvailability
|
|
5387
|
+
? this.#writeLifecycleCleanup(pending.payload)
|
|
5388
|
+
: this.#writeTerminal(pending.payload);
|
|
5389
|
+
if (!written) return;
|
|
4501
5390
|
this.#pendingTerminalCleanup.shift();
|
|
4502
5391
|
pending.onDelivered?.();
|
|
4503
5392
|
}
|
|
@@ -4508,44 +5397,46 @@ export class TUI extends Container {
|
|
|
4508
5397
|
* transaction. The emitter is an exempt physical overlay: its bytes are
|
|
4509
5398
|
* deliberately kept out of the shared transcript write.
|
|
4510
5399
|
*/
|
|
4511
|
-
setPostRenderEmitter(emitter: (() => string | null) | undefined): void {
|
|
5400
|
+
setPostRenderEmitter(emitter: (() => string | PostRenderEmission | null) | undefined): void {
|
|
4512
5401
|
this.#postRenderEmitter = emitter;
|
|
4513
5402
|
}
|
|
4514
5403
|
|
|
4515
|
-
#postRenderEmitter: (() => string | null) | undefined;
|
|
5404
|
+
#postRenderEmitter: (() => string | PostRenderEmission | null) | undefined;
|
|
4516
5405
|
|
|
4517
5406
|
#writeRenderBufferAndReanchorImeCursor(
|
|
4518
5407
|
buffer: string,
|
|
4519
5408
|
cursorPos: { row: number; col: number } | null,
|
|
4520
5409
|
totalLines: number,
|
|
4521
5410
|
onBufferWritten?: () => void,
|
|
5411
|
+
preserveRasterLeases = false,
|
|
4522
5412
|
): boolean {
|
|
4523
|
-
|
|
4524
|
-
|
|
4525
|
-
|
|
4526
|
-
|
|
4527
|
-
|
|
4528
|
-
|
|
4529
|
-
|
|
4530
|
-
|
|
4531
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
// fails, so do not make callers retry the shared bytes.
|
|
4538
|
-
if (!this.#writeTerminal(overlayBuffer, true)) {
|
|
4539
|
-
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?.();
|
|
4540
5427
|
}
|
|
4541
|
-
|
|
4542
|
-
|
|
4543
|
-
|
|
4544
|
-
|
|
4545
|
-
|
|
4546
|
-
|
|
4547
|
-
|
|
4548
|
-
|
|
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;
|
|
4549
5440
|
}
|
|
4550
5441
|
|
|
4551
5442
|
/**
|