@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 follows
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 follows
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.3",
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.3",
41
- "@oh-my-pi/pi-utils": "18.0.3"
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(text: string, base: number, tokens: Token[]): { end: number; count: number } {
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 = 0; i < tokens.length; 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 growing prefix every frame (O(n²) → O(n) overall).
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
- const canStream = !HAS_REF_DEF.test(refDefText) && !refDefText.includes("\r");
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
- const frozen = stableBlockBoundary(text, 0, tokens);
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 3 spaces for consistent rendering
1651
- const normalizedText = this.transientRenderCache
1652
- ? replaceTabs(this.#text)
1653
- : repairOrphanClosingFence(replaceTabs(this.#text));
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.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature);
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.#renderContentLines(tokens, renderedUntil, tokens.length, contentWidth, signature));
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) return factory(lang);
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 follows
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";