@oh-my-pi/pi-tui 16.5.2 → 17.0.1

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.
@@ -4,7 +4,7 @@ import { latexToBlock } from "../latex-block";
4
4
  import { inlineMathSpanEnd, isBareMathEnvironment, latexToUnicode } from "../latex-to-unicode";
5
5
  import type { SymbolTheme } from "../symbols";
6
6
  import { TERMINAL } from "../terminal-capabilities";
7
- import type { Component } from "../tui";
7
+ import type { Component, NativeScrollbackCommittedRows, NativeScrollbackReplay } from "../tui";
8
8
  import {
9
9
  applyBackgroundToLine,
10
10
  Ellipsis,
@@ -607,11 +607,17 @@ const RENDER_CACHE_MAX = 256; // sane cap: ~256 distinct message × width combos
607
607
  const RENDER_CACHE_MAX_SIZE = 512 * 1024;
608
608
  const RENDER_CACHE_MAX_ENTRY_SIZE = 32 * 1024;
609
609
  const EMPTY_RENDER_LINES: readonly string[] = [];
610
- const renderCache = new LRUCache<string, readonly string[]>({
610
+
611
+ interface RenderCacheEntry {
612
+ lines: readonly string[];
613
+ tables: readonly RenderedTableLayout[];
614
+ }
615
+
616
+ const renderCache = new LRUCache<string, RenderCacheEntry>({
611
617
  max: RENDER_CACHE_MAX,
612
618
  maxSize: RENDER_CACHE_MAX_SIZE,
613
619
  maxEntrySize: RENDER_CACHE_MAX_ENTRY_SIZE,
614
- sizeCalculation: renderedLinesCacheSize,
620
+ sizeCalculation: renderCacheEntrySize,
615
621
  });
616
622
 
617
623
  function renderedLinesCacheSize(lines: readonly string[]): number {
@@ -620,6 +626,12 @@ function renderedLinesCacheSize(lines: readonly string[]): number {
620
626
  return Math.max(1, size);
621
627
  }
622
628
 
629
+ function renderCacheEntrySize(entry: RenderCacheEntry): number {
630
+ let size = renderedLinesCacheSize(entry.lines);
631
+ for (const table of entry.tables) size += table.key.length + table.columnWidths.length + 4;
632
+ return size;
633
+ }
634
+
623
635
  // A reference-link definition (`[label]: dest`) resolves across the whole
624
636
  // document, so a split lex cannot reproduce it — disable the streaming fast path
625
637
  // when one is present (rare in streamed output). The label may contain
@@ -951,6 +963,7 @@ interface StreamPrefixLineCache extends RenderSignature {
951
963
  text: string;
952
964
  tokenCount: number;
953
965
  lines: readonly string[];
966
+ tables: readonly TableRenderSpec[];
954
967
  }
955
968
  interface StreamingDiffLineCache extends RenderSignature {
956
969
  lang: string | undefined;
@@ -958,7 +971,25 @@ interface StreamingDiffLineCache extends RenderSignature {
958
971
  lines: readonly string[];
959
972
  }
960
973
 
961
- export class Markdown implements Component {
974
+ interface TableLayoutLock {
975
+ availableWidth: number;
976
+ columnWidths: readonly number[];
977
+ }
978
+
979
+ interface TableRenderSpec extends TableLayoutLock {
980
+ key: string;
981
+ lineCount: number;
982
+ startRow: number;
983
+ endRow: number;
984
+ }
985
+
986
+ interface RenderedTableLayout extends TableLayoutLock {
987
+ key: string;
988
+ startRow: number;
989
+ endRow: number;
990
+ }
991
+
992
+ export class Markdown implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay {
962
993
  #text: string;
963
994
  #paddingX: number; // Left/right padding
964
995
  #paddingY: number; // Top/bottom padding
@@ -1005,10 +1036,19 @@ export class Markdown implements Component {
1005
1036
  #renderingFrozenPrefix = false;
1006
1037
  #streamingDiffLineCache?: StreamingDiffLineCache;
1007
1038
  #activeRenderSignature?: RenderSignature;
1039
+ // Streaming tables may grow naturally while wholly repaintable. Once any
1040
+ // physical row of a table enters native scrollback, its current column widths
1041
+ // are locked for the rest of this append-only text lineage: future wider cells
1042
+ // wrap inside those columns instead of reflowing immutable history above.
1043
+ #tableLayoutWidth?: number;
1044
+ #lockedTableLayouts = new Map<string, TableLayoutLock>();
1045
+ #lastRenderedTableLayouts: RenderedTableLayout[] = [];
1046
+ #activeTableRenderSpecs?: TableRenderSpec[];
1008
1047
 
1009
1048
  #ignoreTight = false;
1010
1049
 
1011
1050
  setIgnoreTight(ignore: boolean): this {
1051
+ if (this.#ignoreTight !== ignore) this.#clearTableLayouts();
1012
1052
  this.#ignoreTight = ignore;
1013
1053
  this.invalidate();
1014
1054
  return this;
@@ -1037,6 +1077,7 @@ export class Markdown implements Component {
1037
1077
  // full lex + wrap runs per re-emit — one of the top CPU hotspots during
1038
1078
  // streaming (issue #4353). Mirrors `Text.setText`'s guard.
1039
1079
  if (text === this.#text) return false;
1080
+ if (!text.startsWith(this.#text)) this.#clearTableLayouts();
1040
1081
  this.#text = text;
1041
1082
  if (!text.trim()) {
1042
1083
  // Blank replacement: render() early-returns before #lexTokens can see
@@ -1080,6 +1121,41 @@ export class Markdown implements Component {
1080
1121
  return this.#lastRenderSettledRows;
1081
1122
  }
1082
1123
 
1124
+ /**
1125
+ * Freeze every table whose first physical row is already part of the native
1126
+ * scrollback prefix. The recorded widths came from the exact frame that was
1127
+ * just emitted, so the next streamed delta cannot retroactively widen it.
1128
+ */
1129
+ setNativeScrollbackCommittedRows(rows: number): void {
1130
+ const committed = Number.isFinite(rows) ? Math.max(0, Math.trunc(rows)) : 0;
1131
+ let changed = false;
1132
+ for (const table of this.#lastRenderedTableLayouts) {
1133
+ if (table.startRow >= committed || this.#lockedTableLayouts.has(table.key)) continue;
1134
+ this.#lockedTableLayouts.set(table.key, {
1135
+ availableWidth: table.availableWidth,
1136
+ columnWidths: table.columnWidths.slice(),
1137
+ });
1138
+ changed = true;
1139
+ }
1140
+ if (changed) this.invalidate();
1141
+ }
1142
+
1143
+ /** A destructive replay removes the immutable tape this layout was guarding. */
1144
+ prepareNativeScrollbackReplay(): void {
1145
+ this.#clearTableLayouts();
1146
+ this.#tableLayoutWidth = undefined;
1147
+ this.invalidate();
1148
+ }
1149
+
1150
+ #clearTableLayouts(): void {
1151
+ this.#lockedTableLayouts.clear();
1152
+ this.#lastRenderedTableLayouts = [];
1153
+ this.#activeTableRenderSpecs = undefined;
1154
+ // Same-width replay/non-append rewrites could otherwise reuse physical
1155
+ // prefix lines rendered with the retired locked widths.
1156
+ this.#streamPrefixLineCache = undefined;
1157
+ }
1158
+
1083
1159
  // Lex `text` into block tokens, reusing the frozen stable prefix when the text
1084
1160
  // only grew (the streaming path). Falls back to a full lex whenever the prefix
1085
1161
  // is no longer a prefix (non-append edit), the text carries reference-link
@@ -1159,6 +1235,11 @@ export class Markdown implements Component {
1159
1235
  }
1160
1236
 
1161
1237
  render(width: number): readonly string[] {
1238
+ if (this.#tableLayoutWidth !== undefined && this.#tableLayoutWidth !== width) {
1239
+ this.#clearTableLayouts();
1240
+ this.invalidate();
1241
+ }
1242
+ this.#tableLayoutWidth = width;
1162
1243
  // L1: per-instance cache — fastest path for repeated renders of the same
1163
1244
  // instance at the same width (e.g. resize debounce, repeated redraws).
1164
1245
  // Returning the cached reference is load-bearing: parents memoize their
@@ -1200,29 +1281,40 @@ export class Markdown implements Component {
1200
1281
  // theme.heading is used as the representative theme probe — it's required
1201
1282
  // by MarkdownTheme and is one of the most styling-sensitive entries.
1202
1283
  let cacheKey: string | undefined;
1203
- if (!this.transientRenderCache) {
1284
+ if (!this.transientRenderCache && this.#lockedTableLayouts.size === 0) {
1204
1285
  cacheKey = this.#renderCacheKey(normalizedText, signature);
1205
1286
  const cached = renderCache.get(cacheKey);
1206
1287
  if (cached !== undefined) {
1288
+ // Restore both the rendered rows and the geometry metadata that produced
1289
+ // them. A later scrollback publication must never lock widths from an
1290
+ // older transient frame against rows served from this cache entry.
1291
+ this.#lastRenderedTableLayouts = cached.tables.map(table => ({
1292
+ ...table,
1293
+ columnWidths: table.columnWidths.slice(),
1294
+ }));
1207
1295
  // Populate L1 so subsequent calls from this instance are O(1) map lookup.
1208
1296
  this.#cachedText = this.#text;
1209
1297
  this.#cachedWidth = width;
1210
- this.#cachedLines = cached;
1211
- return cached;
1298
+ this.#cachedLines = cached.lines;
1299
+ return cached.lines;
1212
1300
  }
1213
1301
  }
1214
1302
 
1215
1303
  // Parse markdown to HTML-like tokens
1216
1304
  const tokens = this.#lexTokens(normalizedText);
1217
1305
  let contentLines: string[];
1306
+ const tableRenderSpecs: TableRenderSpec[] = [];
1307
+ this.#activeTableRenderSpecs = tableRenderSpecs;
1218
1308
  this.#activeRenderSignature = signature;
1219
1309
  try {
1220
1310
  contentLines = this.transientRenderCache
1221
1311
  ? this.#renderStreamingContentLines(tokens, normalizedText, signature, contentWidth)
1222
- : this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature);
1312
+ : this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature, 0, 0);
1223
1313
  } finally {
1224
1314
  this.#activeRenderSignature = undefined;
1315
+ this.#activeTableRenderSpecs = undefined;
1225
1316
  }
1317
+ this.#lastRenderedTableLayouts = this.#resolveRenderedTableLayouts(tableRenderSpecs, signature.paddingY);
1226
1318
  const emptyLines = this.#renderEmptyPaddingLines(signature);
1227
1319
 
1228
1320
  // Combine top padding, content, and bottom padding
@@ -1239,7 +1331,13 @@ export class Markdown implements Component {
1239
1331
  // Update L2 module-level LRU so future instances with the same key skip
1240
1332
  // the marked.lexer + highlightCode (Rust FFI) work entirely.
1241
1333
  if (cacheKey !== undefined) {
1242
- renderCache.set(cacheKey, result);
1334
+ renderCache.set(cacheKey, {
1335
+ lines: result,
1336
+ tables: this.#lastRenderedTableLayouts.map(table => ({
1337
+ ...table,
1338
+ columnWidths: table.columnWidths.slice(),
1339
+ })),
1340
+ });
1243
1341
  }
1244
1342
 
1245
1343
  return result;
@@ -1276,15 +1374,18 @@ export class Markdown implements Component {
1276
1374
  const frozenText = this.#streamPrefixText;
1277
1375
  const frozenTokenCount = this.#streamPrefixTokens?.length ?? 0;
1278
1376
  if (frozenText === undefined || frozenTokenCount === 0 || !normalizedText.startsWith(frozenText)) {
1279
- return this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature);
1377
+ return this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature, 0, 0);
1280
1378
  }
1281
1379
 
1282
1380
  const contentLines: string[] = [];
1283
1381
  const reusablePrefix = this.#matchingStreamPrefixLineCache(normalizedText, frozenText, signature);
1284
1382
  let renderedUntil = 0;
1383
+ let renderedSourceOffset = 0;
1285
1384
  if (reusablePrefix && reusablePrefix.tokenCount <= frozenTokenCount) {
1286
1385
  contentLines.push(...reusablePrefix.lines);
1386
+ this.#activeTableRenderSpecs?.push(...reusablePrefix.tables);
1287
1387
  renderedUntil = reusablePrefix.tokenCount;
1388
+ renderedSourceOffset = reusablePrefix.text.length;
1288
1389
  }
1289
1390
 
1290
1391
  if (renderedUntil < frozenTokenCount) {
@@ -1293,7 +1394,15 @@ export class Markdown implements Component {
1293
1394
  this.#renderingFrozenPrefix = true;
1294
1395
  try {
1295
1396
  contentLines.push(
1296
- ...this.#renderContentLines(tokens, renderedUntil, frozenTokenCount, contentWidth, signature),
1397
+ ...this.#renderContentLines(
1398
+ tokens,
1399
+ renderedUntil,
1400
+ frozenTokenCount,
1401
+ contentWidth,
1402
+ signature,
1403
+ contentLines.length,
1404
+ renderedSourceOffset,
1405
+ ),
1297
1406
  );
1298
1407
  } finally {
1299
1408
  this.#renderingFrozenPrefix = false;
@@ -1306,6 +1415,7 @@ export class Markdown implements Component {
1306
1415
  text: frozenText,
1307
1416
  tokenCount: frozenTokenCount,
1308
1417
  lines: contentLines.slice(),
1418
+ tables: this.#activeTableRenderSpecs?.slice() ?? [],
1309
1419
  };
1310
1420
 
1311
1421
  // Settled exposure (hard-monotone): these rows are declared final to
@@ -1322,7 +1432,17 @@ export class Markdown implements Component {
1322
1432
  }
1323
1433
 
1324
1434
  if (renderedUntil < tokens.length) {
1325
- contentLines.push(...this.#renderContentLines(tokens, renderedUntil, tokens.length, contentWidth, signature));
1435
+ contentLines.push(
1436
+ ...this.#renderContentLines(
1437
+ tokens,
1438
+ renderedUntil,
1439
+ tokens.length,
1440
+ contentWidth,
1441
+ signature,
1442
+ contentLines.length,
1443
+ frozenText.length,
1444
+ ),
1445
+ );
1326
1446
  }
1327
1447
 
1328
1448
  return contentLines;
@@ -1356,23 +1476,57 @@ export class Markdown implements Component {
1356
1476
  end: number,
1357
1477
  contentWidth: number,
1358
1478
  signature: RenderSignature,
1479
+ rowOffset: number,
1480
+ startingSourceOffset: number,
1359
1481
  ): string[] {
1360
- const renderedLines: string[] = [];
1482
+ const wrappedLines: string[] = [];
1483
+ let sourceOffset = startingSourceOffset;
1361
1484
  for (let i = start; i < end; i++) {
1362
1485
  const token = tokens[i];
1363
1486
  const nextToken = tokens[i + 1];
1364
- renderedLines.push(...this.#renderToken(token, contentWidth, nextToken?.type));
1365
- }
1366
-
1367
- const wrappedLines: string[] = [];
1368
- for (const line of renderedLines) {
1369
- // Skip wrapping for image protocol lines and OSC 66 sized headings
1370
- // (would corrupt escape sequences / split the indivisible sized span).
1371
- if (TERMINAL.isImageLine(line) || isOsc66Line(line)) {
1372
- wrappedLines.push(line);
1373
- } else {
1374
- wrappedLines.push(...wrapTextWithAnsi(line, contentWidth));
1487
+ const tableSpecStart = this.#activeTableRenderSpecs?.length ?? 0;
1488
+ const tokenWrappedRowStart = wrappedLines.length;
1489
+ const tokenRowStart = rowOffset + tokenWrappedRowStart;
1490
+ const renderedTokenLines = this.#renderToken(
1491
+ token,
1492
+ contentWidth,
1493
+ nextToken?.type,
1494
+ undefined,
1495
+ `offset:${sourceOffset}`,
1496
+ );
1497
+ const tokenLineOffsets = [0];
1498
+ for (const line of renderedTokenLines) {
1499
+ // Skip wrapping for image protocol lines and OSC 66 sized headings
1500
+ // (would corrupt escape sequences / split the indivisible sized span).
1501
+ if (TERMINAL.isImageLine(line) || isOsc66Line(line)) {
1502
+ wrappedLines.push(line);
1503
+ } else {
1504
+ wrappedLines.push(...wrapTextWithAnsi(line, contentWidth));
1505
+ }
1506
+ tokenLineOffsets.push(wrappedLines.length - tokenWrappedRowStart);
1375
1507
  }
1508
+ const tableSpecs = this.#activeTableRenderSpecs;
1509
+ if (tableSpecs !== undefined) {
1510
+ for (let specIndex = tableSpecStart; specIndex < tableSpecs.length; specIndex++) {
1511
+ const spec = tableSpecs[specIndex]!;
1512
+ let relativeStart: number;
1513
+ let relativeEnd: number;
1514
+ if (token.type === "table") {
1515
+ // Exclude the optional inter-block blank from a top-level table's span.
1516
+ relativeStart = 0;
1517
+ relativeEnd = Math.min(renderedTokenLines.length, spec.lineCount);
1518
+ } else {
1519
+ // Container renderers express nested table spans relative to their
1520
+ // returned lines. Preserve that exact span through this final wrap.
1521
+ if (spec.startRow < 0 || spec.endRow <= spec.startRow) continue;
1522
+ relativeStart = Math.min(renderedTokenLines.length, spec.startRow);
1523
+ relativeEnd = Math.min(renderedTokenLines.length, spec.endRow);
1524
+ }
1525
+ spec.startRow = tokenRowStart + tokenLineOffsets[relativeStart]!;
1526
+ spec.endRow = tokenRowStart + tokenLineOffsets[relativeEnd]!;
1527
+ }
1528
+ }
1529
+ sourceOffset += token.raw.length;
1376
1530
  }
1377
1531
 
1378
1532
  const leftMargin = padding(signature.paddingX);
@@ -1416,6 +1570,21 @@ export class Markdown implements Component {
1416
1570
  return contentLines;
1417
1571
  }
1418
1572
 
1573
+ #resolveRenderedTableLayouts(specs: readonly TableRenderSpec[], topPadding: number): RenderedTableLayout[] {
1574
+ const layouts: RenderedTableLayout[] = [];
1575
+ for (const spec of specs) {
1576
+ if (spec.startRow < 0 || spec.endRow <= spec.startRow) continue;
1577
+ layouts.push({
1578
+ key: spec.key,
1579
+ availableWidth: spec.availableWidth,
1580
+ columnWidths: spec.columnWidths.slice(),
1581
+ startRow: topPadding + spec.startRow,
1582
+ endRow: topPadding + spec.endRow,
1583
+ });
1584
+ }
1585
+ return layouts;
1586
+ }
1587
+
1419
1588
  #renderCodeBodyLines(token: Token, codeIndent: string): string[] {
1420
1589
  const bodyLines: string[] = [];
1421
1590
  const tokenText = "text" in token && typeof token.text === "string" ? token.text : "";
@@ -1626,7 +1795,13 @@ export class Markdown implements Component {
1626
1795
  };
1627
1796
  }
1628
1797
 
1629
- #renderToken(token: Token, width: number, nextTokenType?: string, styleContext?: InlineStyleContext): string[] {
1798
+ #renderToken(
1799
+ token: Token,
1800
+ width: number,
1801
+ nextTokenType?: string,
1802
+ styleContext?: InlineStyleContext,
1803
+ tokenKey = "root",
1804
+ ): string[] {
1630
1805
  const lines: string[] = [];
1631
1806
 
1632
1807
  // Display math block (own-line `$$…$$` / `\[…\]`): stack `\frac` vertically
@@ -1728,7 +1903,7 @@ export class Markdown implements Component {
1728
1903
  }
1729
1904
 
1730
1905
  case "table": {
1731
- const tableLines = this.#renderTable(token as TableToken, width, nextTokenType, styleContext);
1906
+ const tableLines = this.#renderTable(token as TableToken, width, nextTokenType, styleContext, tokenKey);
1732
1907
  lines.push(...tableLines);
1733
1908
  break;
1734
1909
  }
@@ -1741,20 +1916,59 @@ export class Markdown implements Component {
1741
1916
  const quoteContentWidth = Math.max(1, width - 2);
1742
1917
  const quoteTokens = token.tokens || [];
1743
1918
  const renderedQuoteLines: string[] = [];
1919
+ const blockquoteSpecStart = this.#activeTableRenderSpecs?.length ?? 0;
1744
1920
 
1745
1921
  for (let i = 0; i < quoteTokens.length; i++) {
1746
1922
  const quoteToken = quoteTokens[i];
1747
1923
  const nextQuoteToken = quoteTokens[i + 1];
1748
- renderedQuoteLines.push(
1749
- ...this.#renderToken(quoteToken, quoteContentWidth, nextQuoteToken?.type, quoteInlineStyleContext),
1924
+ const quoteTokenRowStart = renderedQuoteLines.length;
1925
+ const quoteSpecStart = this.#activeTableRenderSpecs?.length ?? 0;
1926
+ const quoteTokenLines = this.#renderToken(
1927
+ quoteToken,
1928
+ quoteContentWidth,
1929
+ nextQuoteToken?.type,
1930
+ quoteInlineStyleContext,
1931
+ `${tokenKey}/quote:${i}`,
1750
1932
  );
1933
+ renderedQuoteLines.push(...quoteTokenLines);
1934
+
1935
+ const tableSpecs = this.#activeTableRenderSpecs;
1936
+ if (tableSpecs !== undefined) {
1937
+ for (let specIndex = quoteSpecStart; specIndex < tableSpecs.length; specIndex++) {
1938
+ const spec = tableSpecs[specIndex]!;
1939
+ if (spec.startRow < 0) {
1940
+ // Direct child tables initially have no row coordinates. Their
1941
+ // structural line count excludes any inter-block blank.
1942
+ spec.startRow = quoteTokenRowStart;
1943
+ spec.endRow = quoteTokenRowStart + Math.min(quoteTokenLines.length, spec.lineCount);
1944
+ } else {
1945
+ // A nested blockquote already mapped the table into its own
1946
+ // returned rows; translate those rows into this quote's input.
1947
+ spec.startRow += quoteTokenRowStart;
1948
+ spec.endRow += quoteTokenRowStart;
1949
+ }
1950
+ }
1951
+ }
1751
1952
  }
1752
1953
 
1753
1954
  while (renderedQuoteLines.length > 0 && renderedQuoteLines[renderedQuoteLines.length - 1] === "") {
1754
1955
  renderedQuoteLines.pop();
1755
1956
  }
1756
1957
 
1757
- lines.push(...this.#applyQuoteBorder(renderedQuoteLines, width));
1958
+ const quoteRowOffsets: number[] = [];
1959
+ const borderedQuoteLines = this.#applyQuoteBorder(renderedQuoteLines, width, quoteRowOffsets);
1960
+ const tableSpecs = this.#activeTableRenderSpecs;
1961
+ if (tableSpecs !== undefined) {
1962
+ for (let specIndex = blockquoteSpecStart; specIndex < tableSpecs.length; specIndex++) {
1963
+ const spec = tableSpecs[specIndex]!;
1964
+ if (spec.startRow < 0 || spec.endRow <= spec.startRow) continue;
1965
+ const relativeStart = Math.min(renderedQuoteLines.length, spec.startRow);
1966
+ const relativeEnd = Math.min(renderedQuoteLines.length, spec.endRow);
1967
+ spec.startRow = quoteRowOffsets[relativeStart]!;
1968
+ spec.endRow = quoteRowOffsets[relativeEnd]!;
1969
+ }
1970
+ }
1971
+ lines.push(...borderedQuoteLines);
1758
1972
  if (nextTokenType && nextTokenType !== "space") {
1759
1973
  lines.push(""); // Add spacing after blockquotes (unless space token follows)
1760
1974
  }
@@ -1801,7 +2015,7 @@ export class Markdown implements Component {
1801
2015
  * Wrap already-rendered lines in the blockquote border and quote styling.
1802
2016
  * `width` is the full content width; the border reserves two cells.
1803
2017
  */
1804
- #applyQuoteBorder(renderedLines: string[], width: number): string[] {
2018
+ #applyQuoteBorder(renderedLines: string[], width: number, sourceRowOffsets?: number[]): string[] {
1805
2019
  const quoteStyle = (text: string) => this.#theme.quote(this.#theme.italic(text));
1806
2020
  const quoteStylePrefix = this.#getStylePrefix(quoteStyle);
1807
2021
  const applyQuoteStyle = (line: string): string => {
@@ -1813,11 +2027,13 @@ export class Markdown implements Component {
1813
2027
  };
1814
2028
  const quoteContentWidth = Math.max(1, width - 2);
1815
2029
  const lines: string[] = [];
2030
+ sourceRowOffsets?.push(0);
1816
2031
  for (const quoteLine of renderedLines) {
1817
2032
  const styledLine = applyQuoteStyle(quoteLine);
1818
2033
  for (const wrappedLine of wrapTextWithAnsi(styledLine, quoteContentWidth)) {
1819
2034
  lines.push(this.#theme.quoteBorder(`${this.#theme.symbols.quoteBorder} `) + wrappedLine);
1820
2035
  }
2036
+ sourceRowOffsets?.push(lines.length);
1821
2037
  }
1822
2038
  return lines;
1823
2039
  }
@@ -2149,6 +2365,7 @@ export class Markdown implements Component {
2149
2365
  availableWidth: number,
2150
2366
  nextTokenType?: string,
2151
2367
  styleContext?: InlineStyleContext,
2368
+ tableKey = "table",
2152
2369
  ): string[] {
2153
2370
  const lines: string[] = [];
2154
2371
  const numCols = token.header.length;
@@ -2263,6 +2480,17 @@ export class Markdown implements Component {
2263
2480
  }
2264
2481
  }
2265
2482
 
2483
+ const lockedLayout = this.#lockedTableLayouts.get(tableKey);
2484
+ if (
2485
+ lockedLayout !== undefined &&
2486
+ lockedLayout.availableWidth === availableWidth &&
2487
+ lockedLayout.columnWidths.length === numCols &&
2488
+ lockedLayout.columnWidths.every(width => Number.isFinite(width) && width >= 1) &&
2489
+ lockedLayout.columnWidths.reduce((total, width) => total + width, borderOverhead) <= availableWidth
2490
+ ) {
2491
+ columnWidths = lockedLayout.columnWidths.slice();
2492
+ }
2493
+
2266
2494
  const t = this.#theme.symbols.table;
2267
2495
  const h = t.horizontal;
2268
2496
  const v = t.vertical;
@@ -2316,7 +2544,16 @@ export class Markdown implements Component {
2316
2544
 
2317
2545
  // Render bottom border
2318
2546
  const bottomBorderCells = columnWidths.map(w => h.repeat(w));
2319
- lines.push(`${t.bottomLeft}${h}${bottomBorderCells.join(`${h}${t.teeUp}${h}`)}${h}${t.bottomRight}`);
2547
+ const bottomBorder = `${t.bottomLeft}${h}${bottomBorderCells.join(`${h}${t.teeUp}${h}`)}${h}${t.bottomRight}`;
2548
+ lines.push(bottomBorder);
2549
+ this.#activeTableRenderSpecs?.push({
2550
+ key: tableKey,
2551
+ availableWidth,
2552
+ columnWidths: columnWidths.slice(),
2553
+ lineCount: lines.length,
2554
+ startRow: -1,
2555
+ endRow: -1,
2556
+ });
2320
2557
 
2321
2558
  if (nextTokenType && nextTokenType !== "space") {
2322
2559
  lines.push(""); // Add spacing after table
@@ -11,7 +11,8 @@
11
11
  // fractions and `\binom`, stretch delimiters (`\left…\right`, tall bare parens,
12
12
  // matrix brackets), render matrix/cases/array environments as baseline-aligned
13
13
  // grids, place big-operator limits (`\sum`, `\lim`, `\int\limits`) above and
14
- // below the symbol, draw radicals, raise/lower block scripts, and
14
+ // below the symbol, draw radicals, raise/lower block scripts, draw labeled
15
+ // horizontal braces (`\underbrace{x}_{lbl}`), stack `\overset`/`\underset`, and
15
16
  // align `&` columns in `align`-family environments. Flat runs — symbols, fonts,
16
17
  // colors, inline scripts — are delegated to `latexToUnicode`.
17
18
  //
@@ -126,6 +127,25 @@ const INTEGRAL_OPERATORS: Record<string, true> = {
126
127
  smallint: true,
127
128
  };
128
129
 
130
+ // Horizontal brace/bracket decorations drawn as a rule row beside the content,
131
+ // with an optional limits-style label beyond the rule (`\underbrace{x}_{lbl}`).
132
+ interface HBraceSpec {
133
+ left: string;
134
+ mid: string;
135
+ center: string;
136
+ right: string;
137
+ over: boolean;
138
+ }
139
+
140
+ const HBRACE_COMMANDS: Record<string, HBraceSpec> = {
141
+ overbrace: { left: "╭", mid: "─", center: "┴", right: "╮", over: true },
142
+ underbrace: { left: "╰", mid: "─", center: "┬", right: "╯", over: false },
143
+ overbracket: { left: "┌", mid: "─", center: "─", right: "┐", over: true },
144
+ underbracket: { left: "└", mid: "─", center: "─", right: "┘", over: false },
145
+ overparen: { left: "╭", mid: "─", center: "─", right: "╮", over: true },
146
+ underparen: { left: "╰", mid: "─", center: "─", right: "╯", over: false },
147
+ };
148
+
129
149
  // Vertical delimiter piece characters: `only` for single-line content, then
130
150
  // top/mid/bot columns for stretched forms; `axis` replaces `mid` at the
131
151
  // baseline row (the brace point).
@@ -363,6 +383,31 @@ function limitsBox(glyph: Box, sub: Box | null, sup: Box | null): Box {
363
383
  return { lines, baseline, width };
364
384
  }
365
385
 
386
+ /**
387
+ * `\underbrace{content}_{label}` / `\overbrace{content}^{label}`: the content
388
+ * with a drawn horizontal brace beside it and the label centered beyond the
389
+ * brace. The baseline stays on the content so neighbors align with it.
390
+ */
391
+ function hbraceBox(content: Box, spec: HBraceSpec, label: Box | null): Box {
392
+ const braceWidth = Math.max(content.width, 3);
393
+ const width = Math.max(braceWidth, label?.width ?? 0);
394
+ const lead = (braceWidth - 3) >> 1;
395
+ const brace = center(
396
+ spec.left + spec.mid.repeat(lead) + spec.center + spec.mid.repeat(braceWidth - 3 - lead) + spec.right,
397
+ width,
398
+ );
399
+ const contentLines = content.lines.map(line => center(line, width));
400
+ const labelLines = label === null ? [] : label.lines.map(line => center(line, width));
401
+ if (spec.over) {
402
+ return {
403
+ lines: [...labelLines, brace, ...contentLines],
404
+ baseline: labelLines.length + 1 + content.baseline,
405
+ width,
406
+ };
407
+ }
408
+ return { lines: [...contentLines, brace, ...labelLines], baseline: content.baseline, width };
409
+ }
410
+
366
411
  /**
367
412
  * Attach block scripts to `base` as one shared right-hand column: the
368
413
  * superscript ends level with the base's top row (raised one row above a
@@ -878,6 +923,59 @@ function parseExpr(src: string, ctx: Ctx = ROOT_CTX): Box {
878
923
  i = bottom.end;
879
924
  continue;
880
925
  }
926
+ if (name && HBRACE_COMMANDS[name]) {
927
+ flush();
928
+ const spec = HBRACE_COMMANDS[name];
929
+ const arg = readArg(src, j);
930
+ // Limits-style scripts: the brace-side script is the label; an
931
+ // opposite-side script attaches as a regular corner script.
932
+ let subText: string | null = null;
933
+ let supText: string | null = null;
934
+ let m = arg.end;
935
+ for (;;) {
936
+ let n = m;
937
+ while (src[n] === " ") n++;
938
+ if (src[n] === "_" && subText === null) {
939
+ const s = readArg(src, n + 1);
940
+ subText = s.text;
941
+ m = s.end;
942
+ continue;
943
+ }
944
+ if (src[n] === "^" && supText === null) {
945
+ const s = readArg(src, n + 1);
946
+ supText = s.text;
947
+ m = s.end;
948
+ continue;
949
+ }
950
+ break;
951
+ }
952
+ const labelText = spec.over ? supText : subText;
953
+ const otherText = spec.over ? subText : supText;
954
+ let box = hbraceBox(
955
+ parseExpr(arg.text, inner()),
956
+ spec,
957
+ labelText === null ? null : parseExpr(labelText, inner()),
958
+ );
959
+ if (otherText !== null) {
960
+ const other = parseExpr(otherText, inner());
961
+ box = attachScripts(box, spec.over ? other : null, spec.over ? null : other);
962
+ }
963
+ boxes.push(paint(box));
964
+ i = m;
965
+ continue;
966
+ }
967
+ if (name === "overset" || name === "underset" || name === "stackrel") {
968
+ flush();
969
+ const anno = readArg(src, j);
970
+ const base = readArg(src, anno.end);
971
+ const annoBox = parseExpr(anno.text, inner());
972
+ const baseBox = parseExpr(base.text, inner());
973
+ boxes.push(
974
+ paint(limitsBox(baseBox, name === "underset" ? annoBox : null, name === "underset" ? null : annoBox)),
975
+ );
976
+ i = base.end;
977
+ continue;
978
+ }
881
979
  if (name === "sqrt") {
882
980
  let k = j;
883
981
  while (src[k] === " ") k++;
@@ -1108,8 +1206,18 @@ function parseExpr(src: string, ctx: Ctx = ROOT_CTX): Box {
1108
1206
  const flat = latexToUnicode(raw);
1109
1207
  return flat.startsWith("^") || flat.startsWith("_");
1110
1208
  };
1209
+ // Multi-letter script words (`N_{turns}`) would convert per-char into
1210
+ // Unicode glyphs of uneven height and read ragged; box them too.
1211
+ // Commands are stripped: their output (`\prime` → ′) is not letters.
1212
+ const ragged = (raw: string | undefined): boolean => {
1213
+ if (raw === undefined) return false;
1214
+ const letters = scriptArgOf(raw)
1215
+ .replace(/\\[A-Za-z]+/g, "")
1216
+ .match(/[A-Za-z]/g);
1217
+ return letters !== null && letters.length >= 2;
1218
+ };
1111
1219
  const tall = (supBox !== null && supBox.lines.length > 1) || (subBox !== null && subBox.lines.length > 1);
1112
- if (tall || unconvertible(supText) || unconvertible(subText)) {
1220
+ if (tall || unconvertible(supText) || unconvertible(subText) || ragged(supText) || ragged(subText)) {
1113
1221
  // Block script (`x^{\frac{1}{2}}`, `x^q`): raise/lower the boxes
1114
1222
  // against the run or box they follow.
1115
1223
  flush();