@linxiraos/pi-tui 1.1.4 → 1.1.5

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,11 +2,9 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
- ## [1.1.4] - 2026-08-26
5
+ ## [1.1.5] - 2026-08-26
6
6
 
7
- ### Changed
8
-
9
- - 同步 1.1.4 发布线(与 1.1.3 无功能差异)。
7
+ - 同步上游 OMP v18.0.5 / v18.0.6:append-only transcript 声明与稳定行 API、markdown 渲染重构。
10
8
 
11
9
  ## [1.1.3] - 2026-08-25
12
10
 
@@ -14,7 +14,7 @@ export declare class Loader extends Text {
14
14
  private spinnerColorFn;
15
15
  private messageColorFn;
16
16
  private message;
17
- constructor(ui: TUI, spinnerColorFn: ColorFn, messageColorFn: LoaderMessageColorFn, message?: string, spinnerFrames?: string[]);
17
+ constructor(ui: TUI, spinnerColorFn: ColorFn, messageColorFn: LoaderMessageColorFn, message?: string | (() => string), spinnerFrames?: string[]);
18
18
  render(width: number): readonly string[];
19
19
  start(): void;
20
20
  stop(): void;
@@ -6,6 +6,14 @@ export declare function mathStartIndex(src: string): number | undefined;
6
6
  export declare function autolinkSchemeScanIndex(src: string): number | undefined;
7
7
  /** @internal exported for tests — must never return false for a src the built-in url regex matches. */
8
8
  export declare function urlTokenPossible(src: string): boolean;
9
+ /** @internal exported for tests — counts fast-tail splice frames. A future
10
+ * regression that silently disarms the fast path (e.g. an over-broad gate)
11
+ * leaves byte-identity intact but drops the counter to zero. */
12
+ export declare let fastTailSplices: number;
13
+ /** @internal exported for tests — resets the splice counter. */
14
+ export declare function resetFastTailSplices(): void;
15
+ /** @internal exported for tests — the grown-line-start block-kind gate. */
16
+ export declare function fastLineStartHazard(grownLine: string): boolean;
9
17
  /** Drop all L2 cache entries. Call on theme change to prevent stale styled output. */
10
18
  export declare function clearRenderCache(): void;
11
19
  /**
@@ -75,6 +83,12 @@ export declare class Markdown implements Component {
75
83
  constructor(text: string, paddingX: number, paddingY: number, theme: MarkdownTheme, defaultTextStyle?: DefaultTextStyle, codeBlockIndent?: number);
76
84
  setText(text: string): boolean;
77
85
  invalidate(): void;
86
+ /**
87
+ * Width-independent source prefix of the last render ending at a frozen
88
+ * Markdown block boundary. Only meaningful while streaming (transient
89
+ * render cache on); grows monotonically under append-only `setText`.
90
+ */
91
+ getLastRenderStableText(): string;
78
92
  get transientRenderCache(): boolean;
79
93
  set transientRenderCache(value: boolean);
80
94
  render(width: number): readonly string[];
@@ -23,12 +23,19 @@ export interface ViewportSize {
23
23
  readonly columns: number;
24
24
  readonly rows: number;
25
25
  }
26
- /** Immutable finalized rows offered until the terminal accepts this identifier. */
26
+ /** Immutable append or complete replay offered until the terminal accepts this identifier. */
27
27
  export interface HistoryBatch {
28
28
  readonly id: number;
29
29
  readonly rows: readonly string[];
30
+ /**
31
+ * `append` (the default) adds finalized or naturally emitted rows. `replay`
32
+ * is the complete logical ledger; the writer bottom-splits it against the
33
+ * leading blank viewport and serializes the remainder plus final viewport in
34
+ * one synchronous terminal write.
35
+ */
36
+ readonly kind?: "append" | "replay";
30
37
  }
31
- /** One history append and the complete mutable viewport for a terminal frame. */
38
+ /** One history append or complete replay plus the mutable viewport for a terminal frame. */
32
39
  export interface TerminalFramePlan {
33
40
  readonly history?: HistoryBatch;
34
41
  readonly viewport: readonly string[];
@@ -40,7 +47,9 @@ export interface TerminalFrameProvider {
40
47
  /** Full semantic viewport used only on the transient resize buffer. */
41
48
  renderResizeFrame?(viewport: ViewportSize): readonly string[];
42
49
  /** Re-offer finalized history after a display reset or resize replay. */
43
- resetHistory?(): void;
50
+ beginHistoryReplay?(): void;
51
+ /** Force every currently eligible finalized prefix to retire before stop. */
52
+ beginHistoryFlush?(): void;
44
53
  }
45
54
  export interface TUIStartOptions {
46
55
  /** Clear saved native scrollback before the first paint. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@linxiraos/pi-tui",
4
- "version": "1.1.4",
4
+ "version": "1.1.5",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://linxira-os.github.io/zeta/",
7
7
  "author": "Stencil Labs, Inc.",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@linxiraos/pi-natives": "1.1.4",
41
- "@linxiraos/pi-utils": "1.1.4"
40
+ "@linxiraos/pi-natives": "1.1.5",
41
+ "@linxiraos/pi-utils": "1.1.5"
42
42
  },
43
43
  "devDependencies": {
44
44
  "kitty-vt-wasm": "^0.2.0"
@@ -32,7 +32,7 @@ export class Loader extends Text {
32
32
  ui: TUI,
33
33
  private spinnerColorFn: ColorFn,
34
34
  private messageColorFn: LoaderMessageColorFn,
35
- private message: string = "Loading...",
35
+ private message: string | (() => string) = "Loading...",
36
36
  spinnerFrames?: string[],
37
37
  ) {
38
38
  super("", 1, 0);
@@ -149,11 +149,18 @@ export class Loader extends Text {
149
149
  }, delayMs);
150
150
  this.#intervalId = timer;
151
151
  }
152
- /** Re-wrap the underlying Text only when its message or frame width changes. */
152
+ #resolveMessage(): string {
153
+ return typeof this.message === "function" ? this.message() : this.message;
154
+ }
155
+
156
+ /** Re-wrap the underlying Text only when its message or frame width changes.
157
+ * When {@link message} is a function it is re-evaluated on every spinner
158
+ * tick, so a dynamic label (e.g. a live countdown) advances in sync with
159
+ * the glyph instead of freezing on the initial value. */
153
160
  #syncText(): boolean {
154
161
  const layoutFrame = this.#layoutFrames[this.#currentFrame];
155
162
  this.#layoutFrame = layoutFrame;
156
- return this.setText(`${layoutFrame} ${this.message}`);
163
+ return this.setText(`${layoutFrame} ${this.#resolveMessage()}`);
157
164
  }
158
165
 
159
166
  #requestPaint() {
@@ -953,12 +953,167 @@ function renderedLinesCacheSize(lines: readonly string[]): number {
953
953
  return Math.max(1, size);
954
954
  }
955
955
 
956
+ // ---------------------------------------------------------------------------
957
+ // Fast-tail (B+) hazard gates
958
+ // ---------------------------------------------------------------------------
959
+ // Tier-1 eligibility: the appended delta is "markdown-inert" — it cannot open
960
+ // or close an inline token, change block structure, or shift a swatch
961
+ // boundary. A delta carrying a marker is re-lexed through the REAL inline
962
+ // pipeline so self-contained marker pairs render styled — exactly what a full
963
+ // re-lex of the grown row produces. `_` is included but narrowed: an
964
+ // intraword `_` is literal per CommonMark flanking rules, so only FLANKED
965
+ // underscores disarm (FAST_ROW_UNDERSCORE_RE on the row text, plus the
966
+ // delta-edge trailingDelimiterSeamHazard check).
967
+ const FAST_DELTA_RE = /[\n\r\\[`<!*_~$#&@\x1b]/;
968
+
969
+ // Disarm when the captured row's RAW tail ends in trailing whitespace (wrap
970
+ // trims it; appending a char moves the trim boundary), a trailing backslash
971
+ // (it can become an escape once the delta supplies the next char — the `\\`
972
+ // clause covers that escape-completion hazard), or a full/partial hex swatch
973
+ // run (a `#` + 3-8 hex is a swatch glyph; the byte range may shift).
974
+ const FAST_RUN_END_RE = /(?:[ \t\\]|#[0-9a-fA-F]{3,8}|#+)$/i;
975
+
976
+ // A partial `#` + 1-2 hex digits can grow into a 3-8 digit swatch glyph
977
+ // across the seam (delta hex digits are inert).
978
+ const FAST_SWATCH_SEAM_RE = /#[0-9a-fA-F]{0,2}$/;
979
+
980
+ // A partial HTML entity at the seam (`&am` + delta `p;`) OR a complete
981
+ // numeric entity (`&#35;`, `&#x1f600;`) — which decodes to `#`, a swatch
982
+ // lead — would normalize to different bytes than the plain concat.
983
+ const FAST_ENTITY_SEAM_RE = /&(?:[A-Za-z0-9#]{0,31}|#[0-9]{1,7};|#[xX][0-9a-fA-F]{1,6};)$/;
984
+
985
+ // A bare URL/email anywhere in the delta or across the seam (a URL the regex
986
+ // cut at a trailing delimiter can re-link once the delta supplies more chars;
987
+ // a protocol head ending at the seam completes in the delta) makes the full
988
+ // re-lex autolink while the plain concat would not.
989
+ const FAST_URL_ANYWHERE_RE = /(?:https?|ftp):\/\/|www\.[A-Za-z0-9]|[A-Za-z0-9._%+-]+@/i;
990
+
991
+ // A bare-URL/email PREFIX may end at the seam and complete in the delta
992
+ // (`ht` + `tps://x`, `foo@` + `bar.com`).
993
+ const FAST_URL_PREFIX_SEAM_RE = /(?:https?|ftp):?\/{0,2}$|www\.$|[A-Za-z0-9._+-]+@[A-Za-z0-9._+-]*$/;
994
+
995
+ // Inline-markup delimiters that survive into rendered output as LITERAL text
996
+ // when unpaired. The fast path detects open constructs by walking the REAL
997
+ // inline token stream (capture) and the delta's inline token stream (frame):
998
+ // any top-level `text` token still carrying one of these bytes holds an open
999
+ // delimiter, so a later delta could close it and a full re-lex would restyle
1000
+ // the seam. Closed pairs tokenize into styled tokens and never appear here.
1001
+ // `_` is excluded (intraword `_` is inert); a FLANKED underscore is caught
1002
+ // by FAST_ROW_UNDERSCORE_RE on the raw text (SGR bytes precede text, so word
1003
+ // boundaries are invisible after styling).
1004
+ const FAST_LITERAL_MARKER_RE = /[*~`[\]<>()$&#]/;
1005
+
1006
+ // A flanking underscore (start-of-line or preceded by a non-word char) can
1007
+ // open an emphasis that a future delta closes. Only flanked `_` is a
1008
+ // delimiter; intraword `_` (a_b) is literal.
1009
+ const FAST_ROW_UNDERSCORE_RE = /(?:^|[^\w])_/;
1010
+ // Two distinct CommonMark word-char notions drive the seam re-flank checks.
1011
+ // For the `_`-underscore seam, "word char" = ASCII `\w` (which includes `_`)
1012
+ // plus Unicode letters/numbers — `[\w\p{L}\p{N}]`. `\w` alone missed a row
1013
+ // ending in a Unicode letter (`é`); CM's char class `[^\s\p{P}\p{S}]` would
1014
+ // wrongly treat `_` (\p{Pc}) as a word char and break the `_..._` intraword
1015
+ // gate. For `*`/`~` closing emphasis, marked's flanking test uses the full
1016
+ // class `[^\s\p{P}\p{S}]` (which covers format/combining marks like U+200C and
1017
+ // U+0301), so branch 2 must use that wider class.
1018
+ const FAST_UNDERSCORE_WORD_AT_END_RE = /[\w\p{L}\p{N}]$/u;
1019
+ const FAST_CMARK_WORD_AT_START_RE = /^[^\s\p{P}\p{S}]/u;
1020
+ // A GFM table delimiter row lets a preceding pipe-header line flip into a
1021
+ // table when a future inert delta completes it — even a marker-free delta
1022
+ // (`| col_a | col_b |\n| --` + `--- | -`). The cold render then re-wraps and
1023
+ // restyles the header, so the splice must disarm. The gate runs on the GROWN
1024
+ // last line (`recipe.rowRaw`'s last line + deltaTabs) in render().
1025
+ const FAST_TABLE_DELIM_ROW_RE = /^\s*(?:\|[\s:]*-+\s*(?:\|[\s:]*-+\s*)*|[\s:]*-+\s*(?:\|[\s:]*-+\s*)+)\|?\s*$/;
1026
+
1027
+ // A paragraph's LAST line can complete into a different block kind under an
1028
+ // inert delta (ATX heading, blockquote, bullet marker, HR, ref-def) — disarm
1029
+ // when the grown line starts one (ref-def grammar: REF_DEF_LINE_RE).
1030
+ const FAST_LINE_START_HAZARD_RE =
1031
+ // `-` is placed LAST so it is a literal, not a range bound. The other
1032
+ // chars are in ASCENDING code-point order (no reversed ranges that
1033
+ // rely on engine leniency): * + = – — ─ ━ ═ then the literal `-`.
1034
+ /^ {0,3}(?:#{1,6}(?:[ \t]|$)|>|\d{1,9}[.)](?:[ \t]|$)|[*+=–—─━═-](?:[ \t]|$)|(?:[*+=–—─━═-][ \t]*){2,}[ \t]*$)/;
1035
+ /** @internal exported for tests — counts fast-tail splice frames. A future
1036
+ * regression that silently disarms the fast path (e.g. an over-broad gate)
1037
+ * leaves byte-identity intact but drops the counter to zero. */
1038
+ export let fastTailSplices = 0;
1039
+ /** @internal exported for tests — resets the splice counter. */
1040
+ export function resetFastTailSplices(): void {
1041
+ fastTailSplices = 0;
1042
+ }
1043
+
1044
+ /** @internal exported for tests — the grown-line-start block-kind gate. */
1045
+ export function fastLineStartHazard(grownLine: string): boolean {
1046
+ return FAST_LINE_START_HAZARD_RE.test(grownLine) || REF_DEF_LINE_RE.test(grownLine);
1047
+ }
1048
+
1049
+ /** Seam hazards between the captured raw row tail and the delta: the row must
1050
+ * not end in a wrap-trim, escape, swatch, entity, or URL/email prefix, and
1051
+ * must hold no unbalanced bracket an inert delta could close into a link. */
1052
+ function fastTailSeamSafe(raw: string): boolean {
1053
+ if (FAST_RUN_END_RE.test(raw)) return false;
1054
+ if (FAST_SWATCH_SEAM_RE.test(raw)) return false;
1055
+ if (FAST_ENTITY_SEAM_RE.test(raw)) return false;
1056
+ // Entities decode before swatch/whitespace detection (`&#35;ab` → `#ab`,
1057
+ // `&nbsp;` → ` `): scan the DECODED tail so an entity-indirected swatch
1058
+ // lead OR a decoded trailing space (wrap-trim boundary shifts) disarms.
1059
+ // Autolinks are lex-time on RAW text, so URL prefix stays raw.
1060
+ const rawTail = raw.length > 32 ? raw.slice(-32) : raw;
1061
+ const decodedTail = normalizeHtmlEntitiesForTerminal(rawTail);
1062
+ if (decodedTail !== rawTail && (FAST_RUN_END_RE.test(decodedTail) || FAST_SWATCH_SEAM_RE.test(decodedTail))) {
1063
+ return false;
1064
+ }
1065
+ if (FAST_URL_PREFIX_SEAM_RE.test(raw)) return false;
1066
+ if (raw.endsWith("]") || raw.lastIndexOf("[") > raw.lastIndexOf("]")) return false;
1067
+ if (raw.lastIndexOf("(") > raw.lastIndexOf(")") || raw.lastIndexOf("<") > raw.lastIndexOf(">")) return false;
1068
+ return true;
1069
+ }
1070
+ // Fast-tail (B+) recipe: captured frame state for the next inert-delta splice.
1071
+ interface FastTailRecipe {
1072
+ readonly lines: readonly string[]; // frame rows at capture
1073
+ readonly source: string; // raw #text at capture (append-only predicate)
1074
+ readonly width: number; // contentWidth at capture
1075
+ rowText: string; // RENDERED last wrap-output row (re-wrap input)
1076
+ rowRaw: string; // RAW tail source backing that row (seam scan)
1077
+ rowStart: number; // result[] index of the replaced row
1078
+ rowEnd: number; // exclusive result[] index
1079
+ readonly signature: RenderSignature; // full render signature at capture (bgColor etc.)
1080
+ }
1081
+
1082
+ /** True when the inline token stream holds an OPEN construct: a `text` token
1083
+ * that still carries a literal delimiter (an unpaired `*`/`` ` ``/`[`/… or a
1084
+ * flanking `_`), or raw HTML. Closed constructs are styled tokens whose
1085
+ * delimiters are absent from their text. An open means a FUTURE delta could
1086
+ * close it — the fast path disarms so the splice always matches a full lex. */
1087
+ function inlineHasOpen(tokens: readonly Token[]): boolean {
1088
+ for (const token of tokens) {
1089
+ if (isMathToken(token)) continue;
1090
+ if (token.type === "codespan") continue; // styled leaf; its content cannot re-pair
1091
+ if (token.type === "html") return true; // raw HTML — conservative
1092
+ if (token.type === "text") {
1093
+ const text = "text" in token && typeof token.text === "string" ? token.text : "";
1094
+ if (FAST_LITERAL_MARKER_RE.test(text) || FAST_ROW_UNDERSCORE_RE.test(text)) return true;
1095
+ }
1096
+ if ("tokens" in token && Array.isArray(token.tokens)) {
1097
+ if (inlineHasOpen(token.tokens as Token[])) return true;
1098
+ }
1099
+ }
1100
+ return false;
1101
+ }
1102
+
1103
+ /** Isolated inline lex of a same-line delta. A single-line delta has no block
1104
+ * structure, so the isolated inline pass equals the full lex's inline pass
1105
+ * (marked's paragraph tokens run the same `inlineTokens` entry point). */
1106
+ function lexInlineTokens(text: string): Token[] {
1107
+ return new Lexer(markdownParser.defaults).inlineTokens(text);
1108
+ }
1109
+
956
1110
  // A reference-link definition (`[label]: dest`) resolves across the whole
957
1111
  // document, so a split lex cannot reproduce it — disable the streaming fast path
958
1112
  // when one is present (rare in streamed output). The label may contain
959
1113
  // backslash-escaped characters (`[a\]b]: x`), so escapes are matched explicitly;
960
1114
  // over-matching is safe (it only costs the fast path), under-matching is not.
961
- const HAS_REF_DEF = /^ {0,3}\[(?:\\.|[^\]\\])+\]:/m;
1115
+ const REF_DEF_LINE_RE = /^ {0,3}\[(?:\\.|[^\]\\])+\]:/;
1116
+ const HAS_REF_DEF = new RegExp(REF_DEF_LINE_RE.source, "m");
962
1117
 
963
1118
  // marked's list tokenizer (Tokenizer.list, marked v18) continues a list across
964
1119
  // blank lines only when the remaining source matches
@@ -1620,6 +1775,9 @@ export class Markdown implements Component {
1620
1775
  #renderingStablePrefix = false;
1621
1776
  #streamingHighlightCache?: StreamingHighlightCache;
1622
1777
  #activeRenderSignature?: RenderSignature;
1778
+ #fastTail?: FastTailRecipe; // undefined = disarmed
1779
+ // B+ capture plumbing: #renderContentLines records the last rendered paragraph row.
1780
+ #lastTailCapture?: { kind: "paragraph"; open: boolean; rowInput: string; rowRaw: string };
1623
1781
  #ignoreTight = false;
1624
1782
  setIgnoreTight(ignore: boolean): this {
1625
1783
  this.#ignoreTight = ignore;
@@ -1694,6 +1852,9 @@ export class Markdown implements Component {
1694
1852
  this.#streamPrefixTokens = undefined;
1695
1853
  this.#streamPrefixLineCache = undefined;
1696
1854
  this.#tailRowCache = undefined;
1855
+ // B+: the captured fast-path rows index the replaced content — drop
1856
+ // the recipe so a fresh stream cannot splice onto stale rows.
1857
+ this.#fastTail = undefined;
1697
1858
  }
1698
1859
  this.invalidate();
1699
1860
  return true;
@@ -1704,6 +1865,16 @@ export class Markdown implements Component {
1704
1865
  this.#cachedWidth = undefined;
1705
1866
  this.#cachedLines = undefined;
1706
1867
  }
1868
+
1869
+ /**
1870
+ * Width-independent source prefix of the last render ending at a frozen
1871
+ * Markdown block boundary. Only meaningful while streaming (transient
1872
+ * render cache on); grows monotonically under append-only `setText`.
1873
+ */
1874
+ getLastRenderStableText(): string {
1875
+ return this.#transientRenderCache ? (this.#streamPrefixText ?? "") : "";
1876
+ }
1877
+
1707
1878
  get transientRenderCache(): boolean {
1708
1879
  return this.#transientRenderCache;
1709
1880
  }
@@ -1717,6 +1888,11 @@ export class Markdown implements Component {
1717
1888
  // so a memo computed on the other mode's buffer must not be reused —
1718
1889
  // re-derive on the next frame instead.
1719
1890
  this.#appendOnlySinceLastScan = false;
1891
+ // B+ reset site: a transient flip (finalize, or a fresh stream on
1892
+ // rewound text) means the next render re-lexes from the current
1893
+ // source — drop the fast-path recipe so stale rows cannot be served
1894
+ // across the transition.
1895
+ this.#fastTail = undefined;
1720
1896
  this.invalidate();
1721
1897
  }
1722
1898
 
@@ -1863,6 +2039,132 @@ export class Markdown implements Component {
1863
2039
  this.#lastScanValid = false;
1864
2040
  }
1865
2041
  const signature = this.#renderSignature(width, paddingX);
2042
+ // B+ fast path: an append-only, same-line delta re-renders ONLY the
2043
+ // last content row (the paragraph's trailing wrapped row) with the
2044
+ // grown source, so the new text shows every frame while staying
2045
+ // byte-identical to a cold full render (the full re-lex produces the
2046
+ // same grown inline tokens and the same wrap). The previous frame's
2047
+ // rows live in #fastTail.lines — the L1 #cachedLines was invalidated
2048
+ // by setText, so the fast path cannot read it back. An inert delta
2049
+ // has no "\n", so the frozen prefix cannot advance and the rows above
2050
+ // the spliced span stay byte-identical.
2051
+ if (
2052
+ this.transientRenderCache &&
2053
+ this.#fastTail !== undefined &&
2054
+ contentWidth === this.#fastTail.width &&
2055
+ this.#text.length > this.#fastTail.source.length &&
2056
+ this.#text.startsWith(this.#fastTail.source)
2057
+ ) {
2058
+ // Re-probe signature (pure) and require equality — a bgColor/theme
2059
+ // change (width-constant) must not splice rows of the stale recipe.
2060
+ const recipe = this.#fastTail;
2061
+ if (!this.#signatureEquals(signature, recipe.signature)) {
2062
+ this.#fastTail = undefined;
2063
+ } else {
2064
+ const delta = this.#text.slice(recipe.source.length);
2065
+ const deltaTabs = replaceTabs(delta);
2066
+ // Seam window contains the delta, so one URL/email scan catches both.
2067
+ const seamSafe = fastTailSeamSafe(recipe.rowRaw);
2068
+ const seamWindow =
2069
+ recipe.rowRaw.slice(Math.max(recipe.rowRaw.lastIndexOf(" "), recipe.rowRaw.lastIndexOf("\t")) + 1) +
2070
+ deltaTabs;
2071
+ // A paragraph's last line can complete into a different block
2072
+ // kind under an inert delta — disarm (single gate helper, kept
2073
+ // in sync with the exported test surface).
2074
+ const grownLine = recipe.rowRaw.slice(recipe.rowRaw.lastIndexOf("\n") + 1) + deltaTabs;
2075
+ const lineStartHazard = fastLineStartHazard(grownLine);
2076
+ // Only same-line deltas splice; marker deltas re-lex through the
2077
+ // REAL inline pipeline so self-contained pairs render styled.
2078
+ const hardDelta = /[\n\r\x1b]/.test(delta);
2079
+ const markerDelta = FAST_DELTA_RE.test(delta);
2080
+ // A delta starting/ending `_` after a word char pairs in isolation
2081
+ // but stays intraword-literal in the full text. Symmetrically, a
2082
+ // row ending with a closing delimiter (`_`, `*`, `~`) followed by
2083
+ // a word-char delta makes the delimiter intraword / non-flanking in
2084
+ // the joined text — the cold render drops the emphasis, but the
2085
+ // splice keeps it. A row ending `$` (closed inline math) followed
2086
+ // by a digit is invalidated by the anti-currency rule ($x$123 is
2087
+ // literal, not math) — disarm.
2088
+ const grownLastLine = recipe.rowRaw.slice(recipe.rowRaw.lastIndexOf("\n") + 1) + deltaTabs;
2089
+ const trailingDelimiterSeamHazard =
2090
+ (markerDelta &&
2091
+ (deltaTabs.startsWith("_") || deltaTabs.endsWith("_")) &&
2092
+ FAST_UNDERSCORE_WORD_AT_END_RE.test(recipe.rowRaw)) ||
2093
+ (!markerDelta && /[*~_]$/.test(recipe.rowRaw) && FAST_CMARK_WORD_AT_START_RE.test(deltaTabs)) ||
2094
+ (!markerDelta && recipe.rowRaw.endsWith("$") && /^[0-9]/.test(deltaTabs));
2095
+ // A delta opening a pairing char when the captured row ENDS with the
2096
+ // same char can re-pair across the seam: cold lex of the joined run
2097
+ // makes ONE token (x *a**b* → em("a**b")), the splice keeps two.
2098
+ // An image marker (`x!` + `[a](u)`) re-pairs the same way.
2099
+ const pairSeamHazard =
2100
+ markerDelta &&
2101
+ ((/^[*~`]/.test(deltaTabs) && /[*~`]$/.test(recipe.rowRaw)) ||
2102
+ // "x!" + "[a](u)": cold lexes text("x") + image(alt); the splice would
2103
+ // keep "x!" + a styled link byte-run.
2104
+ (deltaTabs.startsWith("[") && recipe.rowRaw.endsWith("!")));
2105
+ const deltaTokens = markerDelta && !hardDelta ? lexInlineTokens(deltaTabs) : null;
2106
+ if (
2107
+ seamSafe &&
2108
+ !lineStartHazard &&
2109
+ !hardDelta &&
2110
+ !trailingDelimiterSeamHazard &&
2111
+ // A grown GFM delimiter last line flips a preceding pipe-header
2112
+ // into a table on a marker-free delta (`| --` + `--- | -`).
2113
+ !FAST_TABLE_DELIM_ROW_RE.test(grownLastLine) &&
2114
+ (!markerDelta || (!this.#lastTailCapture?.open && !pairSeamHazard && !inlineHasOpen(deltaTokens!))) &&
2115
+ !FAST_URL_ANYWHERE_RE.test(seamWindow)
2116
+ ) {
2117
+ // Same text paths a full re-lex applies: real pipeline for marker
2118
+ // deltas, plain swatch/entity render for inert deltas.
2119
+ const { applyText } = this.#getDefaultInlineStyleContext();
2120
+ const grown =
2121
+ recipe.rowText +
2122
+ (markerDelta
2123
+ ? this.#renderInlineTokens(deltaTokens!)
2124
+ : renderTextWithSwatches(
2125
+ normalizeHtmlEntitiesForTerminal(deltaTabs),
2126
+ applyText,
2127
+ this.#theme.symbols.colorSwatch || DEFAULT_COLOR_SWATCH_GLYPH,
2128
+ ));
2129
+ const wrapped = wrapTextWithAnsi(grown, contentWidth);
2130
+ const fastPaddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX);
2131
+ const leftMargin = padding(fastPaddingX);
2132
+ const rightMargin = padding(fastPaddingX);
2133
+ const bgFn = this.#defaultTextStyle?.bgColor;
2134
+ const fastRows: string[] = [];
2135
+ for (const row of wrapped) {
2136
+ const withMargins = leftMargin + row + rightMargin;
2137
+ fastRows.push(
2138
+ bgFn
2139
+ ? applyBackgroundToLine(withMargins, width, bgFn)
2140
+ : withMargins + padding(Math.max(0, width - visibleWidth(withMargins))),
2141
+ );
2142
+ }
2143
+ // Splice onto the previous frame's rows (new array — parent may
2144
+ // hold the old one).
2145
+ const prev = recipe.lines;
2146
+ const fastResult = [...prev.slice(0, recipe.rowStart), ...fastRows, ...prev.slice(recipe.rowEnd)];
2147
+ this.#cachedText = this.#text;
2148
+ this.#cachedWidth = width;
2149
+ this.#cachedLines = fastResult;
2150
+ this.#fastTail = {
2151
+ lines: fastResult,
2152
+ source: this.#text,
2153
+ width: recipe.width,
2154
+ rowText: wrapped[wrapped.length - 1] ?? "",
2155
+ rowRaw: recipe.rowRaw + deltaTabs,
2156
+ rowStart: recipe.rowStart + wrapped.length - 1,
2157
+ rowEnd: recipe.rowStart + wrapped.length,
2158
+ signature: recipe.signature,
2159
+ };
2160
+ fastTailSplices++;
2161
+ return fastResult;
2162
+ }
2163
+ }
2164
+ // Hazard → disarm until the next real render re-captures.
2165
+ this.#fastTail = undefined;
2166
+ }
2167
+ // Replace tabs with 3 spaces for consistent rendering
1866
2168
 
1867
2169
  // L2: module-level LRU — survives component disposal/recreation across
1868
2170
  // session-tree navigations. Key encodes every dimension that affects the
@@ -1913,6 +2215,37 @@ export class Markdown implements Component {
1913
2215
  this.#cachedWidth = width;
1914
2216
  this.#cachedLines = result;
1915
2217
 
2218
+ const fastEligible =
2219
+ this.transientRenderCache &&
2220
+ contentLines.length > 0 &&
2221
+ // B+ invariant: only the FINAL #renderContentLines call captures;
2222
+ // the all-cache-hit prefix path clears #lastTailCapture at its top.
2223
+ this.#lastTailCapture !== undefined &&
2224
+ this.#lastTailCapture.kind === "paragraph" &&
2225
+ // Run-level default styling (color/bold/italic/strikethrough/
2226
+ // underline) disarms: the splice yields two ANSI runs where a cold
2227
+ // render yields one; bgColor is line-level and stays eligible.
2228
+ !this.#defaultTextStyle?.color &&
2229
+ !this.#defaultTextStyle?.bold &&
2230
+ !this.#defaultTextStyle?.italic &&
2231
+ !this.#defaultTextStyle?.strikethrough &&
2232
+ !this.#defaultTextStyle?.underline;
2233
+ if (fastEligible && this.#lastTailCapture !== undefined) {
2234
+ const capture = this.#lastTailCapture;
2235
+ this.#fastTail = {
2236
+ lines: result,
2237
+ source: this.#text,
2238
+ width: contentWidth,
2239
+ rowText: capture.rowInput,
2240
+ rowRaw: capture.rowRaw,
2241
+ rowStart: signature.paddingY + contentLines.length - 1,
2242
+ rowEnd: signature.paddingY + contentLines.length,
2243
+ signature,
2244
+ };
2245
+ } else {
2246
+ this.#fastTail = undefined;
2247
+ }
2248
+
1916
2249
  // Update L2 module-level LRU so future instances with the same key skip
1917
2250
  // the marked.lexer + highlightCode (Rust FFI) work entirely.
1918
2251
  if (cacheKey !== undefined) {
@@ -1938,6 +2271,10 @@ export class Markdown implements Component {
1938
2271
  headingProbe,
1939
2272
  };
1940
2273
  }
2274
+ // All-primitive signature — compare via the canonical render-cache encoding.
2275
+ #signatureEquals(a: RenderSignature, b: RenderSignature): boolean {
2276
+ return this.#renderCacheKey("", a) === this.#renderCacheKey("", b);
2277
+ }
1941
2278
 
1942
2279
  #renderCacheKey(normalizedText: string, signature: RenderSignature): string {
1943
2280
  return `${normalizedText}\x00${signature.width}\x00${signature.paddingX}\x00${signature.paddingY}\x00${signature.codeBlockIndent}\x00${signature.themeId}\x00${signature.defaultTextStyleId}\x00${signature.imageProtocol}\x00${signature.hyperlinks ? 1 : 0}\x00${signature.textSizing ? 1 : 0}\x00${signature.bgColorProbe}\x00${signature.headingProbe}`;
@@ -2114,6 +2451,9 @@ export class Markdown implements Component {
2114
2451
  signature: RenderSignature,
2115
2452
  tailRecorder?: TailRenderRecorder,
2116
2453
  ): string[] {
2454
+ // A non-capturing final call must not serve a stale recipe, so the
2455
+ // B+ plumbing is cleared up front; the per-token capture re-fills it.
2456
+ if (end === tokens.length) this.#lastTailCapture = undefined;
2117
2457
  const wrappedLines: RenderedLine[] = [];
2118
2458
  // Wrapped-row span per absolute token index. Call-local: stale values
2119
2459
  // are never read across renders.
@@ -2141,6 +2481,36 @@ export class Markdown implements Component {
2141
2481
  }
2142
2482
  }
2143
2483
  tokenWrappedRowCounts[i] = wrappedLines.length - tokenWrappedRowStart;
2484
+ // B+ capture hook: the LAST token of the FINAL call is the frame's
2485
+ // true trailing content row — record its rendered last row and raw
2486
+ // tail so render() can build the fast-path recipe. The frozen
2487
+ // prefix call (end < tokens.length) must never capture.
2488
+ if (end === tokens.length && i === end - 1 && token.type === "paragraph") {
2489
+ const raw = "raw" in token && typeof token.raw === "string" ? token.raw : "";
2490
+ const lastLine = renderedTokenLines[renderedTokenLines.length - 1];
2491
+ // Display math, a newline-terminated paragraph (a fresh line
2492
+ // grows next frame — the captured row is not the mutable tail),
2493
+ // or a tree-guide/OSC-8/OSC-66 trailing row are not
2494
+ // self-contained.
2495
+ if (
2496
+ soleDisplayMath(token.tokens) ||
2497
+ raw.endsWith("\n") ||
2498
+ !lastLine ||
2499
+ TREE_GUIDE_ANCHOR_RE.test(lastLine.text) ||
2500
+ lastLine.text.includes("\x1b]") ||
2501
+ TERMINAL.isImageLine(lastLine.text) ||
2502
+ isOsc66Line(lastLine.text)
2503
+ ) {
2504
+ continue;
2505
+ }
2506
+ const wrappedLast = wrappedLines[wrappedLines.length - 1];
2507
+ this.#lastTailCapture = {
2508
+ kind: "paragraph",
2509
+ rowInput: wrappedLast?.text ?? lastLine.text,
2510
+ rowRaw: raw.slice(raw.lastIndexOf("\n") + 1),
2511
+ open: inlineHasOpen(token.tokens ?? []),
2512
+ };
2513
+ }
2144
2514
  }
2145
2515
 
2146
2516
  const leftMargin = padding(signature.paddingX);
package/src/tui.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  * Minimal TUI implementation with explicit history batches.
3
3
  *
4
4
  * Two output channels: a product-owned {@link TerminalFrameProvider} returns,
5
- * per frame, an optional immutable {@link HistoryBatch} (finalized transcript
6
- * rows appended to native history exactly once, gated by a monotonic id and
5
+ * per frame, an optional immutable {@link HistoryBatch} (finalized or naturally
6
+ * emitted stable rows, or one complete replay, gated by a monotonic id and
7
7
  * acknowledgement) plus the complete mutable viewport. The writer anchors the
8
8
  * viewport directly below whatever history remains visible, diffs
9
9
  * viewport-only frames, and never infers finality from a row's position.
@@ -109,13 +109,20 @@ export interface ViewportSize {
109
109
  readonly rows: number;
110
110
  }
111
111
 
112
- /** Immutable finalized rows offered until the terminal accepts this identifier. */
112
+ /** Immutable append or complete replay offered until the terminal accepts this identifier. */
113
113
  export interface HistoryBatch {
114
114
  readonly id: number;
115
115
  readonly rows: readonly string[];
116
+ /**
117
+ * `append` (the default) adds finalized or naturally emitted rows. `replay`
118
+ * is the complete logical ledger; the writer bottom-splits it against the
119
+ * leading blank viewport and serializes the remainder plus final viewport in
120
+ * one synchronous terminal write.
121
+ */
122
+ readonly kind?: "append" | "replay";
116
123
  }
117
124
 
118
- /** One history append and the complete mutable viewport for a terminal frame. */
125
+ /** One history append or complete replay plus the mutable viewport for a terminal frame. */
119
126
  export interface TerminalFramePlan {
120
127
  readonly history?: HistoryBatch;
121
128
  readonly viewport: readonly string[];
@@ -128,7 +135,9 @@ export interface TerminalFrameProvider {
128
135
  /** Full semantic viewport used only on the transient resize buffer. */
129
136
  renderResizeFrame?(viewport: ViewportSize): readonly string[];
130
137
  /** Re-offer finalized history after a display reset or resize replay. */
131
- resetHistory?(): void;
138
+ beginHistoryReplay?(): void;
139
+ /** Force every currently eligible finalized prefix to retire before stop. */
140
+ beginHistoryFlush?(): void;
132
141
  }
133
142
 
134
143
  export interface TUIStartOptions {
@@ -640,9 +649,50 @@ export class TUI extends Container {
640
649
  #parkedViewportOffset = 0;
641
650
  // In-flight post-resize anchor probe: the stale viewport snapshot and park
642
651
  // offset captured when CSI 6n was written, plus the no-reply fallback timer.
643
- #resizeProbe: { window: readonly string[]; offset: number; timer: RenderTimer } | undefined;
644
- // Unanswered CSI 6n probes; CPR replies are consumed from input while > 0.
645
- #pendingCprReplies = 0;
652
+ #resizeProbe:
653
+ | {
654
+ window: readonly string[];
655
+ offset: number;
656
+ timer: RenderTimer;
657
+ epoch: number;
658
+ retried: boolean;
659
+ }
660
+ | undefined;
661
+ // Pre-erase viewport snapshot for the settled resize-anchor probe: the erase
662
+ // in #beginResizeAltPaint empties #providerWindow, so the probe must bound
663
+ // the anchor with the window that was actually on screen when the resize
664
+ // began (see #resolveResizeAnchor's `height - staleRows` clamp).
665
+ #resizeProbeWindow: readonly string[] = [];
666
+ #resizeProbeOffset = 0;
667
+ // Direction tracking for the current coalesced resize burst (reset when a
668
+ // plan frame commits, alongside #previousHeight). A burst containing any
669
+ // height grow invalidates the multiplexer clip model in
670
+ // #resolveResizeAnchor: the grow pulls scrollback into the pane and moves
671
+ // the parked logical row, so the net shrink no longer telescopes from
672
+ // pre-burst state.
673
+ #resizeBurstGrew = false;
674
+ #resizeBurstLastHeight: number | undefined;
675
+ // Sum of every grow step in the burst: bounds how much scrollback a
676
+ // multiplexer can have pulled down across the whole burst, including a
677
+ // shrink-then-regrow that never exceeds the pre-burst height (see the
678
+ // CPR-timeout fallback in #resolveResizeAnchor).
679
+ #resizeBurstPull = 0;
680
+ // Geometry epoch: bumped on every resize transaction entry, so each CSI 6n
681
+ // request records the geometry it was parked under.
682
+ #geometryEpoch = 0;
683
+ // CPR attribution: each request parks a distinct column (CHA) before its
684
+ // CSI 6n; the terminal processes requests serially, so every reply carries
685
+ // its own request's column. That makes attribution exact even when replies
686
+ // are dropped or arbitrarily delayed — anonymous FIFO counting cannot
687
+ // survive drops (forgetting retired requests eagerly misattributes late
688
+ // replies, remembering them forever poisons later probes with phantoms,
689
+ // and age expiry is unsound because replies carry no lifetime guarantee).
690
+ // A rewrap can only invalidate a reply's row via a width-change SIGWINCH,
691
+ // which bumps the geometry epoch and discards the reply anyway, so the
692
+ // scheme is sound on direct terminals too. Tags are never expired; a late
693
+ // reply to a dead tag is stripped and discarded by column.
694
+ #cprColumnTags = new Map<number, number>();
695
+ #cprProbeSeq = 0;
646
696
  // Prepared rows painted by the previous provider frame, for row diffing.
647
697
  #providerWindow: string[] = [];
648
698
  #previousFrameLength = 0;
@@ -1004,7 +1054,7 @@ export class TUI extends Container {
1004
1054
  if (this.#resizeProbe) {
1005
1055
  // The anchor being probed is already stale; restart the transaction.
1006
1056
  this.#cancelResizeProbe();
1007
- this.#beginResizeAltPaint();
1057
+ this.#beginResizeAltPaint(true);
1008
1058
  return;
1009
1059
  }
1010
1060
  if (this.#renderScheduler.now() < this.#suppressResizeUntil) {
@@ -1032,12 +1082,23 @@ export class TUI extends Container {
1032
1082
  }
1033
1083
  this.requestRender(true, { clearScrollback: options?.clearScrollback === true });
1034
1084
  }
1035
- /** Borrow the alternate buffer for stable, history-free resize repainting. */
1036
- #beginResizeAltPaint(): void {
1085
+ /**
1086
+ * Borrow the alternate buffer for stable, history-free resize repainting.
1087
+ * `restartingProbe` marks a transaction restarted by a SIGWINCH that
1088
+ * arrived while the settled anchor probe was in flight: the live window
1089
+ * was already stashed and emptied, so the snapshot below must be skipped
1090
+ * to keep the good stash.
1091
+ */
1092
+ #beginResizeAltPaint(restartingProbe = false): void {
1037
1093
  if (this.#altActive) {
1038
1094
  this.requestRender(true);
1039
1095
  return;
1040
1096
  }
1097
+ const burstLastHeight = this.#resizeBurstLastHeight ?? this.#previousHeight;
1098
+ if (this.terminal.rows > burstLastHeight) this.#resizeBurstGrew = true;
1099
+ this.#resizeBurstLastHeight = this.terminal.rows;
1100
+ this.#resizeBurstPull += Math.max(0, this.terminal.rows - burstLastHeight);
1101
+ this.#geometryEpoch++;
1041
1102
  if (!this.#resizeAltActive) {
1042
1103
  this.#resizeAltActive = true;
1043
1104
  setAltScreenActive(true);
@@ -1056,10 +1117,19 @@ export class TUI extends Container {
1056
1117
  // cursor-relative movement lands on the viewport's top row. On height
1057
1118
  // shrink kitty clamps the cursor instead of moving it with pushed rows,
1058
1119
  // so cursor-relative addressing would start rows late; fall back to the
1059
- // same bottom-preserving bound as resize-anchor recovery. The blank top
1060
- // row then anchors the settled CPR probe (empty window means staleRows 0).
1120
+ // same bottom-preserving bound as resize-anchor recovery. The pre-erase
1121
+ // window is stashed for the settled CPR probe: its reflowed row count
1122
+ // bounds the anchor to `height - staleRows`, so a mis-parked cursor (a
1123
+ // single-step tmux zoom re-lays the pane before SIGWINCH delivery,
1124
+ // moving the park target under us) cannot anchor the settled repaint
1125
+ // over pulled-back history rows or scroll-push the frame into
1126
+ // scrollback again.
1061
1127
  let erase = "";
1062
- if (this.#hasEverRendered && this.#providerWindow.length > 0) {
1128
+ if (!restartingProbe) {
1129
+ this.#resizeProbeWindow = this.#providerWindow;
1130
+ this.#resizeProbeOffset = this.#parkedViewportOffset;
1131
+ }
1132
+ if (this.#hasEverRendered && this.#providerWindow.length > 0 && !isInsideTerminalMultiplexer()) {
1063
1133
  if (this.terminal.rows < this.#previousHeight) {
1064
1134
  const staleRows = this.#reflowedRowCount(
1065
1135
  this.#providerWindow,
@@ -1078,6 +1148,22 @@ export class TUI extends Container {
1078
1148
  );
1079
1149
  erase = `\x1b[?25l${up > 0 ? `\x1b[${up}A` : ""}\r\x1b[J`;
1080
1150
  }
1151
+ // Both erase paths leave the cursor on the viewport's top row, so the
1152
+ // parked offset no longer applies; carrying a stale nonzero offset
1153
+ // into the probe would anchor the settled repaint above the real
1154
+ // viewport top and overwrite visible committed rows.
1155
+ this.#resizeProbeOffset = 0;
1156
+ this.#providerWindow = [];
1157
+ this.#parkedViewportOffset = 0;
1158
+ }
1159
+ if (this.#hasEverRendered && this.#providerWindow.length > 0 && isInsideTerminalMultiplexer()) {
1160
+ // Multiplexers apply the pane re-layout on their own schedule relative
1161
+ // to SIGWINCH delivery, so an immediate erase races it: with the pane
1162
+ // already re-laid the stale coordinates blank pulled-back committed
1163
+ // rows (destroying popped scrollback), and with the pane not yet
1164
+ // re-laid the erase lands on rows about to move. Skip it — the
1165
+ // settled repaint overwrites the live region at the clip-model anchor
1166
+ // and erases below it, race-free after the quiet window.
1081
1167
  this.#providerWindow = [];
1082
1168
  this.#parkedViewportOffset = 0;
1083
1169
  }
@@ -1103,14 +1189,60 @@ export class TUI extends Container {
1103
1189
  * trip against the parked cursor reports where the viewport's logical line
1104
1190
  * landed. The settled repaint waits for the reply (or a short timeout).
1105
1191
  */
1106
- #beginResizeAnchorProbe(): void {
1192
+ #beginResizeAnchorProbe(retry = false): void {
1107
1193
  this.#cancelResizeProbe();
1108
1194
  const timer = this.#renderScheduler.scheduleRender(() => {
1195
+ const probe = this.#resizeProbe;
1196
+ if (probe !== undefined && !probe.retried && (isInsideTerminalMultiplexer() || this.#resizeBurstGrew)) {
1197
+ // A CPR-less resolve is heuristic: a grow's pull span is unknown
1198
+ // on any terminal, and SIGWINCH coalescing can hide intermediate
1199
+ // grows entirely, so even an observed-monotonic multiplexer
1200
+ // shrink cannot be modeled with certainty. A dropped DSR reply
1201
+ // is a transient race — multiplexers in particular answer DSR
1202
+ // themselves — so ask once more before falling back.
1203
+ this.#beginResizeAnchorProbe(true);
1204
+ return;
1205
+ }
1109
1206
  this.#resolveResizeAnchor(undefined);
1110
1207
  }, TUI.#RESIZE_PROBE_TIMEOUT_MS);
1111
- this.#resizeProbe = { window: this.#providerWindow, offset: this.#parkedViewportOffset, timer };
1112
- this.#pendingCprReplies++;
1113
- this.terminal.write("\x1b[6n");
1208
+ this.#resizeProbe = {
1209
+ window: this.#resizeProbeWindow,
1210
+ offset: this.#resizeProbeOffset,
1211
+ timer,
1212
+ epoch: this.#geometryEpoch,
1213
+ retried: retry,
1214
+ };
1215
+ // Tags are never expired by age: a reply has no lifetime guarantee, and
1216
+ // freeing a column while its reply may still arrive would let that
1217
+ // reply match a newer tag on the reused column. Dead tags only
1218
+ // accumulate from genuinely dropped replies; a terminal that drops
1219
+ // enough of them to exhaust the span earns the timeout-only fallback.
1220
+ // Park a distinct column for this request so its reply is
1221
+ // self-identifying, then return the cursor to column 1 immediately:
1222
+ // the reply snapshots the column when the terminal processes the CSI
1223
+ // 6n, but a cursor RESTING on a nonzero column would reflow onto a
1224
+ // later visual row if a direct terminal's width later shrank below it,
1225
+ // corrupting the next probe's cursor-relative math. Columns 1-16 are
1226
+ // never used as tags: column 1 cannot be told apart from a spurious or
1227
+ // clamped reply, and modified F3 keys encode as CSI 1;<mod>R with
1228
+ // modifier codes 2-16, which is byte-identical to a CPR for row 1 on
1229
+ // those columns. A column may not be reused while its tag is live —
1230
+ // the old request's delayed reply would be attributed to the new
1231
+ // epoch — so scan for a free slot.
1232
+ const span = Math.min(30, this.terminal.columns - 16);
1233
+ if (span >= 4) {
1234
+ for (let index = 0; index < span; index++) {
1235
+ const candidate = 17 + ((this.#cprProbeSeq + index) % span);
1236
+ if (this.#cprColumnTags.has(candidate)) continue;
1237
+ this.#cprProbeSeq += index + 1;
1238
+ this.#cprColumnTags.set(candidate, this.#geometryEpoch);
1239
+ this.terminal.write(`\x1b[${candidate}G\x1b[6n\x1b[1G`);
1240
+ return;
1241
+ }
1242
+ }
1243
+ // Degenerate span or full occupancy: an untagged reply could never be
1244
+ // attributed, so no DSR is sent at all; the timeout anchors
1245
+ // conservatively on its own.
1114
1246
  }
1115
1247
 
1116
1248
  #cancelResizeProbe(): void {
@@ -1121,19 +1253,25 @@ export class TUI extends Container {
1121
1253
 
1122
1254
  /**
1123
1255
  * Anchor the settled post-resize repaint. `reportedRow` is the 0-based CPR
1124
- * row of the parked cursor (undefined = probe timed out). The viewport top
1125
- * is `min(reported - parkOffset, height - staleRows)`: terminals track the
1256
+ * row of the parked cursor (undefined = probe timed out). Direct terminals
1257
+ * use `min(reported - parkOffset, height - staleRows)`: they track the
1126
1258
  * cursor exactly through width rewrap, and the second bound reconstructs
1127
- * height-shrink scrollback pushes that leave the cursor behind (kitty clamps
1128
- * the cursor instead of scrolling it) — bottom-preserving resize guarantees
1129
- * the stale viewport ends on the last screen row whenever a push happened.
1130
- * Validated against kitty's real core in resize-anchor-recovery.test.ts.
1259
+ * height-shrink scrollback pushes that leave the cursor behind (kitty
1260
+ * clamps the cursor instead of scrolling it) — bottom-preserving resize
1261
+ * guarantees the stale viewport ends on the last screen row whenever a
1262
+ * push happened; validated against kitty's real core in
1263
+ * resize-anchor-recovery.test.ts. Multiplexers clip on height changes
1264
+ * (though they reflow on width changes), so that bound never applies:
1265
+ * monotonic shrinks use the deterministic clip model below, everything
1266
+ * else trusts the CPR directly.
1131
1267
  */
1132
1268
  #resolveResizeAnchor(reportedRow: number | undefined): void {
1133
1269
  const probe = this.#resizeProbe;
1134
1270
  if (!probe) return;
1135
1271
  probe.timer.cancel();
1136
1272
  this.#resizeProbe = undefined;
1273
+ // Column tags stay live across resolves: their replies are
1274
+ // self-identifying and discarded by tag whenever they arrive.
1137
1275
  const width = this.terminal.columns;
1138
1276
  const height = this.terminal.rows;
1139
1277
  const staleRows = this.#reflowedRowCount(probe.window, 0, probe.window.length, width);
@@ -1141,7 +1279,58 @@ export class TUI extends Container {
1141
1279
  reportedRow === undefined
1142
1280
  ? this.#providerViewportTop
1143
1281
  : reportedRow - this.#reflowedRowCount(probe.window, 0, probe.offset, width);
1144
- const top = Math.max(0, Math.min(reportedTop, height - staleRows));
1282
+ let top: number;
1283
+ if (isInsideTerminalMultiplexer()) {
1284
+ if (reportedRow !== undefined) {
1285
+ // The parked cursor's reply is exact under multiplexer clipping:
1286
+ // discards leave the cursor in place, pushes only occur after
1287
+ // everything below it is discarded (the bottom row IS the
1288
+ // attached position), and grow pull-down rides it down. It
1289
+ // therefore also reflects intermediate geometries that SIGWINCH
1290
+ // coalescing hid from the burst tracker, and always outranks the
1291
+ // clip model. The `height - staleRows` bound must NOT apply here:
1292
+ // it encodes bottom-preserving rewrap, but a multiplexer shrink
1293
+ // may have discarded stale rows below the cursor instead of
1294
+ // pushing the top ones. Frame-size clamping happens when the
1295
+ // settled plan frame is emitted.
1296
+ top = Math.max(0, reportedTop);
1297
+ } else if (height < this.#previousHeight && !this.#resizeBurstGrew) {
1298
+ // Last resort after the retry: model the clip deterministically
1299
+ // from the saved parked cursor. Rows strictly below the cursor
1300
+ // are discarded first (even non-blank ones — measured against
1301
+ // real tmux), and only the remainder of the shrink pushes top
1302
+ // rows into scrollback; across an observed burst the totals
1303
+ // telescope from pre-burst state. SIGWINCH coalescing can hide a
1304
+ // grow from this model, which is why a reply always wins above.
1305
+ const parkedRow = this.#providerViewportTop + this.#reflowedRowCount(probe.window, 0, probe.offset, width);
1306
+ const shrink = this.#previousHeight - height;
1307
+ const discardedBelow = Math.min(shrink, Math.max(0, this.#previousHeight - 1 - parkedRow));
1308
+ const pushed = Math.max(0, shrink - discardedBelow);
1309
+ top = Math.max(0, this.#providerViewportTop - pushed);
1310
+ } else {
1311
+ // CPR-less grow or reversed burst: the pre-resize top is
1312
+ // stale-low, every grow step already pulled scrollback down.
1313
+ // Anchor at the conservative upper bound — pull never exceeds
1314
+ // the burst's accumulated growth, and pushes/discards only lower
1315
+ // the top. Exact when scrollback covers the pull; when it does
1316
+ // not, the repaint lands below the real viewport and leaves
1317
+ // stale rows above rather than overwriting committed ones.
1318
+ top = Math.max(0, this.#providerViewportTop + this.#resizeBurstPull);
1319
+ }
1320
+ } else {
1321
+ // Direct terminals rewrap bottom-preserving: with `staleRows` stale
1322
+ // rows on screen the viewport top cannot exceed `height - staleRows`
1323
+ // whenever a push happened, so the bound reconstructs height-shrink
1324
+ // pushes that leave the cursor behind (kitty clamps the cursor
1325
+ // instead of scrolling it). A CPR-less grow is stale-low like the
1326
+ // multiplexer case — grow pull-down moved the real viewport — so it
1327
+ // anchors at the accumulated pull bound, still under the clamp.
1328
+ const fallbackTop =
1329
+ reportedRow === undefined && this.#resizeBurstGrew
1330
+ ? this.#providerViewportTop + this.#resizeBurstPull
1331
+ : reportedTop;
1332
+ top = Math.max(0, Math.min(fallbackTop, height - staleRows));
1333
+ }
1145
1334
  if ($flag("PI_DEBUG_REDRAW")) {
1146
1335
  const msg = `[${new Date().toISOString()}] resize anchor: size=${width}x${height} cpr=${reportedRow ?? "timeout"} park=${probe.offset} stale=${staleRows} old=${this.#providerViewportTop} top=${top}\n`;
1147
1336
  fs.appendFileSync(getDebugLogPath(), msg);
@@ -1152,14 +1341,17 @@ export class TUI extends Container {
1152
1341
  }
1153
1342
 
1154
1343
  /**
1155
- * Rows `[start, end)` of a previously painted window re-measured at `width`.
1156
- * Direct terminals rewrap on resize (a row spans ceil(cells/width) rows);
1157
- * multiplexers clip in place, so every row still spans exactly one row.
1344
+ * Rows `[start, end)` of a previously painted window re-measured at
1345
+ * `width`. Every terminal rewraps content on a width change including
1346
+ * multiplexers: tmux clips in place on height changes only, and reflows
1347
+ * the pane (scrollback included) when the width moves, so a row painted
1348
+ * wider than the current width spans ceil(cells/width) physical rows
1349
+ * everywhere. For height-only resizes the painted rows already fit the
1350
+ * width and the count is unchanged.
1158
1351
  */
1159
1352
  #reflowedRowCount(window: readonly string[], start: number, end: number, width: number): number {
1160
1353
  const stop = Math.min(end, window.length);
1161
1354
  const from = Math.max(0, start);
1162
- if (isInsideTerminalMultiplexer()) return Math.max(0, stop - from);
1163
1355
  let rows = 0;
1164
1356
  for (let index = from; index < stop; index++) {
1165
1357
  rows += Math.max(1, Math.ceil(visibleWidth(window[index]!) / Math.max(1, width)));
@@ -1340,6 +1532,28 @@ export class TUI extends Container {
1340
1532
  this.#paintEndSequence = enabled ? PAINT_END : PAINT_END_NO_SYNC;
1341
1533
  }
1342
1534
 
1535
+ #flushHistoryBeforeStop(): void {
1536
+ const provider = this.#frameProvider;
1537
+ if (provider?.beginHistoryFlush === undefined) return;
1538
+ const width = this.terminal.columns;
1539
+ const height = this.terminal.rows;
1540
+ if (width <= 0 || height <= 0) return;
1541
+ provider.beginHistoryFlush();
1542
+ while (true) {
1543
+ this.#imageBudget.beginPass();
1544
+ const plan = provider.renderFrame({ columns: width, rows: height });
1545
+ this.#imageBudget.endPass();
1546
+ if (plan.history === undefined) return;
1547
+ let viewport = Array.from(plan.viewport);
1548
+ if (viewport.length > height) viewport = viewport.slice(0, height);
1549
+ const acceptedBefore = this.#acceptedHistoryBatchId;
1550
+ this.#emitPlanFrame(width, height, viewport, plan.history, provider);
1551
+ if (plan.history.id > acceptedBefore && this.#acceptedHistoryBatchId === acceptedBefore) {
1552
+ throw new Error("History flush did not accept the offered batch");
1553
+ }
1554
+ }
1555
+ }
1556
+
1343
1557
  stop(): void {
1344
1558
  this.#resizeSettleTimer?.cancel();
1345
1559
  this.#resizeSettleTimer = undefined;
@@ -1359,6 +1573,12 @@ export class TUI extends Container {
1359
1573
  this.#altPreviousLines = [];
1360
1574
  this.#pendingAltExit = "";
1361
1575
  }
1576
+ // A latched destructive reset (settled rebuild-mode resize, /clear) pairs
1577
+ // ED3 with a complete-ledger replay. Running that pair during stop would
1578
+ // erase native history and re-stream the whole transcript at quit; drop
1579
+ // the latch so the flush below writes only un-retired rows.
1580
+ this.#clearScrollbackOnNextRender = false;
1581
+ this.#flushHistoryBeforeStop();
1362
1582
  // Deliberately leave transmitted images in the terminal's graphics store:
1363
1583
  // placeholder cells committed to native scrollback render only while their
1364
1584
  // image data lives, so a delete-by-id here blanks every transcript image
@@ -1492,7 +1712,7 @@ export class TUI extends Container {
1492
1712
  }
1493
1713
  #prepareForcedRender(clearScrollback: boolean): void {
1494
1714
  if (clearScrollback && !this.#clearScrollbackOnNextRender) {
1495
- this.#frameProvider?.resetHistory?.();
1715
+ this.#frameProvider?.beginHistoryReplay?.();
1496
1716
  if (TERMINAL.imageProtocol === ImageProtocol.Kitty) this.#imageBudget.forgetTransmitted();
1497
1717
  }
1498
1718
  this.#clearScrollbackOnNextRender ||= clearScrollback;
@@ -1568,12 +1788,39 @@ export class TUI extends Container {
1568
1788
  // Consume CPR replies (CSI row;col R) while an anchor probe is unanswered;
1569
1789
  // they are terminal reports, never keystrokes, and must not reach the
1570
1790
  // focused component.
1571
- while (this.#pendingCprReplies > 0) {
1572
- const match = data.match(/\x1b\[(\d+);(\d+)R/);
1791
+ let searchFrom = 0;
1792
+ while (this.#cprColumnTags.size > 0) {
1793
+ const match = data.slice(searchFrom).match(/\x1b\[(\d+);(\d+)R/);
1573
1794
  if (!match || match.index === undefined) break;
1574
- this.#pendingCprReplies--;
1575
- this.#resolveResizeAnchor(Number(match[1]) - 1);
1576
- data = data.slice(0, match.index) + data.slice(match.index + match[0].length);
1795
+ const row = Number(match[1]);
1796
+ const column = Number(match[2]);
1797
+ if (!this.#cprColumnTags.has(column) && row === 1 && column >= 2) {
1798
+ // CSI 1;<mod>R with no live tag is modified F3: the modifier
1799
+ // parameter spans 2-256 once the lock-state bits (caps 64,
1800
+ // num 128) and hyper/meta are included, so no practical tag
1801
+ // range escapes it entirely. Anything row-1 we did not tag is
1802
+ // treated as a keystroke and left for the focused component;
1803
+ // a tagged column hit by a hyper/meta-modified F3 (params
1804
+ // 17-64, practically unused) is still gated by the epoch check
1805
+ // below.
1806
+ searchFrom += match.index + match[0].length;
1807
+ continue;
1808
+ }
1809
+ if (this.#cprColumnTags.has(column)) {
1810
+ // Column-tagged reply: exact attribution. Resolve only when its
1811
+ // request was parked under the active probe's geometry; a reply
1812
+ // from an older epoch is stale and discarded.
1813
+ const tagEpoch = this.#cprColumnTags.get(column);
1814
+ this.#cprColumnTags.delete(column);
1815
+ const probe = this.#resizeProbe;
1816
+ if (probe !== undefined && tagEpoch === probe.epoch) {
1817
+ this.#resolveResizeAnchor(Number(match[1]) - 1);
1818
+ }
1819
+ }
1820
+ // Other unknown-column replies while expecting tagged ones are our
1821
+ // requests answered with a clamped or mangled column: strip and
1822
+ // discard; the probe timeout covers recovery.
1823
+ data = data.slice(0, searchFrom + match.index) + data.slice(searchFrom + match.index + match[0].length);
1577
1824
  }
1578
1825
  if (data.length === 0) return;
1579
1826
  // Ctrl+C/Esc use app-level double-press windows. Give those gestures one
@@ -1983,7 +2230,7 @@ export class TUI extends Container {
1983
2230
  return;
1984
2231
  }
1985
2232
  const provider = this.#frameProvider;
1986
- if (!provider?.resetHistory) return;
2233
+ if (!provider?.beginHistoryReplay) return;
1987
2234
  this.#resizeReplaySize = size;
1988
2235
  if (this.#clearScrollbackOnNextRender) {
1989
2236
  this.#forceViewportRepaintOnNextRender = true;
@@ -1993,15 +2240,14 @@ export class TUI extends Container {
1993
2240
  this.#prepareForcedRender(true);
1994
2241
  return;
1995
2242
  }
1996
- provider.resetHistory();
2243
+ provider.beginHistoryReplay();
1997
2244
  this.#forceViewportRepaintOnNextRender = true;
1998
2245
  }
1999
2246
 
2000
2247
  /**
2001
- * Physical write transaction: append an unacknowledged history batch above
2002
- * the anchored mutable viewport, repaint the viewport, and erase stale rows
2003
- * below. Appending K rows scrolls at most K rows off the top — the oldest
2004
- * visible history — and the viewport anchor follows the retained history.
2248
+ * Physical write transaction: append an ordinary batch, or bottom-split one
2249
+ * complete replay into a history remainder and final viewport, then serialize
2250
+ * the whole result in one terminal write before acknowledgement.
2005
2251
  */
2006
2252
  #emitPlanFrame(
2007
2253
  width: number,
@@ -2015,11 +2261,28 @@ export class TUI extends Container {
2015
2261
  while (viewport.length < height) viewport.push("");
2016
2262
  viewport = this.#compositeOverlaysIntoWindow(viewport, width, height);
2017
2263
  }
2018
- const markers = this.#extractCursorMarkers(viewport);
2019
- const prepared = this.#prepareLinesArray(viewport, width);
2020
2264
  const history = offered !== undefined && offered.id > this.#acceptedHistoryBatchId ? offered : undefined;
2021
2265
  if (offered !== undefined && offered.id <= this.#acceptedHistoryBatchId) provider?.acknowledgeHistory(offered.id);
2022
- const historyRows = history?.rows ?? [];
2266
+
2267
+ let historyRows = history?.rows ?? [];
2268
+ let replayViewportRows = 0;
2269
+ if (history?.kind === "replay") {
2270
+ // Providers may omit unused leading rows from a short viewport. Make
2271
+ // that logical space explicit before the bottom-first replay split.
2272
+ while (viewport.length < height) viewport.unshift("");
2273
+ let leadingBlankRows = 0;
2274
+ while (leadingBlankRows < viewport.length && !/\S/.test(viewport[leadingBlankRows]!)) {
2275
+ leadingBlankRows++;
2276
+ }
2277
+ const moved = Math.min(historyRows.length, leadingBlankRows);
2278
+ if (moved > 0) {
2279
+ viewport = [...historyRows.slice(historyRows.length - moved), ...viewport.slice(moved)];
2280
+ historyRows = historyRows.slice(0, historyRows.length - moved);
2281
+ replayViewportRows = moved;
2282
+ }
2283
+ }
2284
+ const markers = this.#extractCursorMarkers(viewport);
2285
+ const prepared = this.#prepareLinesArray(viewport, width);
2023
2286
  const preparedHistory = this.#prepareLinesArray(historyRows, width);
2024
2287
  const rows = prepared.length;
2025
2288
  // Destructive reset (session replace, /tree, explicit clear, or a settled
@@ -2051,7 +2314,14 @@ export class TUI extends Container {
2051
2314
  } else {
2052
2315
  this.#imageBudget.takePurgeIds();
2053
2316
  }
2054
- if (destructiveReset) buffer += "\x1b[H\x1b[3J\x1b[2J";
2317
+ // ED2 MUST precede ED3: tmux implements ED2 by scrolling the live screen
2318
+ // into pane history (so cleared content stays reachable), so erasing
2319
+ // history first would let ED2 refill it with a copy of the old screen —
2320
+ // which the replay then repaints, duplicating one full frame per reset.
2321
+ // ED2-then-ED3 clears the screen, then wipes history including that
2322
+ // push. On xterm-family terminals the two erases are independent and
2323
+ // the order is irrelevant.
2324
+ if (destructiveReset) buffer += "\x1b[H\x1b[2J\x1b[3J";
2055
2325
  const diffable =
2056
2326
  geometryStable &&
2057
2327
  historyRows.length === 0 &&
@@ -2112,6 +2382,8 @@ export class TUI extends Container {
2112
2382
  }
2113
2383
  if (newTop + rows < height) buffer += `\x1b[${newTop + rows + 1};1H\x1b[J`;
2114
2384
  }
2385
+ const mutableTop = newTop + replayViewportRows;
2386
+ const mutablePrepared = replayViewportRows > 0 ? prepared.slice(replayViewportRows) : prepared;
2115
2387
  const marker = markers[0];
2116
2388
  const target =
2117
2389
  marker !== undefined && rows > 0
@@ -2119,23 +2391,26 @@ export class TUI extends Container {
2119
2391
  : null;
2120
2392
  if (target) {
2121
2393
  buffer += `\x1b[${target.row + 1};${target.col + 1}H${target.visible ? "\x1b[?25h" : "\x1b[?25l"}`;
2122
- this.#parkedViewportOffset = Math.max(0, target.row - newTop);
2394
+ this.#parkedViewportOffset = Math.max(0, target.row - mutableTop);
2123
2395
  } else {
2124
2396
  // Park the hidden cursor on the viewport's top row: terminals keep the
2125
2397
  // cursor attached to its logical line through resize reflow, so the
2126
2398
  // post-resize anchor probe can recover where the viewport landed.
2127
- buffer += `\x1b[?25l\x1b[${newTop + 1};1H`;
2399
+ buffer += `\x1b[?25l\x1b[${mutableTop + 1};1H`;
2128
2400
  this.#parkedViewportOffset = 0;
2129
2401
  }
2130
2402
  buffer += this.#paintEndSequence;
2131
2403
  this.terminal.write(buffer);
2132
2404
  if (target) this.#recordHardwareCursorState(target);
2133
2405
  else this.#recordHardwareCursorHidden();
2134
- this.#providerWindow = prepared;
2135
- this.#providerViewportTop = newTop;
2406
+ this.#providerWindow = mutablePrepared;
2407
+ this.#providerViewportTop = mutableTop;
2136
2408
  this.#previousWidth = width;
2137
2409
  this.#previousHeight = height;
2138
- this.#previousFrameLength = rows;
2410
+ this.#resizeBurstGrew = false;
2411
+ this.#resizeBurstLastHeight = undefined;
2412
+ this.#resizeBurstPull = 0;
2413
+ this.#previousFrameLength = mutablePrepared.length;
2139
2414
  this.#clearScrollbackOnNextRender = false;
2140
2415
  this.#forceViewportRepaintOnNextRender = false;
2141
2416
  this.#hasEverRendered = true;
@@ -2143,10 +2418,9 @@ export class TUI extends Container {
2143
2418
  if (history !== undefined) {
2144
2419
  this.#acceptedHistoryBatchId = history.id;
2145
2420
  provider?.acknowledgeHistory(history.id);
2146
- // Draining is one batch per frame; the provider may hold further
2147
- // finalized prefixes (large resumed transcripts). Pump the next frame
2148
- // instead of waiting for an unrelated render request.
2149
- this.requestRender();
2421
+ // Normal retirement may hold another ordered batch. Replay is always
2422
+ // complete, so pumping it would create a second visible redraw/write.
2423
+ if (history.kind !== "replay") this.requestRender();
2150
2424
  }
2151
2425
  }
2152
2426