@oh-my-pi/pi-tui 18.0.10 → 18.1.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 +20 -0
- package/dist/types/components/composer/registry.d.ts +7 -0
- package/dist/types/components/composer/types.d.ts +6 -0
- package/dist/types/components/editor.d.ts +2 -0
- package/dist/types/components/image.d.ts +5 -6
- package/dist/types/kitty-graphics.d.ts +5 -5
- package/dist/types/terminal-capabilities.d.ts +13 -5
- package/dist/types/terminal-multiplexer.d.ts +2 -0
- package/dist/types/terminal.d.ts +36 -19
- package/package.json +7 -7
- package/src/autocomplete.ts +1 -1
- package/src/components/composer/field.ts +1 -0
- package/src/components/composer/rail.ts +1 -0
- package/src/components/composer/registry.ts +10 -0
- package/src/components/composer/types.ts +6 -0
- package/src/components/editor.ts +7 -1
- package/src/components/image.ts +31 -24
- package/src/components/markdown.ts +26 -15
- package/src/components/select-list.ts +1 -0
- package/src/components/spacer.ts +1 -1
- package/src/components/text.ts +1 -1
- package/src/deccara.ts +2 -0
- package/src/kitty-graphics.ts +9 -6
- package/src/latex-block.ts +1 -0
- package/src/stdin-buffer.ts +68 -71
- package/src/terminal-capabilities.ts +25 -19
- package/src/terminal-multiplexer.ts +12 -0
- package/src/terminal.ts +153 -76
- package/src/tui.ts +55 -22
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.1.0] - 2026-09-01
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Improved terminal stability when resuming image-heavy sessions, preventing large transcript repaints from being mistaken for stalled output or exceeding the terminal output limit.
|
|
10
|
+
- Fixed inline images leaving blank rows in Herdr panes when resuming or rendering sessions in nested terminals.
|
|
11
|
+
- Fixed the TUI crashing on reference-style Markdown links whose labels match JavaScript built-in names; these links now render safely as plain text.
|
|
12
|
+
- Fixed fatal cleanup leaving the cursor inside a focused input before error output is displayed.
|
|
13
|
+
- Fixed resumed sessions showing stale background bands until the next keypress in WSL and Windows Terminal.
|
|
14
|
+
|
|
15
|
+
## [18.0.11] - 2026-08-29
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Added `setTerminalHyperlinks()` to let hosts control OSC 8 hyperlink behavior in rendered Markdown links.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Fixed inline color swatches appearing for words with hex-like prefixes, such as `#each`; swatches now appear only when the entire word is a valid color.
|
|
24
|
+
|
|
5
25
|
## [18.0.10] - 2026-08-28
|
|
6
26
|
|
|
7
27
|
### Added
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import type { ComposerStyle, EditorBorderStyle } from "./types.js";
|
|
2
2
|
/** Whether an id names a composer style shipped by pi-tui. */
|
|
3
3
|
export declare function isBuiltinComposerStyle(id: string): boolean;
|
|
4
|
+
/**
|
|
5
|
+
* Whether a style paints its own row foreground.
|
|
6
|
+
*
|
|
7
|
+
* Extensions registered before `filledSurface` existed received undecorated
|
|
8
|
+
* row text, so an omitted flag remains filled for extension-owned ids.
|
|
9
|
+
*/
|
|
10
|
+
export declare function isFilledComposerStyle(style: ComposerStyle): boolean;
|
|
4
11
|
/**
|
|
5
12
|
* Register one extension-owned composer style for this process.
|
|
6
13
|
*
|
|
@@ -60,6 +60,12 @@ export interface ComposerRowContext extends ComposerChromeContext {
|
|
|
60
60
|
}
|
|
61
61
|
export interface ComposerStyle {
|
|
62
62
|
readonly id: EditorBorderStyle;
|
|
63
|
+
/**
|
|
64
|
+
* True when rows paint their own foreground through `surfaceColor`.
|
|
65
|
+
* Built-ins default to transparent; registered extensions that omit this
|
|
66
|
+
* field retain the pre-field behavior and receive undecorated row text.
|
|
67
|
+
*/
|
|
68
|
+
readonly filledSurface?: boolean;
|
|
63
69
|
/** Content rows carry left/right border glyphs; drives the cursor-reserve
|
|
64
70
|
* column, IME-safe layout, and the right-border scrollbar. */
|
|
65
71
|
readonly sideBorders: boolean;
|
|
@@ -10,6 +10,8 @@ export interface EditorTheme {
|
|
|
10
10
|
accentColor?: (str: string) => string;
|
|
11
11
|
/** Background fill used by filled composer styles. */
|
|
12
12
|
surfaceColor?: (str: string) => string;
|
|
13
|
+
/** Foreground used when the composer shape leaves its text surface transparent. */
|
|
14
|
+
textColor?: (str: string) => string;
|
|
13
15
|
selectList: SelectListTheme;
|
|
14
16
|
symbols: SymbolTheme;
|
|
15
17
|
editorPaddingX?: number;
|
|
@@ -29,9 +29,9 @@ export declare const DEFAULT_MAX_INLINE_IMAGES = 8;
|
|
|
29
29
|
*
|
|
30
30
|
* The budget keeps the most recent `cap` images live and demotes older ones to
|
|
31
31
|
* their text fallback. Demotion needs a full redraw (so off-screen rows are
|
|
32
|
-
* rewritten) plus an explicit graphics purge of the demoted ids
|
|
33
|
-
* reports display order via {@link observe}
|
|
34
|
-
*
|
|
32
|
+
* rewritten) plus an explicit graphics purge of the demoted ids. {@link Image}
|
|
33
|
+
* reports display order via {@link observe}; when that reveals a stricter split,
|
|
34
|
+
* the TUI repeats the pass before emitting its terminal frame.
|
|
35
35
|
*
|
|
36
36
|
* `cap <= 0` disables budgeting: every image stays a live graphic.
|
|
37
37
|
*/
|
|
@@ -67,9 +67,8 @@ export declare class ImageBudget {
|
|
|
67
67
|
*/
|
|
68
68
|
observe(imageId: number): boolean;
|
|
69
69
|
/**
|
|
70
|
-
* End a render pass. Returns true when
|
|
71
|
-
*
|
|
72
|
-
* {@link takePurgeIds}.
|
|
70
|
+
* End a render pass. Returns true when the pass discovered a stricter budget
|
|
71
|
+
* and must be repeated before its terminal frame is emitted.
|
|
73
72
|
*/
|
|
74
73
|
endPass(): boolean;
|
|
75
74
|
/** Image ids to delete from the terminal this frame; clears the pending set. */
|
|
@@ -26,12 +26,12 @@ export interface KittyGraphicsFeatures {
|
|
|
26
26
|
* Whether the detected terminal renders Kitty Unicode placeholders (`U=1` +
|
|
27
27
|
* U+10EEEE with row/column diacritics).
|
|
28
28
|
*
|
|
29
|
-
* Kitty and Ghostty advertise placeholder support directly. A
|
|
29
|
+
* Kitty and Ghostty advertise placeholder support directly. A multiplexer
|
|
30
30
|
* cannot use cursor-positioned placements because the outer terminal does not
|
|
31
|
-
* know pane scroll/reflow state
|
|
32
|
-
*
|
|
33
|
-
* fallback stays off because
|
|
34
|
-
*
|
|
31
|
+
* know pane scroll/reflow state. An explicit `PI_FORCE_IMAGE_PROTOCOL=kitty`
|
|
32
|
+
* opts into placeholders under any multiplexer — matching `timg -pk`.
|
|
33
|
+
* Automatic Herdr fallback stays off because its pane marker does not prove
|
|
34
|
+
* that the experimental Kitty renderer is enabled.
|
|
35
35
|
*
|
|
36
36
|
* `PI_NO_KITTY_PLACEHOLDERS=1` and `PI_KITTY_PLACEHOLDERS=0` remain hard
|
|
37
37
|
* opt-outs; `PI_KITTY_PLACEHOLDERS=1` explicitly opts in anywhere else.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { HangulCompatibilityJamoWidth } from "./utils.js";
|
|
2
|
+
export * from "./terminal-multiplexer.js";
|
|
2
3
|
export { isInsideTmux, wrapTmuxPassthrough } from "./tmux.js";
|
|
3
4
|
export declare enum ImageProtocol {
|
|
4
5
|
Kitty = "\u001B_G",
|
|
@@ -48,8 +49,6 @@ export declare class TerminalInfo {
|
|
|
48
49
|
formatNotification(message: string | TerminalNotification): string;
|
|
49
50
|
sendNotification(message: string | TerminalNotification): void;
|
|
50
51
|
}
|
|
51
|
-
/** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
|
|
52
|
-
export declare function isInsideTerminalMultiplexer(env?: NodeJS.ProcessEnv): boolean;
|
|
53
52
|
/**
|
|
54
53
|
* Whether the agent process is running inside a Zellij session. Read fresh on
|
|
55
54
|
* each call (like {@link isInsideTmux}) so a session attached/detached mid-run
|
|
@@ -165,9 +164,9 @@ export declare function isPaseoEmbedder(env?: NodeJS.ProcessEnv): boolean;
|
|
|
165
164
|
/**
|
|
166
165
|
* Resolve the image protocol for a non-forced runtime: static per-terminal
|
|
167
166
|
* support (with Warp's platform carve-out), then the multiplexer fallback,
|
|
168
|
-
* then
|
|
169
|
-
*
|
|
170
|
-
*
|
|
167
|
+
* then host carve-outs. `isTTY` is injectable because the fallback only fires
|
|
168
|
+
* on a real TTY — a piped subprocess cannot exercise that path, so regression
|
|
169
|
+
* tests call this directly.
|
|
171
170
|
*/
|
|
172
171
|
export declare function resolveImageProtocol(terminalId: TerminalId, env?: NodeJS.ProcessEnv, isTTY?: boolean): ImageProtocol | null;
|
|
173
172
|
/** Resolve terminal identity from environment markers used by common emulators. */
|
|
@@ -206,6 +205,15 @@ export declare function setTerminalScreenToScrollback(enabled: boolean): void;
|
|
|
206
205
|
* capability); tests flip it directly to exercise the scaled-heading path.
|
|
207
206
|
*/
|
|
208
207
|
export declare function setTerminalTextSizing(enabled: boolean): void;
|
|
208
|
+
/**
|
|
209
|
+
* Override OSC 8 hyperlink capability at runtime. The coding-agent calls this
|
|
210
|
+
* from the `tui.hyperlinks` setting so its resolved policy (`off`/`auto`/`always`)
|
|
211
|
+
* drives every renderer that gates on {@link TERMINAL}`.hyperlinks` — notably the
|
|
212
|
+
* Markdown component's `[text](url)`/bare-URL links — consistently with the
|
|
213
|
+
* path/resource links that already consult the setting directly. Tests flip it
|
|
214
|
+
* to exercise the OSC 8 and plain-text paths deterministically.
|
|
215
|
+
*/
|
|
216
|
+
export declare function setTerminalHyperlinks(enabled: boolean): void;
|
|
209
217
|
export declare function getTerminalInfo(terminalId: TerminalId, platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): TerminalInfo;
|
|
210
218
|
export interface CellDimensions {
|
|
211
219
|
widthPx: number;
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -20,33 +20,50 @@
|
|
|
20
20
|
*/
|
|
21
21
|
export declare function chunkForConPTY(data: string, maxChunkBytes?: number): string[];
|
|
22
22
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* Backlog at or below which stdout is healthy again: the pump has kept up, the
|
|
24
|
+
* TUI resumes composing frames, and a {@link StdoutStallWatchdog} episode ends.
|
|
25
|
+
* The TUI render gate (`TUI.#MAX_PENDING_OUTPUT_BYTES`) is this same value, so
|
|
26
|
+
* the watchdog stays armed across the entire range where frames are deferred —
|
|
27
|
+
* otherwise a consumer that wedges between this level and the arm cap is never
|
|
28
|
+
* re-sampled and the session freezes instead of disconnecting (#10434 review).
|
|
29
|
+
*/
|
|
30
|
+
export declare const STDOUT_BACKLOG_CLEAR_BYTES: number;
|
|
31
|
+
/**
|
|
32
|
+
* Bounds a never-draining stdout backlog without killing a single large but
|
|
33
|
+
* actively-draining frame.
|
|
25
34
|
*
|
|
26
35
|
* `process.stdout.write()` returns `false` once its buffer exceeds the stream
|
|
27
|
-
* high-water mark
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
36
|
+
* high-water mark, and the off-thread pump's `pending()` climbs the same way;
|
|
37
|
+
* a stalled-but-alive PTY reader never throws, so the byte count is the only
|
|
38
|
+
* signal that output is going nowhere. Tripping on the instantaneous count
|
|
39
|
+
* alone is wrong: a legitimate oversized frame (a resume repaint of dozens of
|
|
40
|
+
* inline screenshots, #10430) briefly exceeds the cap and then drains.
|
|
41
|
+
*
|
|
42
|
+
* An episode starts when the backlog first exceeds `armBytes` and lasts until
|
|
43
|
+
* it drains back to `clearBytes` (healthy). The backlog can fall below
|
|
44
|
+
* `armBytes` while still unhealthy, so the episode must outlive that dip
|
|
45
|
+
* (#10434): during it the watchdog declares the terminal disconnected only when
|
|
46
|
+
* the backlog makes no drain progress (no new low-water mark) for `stallMs` —
|
|
47
|
+
* a draining terminal keeps lowering the mark and never trips, while a wedged
|
|
48
|
+
* one (#6854) still tears down within the window.
|
|
34
49
|
*
|
|
35
50
|
* Exported for unit testing; `ProcessTerminal` is the sole production user.
|
|
36
51
|
*/
|
|
37
|
-
export declare class
|
|
52
|
+
export declare class StdoutStallWatchdog {
|
|
38
53
|
#private;
|
|
39
|
-
private readonly
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
54
|
+
private readonly armBytes;
|
|
55
|
+
private readonly clearBytes;
|
|
56
|
+
private readonly stallMs;
|
|
57
|
+
constructor(armBytes?: number, clearBytes?: number, stallMs?: number);
|
|
58
|
+
/** True while an episode is active and the backlog must be polled to completion. */
|
|
59
|
+
get armed(): boolean;
|
|
43
60
|
/**
|
|
44
|
-
*
|
|
45
|
-
* `
|
|
46
|
-
*
|
|
61
|
+
* Feed the current pending-byte count and clock reading. Returns true once an
|
|
62
|
+
* armed episode has gone `stallMs` with no drain progress, at which point the
|
|
63
|
+
* caller treats the terminal as disconnected.
|
|
47
64
|
*/
|
|
48
|
-
|
|
49
|
-
/**
|
|
65
|
+
sample(pending: number, nowMs: number): boolean;
|
|
66
|
+
/** Episode ended (drained) or terminal torn down: stop watching. */
|
|
50
67
|
reset(): void;
|
|
51
68
|
}
|
|
52
69
|
/** Record alternate-screen state (called by the TUI on `?1049h`/`?1049l` writes). */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "18.0
|
|
4
|
+
"version": "18.1.0",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Stencil Labs, Inc.",
|
|
@@ -29,16 +29,16 @@
|
|
|
29
29
|
"main": "./src/index.ts",
|
|
30
30
|
"types": "./dist/types/index.d.ts",
|
|
31
31
|
"scripts": {
|
|
32
|
-
"check": "
|
|
32
|
+
"check": "oxlint . && oxfmt --check --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts' && bun run check:types",
|
|
33
33
|
"check:types": "tsgo -p tsconfig.json --noEmit",
|
|
34
|
-
"lint": "
|
|
34
|
+
"lint": "oxlint .",
|
|
35
35
|
"test": "bun test --parallel test/*.test.ts",
|
|
36
|
-
"fix": "
|
|
37
|
-
"fmt": "
|
|
36
|
+
"fix": "oxlint --fix --fix-suggestions . && bun run fmt",
|
|
37
|
+
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-natives": "18.0
|
|
41
|
-
"@oh-my-pi/pi-utils": "18.0
|
|
40
|
+
"@oh-my-pi/pi-natives": "18.1.0",
|
|
41
|
+
"@oh-my-pi/pi-utils": "18.1.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"kitty-vt-wasm": "^0.2.0"
|
package/src/autocomplete.ts
CHANGED
|
@@ -382,7 +382,7 @@ function buildSlashCommandCompletions(
|
|
|
382
382
|
// Equal text-match scores fall back to usage frequency, then to the
|
|
383
383
|
// stable registry order.
|
|
384
384
|
.sort((a, b) => b.score - a.score || b.usage - a.usage)
|
|
385
|
-
.map(({ score:
|
|
385
|
+
.map(({ score: _score, usage: _usage, ...rest }) => rest)
|
|
386
386
|
);
|
|
387
387
|
}
|
|
388
388
|
|
|
@@ -10,6 +10,7 @@ const ACCENT_RAIL = "▎";
|
|
|
10
10
|
/** Filled composer surface anchored by a single left accent rail. */
|
|
11
11
|
export const railComposerStyle: ComposerStyle = {
|
|
12
12
|
id: "rail",
|
|
13
|
+
filledSurface: true,
|
|
13
14
|
sideBorders: true,
|
|
14
15
|
verticalChrome: 0,
|
|
15
16
|
statusAttachment: "none",
|
|
@@ -25,6 +25,16 @@ export function isBuiltinComposerStyle(id: string): boolean {
|
|
|
25
25
|
return Object.hasOwn(BUILTIN_COMPOSER_STYLES, id);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Whether a style paints its own row foreground.
|
|
30
|
+
*
|
|
31
|
+
* Extensions registered before `filledSurface` existed received undecorated
|
|
32
|
+
* row text, so an omitted flag remains filled for extension-owned ids.
|
|
33
|
+
*/
|
|
34
|
+
export function isFilledComposerStyle(style: ComposerStyle): boolean {
|
|
35
|
+
return style.filledSurface ?? !isBuiltinComposerStyle(style.id);
|
|
36
|
+
}
|
|
37
|
+
|
|
28
38
|
/**
|
|
29
39
|
* Register one extension-owned composer style for this process.
|
|
30
40
|
*
|
|
@@ -77,6 +77,12 @@ export interface ComposerRowContext extends ComposerChromeContext {
|
|
|
77
77
|
|
|
78
78
|
export interface ComposerStyle {
|
|
79
79
|
readonly id: EditorBorderStyle;
|
|
80
|
+
/**
|
|
81
|
+
* True when rows paint their own foreground through `surfaceColor`.
|
|
82
|
+
* Built-ins default to transparent; registered extensions that omit this
|
|
83
|
+
* field retain the pre-field behavior and receive undecorated row text.
|
|
84
|
+
*/
|
|
85
|
+
readonly filledSurface?: boolean;
|
|
80
86
|
/** Content rows carry left/right border glyphs; drives the cursor-reserve
|
|
81
87
|
* column, IME-safe layout, and the right-border scrollbar. */
|
|
82
88
|
readonly sideBorders: boolean;
|
package/src/components/editor.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
type EditorBorderStyle,
|
|
33
33
|
type EditorTopBorder,
|
|
34
34
|
getComposerStyle,
|
|
35
|
+
isFilledComposerStyle,
|
|
35
36
|
} from "./composer";
|
|
36
37
|
|
|
37
38
|
export type { EditorBorderStyle, EditorTopBorder };
|
|
@@ -401,6 +402,8 @@ export interface EditorTheme {
|
|
|
401
402
|
accentColor?: (str: string) => string;
|
|
402
403
|
/** Background fill used by filled composer styles. */
|
|
403
404
|
surfaceColor?: (str: string) => string;
|
|
405
|
+
/** Foreground used when the composer shape leaves its text surface transparent. */
|
|
406
|
+
textColor?: (str: string) => string;
|
|
404
407
|
selectList: SelectListTheme;
|
|
405
408
|
symbols: SymbolTheme;
|
|
406
409
|
editorPaddingX?: number;
|
|
@@ -1271,13 +1274,16 @@ export class Editor implements Component, Focusable {
|
|
|
1271
1274
|
displayWidth = visibleWidth(displayText);
|
|
1272
1275
|
}
|
|
1273
1276
|
}
|
|
1277
|
+
const renderedText = isFilledComposerStyle(style)
|
|
1278
|
+
? displayText
|
|
1279
|
+
: (this.#theme.textColor ?? PASSTHROUGH_COLOR)(displayText);
|
|
1274
1280
|
|
|
1275
1281
|
const linePad = padding(Math.max(0, lineContentWidth - displayWidth));
|
|
1276
1282
|
|
|
1277
1283
|
result.push(
|
|
1278
1284
|
...style.renderRow({
|
|
1279
1285
|
...chromeCtx,
|
|
1280
|
-
text:
|
|
1286
|
+
text: renderedText,
|
|
1281
1287
|
pad: linePad,
|
|
1282
1288
|
gutter: gutterText,
|
|
1283
1289
|
isLastRow: visibleIndex === visibleLayoutLines.length - 1,
|
package/src/components/image.ts
CHANGED
|
@@ -73,9 +73,9 @@ function nextImageIdSeed(): number {
|
|
|
73
73
|
*
|
|
74
74
|
* The budget keeps the most recent `cap` images live and demotes older ones to
|
|
75
75
|
* their text fallback. Demotion needs a full redraw (so off-screen rows are
|
|
76
|
-
* rewritten) plus an explicit graphics purge of the demoted ids
|
|
77
|
-
* reports display order via {@link observe}
|
|
78
|
-
*
|
|
76
|
+
* rewritten) plus an explicit graphics purge of the demoted ids. {@link Image}
|
|
77
|
+
* reports display order via {@link observe}; when that reveals a stricter split,
|
|
78
|
+
* the TUI repeats the pass before emitting its terminal frame.
|
|
79
79
|
*
|
|
80
80
|
* `cap <= 0` disables budgeting: every image stays a live graphic.
|
|
81
81
|
*/
|
|
@@ -87,6 +87,8 @@ export class ImageBudget {
|
|
|
87
87
|
#idToKey = new Map<number, string>();
|
|
88
88
|
/** Display-order image ids observed during the in-flight pass. */
|
|
89
89
|
#passIds: number[] = [];
|
|
90
|
+
/** Per-id suppression decision from the first observation in this pass. */
|
|
91
|
+
#passSuppression = new Map<number, boolean>();
|
|
90
92
|
/**
|
|
91
93
|
* Suppress threshold reflected in the frame currently on the terminal: images
|
|
92
94
|
* at display indices `[0, #onTerminal)` are shown as text there.
|
|
@@ -104,7 +106,7 @@ export class ImageBudget {
|
|
|
104
106
|
/** Image ids whose data is believed to be loaded in the terminal's store. */
|
|
105
107
|
#transmitted = new Set<number>();
|
|
106
108
|
/** Transmit sequences (full base64) to write once, before this frame's placements. */
|
|
107
|
-
#pendingTransmits
|
|
109
|
+
#pendingTransmits = new Map<number, string>();
|
|
108
110
|
// True while the in-flight pass is a partial/throwaway pass (the
|
|
109
111
|
// non-multiplexer resize viewport fast path) that walks only the visible
|
|
110
112
|
// tail, bottom-up. Such a pass cannot derive display order from observe()
|
|
@@ -185,6 +187,7 @@ export class ImageBudget {
|
|
|
185
187
|
*/
|
|
186
188
|
beginPass(stable = false): void {
|
|
187
189
|
this.#passIds.length = 0;
|
|
190
|
+
this.#passSuppression.clear();
|
|
188
191
|
this.#stablePass = stable;
|
|
189
192
|
this.#applyingReset = !stable && this.#cap > 0 && this.#planned > this.#onTerminal;
|
|
190
193
|
}
|
|
@@ -199,47 +202,49 @@ export class ImageBudget {
|
|
|
199
202
|
* (`#suppressedIds`) keyed by id — order- and partiality-independent.
|
|
200
203
|
*/
|
|
201
204
|
observe(imageId: number): boolean {
|
|
205
|
+
const existing = this.#passSuppression.get(imageId);
|
|
206
|
+
if (existing !== undefined) return existing;
|
|
202
207
|
if (this.#stablePass) {
|
|
203
208
|
const suppressed = this.#cap > 0 && this.#suppressedIds.has(imageId);
|
|
209
|
+
this.#passSuppression.set(imageId, suppressed);
|
|
204
210
|
if (suppressed) this.#forgetKeyForId(imageId);
|
|
205
211
|
return suppressed;
|
|
206
212
|
}
|
|
207
213
|
const index = this.#passIds.length;
|
|
208
214
|
this.#passIds.push(imageId);
|
|
209
215
|
const suppressed = this.#cap > 0 && index < this.#planned;
|
|
216
|
+
this.#passSuppression.set(imageId, suppressed);
|
|
210
217
|
if (suppressed) this.#forgetKeyForId(imageId);
|
|
211
218
|
return suppressed;
|
|
212
219
|
}
|
|
213
220
|
|
|
214
221
|
/**
|
|
215
|
-
* End a render pass. Returns true when
|
|
216
|
-
*
|
|
217
|
-
* {@link takePurgeIds}.
|
|
222
|
+
* End a render pass. Returns true when the pass discovered a stricter budget
|
|
223
|
+
* and must be repeated before its terminal frame is emitted.
|
|
218
224
|
*/
|
|
219
225
|
endPass(): boolean {
|
|
220
226
|
const total = this.#passIds.length;
|
|
221
227
|
this.#lastTotal = total;
|
|
222
|
-
let reset = false;
|
|
223
228
|
if (this.#applyingReset) {
|
|
224
229
|
for (let i = this.#onTerminal; i < this.#planned && i < total; i++) {
|
|
225
230
|
const id = this.#passIds[i];
|
|
226
|
-
|
|
227
|
-
//
|
|
231
|
+
// A transmit queued by a discarded discovery pass never reached
|
|
232
|
+
// the terminal, so cancel it instead of transmitting then purging.
|
|
233
|
+
if (!this.#pendingTransmits.delete(id)) this.#purgeIds.push(id);
|
|
228
234
|
this.#transmitted.delete(id);
|
|
229
235
|
this.#deletePlacementState(id);
|
|
230
236
|
this.#forgetKeyForId(id);
|
|
231
237
|
}
|
|
232
238
|
this.#onTerminal = this.#planned;
|
|
233
239
|
this.#applyingReset = false;
|
|
234
|
-
reset = true;
|
|
235
240
|
}
|
|
236
|
-
this.#reconcile(total);
|
|
241
|
+
const retry = this.#reconcile(total);
|
|
237
242
|
// Snapshot the committed display-order suppression by id: the prefix
|
|
238
243
|
// [0, #onTerminal) is what the terminal currently shows as text. Partial
|
|
239
244
|
// passes replay this per id (see #stablePass) instead of re-deriving it
|
|
240
245
|
// from a reversed, tail-only walk.
|
|
241
246
|
this.#suppressedIds = new Set(this.#passIds.slice(0, this.#onTerminal));
|
|
242
|
-
return
|
|
247
|
+
return retry;
|
|
243
248
|
}
|
|
244
249
|
|
|
245
250
|
/** Image ids to delete from the terminal this frame; clears the pending set. */
|
|
@@ -256,7 +261,7 @@ export class ImageBudget {
|
|
|
256
261
|
const ids = [...this.#transmitted];
|
|
257
262
|
this.#transmitted.clear();
|
|
258
263
|
this.#purgeIds = [];
|
|
259
|
-
this.#pendingTransmits
|
|
264
|
+
this.#pendingTransmits.clear();
|
|
260
265
|
this.#keyToId.clear();
|
|
261
266
|
this.#idToKey.clear();
|
|
262
267
|
this.#placementState.clear();
|
|
@@ -393,12 +398,12 @@ export class ImageBudget {
|
|
|
393
398
|
enqueueTransmit(imageId: number, sequence: string): void {
|
|
394
399
|
if (this.#transmitted.has(imageId)) return;
|
|
395
400
|
this.#transmitted.add(imageId);
|
|
396
|
-
this.#pendingTransmits.
|
|
401
|
+
this.#pendingTransmits.set(imageId, sequence);
|
|
397
402
|
}
|
|
398
403
|
|
|
399
404
|
/** Whether a frame has image data queued but not yet written to the terminal. */
|
|
400
405
|
hasPendingTransmits(): boolean {
|
|
401
|
-
return this.#pendingTransmits.
|
|
406
|
+
return this.#pendingTransmits.size > 0;
|
|
402
407
|
}
|
|
403
408
|
|
|
404
409
|
/**
|
|
@@ -410,7 +415,7 @@ export class ImageBudget {
|
|
|
410
415
|
get quiescent(): boolean {
|
|
411
416
|
return (
|
|
412
417
|
this.#lastTotal === 0 &&
|
|
413
|
-
this.#pendingTransmits.
|
|
418
|
+
this.#pendingTransmits.size === 0 &&
|
|
414
419
|
this.#purgeIds.length === 0 &&
|
|
415
420
|
this.#planned === this.#onTerminal
|
|
416
421
|
);
|
|
@@ -418,9 +423,9 @@ export class ImageBudget {
|
|
|
418
423
|
|
|
419
424
|
/** Transmit sequences to write before this frame's placements; clears the queue. */
|
|
420
425
|
takeTransmits(): readonly string[] {
|
|
421
|
-
if (this.#pendingTransmits.
|
|
422
|
-
const sequences = this.#pendingTransmits;
|
|
423
|
-
this.#pendingTransmits
|
|
426
|
+
if (this.#pendingTransmits.size === 0) return EMPTY_TRANSMITS;
|
|
427
|
+
const sequences = [...this.#pendingTransmits.values()];
|
|
428
|
+
this.#pendingTransmits.clear();
|
|
424
429
|
return sequences;
|
|
425
430
|
}
|
|
426
431
|
|
|
@@ -433,9 +438,9 @@ export class ImageBudget {
|
|
|
433
438
|
* re-emit together; keeps no base64 in budget state (the transmit-once design).
|
|
434
439
|
*/
|
|
435
440
|
forgetTransmitted(): void {
|
|
436
|
-
if (this.#transmitted.size === 0 && this.#pendingTransmits.
|
|
441
|
+
if (this.#transmitted.size === 0 && this.#pendingTransmits.size === 0) return;
|
|
437
442
|
this.#transmitted.clear();
|
|
438
|
-
this.#pendingTransmits
|
|
443
|
+
this.#pendingTransmits.clear();
|
|
439
444
|
}
|
|
440
445
|
|
|
441
446
|
#forgetKeyForId(id: number): void {
|
|
@@ -445,15 +450,16 @@ export class ImageBudget {
|
|
|
445
450
|
if (this.#keyToId.get(key) === id) this.#keyToId.delete(key);
|
|
446
451
|
}
|
|
447
452
|
|
|
448
|
-
#reconcile(total: number):
|
|
453
|
+
#reconcile(total: number): boolean {
|
|
449
454
|
const desired = this.#cap > 0 ? Math.max(0, total - this.#cap) : 0;
|
|
450
455
|
if (desired === this.#planned) {
|
|
451
456
|
// Budget relaxed without a stricter frame (cap raised or images
|
|
452
457
|
// removed): surviving graphics are untouched and re-exposed rows
|
|
453
458
|
// repaint normally, so just track the looser threshold.
|
|
454
459
|
if (this.#planned < this.#onTerminal) this.#onTerminal = this.#planned;
|
|
455
|
-
return;
|
|
460
|
+
return false;
|
|
456
461
|
}
|
|
462
|
+
const retry = desired > this.#onTerminal;
|
|
457
463
|
this.#planned = desired;
|
|
458
464
|
// More images must be demoted than the terminal shows: schedule the purge +
|
|
459
465
|
// full-redraw frame. Fewer: no ghosts to clear, so just catch the tracking
|
|
@@ -461,6 +467,7 @@ export class ImageBudget {
|
|
|
461
467
|
// render is needed to apply the new threshold.
|
|
462
468
|
if (desired <= this.#onTerminal) this.#onTerminal = desired;
|
|
463
469
|
this.#requestRender();
|
|
470
|
+
return retry;
|
|
464
471
|
}
|
|
465
472
|
}
|
|
466
473
|
|