@oh-my-pi/pi-tui 17.2.14 → 17.3.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 +7 -0
- package/dist/types/components/editor.d.ts +6 -2
- package/dist/types/components/image.d.ts +6 -0
- package/dist/types/components/markdown.d.ts +6 -2
- package/dist/types/components/text.d.ts +1 -0
- package/dist/types/tui.d.ts +24 -1
- package/dist/types/utils.d.ts +12 -0
- package/package.json +3 -3
- package/src/components/editor.ts +64 -3
- package/src/components/image.ts +10 -0
- package/src/components/markdown.ts +79 -11
- package/src/components/text.ts +10 -0
- package/src/tui.ts +1022 -48
- package/src/utils.ts +28 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [17.3.0] - 2026-08-13
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed an issue where repeated pane-width adjustments or terminal resizing could corrupt native scrollback and soft-wrap behavior.
|
|
10
|
+
- Fixed an issue where scaled OSC 66 Markdown headings (such as "Large Headings" on Kitty) would render as invisible placeholders or get partially cleared after a redraw or terminal resize.
|
|
11
|
+
|
|
5
12
|
## [17.2.13] - 2026-08-11
|
|
6
13
|
|
|
7
14
|
### Fixed
|
|
@@ -15,6 +15,8 @@ export interface EditorTopBorder {
|
|
|
15
15
|
content: string;
|
|
16
16
|
/** Visible width of the content */
|
|
17
17
|
width: number;
|
|
18
|
+
/** Optional logical revision that changes independently of available width. */
|
|
19
|
+
revision?: number;
|
|
18
20
|
}
|
|
19
21
|
interface HistoryEntry {
|
|
20
22
|
prompt: string;
|
|
@@ -74,8 +76,9 @@ export declare class Editor implements Component, Focusable {
|
|
|
74
76
|
*
|
|
75
77
|
* Use this when the top border derives from state that mutates far faster
|
|
76
78
|
* than the render cadence (session events, streaming, subagent updates).
|
|
77
|
-
* The TUI already throttles renders, so a provider is invoked
|
|
78
|
-
* per frame and
|
|
79
|
+
* The TUI already throttles renders, so a provider is invoked exactly once
|
|
80
|
+
* per frame and does no work between paints. Return a logical `revision` to
|
|
81
|
+
* distinguish concurrent status mutations from pure width reflow.
|
|
79
82
|
*/
|
|
80
83
|
setTopBorderProvider(provider: ((availableWidth: number) => EditorTopBorder | undefined) | undefined): void;
|
|
81
84
|
/**
|
|
@@ -111,6 +114,7 @@ export declare class Editor implements Component, Focusable {
|
|
|
111
114
|
render(width: number): readonly string[];
|
|
112
115
|
handleInput(data: string): void;
|
|
113
116
|
getText(): string;
|
|
117
|
+
getNativeScrollbackWidthEpochRevision(): number;
|
|
114
118
|
/** Whether the buffer text equals `value`, without `getText()`'s full join —
|
|
115
119
|
* O(1) for the hot per-keystroke probes against short single-line values. */
|
|
116
120
|
textEquals(value: string): boolean;
|
|
@@ -92,6 +92,12 @@ export declare class ImageBudget {
|
|
|
92
92
|
* re-emission.
|
|
93
93
|
*/
|
|
94
94
|
observeCommitWatermark(committedTo: number): void;
|
|
95
|
+
/**
|
|
96
|
+
* End the physical-row coordinate epoch after observing its final commit
|
|
97
|
+
* watermark. Placement ids and latched archive state survive, but attachment
|
|
98
|
+
* rows do not: the next placement emit records them in the new-width frame.
|
|
99
|
+
*/
|
|
100
|
+
beginPlacementCoordinateEpoch(): void;
|
|
95
101
|
/**
|
|
96
102
|
* Resolve the placement id and geometry for a direct-placement emit whose
|
|
97
103
|
* topmost attached cell sits at `attachTopFrameRow` — the first frame row
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SymbolTheme } from "../symbols.js";
|
|
2
|
-
import type { Component, NativeScrollbackCommittedRows, NativeScrollbackReplay } from "../tui.js";
|
|
2
|
+
import type { Component, NativeScrollbackCommittedRows, NativeScrollbackReplay, NativeScrollbackWidthEpoch } from "../tui.js";
|
|
3
3
|
/** @internal exported for tests — must stay index-identical to the old regex scan. */
|
|
4
4
|
export declare function mathStartIndex(src: string): number | undefined;
|
|
5
5
|
/** @internal exported for tests — must stay index-identical to the old regex scan. */
|
|
@@ -53,7 +53,7 @@ export interface MarkdownTheme {
|
|
|
53
53
|
resolveMermaidAscii?: (source: string, maxWidth?: number) => string | null;
|
|
54
54
|
symbols: SymbolTheme;
|
|
55
55
|
}
|
|
56
|
-
export declare class Markdown implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay {
|
|
56
|
+
export declare class Markdown implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay, NativeScrollbackWidthEpoch {
|
|
57
57
|
#private;
|
|
58
58
|
setIgnoreTight(ignore: boolean): this;
|
|
59
59
|
constructor(text: string, paddingX: number, paddingY: number, theme: MarkdownTheme, defaultTextStyle?: DefaultTextStyle, codeBlockIndent?: number);
|
|
@@ -71,6 +71,10 @@ export declare class Markdown implements Component, NativeScrollbackCommittedRow
|
|
|
71
71
|
* lineage), and on cache-served non-streaming renders.
|
|
72
72
|
*/
|
|
73
73
|
getLastRenderSettledRows(): number;
|
|
74
|
+
captureNativeScrollbackWidthEpoch(): unknown;
|
|
75
|
+
resolveNativeScrollbackWidthEpoch(boundary: unknown): number | undefined;
|
|
76
|
+
getNativeScrollbackWidthEpochRows(): number | undefined;
|
|
77
|
+
isNativeScrollbackWidthEpochAppendOnly(boundary: unknown): boolean;
|
|
74
78
|
/**
|
|
75
79
|
* Freeze every table whose first physical row is already part of the native
|
|
76
80
|
* scrollback prefix. The recorded widths came from the exact frame that was
|
|
@@ -14,6 +14,7 @@ export declare class Text implements Component {
|
|
|
14
14
|
constructor(text?: string, paddingX?: number, paddingY?: number, customBgFn?: (text: string) => string);
|
|
15
15
|
getText(): string;
|
|
16
16
|
setText(text: string): boolean;
|
|
17
|
+
getNativeScrollbackWidthEpochRevision(): number;
|
|
17
18
|
setCustomBgFn(customBgFn?: (text: string) => string): void;
|
|
18
19
|
/**
|
|
19
20
|
* Supply a foreground styler applied to the text at render time (e.g. a
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -102,6 +102,21 @@ export interface NativeScrollbackLiveRegion {
|
|
|
102
102
|
export interface NativeScrollbackCommittedRows {
|
|
103
103
|
setNativeScrollbackCommittedRows(rows: number): void;
|
|
104
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Width-independent source boundary for multiplexer resize epochs. Capture
|
|
107
|
+
* reads the last rendered source state; resolve maps that same logical boundary
|
|
108
|
+
* into the most recent render's physical rows at its new width. The current
|
|
109
|
+
* boundary identifies the source tail after updates queued during the resize.
|
|
110
|
+
*/
|
|
111
|
+
export interface NativeScrollbackWidthEpoch {
|
|
112
|
+
captureNativeScrollbackWidthEpoch(): unknown;
|
|
113
|
+
resolveNativeScrollbackWidthEpoch(boundary: unknown): number | undefined;
|
|
114
|
+
getNativeScrollbackWidthEpochRows(): number | undefined;
|
|
115
|
+
/** False when updates can insert before captured trailing rows. */
|
|
116
|
+
isNativeScrollbackWidthEpochAppendOnly?(boundary: unknown): boolean;
|
|
117
|
+
/** Changes when child structure mutates independently of width reflow. */
|
|
118
|
+
getNativeScrollbackWidthEpochRevision?(): number;
|
|
119
|
+
}
|
|
105
120
|
/**
|
|
106
121
|
* A component that discards rows after they enter native scrollback implements
|
|
107
122
|
* this hook so a destructive full replay can rehydrate its complete frame.
|
|
@@ -261,7 +276,7 @@ export interface OverlayHandle {
|
|
|
261
276
|
/**
|
|
262
277
|
* Container - a component that contains other components
|
|
263
278
|
*/
|
|
264
|
-
export declare class Container implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay {
|
|
279
|
+
export declare class Container implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay, NativeScrollbackWidthEpoch {
|
|
265
280
|
#private;
|
|
266
281
|
children: Component[];
|
|
267
282
|
setIgnoreTight(ignore: boolean): this;
|
|
@@ -287,6 +302,11 @@ export declare class Container implements Component, NativeScrollbackCommittedRo
|
|
|
287
302
|
setNativeScrollbackCommittedRows(rows: number): void;
|
|
288
303
|
/** Recursively discard layout locks that are meaningful only to the old tape. */
|
|
289
304
|
prepareNativeScrollbackReplay(): void;
|
|
305
|
+
captureNativeScrollbackWidthEpoch(): unknown;
|
|
306
|
+
resolveNativeScrollbackWidthEpoch(boundary: unknown): number | undefined;
|
|
307
|
+
getNativeScrollbackWidthEpochRows(): number | undefined;
|
|
308
|
+
isNativeScrollbackWidthEpochAppendOnly(boundary: unknown): boolean;
|
|
309
|
+
getNativeScrollbackWidthEpochRevision(): number;
|
|
290
310
|
render(width: number): readonly string[];
|
|
291
311
|
}
|
|
292
312
|
/**
|
|
@@ -346,6 +366,9 @@ export declare class TUI extends Container {
|
|
|
346
366
|
hidden: boolean;
|
|
347
367
|
}[];
|
|
348
368
|
constructor(terminal: Terminal, showHardwareCursor?: boolean, options?: TUIOptions);
|
|
369
|
+
captureNativeScrollbackWidthEpoch(): unknown;
|
|
370
|
+
resolveNativeScrollbackWidthEpoch(boundary: unknown): number | undefined;
|
|
371
|
+
getNativeScrollbackWidthEpochRows(): number | undefined;
|
|
349
372
|
render(width: number): readonly string[];
|
|
350
373
|
get fullRedraws(): number;
|
|
351
374
|
/**
|
package/dist/types/utils.d.ts
CHANGED
|
@@ -46,6 +46,18 @@ export declare function getSegmenter(): Intl.Segmenter;
|
|
|
46
46
|
* `tabWidth` cells) and OSC 66 text-sizing payloads (scaled by `s=`).
|
|
47
47
|
*/
|
|
48
48
|
export declare function visibleWidth(str: string): number;
|
|
49
|
+
/**
|
|
50
|
+
* True when a row carries a Kitty OSC 66 text-sizing span (`\x1b]66;…`).
|
|
51
|
+
* Scaled spans must bypass wrapping/padding and, when scaled up, reserve the
|
|
52
|
+
* terminal rows their multicell glyphs flow into.
|
|
53
|
+
*/
|
|
54
|
+
export declare function isOsc66Line(line: string): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Largest `s=` scale among the OSC 66 spans in a line (1 when none is scaled).
|
|
57
|
+
* A scale-`s` heading occupies `s` terminal rows, so the `s - 1` blank rows
|
|
58
|
+
* beneath it are the glyph's lower half and must never be erased or overdrawn.
|
|
59
|
+
*/
|
|
60
|
+
export declare function osc66MaxScale(line: string): number;
|
|
49
61
|
/**
|
|
50
62
|
* Normalize text for terminal output without changing logical editor content.
|
|
51
63
|
* Some terminals render precomposed Thai/Lao AM vowels inconsistently during
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "17.
|
|
4
|
+
"version": "17.3.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": "Can Boluk",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-natives": "17.
|
|
41
|
-
"@oh-my-pi/pi-utils": "17.
|
|
40
|
+
"@oh-my-pi/pi-natives": "17.3.0",
|
|
41
|
+
"@oh-my-pi/pi-utils": "17.3.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"ghostty-web": "^0.4.0"
|
package/src/components/editor.ts
CHANGED
|
@@ -391,6 +391,8 @@ export interface EditorTopBorder {
|
|
|
391
391
|
content: string;
|
|
392
392
|
/** Visible width of the content */
|
|
393
393
|
width: number;
|
|
394
|
+
/** Optional logical revision that changes independently of available width. */
|
|
395
|
+
revision?: number;
|
|
394
396
|
}
|
|
395
397
|
|
|
396
398
|
interface HistoryEntry {
|
|
@@ -410,6 +412,8 @@ export class Editor implements Component, Focusable {
|
|
|
410
412
|
cursorLine: 0,
|
|
411
413
|
cursorCol: 0,
|
|
412
414
|
};
|
|
415
|
+
#widthEpochText = "";
|
|
416
|
+
#widthEpochRevision = 0;
|
|
413
417
|
|
|
414
418
|
/** Focusable interface - set by TUI when focus changes */
|
|
415
419
|
focused: boolean = false;
|
|
@@ -515,6 +519,9 @@ export class Editor implements Component, Focusable {
|
|
|
515
519
|
// per-event rebuilds down to one per rendered frame (see #4145).
|
|
516
520
|
#topBorderContent?: EditorTopBorder;
|
|
517
521
|
#topBorderProvider?: (availableWidth: number) => EditorTopBorder | undefined;
|
|
522
|
+
#topBorderProviderWidth: number | undefined;
|
|
523
|
+
#topBorderProviderSignature: string | undefined;
|
|
524
|
+
#topBorderProviderRevision: number | undefined;
|
|
518
525
|
#borderVisible = true;
|
|
519
526
|
|
|
520
527
|
constructor(theme: EditorTheme) {
|
|
@@ -536,7 +543,10 @@ export class Editor implements Component, Focusable {
|
|
|
536
543
|
* per-event rebuilds to one per painted frame.
|
|
537
544
|
*/
|
|
538
545
|
setTopBorder(content: EditorTopBorder | undefined): void {
|
|
546
|
+
if (this.#topBorderContent?.content === content?.content && this.#topBorderContent?.width === content?.width)
|
|
547
|
+
return;
|
|
539
548
|
this.#topBorderContent = content;
|
|
549
|
+
this.#widthEpochRevision++;
|
|
540
550
|
}
|
|
541
551
|
|
|
542
552
|
/**
|
|
@@ -546,18 +556,26 @@ export class Editor implements Component, Focusable {
|
|
|
546
556
|
*
|
|
547
557
|
* Use this when the top border derives from state that mutates far faster
|
|
548
558
|
* than the render cadence (session events, streaming, subagent updates).
|
|
549
|
-
* The TUI already throttles renders, so a provider is invoked
|
|
550
|
-
* per frame and
|
|
559
|
+
* The TUI already throttles renders, so a provider is invoked exactly once
|
|
560
|
+
* per frame and does no work between paints. Return a logical `revision` to
|
|
561
|
+
* distinguish concurrent status mutations from pure width reflow.
|
|
551
562
|
*/
|
|
552
563
|
setTopBorderProvider(provider: ((availableWidth: number) => EditorTopBorder | undefined) | undefined): void {
|
|
564
|
+
if (this.#topBorderProvider === provider) return;
|
|
553
565
|
this.#topBorderProvider = provider;
|
|
566
|
+
this.#topBorderProviderWidth = undefined;
|
|
567
|
+
this.#topBorderProviderSignature = undefined;
|
|
568
|
+
this.#topBorderProviderRevision = undefined;
|
|
569
|
+
this.#widthEpochRevision++;
|
|
554
570
|
}
|
|
555
571
|
|
|
556
572
|
/**
|
|
557
573
|
* Show or hide the editor border chrome.
|
|
558
574
|
*/
|
|
559
575
|
setBorderVisible(borderVisible: boolean): void {
|
|
576
|
+
if (this.#borderVisible === borderVisible) return;
|
|
560
577
|
this.#borderVisible = borderVisible;
|
|
578
|
+
this.#widthEpochRevision++;
|
|
561
579
|
}
|
|
562
580
|
|
|
563
581
|
setPromptGutter(promptGutter: string | undefined): void {
|
|
@@ -578,12 +596,16 @@ export class Editor implements Component, Focusable {
|
|
|
578
596
|
* Use the real terminal cursor instead of rendering a cursor glyph.
|
|
579
597
|
*/
|
|
580
598
|
setUseTerminalCursor(useTerminalCursor: boolean): void {
|
|
599
|
+
if (this.#useTerminalCursor === useTerminalCursor) return;
|
|
581
600
|
this.#useTerminalCursor = useTerminalCursor;
|
|
601
|
+
this.#widthEpochRevision++;
|
|
582
602
|
}
|
|
583
603
|
|
|
584
604
|
/** Render a dedicated bottom border so terminal-local IME preedit cannot shift editor chrome. */
|
|
585
605
|
setImeSafeCursorLayout(enabled: boolean): void {
|
|
606
|
+
if (this.#imeSafeCursorLayout === enabled) return;
|
|
586
607
|
this.#imeSafeCursorLayout = enabled;
|
|
608
|
+
this.#widthEpochRevision++;
|
|
587
609
|
}
|
|
588
610
|
|
|
589
611
|
getUseTerminalCursor(): boolean {
|
|
@@ -593,6 +615,7 @@ export class Editor implements Component, Focusable {
|
|
|
593
615
|
setMaxHeight(maxHeight: number | undefined): void {
|
|
594
616
|
if (this.#maxHeight === maxHeight) return;
|
|
595
617
|
this.#maxHeight = maxHeight;
|
|
618
|
+
this.#widthEpochRevision++;
|
|
596
619
|
// Don't reset scrollOffset — #updateScrollOffset will clamp it on next render
|
|
597
620
|
}
|
|
598
621
|
|
|
@@ -613,6 +636,10 @@ export class Editor implements Component, Focusable {
|
|
|
613
636
|
const newMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5;
|
|
614
637
|
if (this.#autocompleteMaxVisible !== newMaxVisible) {
|
|
615
638
|
this.#autocompleteMaxVisible = newMaxVisible;
|
|
639
|
+
if (this.#autocompleteState !== null) {
|
|
640
|
+
this.#autocompleteList?.setMaxVisible(newMaxVisible);
|
|
641
|
+
this.#widthEpochRevision++;
|
|
642
|
+
}
|
|
616
643
|
}
|
|
617
644
|
}
|
|
618
645
|
|
|
@@ -900,7 +927,27 @@ export class Editor implements Component, Focusable {
|
|
|
900
927
|
// Provider (lazy) wins over eager content — a host that installs both
|
|
901
928
|
// wants the coalesced path; falling back to eager keeps existing
|
|
902
929
|
// setTopBorder callers working unchanged.
|
|
903
|
-
|
|
930
|
+
let topBorder: EditorTopBorder | undefined;
|
|
931
|
+
if (this.#topBorderProvider) {
|
|
932
|
+
const previousWidth = this.#topBorderProviderWidth;
|
|
933
|
+
topBorder = this.#topBorderProvider(topFillWidth);
|
|
934
|
+
const signature = topBorder ? `${topBorder.width}\0${topBorder.content}` : "";
|
|
935
|
+
const revision = topBorder?.revision;
|
|
936
|
+
if (
|
|
937
|
+
(previousWidth !== undefined &&
|
|
938
|
+
revision !== undefined &&
|
|
939
|
+
this.#topBorderProviderRevision !== undefined &&
|
|
940
|
+
revision !== this.#topBorderProviderRevision) ||
|
|
941
|
+
(previousWidth === topFillWidth && signature !== this.#topBorderProviderSignature)
|
|
942
|
+
) {
|
|
943
|
+
this.#widthEpochRevision++;
|
|
944
|
+
}
|
|
945
|
+
this.#topBorderProviderWidth = topFillWidth;
|
|
946
|
+
this.#topBorderProviderSignature = signature;
|
|
947
|
+
this.#topBorderProviderRevision = revision;
|
|
948
|
+
} else {
|
|
949
|
+
topBorder = this.#topBorderContent;
|
|
950
|
+
}
|
|
904
951
|
if (topBorder) {
|
|
905
952
|
const { content, width: statusWidth } = topBorder;
|
|
906
953
|
if (statusWidth <= topFillWidth) {
|
|
@@ -1240,6 +1287,7 @@ export class Editor implements Component, Focusable {
|
|
|
1240
1287
|
kb.matchesCanonical(canonical, "tui.select.pageDown")
|
|
1241
1288
|
) {
|
|
1242
1289
|
this.#autocompleteList.handleInput(data);
|
|
1290
|
+
this.#widthEpochRevision++;
|
|
1243
1291
|
this.onAutocompleteUpdate?.();
|
|
1244
1292
|
return;
|
|
1245
1293
|
}
|
|
@@ -1670,6 +1718,15 @@ export class Editor implements Component, Focusable {
|
|
|
1670
1718
|
return this.#state.lines.join("\n");
|
|
1671
1719
|
}
|
|
1672
1720
|
|
|
1721
|
+
getNativeScrollbackWidthEpochRevision(): number {
|
|
1722
|
+
const text = this.getText();
|
|
1723
|
+
if (text !== this.#widthEpochText) {
|
|
1724
|
+
this.#widthEpochText = text;
|
|
1725
|
+
this.#widthEpochRevision++;
|
|
1726
|
+
}
|
|
1727
|
+
return this.#widthEpochRevision;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1673
1730
|
/** Whether the buffer text equals `value`, without `getText()`'s full join —
|
|
1674
1731
|
* O(1) for the hot per-keystroke probes against short single-line values. */
|
|
1675
1732
|
textEquals(value: string): boolean {
|
|
@@ -3149,6 +3206,7 @@ export class Editor implements Component, Focusable {
|
|
|
3149
3206
|
this.#autocompletePrefix = suggestions.prefix;
|
|
3150
3207
|
this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
3151
3208
|
this.#autocompleteState = "regular";
|
|
3209
|
+
this.#widthEpochRevision++;
|
|
3152
3210
|
this.onAutocompleteUpdate?.();
|
|
3153
3211
|
} else {
|
|
3154
3212
|
this.#cancelAutocomplete();
|
|
@@ -3207,6 +3265,7 @@ export class Editor implements Component, Focusable {
|
|
|
3207
3265
|
this.#autocompletePrefix = suggestions.prefix;
|
|
3208
3266
|
this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
3209
3267
|
this.#autocompleteState = "force";
|
|
3268
|
+
this.#widthEpochRevision++;
|
|
3210
3269
|
this.onAutocompleteUpdate?.();
|
|
3211
3270
|
} else {
|
|
3212
3271
|
this.#cancelAutocomplete();
|
|
@@ -3221,6 +3280,7 @@ export class Editor implements Component, Focusable {
|
|
|
3221
3280
|
this.#autocompleteState = null;
|
|
3222
3281
|
this.#autocompleteList = undefined;
|
|
3223
3282
|
this.#autocompletePrefix = "";
|
|
3283
|
+
if (wasAutocompleting) this.#widthEpochRevision++;
|
|
3224
3284
|
if (notifyCancel && wasAutocompleting) {
|
|
3225
3285
|
this.onAutocompleteCancel?.();
|
|
3226
3286
|
}
|
|
@@ -3252,6 +3312,7 @@ export class Editor implements Component, Focusable {
|
|
|
3252
3312
|
this.#autocompletePrefix = suggestions.prefix;
|
|
3253
3313
|
// Always create new SelectList to ensure update
|
|
3254
3314
|
this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
3315
|
+
this.#widthEpochRevision++;
|
|
3255
3316
|
this.onAutocompleteUpdate?.();
|
|
3256
3317
|
} else {
|
|
3257
3318
|
this.#cancelAutocomplete();
|
package/src/components/image.ts
CHANGED
|
@@ -309,6 +309,16 @@ export class ImageBudget {
|
|
|
309
309
|
}
|
|
310
310
|
}
|
|
311
311
|
|
|
312
|
+
/**
|
|
313
|
+
* End the physical-row coordinate epoch after observing its final commit
|
|
314
|
+
* watermark. Placement ids and latched archive state survive, but attachment
|
|
315
|
+
* rows do not: the next placement emit records them in the new-width frame.
|
|
316
|
+
*/
|
|
317
|
+
beginPlacementCoordinateEpoch(): void {
|
|
318
|
+
for (const state of this.#placementState.values()) state.lastAttachTopFrameRow = undefined;
|
|
319
|
+
this.#watchedPlacements.clear();
|
|
320
|
+
}
|
|
321
|
+
|
|
312
322
|
/**
|
|
313
323
|
* Resolve the placement id and geometry for a direct-placement emit whose
|
|
314
324
|
* topmost attached cell sits at `attachTopFrameRow` — the first frame row
|
|
@@ -11,13 +11,19 @@ import { latexToBlock } from "../latex-block";
|
|
|
11
11
|
import { inlineMathSpanEnd, isBareMathEnvironment, latexToUnicode } from "../latex-to-unicode";
|
|
12
12
|
import type { SymbolTheme } from "../symbols";
|
|
13
13
|
import { TERMINAL } from "../terminal-capabilities";
|
|
14
|
-
import type {
|
|
14
|
+
import type {
|
|
15
|
+
Component,
|
|
16
|
+
NativeScrollbackCommittedRows,
|
|
17
|
+
NativeScrollbackReplay,
|
|
18
|
+
NativeScrollbackWidthEpoch,
|
|
19
|
+
} from "../tui";
|
|
15
20
|
import {
|
|
16
21
|
applyBackgroundToLine,
|
|
17
22
|
Ellipsis,
|
|
18
23
|
encodeTextSized,
|
|
19
24
|
getPaddingX,
|
|
20
25
|
getSegmenter,
|
|
26
|
+
isOsc66Line,
|
|
21
27
|
padding,
|
|
22
28
|
replaceTabs,
|
|
23
29
|
truncateToWidth,
|
|
@@ -38,15 +44,11 @@ function normalizeOsc8Terminators(text: string): string {
|
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
// OSC 66 (Kitty text-sizing) heading spans are emitted as a single indivisible
|
|
41
|
-
// unit by the H1 render path. Like image-protocol lines, they
|
|
42
|
-
//
|
|
43
|
-
// (recomputing the explicit `w=` cell count
|
|
44
|
-
// payload), and padding would append trailing
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
function isOsc66Line(line: string): boolean {
|
|
48
|
-
return line.includes(OSC66_LINE_PREFIX);
|
|
49
|
-
}
|
|
47
|
+
// unit by the H1 render path. Like image-protocol lines, they bypass ANSI
|
|
48
|
+
// wrapping and width padding (see `isOsc66Line` in ../utils): re-wrapping
|
|
49
|
+
// splits/normalizes the sized span (recomputing the explicit `w=` cell count
|
|
50
|
+
// and hoisting SGR out of the OSC payload), and padding would append trailing
|
|
51
|
+
// cells past the doubled glyph.
|
|
50
52
|
|
|
51
53
|
function normalizeHtmlEntitiesForTerminal(raw: string): string {
|
|
52
54
|
const parseCodePoint = (value: number): string => {
|
|
@@ -1412,7 +1414,9 @@ interface RenderedTableLayout extends TableLayoutLock {
|
|
|
1412
1414
|
endRow: number;
|
|
1413
1415
|
}
|
|
1414
1416
|
|
|
1415
|
-
export class Markdown
|
|
1417
|
+
export class Markdown
|
|
1418
|
+
implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay, NativeScrollbackWidthEpoch
|
|
1419
|
+
{
|
|
1416
1420
|
#text: string;
|
|
1417
1421
|
#paddingX: number; // Left/right padding
|
|
1418
1422
|
#paddingY: number; // Top/bottom padding
|
|
@@ -1450,6 +1454,16 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
|
|
|
1450
1454
|
// exposure to 0 and re-earns it — the exposure is hard-monotone within a
|
|
1451
1455
|
// text lineage.
|
|
1452
1456
|
#settledExposedText?: string;
|
|
1457
|
+
// Semantic source state that produced the most recent render. Unlike #text,
|
|
1458
|
+
// it does not advance when streaming updates arrive before the next paint.
|
|
1459
|
+
#lastRenderedText?: string;
|
|
1460
|
+
#lastRenderedTransientRenderCache = false;
|
|
1461
|
+
#lastRenderedHasMutableTrailingRow = false;
|
|
1462
|
+
#widthEpochBoundaries = new WeakMap<
|
|
1463
|
+
object,
|
|
1464
|
+
{ text: string; transientRenderCache: boolean; hasMutableTrailingRow: boolean }
|
|
1465
|
+
>();
|
|
1466
|
+
|
|
1453
1467
|
// True while #renderStreamingContentLines renders the frozen token range:
|
|
1454
1468
|
// frozen code blocks highlight even in transient mode so their bytes match
|
|
1455
1469
|
// the finalized render (they render once into the prefix line cache, so
|
|
@@ -1545,6 +1559,56 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
|
|
|
1545
1559
|
return this.#lastRenderSettledRows;
|
|
1546
1560
|
}
|
|
1547
1561
|
|
|
1562
|
+
captureNativeScrollbackWidthEpoch(): unknown {
|
|
1563
|
+
if (this.#lastRenderedText === undefined) return undefined;
|
|
1564
|
+
const marker = {};
|
|
1565
|
+
this.#widthEpochBoundaries.set(marker, {
|
|
1566
|
+
text: this.#lastRenderedText,
|
|
1567
|
+
transientRenderCache: this.#lastRenderedTransientRenderCache,
|
|
1568
|
+
hasMutableTrailingRow: this.#lastRenderedHasMutableTrailingRow,
|
|
1569
|
+
});
|
|
1570
|
+
return marker;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
resolveNativeScrollbackWidthEpoch(boundary: unknown): number | undefined {
|
|
1574
|
+
if (typeof boundary !== "object" || boundary === null || this.#cachedWidth === undefined) return undefined;
|
|
1575
|
+
const captured = this.#widthEpochBoundaries.get(boundary);
|
|
1576
|
+
if (captured === undefined) return undefined;
|
|
1577
|
+
const snapshot = new Markdown(
|
|
1578
|
+
captured.text,
|
|
1579
|
+
this.#paddingX,
|
|
1580
|
+
this.#paddingY,
|
|
1581
|
+
this.#theme,
|
|
1582
|
+
this.#defaultTextStyle,
|
|
1583
|
+
this.#codeBlockIndent,
|
|
1584
|
+
);
|
|
1585
|
+
snapshot.#ignoreTight = this.#ignoreTight;
|
|
1586
|
+
snapshot.#transientRenderCache = captured.transientRenderCache;
|
|
1587
|
+
return Math.max(
|
|
1588
|
+
0,
|
|
1589
|
+
snapshot.render(this.#cachedWidth).length - this.#paddingY - (captured.hasMutableTrailingRow ? 1 : 0),
|
|
1590
|
+
);
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
getNativeScrollbackWidthEpochRows(): number | undefined {
|
|
1594
|
+
return this.#cachedLines === undefined ? undefined : this.#widthEpochRows(this.#cachedLines.length);
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
isNativeScrollbackWidthEpochAppendOnly(boundary: unknown): boolean {
|
|
1598
|
+
if (typeof boundary !== "object" || boundary === null) return true;
|
|
1599
|
+
return this.#widthEpochBoundaries.get(boundary)?.hasMutableTrailingRow !== true;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
#widthEpochRows(renderedRows: number): number {
|
|
1603
|
+
return Math.max(0, renderedRows - this.#paddingY - (this.#transientRenderCache ? 1 : 0));
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
#recordLastRenderedState(hasContentRows: boolean): void {
|
|
1607
|
+
this.#lastRenderedText = this.#text;
|
|
1608
|
+
this.#lastRenderedTransientRenderCache = this.#transientRenderCache;
|
|
1609
|
+
this.#lastRenderedHasMutableTrailingRow = this.#transientRenderCache && hasContentRows;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1548
1612
|
/**
|
|
1549
1613
|
* Freeze every table whose first physical row is already part of the native
|
|
1550
1614
|
* scrollback prefix. The recorded widths came from the exact frame that was
|
|
@@ -1644,6 +1708,7 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
|
|
|
1644
1708
|
// Returning the cached reference is load-bearing: parents memoize their
|
|
1645
1709
|
// concatenation on reference equality.
|
|
1646
1710
|
if (this.#cachedLines && this.#cachedText === this.#text && this.#cachedWidth === width) {
|
|
1711
|
+
this.#recordLastRenderedState(this.#cachedLines.length > 0);
|
|
1647
1712
|
return this.#cachedLines;
|
|
1648
1713
|
}
|
|
1649
1714
|
|
|
@@ -1660,6 +1725,7 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
|
|
|
1660
1725
|
this.#cachedText = this.#text;
|
|
1661
1726
|
this.#cachedWidth = width;
|
|
1662
1727
|
this.#cachedLines = EMPTY_RENDER_LINES;
|
|
1728
|
+
this.#recordLastRenderedState(false);
|
|
1663
1729
|
return EMPTY_RENDER_LINES;
|
|
1664
1730
|
}
|
|
1665
1731
|
|
|
@@ -1695,6 +1761,7 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
|
|
|
1695
1761
|
this.#cachedText = this.#text;
|
|
1696
1762
|
this.#cachedWidth = width;
|
|
1697
1763
|
this.#cachedLines = cached.lines;
|
|
1764
|
+
this.#recordLastRenderedState(cached.lines.length > 0);
|
|
1698
1765
|
return cached.lines;
|
|
1699
1766
|
}
|
|
1700
1767
|
}
|
|
@@ -1738,6 +1805,7 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
|
|
|
1738
1805
|
})),
|
|
1739
1806
|
});
|
|
1740
1807
|
}
|
|
1808
|
+
this.#recordLastRenderedState(contentLines.length > 0);
|
|
1741
1809
|
|
|
1742
1810
|
return result;
|
|
1743
1811
|
}
|
package/src/components/text.ts
CHANGED
|
@@ -25,11 +25,14 @@ export class Text implements Component {
|
|
|
25
25
|
#paddingY: number; // Top/bottom padding
|
|
26
26
|
#customBgFn?: (text: string) => string;
|
|
27
27
|
#styleFn?: (text: string) => string;
|
|
28
|
+
#widthEpochRevision = 0;
|
|
28
29
|
|
|
29
30
|
#ignoreTight = false;
|
|
30
31
|
|
|
31
32
|
setIgnoreTight(ignore: boolean): this {
|
|
33
|
+
if (this.#ignoreTight === ignore) return this;
|
|
32
34
|
this.#ignoreTight = ignore;
|
|
35
|
+
this.#widthEpochRevision++;
|
|
33
36
|
this.invalidate();
|
|
34
37
|
return this;
|
|
35
38
|
}
|
|
@@ -60,15 +63,21 @@ export class Text implements Component {
|
|
|
60
63
|
this.#cachedWidth = undefined;
|
|
61
64
|
this.#cachedWidthEpoch = undefined;
|
|
62
65
|
this.#cachedLines = undefined;
|
|
66
|
+
this.#widthEpochRevision++;
|
|
63
67
|
return true;
|
|
64
68
|
}
|
|
65
69
|
|
|
70
|
+
getNativeScrollbackWidthEpochRevision(): number {
|
|
71
|
+
return this.#widthEpochRevision;
|
|
72
|
+
}
|
|
73
|
+
|
|
66
74
|
setCustomBgFn(customBgFn?: (text: string) => string): void {
|
|
67
75
|
this.#customBgFn = customBgFn;
|
|
68
76
|
this.#cachedText = undefined;
|
|
69
77
|
this.#cachedWidth = undefined;
|
|
70
78
|
this.#cachedWidthEpoch = undefined;
|
|
71
79
|
this.#cachedLines = undefined;
|
|
80
|
+
this.#widthEpochRevision++;
|
|
72
81
|
}
|
|
73
82
|
|
|
74
83
|
/**
|
|
@@ -83,6 +92,7 @@ export class Text implements Component {
|
|
|
83
92
|
this.#cachedWidth = undefined;
|
|
84
93
|
this.#cachedWidthEpoch = undefined;
|
|
85
94
|
this.#cachedLines = undefined;
|
|
95
|
+
this.#widthEpochRevision++;
|
|
86
96
|
return this;
|
|
87
97
|
}
|
|
88
98
|
|