@oh-my-pi/pi-tui 18.0.3 → 18.0.4
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
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.0.4] - 2026-08-24
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Significantly improved streaming Markdown rendering performance by caching unchanged rows, resuming boundary walks, and inspecting only text deltas for guard scans and OSC 8 normalization.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed TUI aborting when syntax highlighting fails during Markdown rendering by falling back to unhighlighted text.
|
|
14
|
+
- Fixed Korean IME cursor drift in Orca by properly matching two-cell Hangul Compatibility Jamo rendering.
|
|
15
|
+
|
|
5
16
|
## [18.0.3] - 2026-08-23
|
|
6
17
|
|
|
7
18
|
### Fixed
|
|
@@ -10,7 +10,7 @@ export declare enum NotifyProtocol {
|
|
|
10
10
|
Osc99 = "\u001B]99;;",
|
|
11
11
|
Osc9 = "\u001B]9;"
|
|
12
12
|
}
|
|
13
|
-
export type TerminalId = "kitty" | "ghostty" | "wezterm" | "iterm2" | "vscode" | "alacritty" | "warp" | "base" | "trueColor";
|
|
13
|
+
export type TerminalId = "kitty" | "ghostty" | "wezterm" | "iterm2" | "vscode" | "alacritty" | "warp" | "orca" | "base" | "trueColor";
|
|
14
14
|
/** Terminal capability details used for rendering and protocol selection. */
|
|
15
15
|
export declare class TerminalInfo {
|
|
16
16
|
readonly id: TerminalId;
|
|
@@ -23,7 +23,7 @@ export declare class TerminalInfo {
|
|
|
23
23
|
/** Renders the Kitty OSC 66 text-sizing protocol (scaled spans). Kitty only. */
|
|
24
24
|
readonly supportsTextSizing: boolean;
|
|
25
25
|
/**
|
|
26
|
-
* Hangul Compatibility Jamo (U+3131..=U+318E) cell width. Ghostty
|
|
26
|
+
* Hangul Compatibility Jamo (U+3131..=U+318E) cell width. Ghostty and Orca follow
|
|
27
27
|
* UAX#11 (2 cells); Warp paints 1; "platform" keeps the OS default
|
|
28
28
|
* (macOS narrow, otherwise UAX#11).
|
|
29
29
|
*/
|
|
@@ -32,7 +32,7 @@ export declare class TerminalInfo {
|
|
|
32
32
|
/** Renders the Kitty OSC 66 text-sizing protocol (scaled spans). Kitty only. */
|
|
33
33
|
supportsTextSizing?: boolean,
|
|
34
34
|
/**
|
|
35
|
-
* Hangul Compatibility Jamo (U+3131..=U+318E) cell width. Ghostty
|
|
35
|
+
* Hangul Compatibility Jamo (U+3131..=U+318E) cell width. Ghostty and Orca follow
|
|
36
36
|
* UAX#11 (2 cells); Warp paints 1; "platform" keeps the OS default
|
|
37
37
|
* (macOS narrow, otherwise UAX#11).
|
|
38
38
|
*/
|
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.0.4",
|
|
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.",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
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.0.4",
|
|
41
|
+
"@oh-my-pi/pi-utils": "18.0.4"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"kitty-vt-wasm": "^0.2.0"
|
|
@@ -38,6 +38,27 @@ function normalizeOsc8Terminators(text: string): string {
|
|
|
38
38
|
return text.replace(OSC8_ST_PREFIX_REGEX, "$1\x07");
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** The longest suffix of `text` a future append could still complete into a
|
|
42
|
+
* full `\x1b]8;[^\x07\x1b]*\x1b\\` match: the last `\x1b]8;` plus clean
|
|
43
|
+
* body (or that plus the pending ST-ESC `\x1b`), or a strict prefix of the
|
|
44
|
+
* escape start. Any other suffix is already normalized or uncompletable
|
|
45
|
+
* (a BEL or an ESC follows it), so this is exactly the region a crossing
|
|
46
|
+
* match can occupy. */
|
|
47
|
+
function trailingOsc8Partial(text: string): string | undefined {
|
|
48
|
+
const start = text.lastIndexOf("\x1b]8;");
|
|
49
|
+
if (start !== -1) {
|
|
50
|
+
const body = text.slice(start + 4);
|
|
51
|
+
const cut = body.search(/[\x07\x1b]/);
|
|
52
|
+
if (cut === -1 || (cut === body.length - 1 && body.charCodeAt(cut) === 0x1b)) {
|
|
53
|
+
return text.slice(start);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (text.endsWith("\x1b]8;") || text.endsWith("\x1b]8") || text.endsWith("\x1b]") || text.endsWith("\x1b")) {
|
|
57
|
+
return text.slice(text.lastIndexOf("\x1b"));
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
41
62
|
const MARKDOWN_FENCE_LINE = /^ {0,3}(`{3,}|~{3,})[ \t]*(.*)$/;
|
|
42
63
|
const MARKDOWN_HEADING_LINE = /^ {0,3}#{1,6}[ \t]+\S/;
|
|
43
64
|
const FENCED_SOURCE_INTRO = /\b(?:code|example|markdown|output|snippet|source)\s*:?\s*$/i;
|
|
@@ -1020,12 +1041,22 @@ const NO_BLOCK_BOUNDARY = { end: 0, count: 0 } as const;
|
|
|
1020
1041
|
* - A preceding `list` must be provably closed: CommonMark lets a same-marker
|
|
1021
1042
|
* item continue the list across the blank line, and marked merges both into
|
|
1022
1043
|
* one renumbered loose list (`listMayContinueAt`).
|
|
1044
|
+
*
|
|
1045
|
+
* `startIndex` resumes the scan at `tokens[startIndex]` (positions still
|
|
1046
|
+
* accumulate from `base`). The streaming freeze passes the frozen-prefix
|
|
1047
|
+
* token count: that prefix's boundary is permanent under append-only growth
|
|
1048
|
+
* (re-verified when frozen), so only the mutable tail can hold a new one.
|
|
1023
1049
|
*/
|
|
1024
|
-
function stableBlockBoundary(
|
|
1050
|
+
function stableBlockBoundary(
|
|
1051
|
+
text: string,
|
|
1052
|
+
base: number,
|
|
1053
|
+
tokens: Token[],
|
|
1054
|
+
startIndex = 0,
|
|
1055
|
+
): { end: number; count: number } {
|
|
1025
1056
|
let pos = base;
|
|
1026
1057
|
let end = 0;
|
|
1027
1058
|
let count = 0;
|
|
1028
|
-
for (let i =
|
|
1059
|
+
for (let i = startIndex; i < tokens.length; i++) {
|
|
1029
1060
|
const raw = tokens[i].raw;
|
|
1030
1061
|
const tokenEnd = pos + raw.length;
|
|
1031
1062
|
if (raw.endsWith("\n\n")) {
|
|
@@ -1455,6 +1486,57 @@ interface StreamPrefixLineCache extends RenderSignature {
|
|
|
1455
1486
|
tokenCount: number;
|
|
1456
1487
|
lines: readonly string[];
|
|
1457
1488
|
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Per-token row cache for the *unfrozen tail* (PoC H). The tail re-lexes every
|
|
1491
|
+
* streaming frame, but the token sequence is prefix-stable under append-only
|
|
1492
|
+
* growth: only the last block token grows, and a closed block's raw bytes and
|
|
1493
|
+
* token type never change once later text arrives (a growing structure — open
|
|
1494
|
+
* fence, lazy list, setext underline — is always the last token; when it
|
|
1495
|
+
* closes, later appends cannot re-segment it). Cached rows are therefore
|
|
1496
|
+
* byte-identical to a fresh render of the same token, and splicing them skips
|
|
1497
|
+
* the O(tail) styled-text + wrap cost that remains after the lex is skipped.
|
|
1498
|
+
*
|
|
1499
|
+
* Validity gates (checked on every reuse):
|
|
1500
|
+
* - signature equality (width, padding, theme probes — same set as the
|
|
1501
|
+
* prefix cache) and token-list alignment (`tokenStart` matches the frozen
|
|
1502
|
+
* prefix count);
|
|
1503
|
+
* - token raw equality against the cached snapshot (string equality, so the
|
|
1504
|
+
* cache works both with reused token objects and with a fresh lex that
|
|
1505
|
+
* re-produces the same raw text);
|
|
1506
|
+
* - `nextTypes[i]`: `#renderToken` decides trailing spacing rows from the
|
|
1507
|
+
* next token's type, so a cached row is only valid while the following
|
|
1508
|
+
* token keeps the type it had when the row was produced;
|
|
1509
|
+
* - token type: `table` tokens are never cached — their layout depends on
|
|
1510
|
+
* the whole token and the width budget, and the splice path is not
|
|
1511
|
+
* covered by the byte-identity suite, so they stay conservative.
|
|
1512
|
+
* `code` tokens are cacheable: the open-fence highlight stream is
|
|
1513
|
+
* deterministic on the cumulative token text, and whole-block highlight
|
|
1514
|
+
* fidelity applies only to fences that already have a closing fence.
|
|
1515
|
+
*/
|
|
1516
|
+
interface TailRowCache extends RenderSignature {
|
|
1517
|
+
tokenStart: number;
|
|
1518
|
+
// Upper bound (exclusive) of absolute token indices covered by `rows`.
|
|
1519
|
+
cachedThrough: number;
|
|
1520
|
+
// Per-token final content rows (1:1 with the rendered content lines),
|
|
1521
|
+
// indexed relative to `tokenStart`; undefined for uncacheable tokens.
|
|
1522
|
+
rows: (readonly string[] | undefined)[];
|
|
1523
|
+
// Raw snapshot per token (string value gate).
|
|
1524
|
+
raws: (string | undefined)[];
|
|
1525
|
+
// type of token[i+1] when the rows were produced (blank/spacing gate).
|
|
1526
|
+
nextTypes: (string | undefined)[];
|
|
1527
|
+
}
|
|
1528
|
+
/**
|
|
1529
|
+
* Mutable per-token record collector passed to #renderContentLines while
|
|
1530
|
+
* rendering the streaming tail. The render loop fills `raws`/`nextTypes`
|
|
1531
|
+
* per token as it goes and stores each token's final content rows into
|
|
1532
|
+
* `rows` (relative to the render's `start`), so the tail cache can splice byte-identical
|
|
1533
|
+
* rows for every token whose raw text and following-token type match.
|
|
1534
|
+
*/
|
|
1535
|
+
interface TailRenderRecorder {
|
|
1536
|
+
rows: (readonly string[] | undefined)[];
|
|
1537
|
+
raws: (string | undefined)[];
|
|
1538
|
+
nextTypes: (string | undefined)[];
|
|
1539
|
+
}
|
|
1458
1540
|
interface StreamingHighlightCache extends RenderSignature {
|
|
1459
1541
|
lang: string | undefined;
|
|
1460
1542
|
text: string;
|
|
@@ -1474,6 +1556,9 @@ function splitPushedHighlightLines(pushed: string): string[] {
|
|
|
1474
1556
|
|
|
1475
1557
|
export class Markdown implements Component {
|
|
1476
1558
|
#text: string;
|
|
1559
|
+
// Suffix of #text a future append could still complete into a match
|
|
1560
|
+
// (see trailingOsc8Partial); drives the append-only fast path.
|
|
1561
|
+
#oscPartialEscape?: string;
|
|
1477
1562
|
#paddingX: number; // Left/right padding
|
|
1478
1563
|
#paddingY: number; // Top/bottom padding
|
|
1479
1564
|
#defaultTextStyle?: DefaultTextStyle;
|
|
@@ -1500,6 +1585,30 @@ export class Markdown implements Component {
|
|
|
1500
1585
|
#streamPrefixText?: string;
|
|
1501
1586
|
#streamPrefixTokens?: Token[];
|
|
1502
1587
|
#streamPrefixLineCache?: StreamPrefixLineCache;
|
|
1588
|
+
// Guard-scan memo (PoC C): the ref-def/CR verdict with the exact text
|
|
1589
|
+
// length it was checked on. Reuse is sound only while setText has been
|
|
1590
|
+
// append-only since (tracked via the startsWith that setText performs): a
|
|
1591
|
+
// FALSE verdict stays valid — appending cannot remove an offending ref or
|
|
1592
|
+
// CR; a TRUE verdict can flip only when the delta gains a "[" at a fresh
|
|
1593
|
+
// line or a "]" / ":" completing a dangling "[…" that straddles the scan
|
|
1594
|
+
// edge, or "\n" / "\r". Byte-identity of the checked region: replaceTabs
|
|
1595
|
+
// is a per-char map and normalizeOsc8Terminators changes old bytes only
|
|
1596
|
+
// when an OSC8 terminator straddles the boundary (which breaks startsWith
|
|
1597
|
+
// — the flag then reads non-append), so transient-mode appends are
|
|
1598
|
+
// byte-identical. Non-transient repairOrphanClosingFence can additionally
|
|
1599
|
+
// delete a bare fence line; its triggers (heading + table lines) always
|
|
1600
|
+
// bring "\n" with them, so such frames take the suspicious-delta path,
|
|
1601
|
+
// and a deletion that shortens the text trips the length gate — either
|
|
1602
|
+
// way the verdict is re-derived, never reused across the deletion.
|
|
1603
|
+
#lastScanLength = -1;
|
|
1604
|
+
#lastScanCanStream = false;
|
|
1605
|
+
#lastScanValid = false;
|
|
1606
|
+
#appendOnlySinceLastScan = true;
|
|
1607
|
+
// PoC H: per-token row cache for the unfrozen tail. Invalidated together
|
|
1608
|
+
// with the prefix cache (width/signature changes, non-append edits) — see
|
|
1609
|
+
// the blank-replacement branch of setText and the fallback branch of
|
|
1610
|
+
// #lexTokens.
|
|
1611
|
+
#tailRowCache?: TailRowCache;
|
|
1503
1612
|
// True while #renderStreamingContentLines renders the frozen token range:
|
|
1504
1613
|
// frozen code blocks highlight even in transient mode so their bytes match
|
|
1505
1614
|
// the finalized render (they render once into the prefix line cache, so
|
|
@@ -1527,6 +1636,7 @@ export class Markdown implements Component {
|
|
|
1527
1636
|
codeBlockIndent: number = 2,
|
|
1528
1637
|
) {
|
|
1529
1638
|
this.#text = normalizeOsc8Terminators(text);
|
|
1639
|
+
this.#oscPartialEscape = trailingOsc8Partial(this.#text);
|
|
1530
1640
|
this.#paddingX = paddingX;
|
|
1531
1641
|
this.#paddingY = paddingY;
|
|
1532
1642
|
this.#theme = theme;
|
|
@@ -1535,13 +1645,46 @@ export class Markdown implements Component {
|
|
|
1535
1645
|
}
|
|
1536
1646
|
|
|
1537
1647
|
setText(text: string): boolean {
|
|
1648
|
+
// Identical re-emit (throttled tick): fully normalized already.
|
|
1649
|
+
if (text === this.#text) return false;
|
|
1650
|
+
// Streaming path: append-only growth. Only the memoized pending escape
|
|
1651
|
+
// suffix plus the delta can hold a not-yet-normalized match (a crossing
|
|
1652
|
+
// match starts in the pending suffix; everything else is in the delta).
|
|
1653
|
+
// Normalize that region alone and splice it onto the old prefix;
|
|
1654
|
+
// String.replace returns the input unchanged when nothing matches, so
|
|
1655
|
+
// the common clean-delta frame allocates nothing. Once a match is
|
|
1656
|
+
// rewritten (ST → BEL), the caller's raw text no longer aligns with
|
|
1657
|
+
// #text (2-byte ST vs 1-byte BEL), so later frames fall back to the
|
|
1658
|
+
// cold full-document pass — still byte-correct, just not faster.
|
|
1659
|
+
if (text.length > this.#text.length && text.startsWith(this.#text)) {
|
|
1660
|
+
const memoized = this.#oscPartialEscape;
|
|
1661
|
+
const pending = (memoized ?? "") + text.slice(this.#text.length);
|
|
1662
|
+
const normalized = normalizeOsc8Terminators(pending);
|
|
1663
|
+
if (normalized !== pending) {
|
|
1664
|
+
// A stored byte was rewritten (ST → BEL on a crossing match): the
|
|
1665
|
+
// stream-prefix lex caches self-invalidate via startsWith guards
|
|
1666
|
+
// against #text, so nothing else needs clearing.
|
|
1667
|
+
text = this.#text.slice(0, this.#text.length - (memoized?.length ?? 0)) + normalized;
|
|
1668
|
+
}
|
|
1669
|
+
this.#oscPartialEscape = trailingOsc8Partial(normalized);
|
|
1670
|
+
this.#text = text;
|
|
1671
|
+
this.invalidate();
|
|
1672
|
+
return true;
|
|
1673
|
+
}
|
|
1674
|
+
// Non-append edits / cold path: full-document pass.
|
|
1538
1675
|
text = normalizeOsc8Terminators(text);
|
|
1676
|
+
this.#oscPartialEscape = trailingOsc8Partial(text);
|
|
1539
1677
|
// Equality guard: streaming re-emits identical text on ticks that carried
|
|
1540
1678
|
// no delta (throttled provider frames, reconciled tool-execution updates).
|
|
1541
1679
|
// Without this, the caller-side `#cachedLines` gets thrown away and the
|
|
1542
1680
|
// full lex + wrap runs per re-emit — one of the top CPU hotspots during
|
|
1543
1681
|
// streaming (issue #4353). Mirrors `Text.setText`'s guard.
|
|
1544
1682
|
if (text === this.#text) return false;
|
|
1683
|
+
if (!text.startsWith(this.#text)) {
|
|
1684
|
+
// Non-append edit: the previous frame's guard verdict cannot be
|
|
1685
|
+
// reused — the checked region may have changed anywhere.
|
|
1686
|
+
this.#appendOnlySinceLastScan = false;
|
|
1687
|
+
}
|
|
1545
1688
|
this.#text = text;
|
|
1546
1689
|
if (!text.trim()) {
|
|
1547
1690
|
// Blank replacement: render() early-returns before #lexTokens can see
|
|
@@ -1550,6 +1693,7 @@ export class Markdown implements Component {
|
|
|
1550
1693
|
this.#streamPrefixText = undefined;
|
|
1551
1694
|
this.#streamPrefixTokens = undefined;
|
|
1552
1695
|
this.#streamPrefixLineCache = undefined;
|
|
1696
|
+
this.#tailRowCache = undefined;
|
|
1553
1697
|
}
|
|
1554
1698
|
this.invalidate();
|
|
1555
1699
|
return true;
|
|
@@ -1568,6 +1712,11 @@ export class Markdown implements Component {
|
|
|
1568
1712
|
const next = value === true;
|
|
1569
1713
|
if (this.#transientRenderCache === next) return;
|
|
1570
1714
|
this.#transientRenderCache = next;
|
|
1715
|
+
// The mode switch changes which normalization applies to the raw text
|
|
1716
|
+
// (transient: replaceTabs; final: repairOrphanClosingFence(replaceTabs)),
|
|
1717
|
+
// so a memo computed on the other mode's buffer must not be reused —
|
|
1718
|
+
// re-derive on the next frame instead.
|
|
1719
|
+
this.#appendOnlySinceLastScan = false;
|
|
1571
1720
|
this.invalidate();
|
|
1572
1721
|
}
|
|
1573
1722
|
|
|
@@ -1582,13 +1731,52 @@ export class Markdown implements Component {
|
|
|
1582
1731
|
// frozen (#freezeStablePrefix only runs when canStream was true). The prefix
|
|
1583
1732
|
// ends at a "\n\n" block boundary (stableBlockBoundary), so the tail starts
|
|
1584
1733
|
// at a fresh line — scanning only the tail for ref defs is sufficient and
|
|
1585
|
-
// avoids re-scanning the
|
|
1734
|
+
// avoids re-scanning the grown prefix every frame (O(n²) → O(n) overall).
|
|
1586
1735
|
const prefix = this.#streamPrefixText;
|
|
1587
1736
|
const prefixTokens = this.#streamPrefixTokens;
|
|
1588
1737
|
const hasPrefix =
|
|
1589
1738
|
prefix !== undefined && prefixTokens !== undefined && text.length > prefix.length && text.startsWith(prefix);
|
|
1590
1739
|
const refDefText = hasPrefix ? text.slice(prefix.length) : text;
|
|
1591
|
-
|
|
1740
|
+
// Guard-scan memo (PoC C): while setText has been append-only and the
|
|
1741
|
+
// grown delta introduces no "[", "]", ":", "\n" or "\r", the previous
|
|
1742
|
+
// verdict stays valid — the checked region is byte-identical (OSC8/tab
|
|
1743
|
+
// normalization is prefix-stable on appends) and none of the chars a
|
|
1744
|
+
// ref-def or CR needs crossed the scan edge. A false verdict is monotone
|
|
1745
|
+
// (appends cannot delete an existing ref def or CR), so it is reused
|
|
1746
|
+
// even when the delta is suspicious; only a true verdict on a suspicious
|
|
1747
|
+
// delta re-runs the tail scan (PR #9303). The tail scan is also the
|
|
1748
|
+
// cold path after non-append edits, which clear the memo. A FALSE
|
|
1749
|
+
// verdict is monotone under appends alone (transient mode: no repair,
|
|
1750
|
+
// appends cannot delete a ref-def or CR), so there it is reused even
|
|
1751
|
+
// on a suspicious delta. Final mode is the exception: render() detects
|
|
1752
|
+
// repairOrphanClosingFence deletions (the normalized buffer shrank)
|
|
1753
|
+
// and invalidates the memo on the affected frame, so the re-derive
|
|
1754
|
+
// happens exactly when the CR/ref-def trigger behind a false verdict
|
|
1755
|
+
// may have been deleted — never left stale, and never re-scanned on
|
|
1756
|
+
// frames where the memo is sound.
|
|
1757
|
+
let canStream: boolean;
|
|
1758
|
+
if (this.#lastScanValid && this.#appendOnlySinceLastScan && text.length > this.#lastScanLength) {
|
|
1759
|
+
const delta = text.slice(this.#lastScanLength);
|
|
1760
|
+
if (
|
|
1761
|
+
!delta.includes("[") &&
|
|
1762
|
+
!delta.includes("]") &&
|
|
1763
|
+
!delta.includes(":") &&
|
|
1764
|
+
!delta.includes("\n") &&
|
|
1765
|
+
!delta.includes("\r")
|
|
1766
|
+
) {
|
|
1767
|
+
canStream = this.#lastScanCanStream;
|
|
1768
|
+
} else if (this.#lastScanCanStream) {
|
|
1769
|
+
canStream = !HAS_REF_DEF.test(refDefText) && !refDefText.includes("\r");
|
|
1770
|
+
} else {
|
|
1771
|
+
canStream = false;
|
|
1772
|
+
}
|
|
1773
|
+
} else {
|
|
1774
|
+
canStream = !HAS_REF_DEF.test(refDefText) && !refDefText.includes("\r");
|
|
1775
|
+
}
|
|
1776
|
+
this.#lastScanLength = text.length;
|
|
1777
|
+
this.#lastScanCanStream = canStream;
|
|
1778
|
+
this.#lastScanValid = true;
|
|
1779
|
+
this.#appendOnlySinceLastScan = true;
|
|
1592
1780
|
if (canStream && hasPrefix) {
|
|
1593
1781
|
const tailTokens = lexDocument(refDefText);
|
|
1594
1782
|
const tokens = [...prefixTokens, ...tailTokens];
|
|
@@ -1602,6 +1790,7 @@ export class Markdown implements Component {
|
|
|
1602
1790
|
this.#streamPrefixText = undefined;
|
|
1603
1791
|
this.#streamPrefixTokens = undefined;
|
|
1604
1792
|
this.#streamPrefixLineCache = undefined;
|
|
1793
|
+
this.#tailRowCache = undefined;
|
|
1605
1794
|
}
|
|
1606
1795
|
return tokens;
|
|
1607
1796
|
}
|
|
@@ -1612,7 +1801,21 @@ export class Markdown implements Component {
|
|
|
1612
1801
|
// reference definitions, so each token's `raw` is a verbatim slice of `text`
|
|
1613
1802
|
// and the summed offsets address `text` exactly.
|
|
1614
1803
|
#freezeStablePrefix(text: string, tokens: Token[], opts: { preserveExisting: boolean }): void {
|
|
1615
|
-
|
|
1804
|
+
// On the streaming-concat path (preserveExisting), tokens[0..prefixCount)
|
|
1805
|
+
// ARE the previously frozen prefix and the text above it is byte-
|
|
1806
|
+
// identical, so its boundary cannot move: re-walking those tokens every
|
|
1807
|
+
// frame is pure overhead (O(prefix) per frame, O(n²) over a stream).
|
|
1808
|
+
// Skip them and resume at the first tail token; `base` starts at the
|
|
1809
|
+
// prefix length so accumulated offsets stay global. The cold full-lex
|
|
1810
|
+
// path (preserveExisting: false) re-derives the whole stream, so it
|
|
1811
|
+
// must keep walking from 0.
|
|
1812
|
+
const skipPrefix = opts.preserveExisting ? (this.#streamPrefixTokens?.length ?? 0) : 0;
|
|
1813
|
+
const frozen = stableBlockBoundary(
|
|
1814
|
+
text,
|
|
1815
|
+
skipPrefix > 0 ? (this.#streamPrefixText?.length ?? 0) : 0,
|
|
1816
|
+
tokens,
|
|
1817
|
+
skipPrefix,
|
|
1818
|
+
);
|
|
1616
1819
|
if (frozen.count > 0) {
|
|
1617
1820
|
this.#streamPrefixText = text.slice(0, frozen.end);
|
|
1618
1821
|
this.#streamPrefixTokens = tokens.slice(0, frozen.count);
|
|
@@ -1623,6 +1826,7 @@ export class Markdown implements Component {
|
|
|
1623
1826
|
this.#streamPrefixText = undefined;
|
|
1624
1827
|
this.#streamPrefixTokens = undefined;
|
|
1625
1828
|
this.#streamPrefixLineCache = undefined;
|
|
1829
|
+
this.#tailRowCache = undefined;
|
|
1626
1830
|
}
|
|
1627
1831
|
}
|
|
1628
1832
|
|
|
@@ -1647,10 +1851,17 @@ export class Markdown implements Component {
|
|
|
1647
1851
|
return EMPTY_RENDER_LINES;
|
|
1648
1852
|
}
|
|
1649
1853
|
|
|
1650
|
-
// Replace tabs with
|
|
1651
|
-
const
|
|
1652
|
-
|
|
1653
|
-
|
|
1854
|
+
// Replace tabs with spaces, then repair orphan fences in final mode.
|
|
1855
|
+
const tabbed = replaceTabs(this.#text);
|
|
1856
|
+
const normalizedText = this.transientRenderCache ? tabbed : repairOrphanClosingFence(tabbed);
|
|
1857
|
+
if (!this.transientRenderCache && normalizedText.length < tabbed.length) {
|
|
1858
|
+
// repairOrphanClosingFence deleted bytes this frame (orphan fence
|
|
1859
|
+
// removed): the guard-scan memo's checked region is no longer
|
|
1860
|
+
// byte-identical, and a cached false verdict may have been based
|
|
1861
|
+
// on the very CR/ref-def line that was deleted. Invalidate so the
|
|
1862
|
+
// next #lexTokens re-derives on the repaired buffer.
|
|
1863
|
+
this.#lastScanValid = false;
|
|
1864
|
+
}
|
|
1654
1865
|
const signature = this.#renderSignature(width, paddingX);
|
|
1655
1866
|
|
|
1656
1867
|
// L2: module-level LRU — survives component disposal/recreation across
|
|
@@ -1741,7 +1952,7 @@ export class Markdown implements Component {
|
|
|
1741
1952
|
const stableText = this.#streamPrefixText;
|
|
1742
1953
|
const stableTokenCount = this.#streamPrefixTokens?.length ?? 0;
|
|
1743
1954
|
if (stableText === undefined || stableTokenCount === 0 || !normalizedText.startsWith(stableText)) {
|
|
1744
|
-
return this.#
|
|
1955
|
+
return this.#renderStreamingTail(tokens, 0, contentWidth, signature);
|
|
1745
1956
|
}
|
|
1746
1957
|
|
|
1747
1958
|
const contentLines: string[] = [];
|
|
@@ -1774,7 +1985,7 @@ export class Markdown implements Component {
|
|
|
1774
1985
|
};
|
|
1775
1986
|
|
|
1776
1987
|
if (renderedUntil < tokens.length) {
|
|
1777
|
-
contentLines.push(...this.#
|
|
1988
|
+
contentLines.push(...this.#renderStreamingTail(tokens, renderedUntil, contentWidth, signature));
|
|
1778
1989
|
}
|
|
1779
1990
|
|
|
1780
1991
|
return contentLines;
|
|
@@ -1802,17 +2013,115 @@ export class Markdown implements Component {
|
|
|
1802
2013
|
return cache;
|
|
1803
2014
|
}
|
|
1804
2015
|
|
|
2016
|
+
/**
|
|
2017
|
+
* Render the unfrozen tail, splicing byte-identical rows from
|
|
2018
|
+
* {@link #tailRowCache} for every token whose raw text and following-token
|
|
2019
|
+
* type still match the cached snapshot. The splice reuses the exact content
|
|
2020
|
+
* lines a fresh render would produce — the row offsets are implicit in the
|
|
2021
|
+
* array order, so no offset recomputation is needed. The growing last token
|
|
2022
|
+
* is never spliced (its raw text always differs); it renders fresh and is
|
|
2023
|
+
* recorded again, so the cache trails the stream by one token.
|
|
2024
|
+
*/
|
|
2025
|
+
#renderStreamingTail(tokens: Token[], start: number, contentWidth: number, signature: RenderSignature): string[] {
|
|
2026
|
+
const out: string[] = [];
|
|
2027
|
+
let spliceEnd = start;
|
|
2028
|
+
const cache = this.#tailRowCache;
|
|
2029
|
+
if (cache !== undefined) {
|
|
2030
|
+
spliceEnd = this.#tailSpliceEnd(cache, start, signature, tokens);
|
|
2031
|
+
for (let i = start; i < spliceEnd; i++) {
|
|
2032
|
+
out.push(...cache.rows[i - start]!);
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
const recorder: TailRenderRecorder = {
|
|
2037
|
+
rows: new Array(tokens.length - spliceEnd).fill(undefined),
|
|
2038
|
+
raws: new Array(tokens.length - spliceEnd).fill(undefined),
|
|
2039
|
+
nextTypes: new Array(tokens.length - spliceEnd).fill(undefined),
|
|
2040
|
+
};
|
|
2041
|
+
const fresh = this.#renderContentLines(tokens, spliceEnd, tokens.length, contentWidth, signature, recorder);
|
|
2042
|
+
out.push(...fresh);
|
|
2043
|
+
|
|
2044
|
+
// Refresh the cache: keep entries for spliced tokens (their raws stay
|
|
2045
|
+
// valid), overlay the fresh entries, and re-derive the contiguous
|
|
2046
|
+
// covered prefix (splicing stops at the first uncacheable or
|
|
2047
|
+
// changed token). All arrays are tail-relative (index 0 = token
|
|
2048
|
+
// `start`), so a mostly-frozen document allocates only for the
|
|
2049
|
+
// unfrozen tail instead of the whole token list every frame.
|
|
2050
|
+
const tailCount = tokens.length - start;
|
|
2051
|
+
const rows: (readonly string[] | undefined)[] = new Array(tailCount).fill(undefined);
|
|
2052
|
+
const raws: (string | undefined)[] = new Array(tailCount).fill(undefined);
|
|
2053
|
+
const nextTypes: (string | undefined)[] = new Array(tailCount).fill(undefined);
|
|
2054
|
+
if (cache !== undefined && cache.tokenStart === start) {
|
|
2055
|
+
for (let i = start; i < Math.min(cache.cachedThrough, spliceEnd); i++) {
|
|
2056
|
+
rows[i - start] = cache.rows[i - start];
|
|
2057
|
+
raws[i - start] = cache.raws[i - start];
|
|
2058
|
+
nextTypes[i - start] = cache.nextTypes[i - start];
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
for (let i = spliceEnd; i < tokens.length; i++) {
|
|
2062
|
+
rows[i - start] = recorder.rows[i - spliceEnd];
|
|
2063
|
+
raws[i - start] = recorder.raws[i - spliceEnd];
|
|
2064
|
+
nextTypes[i - start] = recorder.nextTypes[i - spliceEnd];
|
|
2065
|
+
}
|
|
2066
|
+
let cachedThrough = start;
|
|
2067
|
+
while (cachedThrough < tokens.length && rows[cachedThrough - start] !== undefined) cachedThrough++;
|
|
2068
|
+
this.#tailRowCache = {
|
|
2069
|
+
...signature,
|
|
2070
|
+
tokenStart: start,
|
|
2071
|
+
cachedThrough,
|
|
2072
|
+
rows,
|
|
2073
|
+
raws,
|
|
2074
|
+
nextTypes,
|
|
2075
|
+
};
|
|
2076
|
+
return out;
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
// Longest cache-spliceable prefix: every cached row from `start` up to
|
|
2080
|
+
// (but not including) the returned index is byte-identical to a fresh
|
|
2081
|
+
// render of the same token. Stops at the first uncacheable token (rows
|
|
2082
|
+
// undefined), the first token whose raw text changed (the growing tail
|
|
2083
|
+
// token), or a following-token type change.
|
|
2084
|
+
#tailSpliceEnd(cache: TailRowCache, start: number, signature: RenderSignature, tokens: Token[]): number {
|
|
2085
|
+
if (cache.tokenStart !== start) return start;
|
|
2086
|
+
if (cache.width !== signature.width) return start;
|
|
2087
|
+
if (cache.paddingX !== signature.paddingX) return start;
|
|
2088
|
+
if (cache.paddingY !== signature.paddingY) return start;
|
|
2089
|
+
if (cache.codeBlockIndent !== signature.codeBlockIndent) return start;
|
|
2090
|
+
if (cache.themeId !== signature.themeId) return start;
|
|
2091
|
+
if (cache.defaultTextStyleId !== signature.defaultTextStyleId) return start;
|
|
2092
|
+
if (cache.imageProtocol !== signature.imageProtocol) return start;
|
|
2093
|
+
if (cache.hyperlinks !== signature.hyperlinks) return start;
|
|
2094
|
+
if (cache.textSizing !== signature.textSizing) return start;
|
|
2095
|
+
if (cache.bgColorProbe !== signature.bgColorProbe) return start;
|
|
2096
|
+
if (cache.headingProbe !== signature.headingProbe) return start;
|
|
2097
|
+
const limit = Math.min(cache.cachedThrough, tokens.length);
|
|
2098
|
+
for (let i = start; i < limit; i++) {
|
|
2099
|
+
if (cache.rows[i - start] === undefined) return i; // uncacheable token stops the splice
|
|
2100
|
+
const cachedRaw = cache.raws[i - start];
|
|
2101
|
+
const token = tokens[i];
|
|
2102
|
+
if (cachedRaw === undefined || token === undefined) return start;
|
|
2103
|
+
if (token.raw !== cachedRaw) return i; // changed/growing token: fresh-render from here
|
|
2104
|
+
if ((tokens[i + 1]?.type ?? undefined) !== cache.nextTypes[i - start]) return i;
|
|
2105
|
+
}
|
|
2106
|
+
return limit;
|
|
2107
|
+
}
|
|
2108
|
+
|
|
1805
2109
|
#renderContentLines(
|
|
1806
2110
|
tokens: Token[],
|
|
1807
2111
|
start: number,
|
|
1808
2112
|
end: number,
|
|
1809
2113
|
contentWidth: number,
|
|
1810
2114
|
signature: RenderSignature,
|
|
2115
|
+
tailRecorder?: TailRenderRecorder,
|
|
1811
2116
|
): string[] {
|
|
1812
2117
|
const wrappedLines: RenderedLine[] = [];
|
|
2118
|
+
// Wrapped-row span per absolute token index. Call-local: stale values
|
|
2119
|
+
// are never read across renders.
|
|
2120
|
+
const tokenWrappedRowCounts: number[] = [];
|
|
1813
2121
|
for (let i = start; i < end; i++) {
|
|
1814
2122
|
const token = tokens[i];
|
|
1815
2123
|
const nextToken = tokens[i + 1];
|
|
2124
|
+
const tokenWrappedRowStart = wrappedLines.length;
|
|
1816
2125
|
const renderedTokenLines = this.#renderToken(token, contentWidth, nextToken?.type);
|
|
1817
2126
|
for (const renderedRow of renderedTokenLines) {
|
|
1818
2127
|
// Lists wrap while their structural prefixes are still available, so
|
|
@@ -1831,6 +2140,7 @@ export class Markdown implements Component {
|
|
|
1831
2140
|
}
|
|
1832
2141
|
}
|
|
1833
2142
|
}
|
|
2143
|
+
tokenWrappedRowCounts[i] = wrappedLines.length - tokenWrappedRowStart;
|
|
1834
2144
|
}
|
|
1835
2145
|
|
|
1836
2146
|
const leftMargin = padding(signature.paddingX);
|
|
@@ -1876,6 +2186,36 @@ export class Markdown implements Component {
|
|
|
1876
2186
|
}
|
|
1877
2187
|
}
|
|
1878
2188
|
|
|
2189
|
+
// PoC H: record per-token row slices for the tail cache. The pad pass
|
|
2190
|
+
// maps every wrapped row to exactly one content line (structural blanks
|
|
2191
|
+
// after OSC 66 sized headings are pushed unpadded but still present), so
|
|
2192
|
+
// slicing by the per-token wrap spans recovers each token's exact rows.
|
|
2193
|
+
if (tailRecorder !== undefined) {
|
|
2194
|
+
const rows = tailRecorder.rows;
|
|
2195
|
+
const raws = tailRecorder.raws;
|
|
2196
|
+
const nextTypes = tailRecorder.nextTypes;
|
|
2197
|
+
let wrappedStart = 0;
|
|
2198
|
+
let contentCursor = 0;
|
|
2199
|
+
for (let i = start; i < end; i++) {
|
|
2200
|
+
const token = tokens[i]!;
|
|
2201
|
+
const wrappedEnd = wrappedStart + tokenWrappedRowCounts[i]!;
|
|
2202
|
+
const rowCount = wrappedEnd - wrappedStart;
|
|
2203
|
+
raws[i - start] = token.raw;
|
|
2204
|
+
nextTypes[i - start] = tokens[i + 1]?.type;
|
|
2205
|
+
// Tables are never cached: their layout depends on the whole
|
|
2206
|
+
// token and the width budget, and the splice path is not
|
|
2207
|
+
// covered by the byte-identity suite. Keep the raw/nextTypes
|
|
2208
|
+
// gates but drop rows.
|
|
2209
|
+
if (token.type === "table") {
|
|
2210
|
+
rows[i - start] = undefined;
|
|
2211
|
+
} else {
|
|
2212
|
+
rows[i - start] = contentLines.slice(contentCursor, contentCursor + rowCount);
|
|
2213
|
+
}
|
|
2214
|
+
contentCursor += rowCount;
|
|
2215
|
+
wrappedStart = wrappedEnd;
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
|
|
1879
2219
|
return contentLines;
|
|
1880
2220
|
}
|
|
1881
2221
|
|
|
@@ -2011,7 +2351,15 @@ export class Markdown implements Component {
|
|
|
2011
2351
|
*/
|
|
2012
2352
|
#createHighlightStream(lang: string | undefined): HighlightStreamSession | null {
|
|
2013
2353
|
const factory = this.#theme.createHighlightStream;
|
|
2014
|
-
if (factory)
|
|
2354
|
+
if (factory) {
|
|
2355
|
+
try {
|
|
2356
|
+
return factory(lang);
|
|
2357
|
+
} catch {
|
|
2358
|
+
// Render must not throw: a broken theme factory (stale natives
|
|
2359
|
+
// `HighlightStream`, napi error) falls through to the unhighlighted
|
|
2360
|
+
// path / diff-family per-line emulation below.
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2015
2363
|
const highlightCode = this.#theme.highlightCode;
|
|
2016
2364
|
if (!highlightCode) return null;
|
|
2017
2365
|
const normalizedLang = lang?.toLowerCase();
|
|
@@ -34,6 +34,7 @@ export type TerminalId =
|
|
|
34
34
|
| "vscode"
|
|
35
35
|
| "alacritty"
|
|
36
36
|
| "warp"
|
|
37
|
+
| "orca"
|
|
37
38
|
| "base"
|
|
38
39
|
| "trueColor";
|
|
39
40
|
|
|
@@ -107,7 +108,7 @@ export class TerminalInfo {
|
|
|
107
108
|
/** Renders the Kitty OSC 66 text-sizing protocol (scaled spans). Kitty only. */
|
|
108
109
|
public readonly supportsTextSizing: boolean = false,
|
|
109
110
|
/**
|
|
110
|
-
* Hangul Compatibility Jamo (U+3131..=U+318E) cell width. Ghostty
|
|
111
|
+
* Hangul Compatibility Jamo (U+3131..=U+318E) cell width. Ghostty and Orca follow
|
|
111
112
|
* UAX#11 (2 cells); Warp paints 1; "platform" keeps the OS default
|
|
112
113
|
* (macOS narrow, otherwise UAX#11).
|
|
113
114
|
*/
|
|
@@ -493,6 +494,7 @@ const KNOWN_TERMINALS = Object.freeze({
|
|
|
493
494
|
iterm2: new TerminalInfo("iterm2", ImageProtocol.Iterm2, true, true, NotifyProtocol.Osc9),
|
|
494
495
|
vscode: new TerminalInfo("vscode", null, true, true, NotifyProtocol.Bell),
|
|
495
496
|
alacritty: new TerminalInfo("alacritty", null, true, true, NotifyProtocol.Bell),
|
|
497
|
+
orca: new TerminalInfo("orca", null, true, false, NotifyProtocol.Bell, false, false, false, 2),
|
|
496
498
|
// Warp identifies via TERM_PROGRAM=WarpTerminal and ships the Kitty graphics
|
|
497
499
|
// protocol on macOS/Linux (direct placement only — no Unicode placeholders, so
|
|
498
500
|
// detectKittyUnicodePlaceholdersSupport correctly excludes it). It does not
|
|
@@ -534,6 +536,7 @@ export function detectTerminalId(env: NodeJS.ProcessEnv = Bun.env): TerminalId {
|
|
|
534
536
|
if (caseEq(TERM_PROGRAM, "vscode")) return "vscode";
|
|
535
537
|
if (caseEq(TERM_PROGRAM, "alacritty")) return "alacritty";
|
|
536
538
|
if (caseEq(TERM_PROGRAM, "warpterminal")) return "warp";
|
|
539
|
+
if (caseEq(TERM_PROGRAM, "orca")) return "orca";
|
|
537
540
|
}
|
|
538
541
|
|
|
539
542
|
if (TERM?.toLowerCase().includes("ghostty")) return "ghostty";
|