@gajae-code/tui 0.16.3 → 0.16.6
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 +21 -0
- package/README.md +54 -0
- package/dist/types/components/markdown.d.ts +15 -0
- package/dist/types/components/settings-list.d.ts +1 -0
- package/dist/types/tui.d.ts +13 -0
- package/package.json +3 -3
- package/src/components/box.ts +3 -3
- package/src/components/editor.ts +3 -6
- package/src/components/input.ts +3 -6
- package/src/components/markdown.ts +173 -39
- package/src/components/select-list.ts +6 -3
- package/src/components/settings-list.ts +5 -0
- package/src/tui.ts +340 -82
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.16.6] - 2026-09-07
|
|
6
|
+
|
|
7
|
+
## [0.16.5] - 2026-09-07
|
|
8
|
+
|
|
9
|
+
### Performance
|
|
10
|
+
|
|
11
|
+
- Skip full-transcript resize-width scans on unchanged-width render frames while preserving the existing resize and forced-redraw checks.
|
|
12
|
+
- Avoid copying unchanged terminal-control spans, reuse retained row widths, consume only the first grapheme for cursor operations, stop select-list width scans at their bounds, and avoid redundant background-row padding.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Markdown cache ownership now finalizes after styling callbacks, so reentrant text replacement cannot inherit stale rejection hints and streaming completion releases local parse tokens. Cache diagnostics measure insertion-time payload sizes; returned render arrays are borrowed and must not be mutated.
|
|
17
|
+
- Streaming Markdown retains only its current parse locally instead of publishing partial documents to the shared render/parse caches. Completed documents remain reusable, with 8 MiB render, 8 MiB parse and 4 MiB highlight accounted-payload budgets and per-entry limits. The render-cache diagnostic now includes UTF-16 keys, full token graphs, styled lines and anchors; it is not a live heap/RSS measurement.
|
|
18
|
+
- Markdown reflow avoids redundant parse-cache admissions and repeated accounting of already rejected normalized content, without retaining completed parse tokens. Render payload accounting uses its owned layout schema, and oversized highlighting inputs are rejected earlier while preserving byte/line limits and guard ordering.
|
|
19
|
+
|
|
20
|
+
## [0.16.4] - 2026-09-05
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- Added cancellable, one-shot `enqueueBeforeRender` preparation on the existing frame scheduler and a single-owner `setRenderPreparationLifecycleCallbacks` seam for invalidation and restart preparation. Stop, terminal loss, and disposal cancel stale work; restart preparation runs before the first forced frame without adding another streaming timer.
|
|
25
|
+
|
|
5
26
|
## [0.16.3] - 2026-09-04
|
|
6
27
|
|
|
7
28
|
## [0.16.2] - 2026-09-04
|
package/README.md
CHANGED
|
@@ -44,6 +44,60 @@ tui.start();
|
|
|
44
44
|
|
|
45
45
|
Main container that manages components and rendering.
|
|
46
46
|
|
|
47
|
+
#### Frame preparation
|
|
48
|
+
|
|
49
|
+
`tui.enqueueBeforeRender(callback: () => void): () => void` registers one synchronous,
|
|
50
|
+
one-shot preparation and returns an idempotent cancellation function. It requests a
|
|
51
|
+
normal full-mutation render on the existing 16 ms frame clock; it adds no timer and
|
|
52
|
+
does not force or expedite a frame. Normal, forced, and input-priority frames drain
|
|
53
|
+
the same snapshot before capturing render generations or reading layout, components,
|
|
54
|
+
viewport sources, or caches. Ordinary render requests made by preparation join that
|
|
55
|
+
frame without a request-only follow-up paint. Registrations made while draining run
|
|
56
|
+
in a later frame; their generations cannot commit before that preparation executes,
|
|
57
|
+
even when a newer ordinary request commits first or terminal output is queued.
|
|
58
|
+
|
|
59
|
+
Cancellation removes callback references, including entries already captured but
|
|
60
|
+
not yet invoked. Retained cancellation handles release their reference to the TUI
|
|
61
|
+
and callback on cancellation, invocation (including failure), or lifecycle
|
|
62
|
+
invalidation. Later cancellation calls are no-ops; an executing callback remains
|
|
63
|
+
live until its synchronous invocation returns. Preparation exceptions are logged, unrelated callbacks continue,
|
|
64
|
+
and the failed preparation's generation is not reported as successfully committed.
|
|
65
|
+
Throwing or lifecycle-invalidated preparations immediately resolve their existing
|
|
66
|
+
commit waiters `false`; late waiters for those generations also resolve `false`,
|
|
67
|
+
even after a newer successful frame. Retired generations are stored as merged,
|
|
68
|
+
sorted failure ranges, separate from live pending-frame exclusions. Contiguous
|
|
69
|
+
failures compact into one range; separated failures retain distinct ranges to
|
|
70
|
+
preserve exact outcomes. Historical failure ranges are not copied or scanned on
|
|
71
|
+
each frame (waiter lookup uses binary search).
|
|
72
|
+
History has no arbitrary expiry: its numeric storage is O(disjoint failed ranges),
|
|
73
|
+
not bounded over an indefinitely alternating failure/success session. Empty active
|
|
74
|
+
pending and hole sets do not imply bounded historical storage or prove leak freedom.
|
|
75
|
+
Callbacks must be synchronous (do not pass async functions).
|
|
76
|
+
|
|
77
|
+
The cancellation lifetime regression inspects Bun's heap snapshot for outgoing
|
|
78
|
+
local closure/entry paths to the owner, callback and payload, while all targets
|
|
79
|
+
remain deliberately rooted. Pending handles and an intentionally retaining arrow
|
|
80
|
+
are positive controls; retired handles must lose those paths. This checks reference
|
|
81
|
+
release without assuming when GC collects an object. It does not prove global leak
|
|
82
|
+
freedom or exclude unrelated module, runtime or test-runner roots.
|
|
83
|
+
|
|
84
|
+
`tui.setRenderPreparationLifecycleCallbacks(callbacks: { invalidate: () => void;
|
|
85
|
+
beforeStart: () => void } | undefined): void` installs a **single** preparation owner.
|
|
86
|
+
Replacing or clearing the owner invalidates its old queued and captured work and
|
|
87
|
+
calls its `invalidate` synchronously. Stop, terminal loss, and disposal do the same;
|
|
88
|
+
exceptions are logged without preventing cancellation. Disposal also clears the
|
|
89
|
+
owner. Enqueue while stopped, unavailable, invalidating, or disposed retains no work.
|
|
90
|
+
Disposal cancels pending render/width-settle timers and render requests; later normal,
|
|
91
|
+
forced, input-priority, and resize requests cannot rearm the disposed renderer.
|
|
92
|
+
|
|
93
|
+
After successful terminal setup, each `start()` calls `beforeStart` synchronously
|
|
94
|
+
before its first forced render request. The owner can enqueue fresh preparation
|
|
95
|
+
from its current authoritative state there, so restarting does not require another
|
|
96
|
+
provider event. Failed setup does not rearm work; a throwing `beforeStart` is logged
|
|
97
|
+
and its queued work is invalidated. Stop/start during a drain cannot revive that
|
|
98
|
+
drain's old snapshot. This API is only a presentation-preparation lifecycle seam,
|
|
99
|
+
not a general lifecycle event bus.
|
|
100
|
+
|
|
47
101
|
```typescript
|
|
48
102
|
const tui = new TUI(terminal);
|
|
49
103
|
tui.addChild(component);
|
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
import type { SymbolTheme } from "../symbols";
|
|
2
2
|
import type { Component } from "../tui";
|
|
3
3
|
import { type ViewportAnchorSpan } from "../utils";
|
|
4
|
+
/** Account retained payload, not live heap. Stop once admission is impossible. */
|
|
5
|
+
export declare function getMarkdownCacheEntryAccountedSize(key: string, value: unknown, cap: number): number;
|
|
4
6
|
/** Test-only clock seam for streaming throttle tests. */
|
|
5
7
|
export declare function __setMarkdownNowForTest(now: (() => number) | undefined): void;
|
|
8
|
+
export interface MarkdownCacheStats {
|
|
9
|
+
count: number;
|
|
10
|
+
accountedSize: number;
|
|
11
|
+
max: number;
|
|
12
|
+
maxSize: number;
|
|
13
|
+
maxEntrySize: number;
|
|
14
|
+
}
|
|
15
|
+
/** Detached diagnostics; exposes neither entries nor mutable budgets. */
|
|
16
|
+
export declare function getMarkdownCacheStats(): Record<"render" | "parse" | "highlight", MarkdownCacheStats>;
|
|
6
17
|
/** Test/diagnostic seam: number of synchronous highlight invocations since the last reset. */
|
|
7
18
|
export declare function getMarkdownHighlightCallCount(): number;
|
|
8
19
|
export declare function resetMarkdownHighlightCallCount(): void;
|
|
@@ -14,6 +25,10 @@ export declare const __markdownPerfCounters: {
|
|
|
14
25
|
};
|
|
15
26
|
/** Drop all L2 cache entries. Call on theme change to prevent stale styled output. */
|
|
16
27
|
export declare function clearRenderCache(): void;
|
|
28
|
+
/**
|
|
29
|
+
* Insertion-time UTF-16 accounting of global keys/tokens/lines/anchors, not heap or RSS.
|
|
30
|
+
* Rendered arrays are borrowed cache payloads: callers must not mutate them.
|
|
31
|
+
*/
|
|
17
32
|
export declare function getRenderCacheRetainedBytes(): number;
|
|
18
33
|
/**
|
|
19
34
|
* Default text styling for markdown content.
|
|
@@ -23,6 +23,7 @@ export interface SettingsListTheme {
|
|
|
23
23
|
export declare class SettingsList implements Component {
|
|
24
24
|
#private;
|
|
25
25
|
constructor(items: SettingItem[], maxVisible: number, theme: SettingsListTheme, onChange: (id: string, newValue: string) => void, onCancel: () => void, onSelectionChange?: (item: SettingItem | undefined) => void, descriptionRows?: number);
|
|
26
|
+
get navigationLocked(): boolean;
|
|
26
27
|
/** Update an item's currentValue */
|
|
27
28
|
updateValue(id: string, newValue: string): void;
|
|
28
29
|
/**
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -361,6 +361,7 @@ type TuiRenderCounterSnapshot = {
|
|
|
361
361
|
debugRedrawEnvReads: number;
|
|
362
362
|
debugRedrawAppendWrites: number;
|
|
363
363
|
differentialGuardVisibleWidthCalls: number;
|
|
364
|
+
widthReflowScanRows: number;
|
|
364
365
|
};
|
|
365
366
|
/**
|
|
366
367
|
* TUI - Main class for managing terminal UI with differential rendering
|
|
@@ -373,6 +374,11 @@ export declare class TUI extends Container {
|
|
|
373
374
|
onDebug?: () => void;
|
|
374
375
|
static resetRenderCountersForTest(): void;
|
|
375
376
|
static getRenderCountersForTest(): TuiRenderCounterSnapshot;
|
|
377
|
+
getRenderPreparationStateForTest(): {
|
|
378
|
+
pending: number;
|
|
379
|
+
holes: number;
|
|
380
|
+
failedRanges: number;
|
|
381
|
+
};
|
|
376
382
|
overlayStack: {
|
|
377
383
|
component: Component;
|
|
378
384
|
options?: OverlayOptions;
|
|
@@ -495,6 +501,13 @@ export declare class TUI extends Container {
|
|
|
495
501
|
*/
|
|
496
502
|
requestResizeRender(): void;
|
|
497
503
|
requestRender(force?: boolean, source?: string): void;
|
|
504
|
+
/** Queue one synchronous preparation on the existing frame clock. Cancellation is idempotent. */
|
|
505
|
+
enqueueBeforeRender(callback: () => void): () => void;
|
|
506
|
+
/** One owner only: replacing or clearing it invalidates all old preparation work. */
|
|
507
|
+
setRenderPreparationLifecycleCallbacks(callbacks: {
|
|
508
|
+
invalidate: () => void;
|
|
509
|
+
beforeStart: () => void;
|
|
510
|
+
} | undefined): void;
|
|
498
511
|
/** Request a frame whose mutation is known to be outside the viewport-anchor subtree. */
|
|
499
512
|
requestLayoutRender(source?: string): void;
|
|
500
513
|
requestRenderWithGeneration(force?: boolean, source?: string): number;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/tui",
|
|
4
|
-
"version": "0.16.
|
|
4
|
+
"version": "0.16.6",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://gajae-code.com",
|
|
7
7
|
"author": "Yeachan-Heo and Gajae Code Contributors",
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"fmt": "biome format --write ."
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@gajae-code/natives": "0.16.
|
|
40
|
-
"@gajae-code/utils": "0.16.
|
|
39
|
+
"@gajae-code/natives": "0.16.6",
|
|
40
|
+
"@gajae-code/utils": "0.16.6",
|
|
41
41
|
"lru-cache": "11.3.6",
|
|
42
42
|
"marked": "18.0.6"
|
|
43
43
|
},
|
package/src/components/box.ts
CHANGED
|
@@ -161,13 +161,13 @@ export class Box implements Component {
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
#applyBg(line: string, width: number): string {
|
|
164
|
+
if (this.#bgFn) {
|
|
165
|
+
return applyBackgroundToLine(line, width, this.#bgFn);
|
|
166
|
+
}
|
|
164
167
|
const visLen = visibleWidth(line);
|
|
165
168
|
const padNeeded = Math.max(0, width - visLen);
|
|
166
169
|
const padded = line + padding(padNeeded);
|
|
167
170
|
|
|
168
|
-
if (this.#bgFn) {
|
|
169
|
-
return applyBackgroundToLine(padded, width, this.#bgFn);
|
|
170
|
-
}
|
|
171
171
|
return padded;
|
|
172
172
|
}
|
|
173
173
|
}
|
package/src/components/editor.ts
CHANGED
|
@@ -1061,8 +1061,7 @@ export class Editor implements Component, Focusable {
|
|
|
1061
1061
|
if (after.length > 0) {
|
|
1062
1062
|
// Cursor is on a character (grapheme) - replace it with highlighted version
|
|
1063
1063
|
// Get the first grapheme from 'after'
|
|
1064
|
-
const
|
|
1065
|
-
const firstGrapheme = afterGraphemes[0]?.segment || "";
|
|
1064
|
+
const firstGrapheme = segmenter.segment(after)[Symbol.iterator]().next().value?.segment || "";
|
|
1066
1065
|
const restAfter = after.slice(firstGrapheme.length);
|
|
1067
1066
|
const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
|
|
1068
1067
|
displayText = before + marker + cursor + restAfter;
|
|
@@ -2643,8 +2642,7 @@ export class Editor implements Component, Focusable {
|
|
|
2643
2642
|
const afterCursor = currentLine.slice(this.#state.cursorCol);
|
|
2644
2643
|
|
|
2645
2644
|
// Find the first grapheme at cursor
|
|
2646
|
-
const
|
|
2647
|
-
const firstGrapheme = graphemes[0];
|
|
2645
|
+
const firstGrapheme = segmenter.segment(afterCursor)[Symbol.iterator]().next().value;
|
|
2648
2646
|
const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
|
|
2649
2647
|
|
|
2650
2648
|
const before = currentLine.slice(0, this.#state.cursorCol);
|
|
@@ -2762,8 +2760,7 @@ export class Editor implements Component, Focusable {
|
|
|
2762
2760
|
this.#setCursorCol(this.#state.cursorCol + 1);
|
|
2763
2761
|
} else {
|
|
2764
2762
|
const afterCursor = currentLine.slice(this.#state.cursorCol);
|
|
2765
|
-
const
|
|
2766
|
-
const firstGrapheme = graphemes[0];
|
|
2763
|
+
const firstGrapheme = segmenter.segment(afterCursor)[Symbol.iterator]().next().value;
|
|
2767
2764
|
this.#setCursorCol(this.#state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
|
|
2768
2765
|
}
|
|
2769
2766
|
} else if (this.#state.cursorLine < this.#state.lines.length - 1) {
|
package/src/components/input.ts
CHANGED
|
@@ -156,8 +156,7 @@ export class Input implements Component, Focusable {
|
|
|
156
156
|
this.#lastAction = null;
|
|
157
157
|
if (this.#cursor < this.#value.length) {
|
|
158
158
|
const afterCursor = this.#value.slice(this.#cursor);
|
|
159
|
-
const
|
|
160
|
-
const firstGrapheme = graphemes[0];
|
|
159
|
+
const firstGrapheme = segmenter.segment(afterCursor)[Symbol.iterator]().next().value;
|
|
161
160
|
this.#cursor += firstGrapheme ? firstGrapheme.segment.length : 1;
|
|
162
161
|
}
|
|
163
162
|
return;
|
|
@@ -231,8 +230,7 @@ export class Input implements Component, Focusable {
|
|
|
231
230
|
this.#pushUndo();
|
|
232
231
|
|
|
233
232
|
const afterCursor = this.#value.slice(this.#cursor);
|
|
234
|
-
const
|
|
235
|
-
const firstGrapheme = graphemes[0];
|
|
233
|
+
const firstGrapheme = segmenter.segment(afterCursor)[Symbol.iterator]().next().value;
|
|
236
234
|
const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
|
|
237
235
|
|
|
238
236
|
this.#value = this.#value.slice(0, this.#cursor) + this.#value.slice(this.#cursor + graphemeLength);
|
|
@@ -442,8 +440,7 @@ export class Input implements Component, Focusable {
|
|
|
442
440
|
|
|
443
441
|
// Build line with fake cursor
|
|
444
442
|
// Insert cursor character at cursor position
|
|
445
|
-
const
|
|
446
|
-
const cursorGrapheme = graphemes[0];
|
|
443
|
+
const cursorGrapheme = segmenter.segment(visibleText.slice(cursorDisplay))[Symbol.iterator]().next().value;
|
|
447
444
|
|
|
448
445
|
const beforeCursor = visibleText.slice(0, cursorDisplay);
|
|
449
446
|
const atCursor = cursorGrapheme?.segment ?? " ";
|
|
@@ -46,14 +46,95 @@ markdownParser.setOptions({
|
|
|
46
46
|
// (Rust FFI) work for content/layout combinations already seen this session.
|
|
47
47
|
|
|
48
48
|
const RENDER_CACHE_MAX = 256; // sane cap: ~256 distinct message × width combos
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
const DOCUMENT_CACHE_SIZE = 8 * 1024 * 1024;
|
|
50
|
+
const DOCUMENT_ENTRY_SIZE = 1024 * 1024;
|
|
51
|
+
|
|
52
|
+
/** Account retained payload, not live heap. Stop once admission is impossible. */
|
|
53
|
+
export function getMarkdownCacheEntryAccountedSize(key: string, value: unknown, cap: number): number {
|
|
54
|
+
let size = 64 + key.length * 2;
|
|
55
|
+
const visited = new Set<object>();
|
|
56
|
+
const pending: Iterator<unknown>[] = [[value][Symbol.iterator]()];
|
|
57
|
+
function* objects(object: object, array: boolean): Generator<object> {
|
|
58
|
+
for (const name in object) {
|
|
59
|
+
if (!Object.hasOwn(object, name)) continue;
|
|
60
|
+
// Array indices are canonical uint32 strings, excluding 2^32 - 1.
|
|
61
|
+
const index = array ? Number(name) >>> 0 : 0;
|
|
62
|
+
if (!array || index === 0xffffffff || String(index) !== name) {
|
|
63
|
+
size += 16 + name.length * 2;
|
|
64
|
+
if (size > cap) return;
|
|
65
|
+
}
|
|
66
|
+
const child = (object as Record<string, unknown>)[name];
|
|
67
|
+
if (typeof child === "string") size += child.length * 2;
|
|
68
|
+
else if (typeof child === "number") size += 8;
|
|
69
|
+
else if (typeof child === "boolean") size += 4;
|
|
70
|
+
else if (child !== null && typeof child === "object") yield child;
|
|
71
|
+
if (size > cap) return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
while (pending.length && size <= cap) {
|
|
75
|
+
const next = pending[pending.length - 1].next();
|
|
76
|
+
if (next.done) {
|
|
77
|
+
pending.pop();
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const item = next.value;
|
|
81
|
+
if (typeof item === "string") size += item.length * 2;
|
|
82
|
+
else if (typeof item === "number") size += 8;
|
|
83
|
+
else if (typeof item === "boolean") size += 4;
|
|
84
|
+
else if (item !== null && typeof item === "object" && !visited.has(item)) {
|
|
85
|
+
visited.add(item);
|
|
86
|
+
const array = Array.isArray(item);
|
|
87
|
+
size += array ? 24 + item.length * 8 : 32;
|
|
88
|
+
if (size <= cap) pending.push(objects(item, array));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return size > cap ? cap + 1 : size;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface MarkdownRenderCacheEntry {
|
|
95
|
+
source: string;
|
|
96
|
+
lines: string[];
|
|
97
|
+
anchorSpans?: Array<ViewportAnchorSpan | null>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const RENDER_ENTRY_BASE_SIZE = 64 + 32 + 16 + "source".length * 2 + 16 + "lines".length * 2 + 24;
|
|
101
|
+
const ANCHOR_SPAN_SIZE =
|
|
102
|
+
32 + 4 * 16 + 4 * 8 + 2 * ("graphemeStart".length + "graphemeEnd".length + "cellStart".length + "cellEnd".length);
|
|
103
|
+
|
|
104
|
+
function renderCacheEntrySize(entry: MarkdownRenderCacheEntry, key: string): number {
|
|
105
|
+
// These arrays and distinct span records are constructed exclusively by #render,
|
|
106
|
+
// with no custom properties, sharing or cycles. Account that owned schema without
|
|
107
|
+
// allocating the generic token-graph traversal machinery.
|
|
108
|
+
let size = RENDER_ENTRY_BASE_SIZE + key.length * 2 + entry.source.length * 2 + entry.lines.length * 8;
|
|
109
|
+
if (size > DOCUMENT_ENTRY_SIZE) return DOCUMENT_ENTRY_SIZE + 1;
|
|
110
|
+
for (const line of entry.lines) {
|
|
111
|
+
size += line.length * 2;
|
|
112
|
+
if (size > DOCUMENT_ENTRY_SIZE) return DOCUMENT_ENTRY_SIZE + 1;
|
|
113
|
+
}
|
|
114
|
+
if (entry.anchorSpans) {
|
|
115
|
+
size += 16 + "anchorSpans".length * 2 + 24 + entry.anchorSpans.length * 8;
|
|
116
|
+
if (size > DOCUMENT_ENTRY_SIZE) return DOCUMENT_ENTRY_SIZE + 1;
|
|
117
|
+
for (const span of entry.anchorSpans) {
|
|
118
|
+
if (span) size += ANCHOR_SPAN_SIZE;
|
|
119
|
+
if (size > DOCUMENT_ENTRY_SIZE) return DOCUMENT_ENTRY_SIZE + 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return size;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const renderCache = new LRUCache<string, MarkdownRenderCacheEntry>({
|
|
53
126
|
max: RENDER_CACHE_MAX,
|
|
127
|
+
maxSize: DOCUMENT_CACHE_SIZE,
|
|
128
|
+
maxEntrySize: DOCUMENT_ENTRY_SIZE,
|
|
129
|
+
sizeCalculation: renderCacheEntrySize,
|
|
54
130
|
});
|
|
55
131
|
const PARSE_CACHE_MAX = 128;
|
|
56
|
-
const parseCache = new LRUCache<string, { source: string; tokens: Token[] }>({
|
|
132
|
+
const parseCache = new LRUCache<string, { source: string; tokens: Token[] }>({
|
|
133
|
+
max: PARSE_CACHE_MAX,
|
|
134
|
+
maxSize: DOCUMENT_CACHE_SIZE,
|
|
135
|
+
maxEntrySize: DOCUMENT_ENTRY_SIZE,
|
|
136
|
+
sizeCalculation: (value, key) => getMarkdownCacheEntryAccountedSize(key, value, DOCUMENT_ENTRY_SIZE),
|
|
137
|
+
});
|
|
57
138
|
const MARKDOWN_STREAM_THROTTLE_MS = 64;
|
|
58
139
|
let markdownNow = (): number => performance.now();
|
|
59
140
|
|
|
@@ -76,20 +157,44 @@ export function __setMarkdownNowForTest(now: (() => number) | undefined): void {
|
|
|
76
157
|
// appends only highlight new/changed blocks instead of re-highlighting the whole
|
|
77
158
|
// prefix on every chunk. Bounded LRU; cleared on theme change via clearRenderCache().
|
|
78
159
|
const HIGHLIGHT_CACHE_MAX = 512;
|
|
79
|
-
const
|
|
160
|
+
const HIGHLIGHT_ENTRY_SIZE = 512 * 1024;
|
|
161
|
+
interface MarkdownHighlightCacheEntry {
|
|
162
|
+
lang: string;
|
|
163
|
+
code: string;
|
|
164
|
+
lines: string[];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const highlightCache = new LRUCache<string, MarkdownHighlightCacheEntry>({
|
|
168
|
+
max: HIGHLIGHT_CACHE_MAX,
|
|
169
|
+
maxSize: 4 * 1024 * 1024,
|
|
170
|
+
maxEntrySize: HIGHLIGHT_ENTRY_SIZE,
|
|
171
|
+
sizeCalculation: (value, key) => getMarkdownCacheEntryAccountedSize(key, value, HIGHLIGHT_ENTRY_SIZE),
|
|
172
|
+
});
|
|
80
173
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
174
|
+
export interface MarkdownCacheStats {
|
|
175
|
+
count: number;
|
|
176
|
+
accountedSize: number;
|
|
177
|
+
max: number;
|
|
178
|
+
maxSize: number;
|
|
179
|
+
maxEntrySize: number;
|
|
85
180
|
}
|
|
86
181
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
182
|
+
/** Detached diagnostics; exposes neither entries nor mutable budgets. */
|
|
183
|
+
export function getMarkdownCacheStats(): Record<"render" | "parse" | "highlight", MarkdownCacheStats> {
|
|
184
|
+
const snapshot = (cache: {
|
|
185
|
+
size: number;
|
|
186
|
+
calculatedSize: number;
|
|
187
|
+
max: number;
|
|
188
|
+
maxSize: number;
|
|
189
|
+
maxEntrySize: number;
|
|
190
|
+
}): MarkdownCacheStats => ({
|
|
191
|
+
count: cache.size,
|
|
192
|
+
accountedSize: cache.calculatedSize,
|
|
193
|
+
max: cache.max,
|
|
194
|
+
maxSize: cache.maxSize,
|
|
195
|
+
maxEntrySize: cache.maxEntrySize,
|
|
196
|
+
});
|
|
197
|
+
return { render: snapshot(renderCache), parse: snapshot(parseCache), highlight: snapshot(highlightCache) };
|
|
93
198
|
}
|
|
94
199
|
|
|
95
200
|
// F18: cap synchronous (Rust FFI) syntax highlighting so a single huge fenced block
|
|
@@ -133,16 +238,12 @@ export function clearRenderCache(): void {
|
|
|
133
238
|
highlightCache.clear();
|
|
134
239
|
}
|
|
135
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Insertion-time UTF-16 accounting of global keys/tokens/lines/anchors, not heap or RSS.
|
|
243
|
+
* Rendered arrays are borrowed cache payloads: callers must not mutate them.
|
|
244
|
+
*/
|
|
136
245
|
export function getRenderCacheRetainedBytes(): number {
|
|
137
|
-
|
|
138
|
-
for (const entry of renderCache.values()) {
|
|
139
|
-
bytes += Buffer.byteLength(entry.source, "utf8");
|
|
140
|
-
bytes += renderedLinesBytes(entry.lines);
|
|
141
|
-
if (entry.anchorSpans) bytes += anchorSpansBytes(entry.anchorSpans);
|
|
142
|
-
}
|
|
143
|
-
for (const entry of parseCache.values()) bytes += Buffer.byteLength(entry.source, "utf8");
|
|
144
|
-
for (const lines of highlightCache.values()) bytes += renderedLinesBytes(lines);
|
|
145
|
-
return bytes;
|
|
246
|
+
return renderCache.calculatedSize + parseCache.calculatedSize + highlightCache.calculatedSize;
|
|
146
247
|
}
|
|
147
248
|
|
|
148
249
|
// Stable numeric IDs for structural theme/style objects (no ID field on type).
|
|
@@ -267,6 +368,8 @@ export class Markdown implements Component {
|
|
|
267
368
|
#cachedWidth?: number;
|
|
268
369
|
#cachedLines?: string[];
|
|
269
370
|
#cachedAnchorSpans?: Array<ViewportAnchorSpan | null>;
|
|
371
|
+
#currentParse?: { source: string; tokens: Token[] };
|
|
372
|
+
#rejectedParseKey?: string;
|
|
270
373
|
|
|
271
374
|
#streaming = false;
|
|
272
375
|
#lastFullParseAt = 0;
|
|
@@ -297,6 +400,7 @@ export class Markdown implements Component {
|
|
|
297
400
|
if (options?.streaming !== undefined) {
|
|
298
401
|
this.setStreaming(options.streaming);
|
|
299
402
|
}
|
|
403
|
+
if (this.#text !== text) this.#rejectedParseKey = undefined;
|
|
300
404
|
this.#text = text;
|
|
301
405
|
if (this.#streaming) {
|
|
302
406
|
return;
|
|
@@ -336,6 +440,8 @@ export class Markdown implements Component {
|
|
|
336
440
|
|
|
337
441
|
dispose(): void {
|
|
338
442
|
this.#clearStaleThrottleTimer();
|
|
443
|
+
this.#currentParse = undefined;
|
|
444
|
+
this.#rejectedParseKey = undefined;
|
|
339
445
|
}
|
|
340
446
|
|
|
341
447
|
invalidate(): void {
|
|
@@ -346,14 +452,17 @@ export class Markdown implements Component {
|
|
|
346
452
|
}
|
|
347
453
|
|
|
348
454
|
#exceedsHighlightCap(code: string): boolean {
|
|
455
|
+
// UTF-8 requires at least one byte per UTF-16 code unit, including lone
|
|
456
|
+
// surrogates. Reject obviously oversized input without scanning or hashing it.
|
|
457
|
+
if (code.length > MAX_HIGHLIGHT_BYTES) return true;
|
|
458
|
+
// Check UTF-8 bytes before the JavaScript line scan so oversized Unicode
|
|
459
|
+
// blocks do not pay for a scan whose result cannot change rejection.
|
|
460
|
+
if (Buffer.byteLength(code, "utf8") > MAX_HIGHLIGHT_BYTES) return true;
|
|
349
461
|
let newlines = 0;
|
|
350
462
|
for (let i = 0; i < code.length; i++) {
|
|
351
|
-
if (code.charCodeAt(i) === 10
|
|
463
|
+
if (code.charCodeAt(i) === 10 && ++newlines >= MAX_HIGHLIGHT_LINES) return true;
|
|
352
464
|
}
|
|
353
|
-
|
|
354
|
-
// UTF-8 byte length (not UTF-16 code-unit count) so a non-ASCII block cannot
|
|
355
|
-
// exceed the advertised byte cap and still reach the synchronous highlighter.
|
|
356
|
-
return Buffer.byteLength(code, "utf8") > MAX_HIGHLIGHT_BYTES;
|
|
465
|
+
return false;
|
|
357
466
|
}
|
|
358
467
|
|
|
359
468
|
#highlightCodeBlock(code: string, lang: string): string[] | null {
|
|
@@ -361,10 +470,10 @@ export class Markdown implements Component {
|
|
|
361
470
|
if (this.#exceedsHighlightCap(code)) return null;
|
|
362
471
|
const key = `${objectId(this.#theme)}\x00${lang}\x00${code}`;
|
|
363
472
|
const cached = highlightCache.get(key);
|
|
364
|
-
if (cached) return cached;
|
|
473
|
+
if (cached?.lang === lang && cached.code === code) return cached.lines;
|
|
365
474
|
highlightCallCount += 1;
|
|
366
475
|
const result = this.#theme.highlightCode(code, lang || undefined);
|
|
367
|
-
highlightCache.set(key, result);
|
|
476
|
+
highlightCache.set(key, { lang, code, lines: result });
|
|
368
477
|
return result;
|
|
369
478
|
}
|
|
370
479
|
|
|
@@ -421,6 +530,7 @@ export class Markdown implements Component {
|
|
|
421
530
|
|
|
422
531
|
// Don't render anything if there's no actual text
|
|
423
532
|
if (!this.#text || this.#text.trim() === "") {
|
|
533
|
+
this.#currentParse = undefined;
|
|
424
534
|
const result: string[] = [];
|
|
425
535
|
// Update per-instance cache
|
|
426
536
|
this.#cachedText = this.#text;
|
|
@@ -430,8 +540,9 @@ export class Markdown implements Component {
|
|
|
430
540
|
return { lines: result, spans: this.#cachedAnchorSpans };
|
|
431
541
|
}
|
|
432
542
|
|
|
433
|
-
//
|
|
434
|
-
const
|
|
543
|
+
// Normalize tabs using the current configured indentation width.
|
|
544
|
+
const renderedText = this.#text;
|
|
545
|
+
const normalizedText = replaceTabs(renderedText);
|
|
435
546
|
this.#clearStaleThrottleTimer();
|
|
436
547
|
const contentKey = markdownContentKey(normalizedText);
|
|
437
548
|
|
|
@@ -450,10 +561,11 @@ export class Markdown implements Component {
|
|
|
450
561
|
(!includeAnchors || cached.anchorSpans !== undefined)
|
|
451
562
|
) {
|
|
452
563
|
// Populate L1 so subsequent calls from this instance are O(1) map lookup.
|
|
453
|
-
this.#cachedText =
|
|
564
|
+
this.#cachedText = renderedText;
|
|
454
565
|
this.#cachedWidth = width;
|
|
455
566
|
this.#cachedLines = cached.lines;
|
|
456
567
|
this.#cachedAnchorSpans = cached.anchorSpans;
|
|
568
|
+
if (!this.#streaming || this.#currentParse?.source !== normalizedText) this.#currentParse = undefined;
|
|
457
569
|
return { lines: cached.lines, spans: cached.anchorSpans };
|
|
458
570
|
}
|
|
459
571
|
|
|
@@ -462,13 +574,17 @@ export class Markdown implements Component {
|
|
|
462
574
|
// final wrapped output must differ by width.
|
|
463
575
|
const cachedParse = parseCache.get(contentKey);
|
|
464
576
|
let tokens: Token[];
|
|
465
|
-
if (
|
|
577
|
+
if (this.#currentParse?.source === normalizedText) {
|
|
578
|
+
tokens = this.#currentParse.tokens;
|
|
579
|
+
} else if (cachedParse !== undefined && cachedParse.source === normalizedText) {
|
|
466
580
|
tokens = cachedParse.tokens;
|
|
467
581
|
} else {
|
|
468
582
|
__markdownPerfCounters.lexerInvocations += 1;
|
|
469
583
|
__markdownPerfCounters.lexedBytes += normalizedText.length;
|
|
470
584
|
tokens = markdownParser.lexer(normalizedText);
|
|
471
|
-
|
|
585
|
+
}
|
|
586
|
+
if (this.#streaming && this.#text === renderedText) {
|
|
587
|
+
this.#currentParse = { source: normalizedText, tokens };
|
|
472
588
|
}
|
|
473
589
|
|
|
474
590
|
// Convert tokens to styled terminal output. When anchoring, record each
|
|
@@ -615,8 +731,24 @@ export class Markdown implements Component {
|
|
|
615
731
|
const result = rawResult.length > 0 ? rawResult : [""];
|
|
616
732
|
const anchorSpans = rawResult.length > 0 ? rawAnchorSpans : wrappedSpans ? [null] : undefined;
|
|
617
733
|
|
|
734
|
+
// Styling callbacks may replace text or end streaming. Publish ownership
|
|
735
|
+
// only after they finish, and never attach an old rejection to new text.
|
|
736
|
+
if (!this.#streaming && this.#text === renderedText) {
|
|
737
|
+
const completedParse = parseCache.get(contentKey);
|
|
738
|
+
// Reparse rejected documents on reflow without retaining their tokens.
|
|
739
|
+
// Occupied collisions still retry admission to preserve replacement rules.
|
|
740
|
+
if (
|
|
741
|
+
completedParse?.source !== normalizedText &&
|
|
742
|
+
(this.#rejectedParseKey !== contentKey || completedParse !== undefined)
|
|
743
|
+
) {
|
|
744
|
+
parseCache.set(contentKey, { source: normalizedText, tokens });
|
|
745
|
+
this.#rejectedParseKey = parseCache.has(contentKey) ? undefined : contentKey;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
if (!this.#streaming || this.#text !== renderedText) this.#currentParse = undefined;
|
|
749
|
+
|
|
618
750
|
// Update L1 per-instance cache
|
|
619
|
-
this.#cachedText =
|
|
751
|
+
this.#cachedText = renderedText;
|
|
620
752
|
this.#cachedWidth = width;
|
|
621
753
|
this.#cachedLines = result;
|
|
622
754
|
this.#cachedAnchorSpans = anchorSpans;
|
|
@@ -624,7 +756,9 @@ export class Markdown implements Component {
|
|
|
624
756
|
|
|
625
757
|
// Update L2 module-level LRU so future instances with the same key skip
|
|
626
758
|
// the marked.lexer + highlightCode (Rust FFI) work entirely.
|
|
627
|
-
|
|
759
|
+
if (!this.#streaming) {
|
|
760
|
+
renderCache.set(cacheKey, { source: normalizedText, lines: result, ...(anchorSpans ? { anchorSpans } : {}) });
|
|
761
|
+
}
|
|
628
762
|
|
|
629
763
|
return { lines: result, spans: anchorSpans };
|
|
630
764
|
}
|
|
@@ -241,9 +241,12 @@ export class SelectList implements Component {
|
|
|
241
241
|
|
|
242
242
|
#getPrimaryColumnWidth(): number {
|
|
243
243
|
const { min, max } = this.#getPrimaryColumnBounds();
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
244
|
+
if (min === max) return min;
|
|
245
|
+
let widestPrimary = 0;
|
|
246
|
+
for (const item of this.#filteredItems) {
|
|
247
|
+
widestPrimary = Math.max(widestPrimary, visibleWidth(this.#getDisplayValue(item)) + PRIMARY_COLUMN_GAP);
|
|
248
|
+
if (widestPrimary >= max) return max;
|
|
249
|
+
}
|
|
247
250
|
|
|
248
251
|
return clamp(widestPrimary, min, max);
|
|
249
252
|
}
|
|
@@ -62,6 +62,11 @@ export class SettingsList implements Component {
|
|
|
62
62
|
this.#notifySelectionChange();
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
get navigationLocked(): boolean {
|
|
66
|
+
const submenu = this.#submenuComponent as (Component & { navigationLocked?: boolean }) | null;
|
|
67
|
+
return submenu?.navigationLocked === true;
|
|
68
|
+
}
|
|
69
|
+
|
|
65
70
|
#clampSelectedIndex(): void {
|
|
66
71
|
if (this.#items.length === 0) {
|
|
67
72
|
this.#selectedIndex = 0;
|
package/src/tui.ts
CHANGED
|
@@ -147,15 +147,17 @@ function csiEnd(bytes: string, start: number): number | undefined {
|
|
|
147
147
|
*/
|
|
148
148
|
function stripTerminalEraseControls(bytes: string): string {
|
|
149
149
|
let sanitized = "";
|
|
150
|
+
let spanStart = 0;
|
|
150
151
|
for (let index = 0; index < bytes.length; index += 1) {
|
|
151
152
|
const value = bytes.charCodeAt(index);
|
|
152
153
|
const isEscapeCsi = value === 0x1b && bytes.charCodeAt(index + 1) === 0x5b;
|
|
153
154
|
const isEightBitCsi = value === 0x9b;
|
|
154
|
-
if (value === 0x1b && index === bytes.length - 1)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
155
|
+
if (value === 0x1b && index === bytes.length - 1) {
|
|
156
|
+
sanitized += bytes.slice(spanStart, index);
|
|
157
|
+
spanStart = bytes.length;
|
|
158
|
+
break;
|
|
158
159
|
}
|
|
160
|
+
if (!isEscapeCsi && !isEightBitCsi) continue;
|
|
159
161
|
|
|
160
162
|
const start = isEightBitCsi ? index + 1 : index + 2;
|
|
161
163
|
const end = csiEnd(bytes, start);
|
|
@@ -168,16 +170,19 @@ function stripTerminalEraseControls(bytes: string): string {
|
|
|
168
170
|
if (!CSI_PARAMETER(nextValue) && !CSI_INTERMEDIATE(nextValue)) break;
|
|
169
171
|
next += 1;
|
|
170
172
|
}
|
|
173
|
+
sanitized += bytes.slice(spanStart, index);
|
|
174
|
+
spanStart = next;
|
|
171
175
|
index = next - 1;
|
|
172
176
|
continue;
|
|
173
177
|
}
|
|
174
178
|
const final = bytes.charCodeAt(end);
|
|
175
|
-
if (final
|
|
176
|
-
sanitized += bytes.slice(
|
|
179
|
+
if (final === 0x4a || final === 0x4b) {
|
|
180
|
+
sanitized += bytes.slice(spanStart, index);
|
|
181
|
+
spanStart = end + 1;
|
|
177
182
|
}
|
|
178
183
|
index = end;
|
|
179
184
|
}
|
|
180
|
-
return sanitized;
|
|
185
|
+
return spanStart === 0 ? bytes : sanitized + bytes.slice(spanStart);
|
|
181
186
|
}
|
|
182
187
|
type InputListenerResult = { consume?: boolean; data?: string } | undefined;
|
|
183
188
|
type InputListener = (data: string) => InputListenerResult;
|
|
@@ -813,6 +818,7 @@ type TuiRenderCounterSnapshot = {
|
|
|
813
818
|
debugRedrawEnvReads: number;
|
|
814
819
|
debugRedrawAppendWrites: number;
|
|
815
820
|
differentialGuardVisibleWidthCalls: number;
|
|
821
|
+
widthReflowScanRows: number;
|
|
816
822
|
};
|
|
817
823
|
type RenderCommitWaiter = {
|
|
818
824
|
resolve: (committed: boolean) => void;
|
|
@@ -960,6 +966,20 @@ function hasDistinctPostContractionRows(
|
|
|
960
966
|
return false;
|
|
961
967
|
}
|
|
962
968
|
|
|
969
|
+
interface RenderPreparation {
|
|
970
|
+
callback?: () => void;
|
|
971
|
+
generation: number;
|
|
972
|
+
cancel?: () => void;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// Keep the public handle outside the enqueue scope: it must capture only the entry,
|
|
976
|
+
// not the TUI or the original callback after the entry has been released.
|
|
977
|
+
function createPreparationCancellation(entry: RenderPreparation): () => void {
|
|
978
|
+
return () => entry.cancel?.();
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function cancelNoPreparation(): void {}
|
|
982
|
+
|
|
963
983
|
/**
|
|
964
984
|
* TUI - Main class for managing terminal UI with differential rendering
|
|
965
985
|
*/
|
|
@@ -1009,6 +1029,21 @@ export class TUI extends Container {
|
|
|
1009
1029
|
#nextRenderGeneration = 0;
|
|
1010
1030
|
#renderRequestedGeneration = 0;
|
|
1011
1031
|
#committedRenderGeneration = 0;
|
|
1032
|
+
#preparations = new Set<RenderPreparation>();
|
|
1033
|
+
#preparationSnapshot: Set<RenderPreparation> | undefined;
|
|
1034
|
+
#preparationEpoch = 0;
|
|
1035
|
+
#preparationDrainEpoch = -1;
|
|
1036
|
+
#preparationDisposed = false;
|
|
1037
|
+
#preparationInvalidating = false;
|
|
1038
|
+
#preparationLifecycle: { invalidate: () => void; beforeStart: () => void } | undefined;
|
|
1039
|
+
// A newer ordinary request may commit while an older nested preparation is still pending.
|
|
1040
|
+
// Keep holes in the high-water mark, including for waiters registered after that commit.
|
|
1041
|
+
#preparationBlocked = new Set<number>();
|
|
1042
|
+
#commitHoles = new Set<number>();
|
|
1043
|
+
// Terminal failures are sorted, disjoint inclusive ranges, not per-frame exclusions.
|
|
1044
|
+
#failedPreparationRanges: Array<[number, number]> = [];
|
|
1045
|
+
// Captured by queued terminal writes; never consult the mutable queue at write settlement.
|
|
1046
|
+
#frameCommitExclusions = new Set<number>();
|
|
1012
1047
|
#renderCommitWaiters = new Map<number, Set<RenderCommitWaiter>>();
|
|
1013
1048
|
#lastRenderWriteSucceeded = false;
|
|
1014
1049
|
/** Generation whose render path is currently capturing terminal output. */
|
|
@@ -1147,6 +1182,7 @@ export class TUI extends Container {
|
|
|
1147
1182
|
debugRedrawEnvReads: 0,
|
|
1148
1183
|
debugRedrawAppendWrites: 0,
|
|
1149
1184
|
differentialGuardVisibleWidthCalls: 0,
|
|
1185
|
+
widthReflowScanRows: 0,
|
|
1150
1186
|
};
|
|
1151
1187
|
|
|
1152
1188
|
static resetRenderCountersForTest(): void {
|
|
@@ -1154,6 +1190,7 @@ export class TUI extends Container {
|
|
|
1154
1190
|
debugRedrawEnvReads: 0,
|
|
1155
1191
|
debugRedrawAppendWrites: 0,
|
|
1156
1192
|
differentialGuardVisibleWidthCalls: 0,
|
|
1193
|
+
widthReflowScanRows: 0,
|
|
1157
1194
|
};
|
|
1158
1195
|
}
|
|
1159
1196
|
|
|
@@ -1161,6 +1198,14 @@ export class TUI extends Container {
|
|
|
1161
1198
|
return { ...TUI.#renderCounters };
|
|
1162
1199
|
}
|
|
1163
1200
|
|
|
1201
|
+
getRenderPreparationStateForTest(): { pending: number; holes: number; failedRanges: number } {
|
|
1202
|
+
return {
|
|
1203
|
+
pending: this.#preparationBlocked.size,
|
|
1204
|
+
holes: this.#commitHoles.size,
|
|
1205
|
+
failedRanges: this.#failedPreparationRanges.length,
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1164
1209
|
static #readDebugRedrawFlag(): boolean {
|
|
1165
1210
|
TUI.#renderCounters.debugRedrawEnvReads += 1;
|
|
1166
1211
|
return $pickflag("GJC_DEBUG_REDRAW", "PI_DEBUG_REDRAW");
|
|
@@ -1249,6 +1294,29 @@ export class TUI extends Container {
|
|
|
1249
1294
|
}
|
|
1250
1295
|
|
|
1251
1296
|
override dispose(): void {
|
|
1297
|
+
if (this.#preparationDisposed) return;
|
|
1298
|
+
this.#preparationDisposed = true;
|
|
1299
|
+
this.#renderRequested = false;
|
|
1300
|
+
this.#renderRequestedGeneration = 0;
|
|
1301
|
+
this.#inputRenderPending = false;
|
|
1302
|
+
this.#resizeRenderQueued = false;
|
|
1303
|
+
this.#resizeRenderMutationQueued = false;
|
|
1304
|
+
this.#renderMutationQueued = false;
|
|
1305
|
+
this.#widthSettleRenderQueued = false;
|
|
1306
|
+
this.#forcedRenderQueued = false;
|
|
1307
|
+
this.#clearSixelProbeState();
|
|
1308
|
+
if (this.#renderTimer) {
|
|
1309
|
+
clearTimeout(this.#renderTimer);
|
|
1310
|
+
this.#renderTimer = undefined;
|
|
1311
|
+
if (renderMetrics.enabled) renderMetrics.setTimerGauge("tui.renderTimer", 0);
|
|
1312
|
+
}
|
|
1313
|
+
if (this.#widthSettleTimer) {
|
|
1314
|
+
clearTimeout(this.#widthSettleTimer);
|
|
1315
|
+
this.#widthSettleTimer = undefined;
|
|
1316
|
+
}
|
|
1317
|
+
this.#invalidatePreparations();
|
|
1318
|
+
this.#preparationLifecycle = undefined;
|
|
1319
|
+
this.#settleRenderCommitWaiters(false);
|
|
1252
1320
|
this.#unsubscribeTabWidthChange?.();
|
|
1253
1321
|
this.#unsubscribeTabWidthChange = undefined;
|
|
1254
1322
|
this.#finalizeRasterLeases("terminal-loss");
|
|
@@ -2314,6 +2382,7 @@ export class TUI extends Container {
|
|
|
2314
2382
|
return true;
|
|
2315
2383
|
}
|
|
2316
2384
|
start(): void {
|
|
2385
|
+
if (this.#preparationDisposed) return;
|
|
2317
2386
|
this.#stopped = false;
|
|
2318
2387
|
this.#terminalUnavailable = false;
|
|
2319
2388
|
this.#clearMouseSelection();
|
|
@@ -2321,31 +2390,36 @@ export class TUI extends Container {
|
|
|
2321
2390
|
// Seed the observed width so a spurious post-start resize event (iTerm2 tab
|
|
2322
2391
|
// activation, the self-sent SIGWINCH after resume) is not read as a reflow.
|
|
2323
2392
|
this.#lastObservedWidth = this.terminal.columns;
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2393
|
+
try {
|
|
2394
|
+
this.terminal.setMouseEnabled?.(this.options.enableMouse === true);
|
|
2395
|
+
this.terminal.start(
|
|
2396
|
+
data => this.#handleInput(data),
|
|
2397
|
+
() => {
|
|
2398
|
+
const hadRasterLease = this.#rasterLeases.size > 0;
|
|
2399
|
+
this.#revokeRasterLeases("resize");
|
|
2400
|
+
// Only a pet raster lease needs refreshed cell metrics on resize; a
|
|
2401
|
+
// plain resize keeps the historical byte stream (no cell query).
|
|
2402
|
+
if (hadRasterLease) this.#queryCellSize(true);
|
|
2403
|
+
this.invalidate();
|
|
2404
|
+
if (this.#pendingTerminalCleanup.length > 0 || this.#rasterCleanup.size > 0) {
|
|
2405
|
+
// Retained pet/cleanup output must be flushed before the resize
|
|
2406
|
+
// repaint so it cannot interleave behind the new frame.
|
|
2407
|
+
this.notifyTerminalLifecycle({
|
|
2408
|
+
kind: "explicit-cleanup",
|
|
2409
|
+
source: "tui",
|
|
2410
|
+
terminalGeneration: this.#terminalGeneration,
|
|
2411
|
+
}).then(result => {
|
|
2412
|
+
if (result.stillPending === 0) this.requestResizeRender();
|
|
2413
|
+
});
|
|
2414
|
+
} else {
|
|
2415
|
+
this.requestResizeRender();
|
|
2416
|
+
}
|
|
2417
|
+
},
|
|
2418
|
+
);
|
|
2419
|
+
} catch (error) {
|
|
2420
|
+
this.#markTerminalUnavailable();
|
|
2421
|
+
throw error;
|
|
2422
|
+
}
|
|
2349
2423
|
if (this.#pendingTerminalCleanup.length > 0 || this.#rasterCleanup.size > 0) {
|
|
2350
2424
|
void this.notifyTerminalLifecycle({
|
|
2351
2425
|
kind: "availability-restored",
|
|
@@ -2356,6 +2430,14 @@ export class TUI extends Container {
|
|
|
2356
2430
|
this.#hideCursor();
|
|
2357
2431
|
this.#querySixelSupport();
|
|
2358
2432
|
this.#queryCellSize();
|
|
2433
|
+
if (this.#stopped || !this.terminalAvailable) return;
|
|
2434
|
+
const epoch = this.#preparationEpoch;
|
|
2435
|
+
if (!this.#callPreparation(this.#preparationLifecycle?.beforeStart, "beforeStart")) {
|
|
2436
|
+
this.#invalidatePreparations();
|
|
2437
|
+
if (!this.#stopped && this.terminalAvailable && !this.#preparationDisposed) this.requestRender(true);
|
|
2438
|
+
return;
|
|
2439
|
+
}
|
|
2440
|
+
if (epoch !== this.#preparationEpoch || this.#stopped || !this.terminalAvailable) return;
|
|
2359
2441
|
this.requestRender(true);
|
|
2360
2442
|
}
|
|
2361
2443
|
|
|
@@ -2368,8 +2450,10 @@ export class TUI extends Container {
|
|
|
2368
2450
|
* holding a session operation behind a dead renderer.
|
|
2369
2451
|
*/
|
|
2370
2452
|
waitForRenderCommit(generation: number, timeoutMs = 250): Promise<boolean> {
|
|
2371
|
-
if (
|
|
2372
|
-
if (this.#
|
|
2453
|
+
if (this.#isFailedPreparation(generation)) return Promise.resolve(false);
|
|
2454
|
+
if (generation <= 0 || (generation <= this.#committedRenderGeneration && !this.#commitHoles.has(generation)))
|
|
2455
|
+
return Promise.resolve(true);
|
|
2456
|
+
if (this.#stopped || !this.terminalAvailable || this.#preparationDisposed) return Promise.resolve(false);
|
|
2373
2457
|
return new Promise<boolean>(resolve => {
|
|
2374
2458
|
const waiter: RenderCommitWaiter = {
|
|
2375
2459
|
resolve,
|
|
@@ -2392,14 +2476,31 @@ export class TUI extends Container {
|
|
|
2392
2476
|
});
|
|
2393
2477
|
}
|
|
2394
2478
|
|
|
2395
|
-
#settleRenderCommitWaiters(
|
|
2396
|
-
|
|
2479
|
+
#settleRenderCommitWaiters(
|
|
2480
|
+
committed: boolean,
|
|
2481
|
+
generation = Number.POSITIVE_INFINITY,
|
|
2482
|
+
exclusions = this.#frameCommitExclusions,
|
|
2483
|
+
): void {
|
|
2484
|
+
if (committed) {
|
|
2485
|
+
for (const blocked of exclusions) {
|
|
2486
|
+
if (
|
|
2487
|
+
blocked <= generation &&
|
|
2488
|
+
blocked > this.#committedRenderGeneration &&
|
|
2489
|
+
!this.#isFailedPreparation(blocked)
|
|
2490
|
+
)
|
|
2491
|
+
this.#commitHoles.add(blocked);
|
|
2492
|
+
}
|
|
2493
|
+
for (const hole of this.#commitHoles) {
|
|
2494
|
+
if (hole <= generation && !exclusions.has(hole)) this.#commitHoles.delete(hole);
|
|
2495
|
+
}
|
|
2496
|
+
this.#committedRenderGeneration = Math.max(this.#committedRenderGeneration, generation);
|
|
2497
|
+
}
|
|
2397
2498
|
for (const [waiterGeneration, waiters] of this.#renderCommitWaiters) {
|
|
2398
|
-
if (committed && waiterGeneration > generation) continue;
|
|
2499
|
+
if (committed && (waiterGeneration > generation || exclusions.has(waiterGeneration))) continue;
|
|
2399
2500
|
this.#renderCommitWaiters.delete(waiterGeneration);
|
|
2400
2501
|
for (const waiter of waiters) {
|
|
2401
2502
|
clearTimeout(waiter.timer);
|
|
2402
|
-
waiter.resolve(committed);
|
|
2503
|
+
waiter.resolve(committed && !this.#isFailedPreparation(waiterGeneration));
|
|
2403
2504
|
}
|
|
2404
2505
|
}
|
|
2405
2506
|
}
|
|
@@ -2440,6 +2541,7 @@ export class TUI extends Container {
|
|
|
2440
2541
|
this.#rasterLeases.clear();
|
|
2441
2542
|
}
|
|
2442
2543
|
#markTerminalUnavailable(settleRenderWaiters = true): void {
|
|
2544
|
+
this.#invalidatePreparations();
|
|
2443
2545
|
this.#terminalGeneration++;
|
|
2444
2546
|
for (const record of this.#rasterCleanup.values()) record.terminalGeneration = this.#terminalGeneration;
|
|
2445
2547
|
this.#revokeRasterLeases("terminal-loss");
|
|
@@ -2676,6 +2778,7 @@ export class TUI extends Container {
|
|
|
2676
2778
|
}
|
|
2677
2779
|
|
|
2678
2780
|
stop(): void {
|
|
2781
|
+
this.#invalidatePreparations();
|
|
2679
2782
|
// Invalidate every raster-queue body captured under the running epoch
|
|
2680
2783
|
// before any teardown: nothing queued before stop may write after
|
|
2681
2784
|
// restoration. Synchronous stop cleanup below writes directly.
|
|
@@ -2785,6 +2888,7 @@ export class TUI extends Container {
|
|
|
2785
2888
|
* the last committed frame.
|
|
2786
2889
|
*/
|
|
2787
2890
|
requestResizeRender(): void {
|
|
2891
|
+
if (this.#preparationDisposed) return;
|
|
2788
2892
|
// Width is tracked against the last OBSERVED terminal width, not against
|
|
2789
2893
|
// #previousWidth (the last committed frame). Those diverge whenever resize
|
|
2790
2894
|
// events coalesce inside one frame budget: a 100->90->100 burst would leave
|
|
@@ -2844,8 +2948,180 @@ export class TUI extends Container {
|
|
|
2844
2948
|
this.requestRenderWithGeneration(force, source);
|
|
2845
2949
|
}
|
|
2846
2950
|
|
|
2951
|
+
/** Queue one synchronous preparation on the existing frame clock. Cancellation is idempotent. */
|
|
2952
|
+
enqueueBeforeRender(callback: () => void): () => void {
|
|
2953
|
+
if (this.#preparationDisposed || this.#preparationInvalidating || this.#stopped || !this.terminalAvailable)
|
|
2954
|
+
return cancelNoPreparation;
|
|
2955
|
+
const entry: RenderPreparation = { callback, generation: this.#nextRenderGeneration + 1 };
|
|
2956
|
+
entry.cancel = () => {
|
|
2957
|
+
entry.callback = undefined;
|
|
2958
|
+
entry.cancel = undefined;
|
|
2959
|
+
this.#preparations.delete(entry);
|
|
2960
|
+
this.#preparationSnapshot?.delete(entry);
|
|
2961
|
+
this.#preparationBlocked.delete(entry.generation);
|
|
2962
|
+
};
|
|
2963
|
+
this.#preparations.add(entry);
|
|
2964
|
+
this.#preparationBlocked.add(entry.generation);
|
|
2965
|
+
this.requestRenderWithGeneration(false, "preparation");
|
|
2966
|
+
return createPreparationCancellation(entry);
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
/** One owner only: replacing or clearing it invalidates all old preparation work. */
|
|
2970
|
+
setRenderPreparationLifecycleCallbacks(
|
|
2971
|
+
callbacks: { invalidate: () => void; beforeStart: () => void } | undefined,
|
|
2972
|
+
): void {
|
|
2973
|
+
if (this.#preparationLifecycle === callbacks) return;
|
|
2974
|
+
this.#invalidatePreparations();
|
|
2975
|
+
this.#preparationLifecycle = this.#preparationDisposed ? undefined : callbacks;
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
#callPreparation(callback: (() => void) | undefined, where: string): boolean {
|
|
2979
|
+
try {
|
|
2980
|
+
callback?.();
|
|
2981
|
+
return true;
|
|
2982
|
+
} catch (error) {
|
|
2983
|
+
logger.error("Render preparation failed", {
|
|
2984
|
+
where,
|
|
2985
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2986
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
2987
|
+
});
|
|
2988
|
+
return false;
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
#invalidatePreparations(): void {
|
|
2993
|
+
this.#preparationEpoch++;
|
|
2994
|
+
for (const generation of this.#preparationBlocked) this.#retirePreparation(generation);
|
|
2995
|
+
for (const entry of this.#preparations) {
|
|
2996
|
+
entry.callback = undefined;
|
|
2997
|
+
entry.cancel = undefined;
|
|
2998
|
+
}
|
|
2999
|
+
for (const entry of this.#preparationSnapshot ?? []) {
|
|
3000
|
+
entry.callback = undefined;
|
|
3001
|
+
entry.cancel = undefined;
|
|
3002
|
+
}
|
|
3003
|
+
this.#preparations.clear();
|
|
3004
|
+
this.#preparationSnapshot?.clear();
|
|
3005
|
+
if (this.#preparationInvalidating) return;
|
|
3006
|
+
this.#preparationInvalidating = true;
|
|
3007
|
+
try {
|
|
3008
|
+
this.#callPreparation(this.#preparationLifecycle?.invalidate, "invalidate");
|
|
3009
|
+
} finally {
|
|
3010
|
+
this.#preparationInvalidating = false;
|
|
3011
|
+
}
|
|
3012
|
+
}
|
|
3013
|
+
|
|
3014
|
+
#isFailedPreparation(generation: number): boolean {
|
|
3015
|
+
let low = 0;
|
|
3016
|
+
let high = this.#failedPreparationRanges.length;
|
|
3017
|
+
while (low < high) {
|
|
3018
|
+
const middle = (low + high) >>> 1;
|
|
3019
|
+
const [start, end] = this.#failedPreparationRanges[middle];
|
|
3020
|
+
if (generation < start) high = middle;
|
|
3021
|
+
else if (generation > end) low = middle + 1;
|
|
3022
|
+
else return true;
|
|
3023
|
+
}
|
|
3024
|
+
return false;
|
|
3025
|
+
}
|
|
3026
|
+
|
|
3027
|
+
#retirePreparation(generation: number): void {
|
|
3028
|
+
this.#preparationBlocked.delete(generation);
|
|
3029
|
+
this.#commitHoles.delete(generation);
|
|
3030
|
+
const ranges = this.#failedPreparationRanges;
|
|
3031
|
+
let low = 0;
|
|
3032
|
+
let high = ranges.length;
|
|
3033
|
+
while (low < high) {
|
|
3034
|
+
const middle = (low + high) >>> 1;
|
|
3035
|
+
if (ranges[middle][1] < generation - 1) low = middle + 1;
|
|
3036
|
+
else high = middle;
|
|
3037
|
+
}
|
|
3038
|
+
let start = generation;
|
|
3039
|
+
let end = generation;
|
|
3040
|
+
let next = low;
|
|
3041
|
+
while (next < ranges.length && ranges[next][0] <= end + 1) {
|
|
3042
|
+
start = Math.min(start, ranges[next][0]);
|
|
3043
|
+
end = Math.max(end, ranges[next][1]);
|
|
3044
|
+
next++;
|
|
3045
|
+
}
|
|
3046
|
+
ranges.splice(low, next - low, [start, end]);
|
|
3047
|
+
const waiters = this.#renderCommitWaiters.get(generation);
|
|
3048
|
+
this.#renderCommitWaiters.delete(generation);
|
|
3049
|
+
for (const waiter of waiters ?? []) {
|
|
3050
|
+
clearTimeout(waiter.timer);
|
|
3051
|
+
waiter.resolve(false);
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
3054
|
+
|
|
3055
|
+
/** The only preparation boundary, before generation capture and all renderer reads. */
|
|
3056
|
+
#renderPreparedFrame(): void {
|
|
3057
|
+
if (this.#stopped || this.#preparationDisposed) return;
|
|
3058
|
+
if (!this.terminalAvailable) {
|
|
3059
|
+
this.#markTerminalUnavailable();
|
|
3060
|
+
return;
|
|
3061
|
+
}
|
|
3062
|
+
const epoch = this.#preparationEpoch;
|
|
3063
|
+
const snapshot = this.#preparations;
|
|
3064
|
+
this.#preparations = new Set();
|
|
3065
|
+
this.#preparationSnapshot = snapshot;
|
|
3066
|
+
this.#preparationDrainEpoch = epoch;
|
|
3067
|
+
try {
|
|
3068
|
+
for (const entry of snapshot) {
|
|
3069
|
+
if (epoch !== this.#preparationEpoch || this.#stopped || !this.terminalAvailable) break;
|
|
3070
|
+
const callback = entry.callback;
|
|
3071
|
+
entry.callback = undefined;
|
|
3072
|
+
entry.cancel = undefined;
|
|
3073
|
+
if (
|
|
3074
|
+
callback &&
|
|
3075
|
+
this.#callPreparation(callback, "beforeRender") &&
|
|
3076
|
+
epoch === this.#preparationEpoch &&
|
|
3077
|
+
!this.#stopped &&
|
|
3078
|
+
this.terminalAvailable
|
|
3079
|
+
) {
|
|
3080
|
+
this.#preparationBlocked.delete(entry.generation);
|
|
3081
|
+
} else if (callback) {
|
|
3082
|
+
this.#retirePreparation(entry.generation);
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
3085
|
+
} finally {
|
|
3086
|
+
for (const entry of snapshot) {
|
|
3087
|
+
entry.callback = undefined;
|
|
3088
|
+
entry.cancel = undefined;
|
|
3089
|
+
}
|
|
3090
|
+
snapshot.clear();
|
|
3091
|
+
this.#preparationSnapshot = undefined;
|
|
3092
|
+
}
|
|
3093
|
+
if (!this.terminalAvailable && !this.#stopped) this.#markTerminalUnavailable();
|
|
3094
|
+
if (epoch !== this.#preparationEpoch || this.#stopped || !this.terminalAvailable || this.#preparationDisposed) {
|
|
3095
|
+
if (!this.#stopped && this.terminalAvailable && !this.#preparationDisposed) this.#scheduleRender();
|
|
3096
|
+
return;
|
|
3097
|
+
}
|
|
3098
|
+
const requestedGeneration = this.#renderRequestedGeneration;
|
|
3099
|
+
this.#renderRequestedGeneration = 0;
|
|
3100
|
+
this.#renderRequested = false;
|
|
3101
|
+
this.#lastRenderAt = performance.now();
|
|
3102
|
+
this.#lastRenderWriteSucceeded = false;
|
|
3103
|
+
this.#frameCommitExclusions = new Set(this.#preparationBlocked);
|
|
3104
|
+
const t0 = renderMetrics.now();
|
|
3105
|
+
try {
|
|
3106
|
+
this.#renderGenerationInProgress = requestedGeneration;
|
|
3107
|
+
this.#doRender();
|
|
3108
|
+
this.#commitRenderGeneration(requestedGeneration);
|
|
3109
|
+
} finally {
|
|
3110
|
+
this.#renderGenerationInProgress = 0;
|
|
3111
|
+
this.#frameCommitExclusions = new Set();
|
|
3112
|
+
if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
|
|
3113
|
+
if (this.#preparations.size > 0 && epoch === this.#preparationEpoch) {
|
|
3114
|
+
for (const entry of this.#preparations)
|
|
3115
|
+
this.#renderRequestedGeneration = Math.max(this.#renderRequestedGeneration, entry.generation);
|
|
3116
|
+
this.#renderRequested = true;
|
|
3117
|
+
this.#scheduleRender();
|
|
3118
|
+
}
|
|
3119
|
+
}
|
|
3120
|
+
}
|
|
3121
|
+
|
|
2847
3122
|
#requestRenderWithScope(force: boolean, source: string, scope: "full" | "layout"): number {
|
|
2848
3123
|
const generation = ++this.#nextRenderGeneration;
|
|
3124
|
+
if (this.#preparationDisposed) return generation;
|
|
2849
3125
|
this.#renderRequestedGeneration = Math.max(this.#renderRequestedGeneration, generation);
|
|
2850
3126
|
if (scope === "full") this.#renderScope = "full";
|
|
2851
3127
|
this.#requestRenderCore(force, source, generation);
|
|
@@ -2863,6 +3139,7 @@ export class TUI extends Container {
|
|
|
2863
3139
|
}
|
|
2864
3140
|
|
|
2865
3141
|
#requestRenderCore(force: boolean, source: string, generation: number): void {
|
|
3142
|
+
if (this.#preparationDisposed) return;
|
|
2866
3143
|
if (!this.terminalAvailable) {
|
|
2867
3144
|
this.#markTerminalUnavailable();
|
|
2868
3145
|
return;
|
|
@@ -2905,6 +3182,7 @@ export class TUI extends Container {
|
|
|
2905
3182
|
this.#viewportTopRow = 0;
|
|
2906
3183
|
this.#maxLinesRendered = 0;
|
|
2907
3184
|
}
|
|
3185
|
+
if (this.#preparationSnapshot && this.#preparationDrainEpoch === this.#preparationEpoch) return;
|
|
2908
3186
|
if (this.#renderTimer) {
|
|
2909
3187
|
clearTimeout(this.#renderTimer);
|
|
2910
3188
|
this.#renderTimer = undefined;
|
|
@@ -2916,20 +3194,12 @@ export class TUI extends Container {
|
|
|
2916
3194
|
this.#settleRenderCommitWaiters(false, generation);
|
|
2917
3195
|
return;
|
|
2918
3196
|
}
|
|
2919
|
-
|
|
2920
|
-
this.#renderRequestedGeneration = 0;
|
|
2921
|
-
this.#renderRequested = false;
|
|
2922
|
-
this.#lastRenderAt = performance.now();
|
|
2923
|
-
this.#lastRenderWriteSucceeded = false;
|
|
2924
|
-
const t0 = renderMetrics.now();
|
|
2925
|
-
this.#renderGenerationInProgress = requestedGeneration;
|
|
2926
|
-
this.#doRender();
|
|
2927
|
-
this.#renderGenerationInProgress = 0;
|
|
2928
|
-
this.#commitRenderGeneration(requestedGeneration);
|
|
2929
|
-
if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
|
|
3197
|
+
this.#renderPreparedFrame();
|
|
2930
3198
|
});
|
|
2931
3199
|
return;
|
|
2932
3200
|
}
|
|
3201
|
+
// Preparation mutations join this frame; nested registrations get the next normal frame.
|
|
3202
|
+
if (this.#preparationSnapshot && this.#preparationDrainEpoch === this.#preparationEpoch) return;
|
|
2933
3203
|
// Input-priority path: expedite so the keystroke echoes within the next tick
|
|
2934
3204
|
// instead of waiting for (or behind) the frame-budget timer. Re-entrant input
|
|
2935
3205
|
// requests in the same turn coalesce via #inputRenderPending, so at most one
|
|
@@ -2950,7 +3220,7 @@ export class TUI extends Container {
|
|
|
2950
3220
|
}
|
|
2951
3221
|
|
|
2952
3222
|
#scheduleRender(): void {
|
|
2953
|
-
if (this.#stopped || this.#renderTimer || !this.#renderRequested) {
|
|
3223
|
+
if (this.#preparationDisposed || this.#stopped || this.#renderTimer || !this.#renderRequested) {
|
|
2954
3224
|
return;
|
|
2955
3225
|
}
|
|
2956
3226
|
const elapsed = performance.now() - this.#lastRenderAt;
|
|
@@ -2961,17 +3231,7 @@ export class TUI extends Container {
|
|
|
2961
3231
|
if (this.#stopped || !this.#renderRequested) {
|
|
2962
3232
|
return;
|
|
2963
3233
|
}
|
|
2964
|
-
|
|
2965
|
-
this.#renderRequestedGeneration = 0;
|
|
2966
|
-
this.#renderRequested = false;
|
|
2967
|
-
this.#lastRenderAt = performance.now();
|
|
2968
|
-
this.#lastRenderWriteSucceeded = false;
|
|
2969
|
-
const t0 = renderMetrics.now();
|
|
2970
|
-
this.#renderGenerationInProgress = requestedGeneration;
|
|
2971
|
-
this.#doRender();
|
|
2972
|
-
this.#renderGenerationInProgress = 0;
|
|
2973
|
-
this.#commitRenderGeneration(requestedGeneration);
|
|
2974
|
-
if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
|
|
3234
|
+
this.#renderPreparedFrame();
|
|
2975
3235
|
if (this.#renderRequested) {
|
|
2976
3236
|
this.#scheduleRender();
|
|
2977
3237
|
}
|
|
@@ -2993,17 +3253,7 @@ export class TUI extends Container {
|
|
|
2993
3253
|
this.#renderTimer = undefined;
|
|
2994
3254
|
if (renderMetrics.enabled) renderMetrics.setTimerGauge("tui.renderTimer", 0);
|
|
2995
3255
|
}
|
|
2996
|
-
|
|
2997
|
-
this.#renderRequestedGeneration = 0;
|
|
2998
|
-
this.#renderRequested = false;
|
|
2999
|
-
this.#lastRenderAt = performance.now();
|
|
3000
|
-
this.#lastRenderWriteSucceeded = false;
|
|
3001
|
-
const t0 = renderMetrics.now();
|
|
3002
|
-
this.#renderGenerationInProgress = requestedGeneration;
|
|
3003
|
-
this.#doRender();
|
|
3004
|
-
this.#renderGenerationInProgress = 0;
|
|
3005
|
-
this.#commitRenderGeneration(requestedGeneration);
|
|
3006
|
-
if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
|
|
3256
|
+
this.#renderPreparedFrame();
|
|
3007
3257
|
}
|
|
3008
3258
|
|
|
3009
3259
|
#handleInput(data: string): void {
|
|
@@ -3798,6 +4048,7 @@ export class TUI extends Container {
|
|
|
3798
4048
|
#normalizeLinesForEmit(lines: string[], width: number, start = 0): string[] {
|
|
3799
4049
|
const widthCheckIndexes: number[] = [];
|
|
3800
4050
|
const widthCheckLines: string[] = [];
|
|
4051
|
+
const retainedWidths: (number | undefined)[] = [];
|
|
3801
4052
|
for (let i = start; i < lines.length; i++) {
|
|
3802
4053
|
const line = lines[i];
|
|
3803
4054
|
if (TERMINAL.isImageLine(line)) continue;
|
|
@@ -3810,19 +4061,21 @@ export class TUI extends Container {
|
|
|
3810
4061
|
continue;
|
|
3811
4062
|
}
|
|
3812
4063
|
widthCheckIndexes.push(i);
|
|
3813
|
-
|
|
4064
|
+
retainedWidths.push(entry.width);
|
|
4065
|
+
if (entry.width === undefined) widthCheckLines.push(normalized);
|
|
3814
4066
|
}
|
|
3815
4067
|
|
|
3816
4068
|
const widths = widthCheckLines.length === 0 ? [] : visibleWidths(widthCheckLines);
|
|
3817
4069
|
const truncateIndexes: number[] = [];
|
|
3818
4070
|
const truncateLines: string[] = [];
|
|
4071
|
+
let measuredIndex = 0;
|
|
3819
4072
|
for (let i = 0; i < widthCheckIndexes.length; i++) {
|
|
3820
4073
|
const lineIndex = widthCheckIndexes[i];
|
|
3821
|
-
const
|
|
3822
|
-
const
|
|
4074
|
+
const entry = this.#normalizeLineForRender(lines[lineIndex]);
|
|
4075
|
+
const { normalized } = entry;
|
|
4076
|
+
const measuredWidth = retainedWidths[i] ?? widths[measuredIndex++] ?? 0;
|
|
4077
|
+
entry.width = measuredWidth;
|
|
3823
4078
|
if (measuredWidth <= width) {
|
|
3824
|
-
const entry = this.#normalizeLineForRender(lines[lineIndex]);
|
|
3825
|
-
entry.width = measuredWidth;
|
|
3826
4079
|
this.#lineEmitWidthCache.set(entry.terminated, measuredWidth);
|
|
3827
4080
|
lines[lineIndex] = entry.terminated;
|
|
3828
4081
|
continue;
|
|
@@ -3831,7 +4084,9 @@ export class TUI extends Container {
|
|
|
3831
4084
|
const key = `${width}\0${normalized}`;
|
|
3832
4085
|
const cached = this.#lineTruncationCache.get(key);
|
|
3833
4086
|
if (cached !== undefined) {
|
|
3834
|
-
this.#lineEmitWidthCache.
|
|
4087
|
+
if (!this.#lineEmitWidthCache.has(cached)) {
|
|
4088
|
+
this.#lineEmitWidthCache.set(cached, visibleWidth(cached));
|
|
4089
|
+
}
|
|
3835
4090
|
lines[lineIndex] = cached;
|
|
3836
4091
|
continue;
|
|
3837
4092
|
}
|
|
@@ -4832,10 +5087,12 @@ export class TUI extends Container {
|
|
|
4832
5087
|
}
|
|
4833
5088
|
const useViewportRepaintPath = this.#viewportRepaintHost();
|
|
4834
5089
|
const widthReflowRequired =
|
|
5090
|
+
widthChanged &&
|
|
4835
5091
|
this.#previousWidth > 0 &&
|
|
4836
|
-
rawLines.some(
|
|
4837
|
-
|
|
4838
|
-
|
|
5092
|
+
rawLines.some(line => {
|
|
5093
|
+
TUI.#renderCounters.widthReflowScanRows += 1;
|
|
5094
|
+
return !TERMINAL.isImageLine(line) && visibleWidth(line) > Math.min(this.#previousWidth, width);
|
|
5095
|
+
});
|
|
4839
5096
|
if (
|
|
4840
5097
|
widthChanged &&
|
|
4841
5098
|
!this.#legacyMultiplexerFullRender &&
|
|
@@ -5607,11 +5864,12 @@ export class TUI extends Container {
|
|
|
5607
5864
|
? (bytes: string) => this.#writeRasterPreservingRenderIngress(bytes)
|
|
5608
5865
|
: (bytes: string) => this.#writeProtectedRenderIngress(bytes);
|
|
5609
5866
|
const renderGeneration = this.#renderGenerationInProgress;
|
|
5867
|
+
const commitExclusions = this.#frameCommitExclusions;
|
|
5610
5868
|
const write = () => {
|
|
5611
5869
|
if (!writeIngress(buffer)) return false;
|
|
5612
5870
|
onBufferWritten?.();
|
|
5613
5871
|
this.#lastRenderWriteSucceeded = true;
|
|
5614
|
-
if (renderGeneration > 0) this.#settleRenderCommitWaiters(true, renderGeneration);
|
|
5872
|
+
if (renderGeneration > 0) this.#settleRenderCommitWaiters(true, renderGeneration, commitExclusions);
|
|
5615
5873
|
const emission = this.#postRenderEmitter?.();
|
|
5616
5874
|
if (emission) {
|
|
5617
5875
|
const overlay = typeof emission === "string" ? emission : emission.payload;
|