@linxiraos/pi-tui 1.0.0 → 1.0.2

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +48 -7
  2. package/README.md +1 -1
  3. package/package.json +67 -68
  4. package/src/components/editor.ts +64 -3
  5. package/src/components/image.ts +169 -3
  6. package/src/components/loader.ts +2 -3
  7. package/src/components/markdown.ts +146 -11
  8. package/src/components/text.ts +10 -0
  9. package/src/latex-block.ts +116 -6
  10. package/src/terminal-capabilities.ts +84 -0
  11. package/src/tui.ts +1109 -56
  12. package/src/utils.ts +28 -0
  13. package/dist/types/autocomplete.d.ts +0 -116
  14. package/dist/types/bracketed-paste.d.ts +0 -51
  15. package/dist/types/components/box.d.ts +0 -31
  16. package/dist/types/components/cancellable-loader.d.ts +0 -21
  17. package/dist/types/components/editor.d.ts +0 -162
  18. package/dist/types/components/image.d.ts +0 -112
  19. package/dist/types/components/input.d.ts +0 -25
  20. package/dist/types/components/loader.d.ts +0 -25
  21. package/dist/types/components/markdown.d.ts +0 -88
  22. package/dist/types/components/scroll-view.d.ts +0 -62
  23. package/dist/types/components/select-list.d.ts +0 -69
  24. package/dist/types/components/settings-list.d.ts +0 -123
  25. package/dist/types/components/spacer.d.ts +0 -11
  26. package/dist/types/components/tab-bar.d.ts +0 -89
  27. package/dist/types/components/text.d.ts +0 -27
  28. package/dist/types/components/truncated-text.d.ts +0 -10
  29. package/dist/types/deccara.d.ts +0 -49
  30. package/dist/types/desktop-notify.d.ts +0 -52
  31. package/dist/types/editor-component.d.ts +0 -38
  32. package/dist/types/fuzzy.d.ts +0 -48
  33. package/dist/types/index.d.ts +0 -32
  34. package/dist/types/keybindings.d.ts +0 -197
  35. package/dist/types/keys.d.ts +0 -210
  36. package/dist/types/kill-ring.d.ts +0 -20
  37. package/dist/types/kitty-graphics.d.ts +0 -76
  38. package/dist/types/latex-block.d.ts +0 -8
  39. package/dist/types/latex-to-unicode.d.ts +0 -50
  40. package/dist/types/loop-watchdog.d.ts +0 -44
  41. package/dist/types/mouse.d.ts +0 -67
  42. package/dist/types/stdin-buffer.d.ts +0 -60
  43. package/dist/types/symbols.d.ts +0 -25
  44. package/dist/types/terminal-capabilities.d.ts +0 -285
  45. package/dist/types/terminal.d.ts +0 -175
  46. package/dist/types/tmux.d.ts +0 -6
  47. package/dist/types/ttyid.d.ts +0 -9
  48. package/dist/types/tui.d.ts +0 -457
  49. package/dist/types/utils.d.ts +0 -100
@@ -11,13 +11,19 @@ import { latexToBlock } from "../latex-block";
11
11
  import { inlineMathSpanEnd, isBareMathEnvironment, latexToUnicode } from "../latex-to-unicode";
12
12
  import type { SymbolTheme } from "../symbols";
13
13
  import { TERMINAL } from "../terminal-capabilities";
14
- import type { Component, NativeScrollbackCommittedRows, NativeScrollbackReplay } from "../tui";
14
+ import type {
15
+ Component,
16
+ NativeScrollbackCommittedRows,
17
+ NativeScrollbackReplay,
18
+ NativeScrollbackWidthEpoch,
19
+ } from "../tui";
15
20
  import {
16
21
  applyBackgroundToLine,
17
22
  Ellipsis,
18
23
  encodeTextSized,
19
24
  getPaddingX,
20
25
  getSegmenter,
26
+ isOsc66Line,
21
27
  padding,
22
28
  replaceTabs,
23
29
  truncateToWidth,
@@ -37,17 +43,78 @@ function normalizeOsc8Terminators(text: string): string {
37
43
  return text.replace(OSC8_ST_PREFIX_REGEX, "$1\x07");
38
44
  }
39
45
 
40
- // OSC 66 (Kitty text-sizing) heading spans are emitted as a single indivisible
41
- // unit by the H1 render path. Like image-protocol lines, they must bypass
42
- // ANSI wrapping and width padding: re-wrapping splits/normalizes the sized span
43
- // (recomputing the explicit `w=` cell count and hoisting SGR out of the OSC
44
- // payload), and padding would append trailing cells past the doubled glyph.
45
- const OSC66_LINE_PREFIX = "\x1b]66;";
46
+ const MARKDOWN_FENCE_LINE = /^ {0,3}(`{3,}|~{3,})[ \t]*(.*)$/;
47
+ const MARKDOWN_HEADING_LINE = /^ {0,3}#{1,6}[ \t]+\S/;
48
+ const FENCED_SOURCE_INTRO = /\b(?:code|example|markdown|output|snippet|source)\s*:?\s*$/i;
49
+
50
+ function isGfmTableDelimiter(line: string, headerLine: string | undefined): boolean {
51
+ if (!headerLine || !line.includes("|") || !headerLine.includes("|")) return false;
52
+ const delimiterCells = line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|");
53
+ const headerCells = headerLine.trim().replace(/^\|/, "").replace(/\|$/, "").split("|");
54
+ return (
55
+ delimiterCells.length >= 2 &&
56
+ headerCells.length === delimiterCells.length &&
57
+ delimiterCells.every(cell => /^:?-{3,}:?$/.test(cell.trim())) &&
58
+ headerCells.every(cell => cell.trim().length > 0)
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Gemini can emit a bare closing fence without its opener, then continue with
64
+ * headings and tables. CommonMark must interpret that lone fence as an opener,
65
+ * which turns the rest of an otherwise valid report into one raw code block.
66
+ *
67
+ * Repair only the unambiguous rich-document shape at final render: one
68
+ * unmatched bare fence after prose, followed by both an ATX heading and a GFM
69
+ * table delimiter. Keep ordinary incomplete code blocks, fenced Markdown
70
+ * examples, and every matched fence untouched.
71
+ */
72
+ function repairOrphanClosingFence(text: string): string {
73
+ const lines = text.split("\n");
74
+ let open: { index: number; marker: string; info: string } | undefined;
75
+ for (let index = 0; index < lines.length; index++) {
76
+ const match = MARKDOWN_FENCE_LINE.exec(lines[index]!);
77
+ if (!match) continue;
78
+ const marker = match[1]!;
79
+ const info = match[2]!.trim();
80
+ if (!open) {
81
+ open = { index, marker, info };
82
+ continue;
83
+ }
84
+ if (marker[0] === open.marker[0] && marker.length >= open.marker.length && info === "") {
85
+ open = undefined;
86
+ }
87
+ }
88
+ if (open?.info !== "") return text;
46
89
 
47
- function isOsc66Line(line: string): boolean {
48
- return line.includes(OSC66_LINE_PREFIX);
90
+ let previous = "";
91
+ for (let index = open.index - 1; index >= 0; index--) {
92
+ previous = lines[index]!.trim();
93
+ if (previous) break;
94
+ }
95
+ if (!previous || previous.endsWith(":") || FENCED_SOURCE_INTRO.test(previous)) return text;
96
+
97
+ let hasHeading = false;
98
+ let hasTableDelimiter = false;
99
+ for (let index = open.index + 1; index < lines.length; index++) {
100
+ const line = lines[index]!;
101
+ hasHeading ||= MARKDOWN_HEADING_LINE.test(line);
102
+ hasTableDelimiter ||= isGfmTableDelimiter(line, lines[index - 1]);
103
+ if (hasHeading && hasTableDelimiter) {
104
+ lines.splice(open.index, 1);
105
+ return lines.join("\n");
106
+ }
107
+ }
108
+ return text;
49
109
  }
50
110
 
111
+ // OSC 66 (Kitty text-sizing) heading spans are emitted as a single indivisible
112
+ // unit by the H1 render path. Like image-protocol lines, they bypass ANSI
113
+ // wrapping and width padding (see `isOsc66Line` in ../utils): re-wrapping
114
+ // splits/normalizes the sized span (recomputing the explicit `w=` cell count
115
+ // and hoisting SGR out of the OSC payload), and padding would append trailing
116
+ // cells past the doubled glyph.
117
+
51
118
  function normalizeHtmlEntitiesForTerminal(raw: string): string {
52
119
  const parseCodePoint = (value: number): string => {
53
120
  if (Number.isFinite(value) && value >= 0 && value <= 0x10ffff) {
@@ -1412,7 +1479,9 @@ interface RenderedTableLayout extends TableLayoutLock {
1412
1479
  endRow: number;
1413
1480
  }
1414
1481
 
1415
- export class Markdown implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay {
1482
+ export class Markdown
1483
+ implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay, NativeScrollbackWidthEpoch
1484
+ {
1416
1485
  #text: string;
1417
1486
  #paddingX: number; // Left/right padding
1418
1487
  #paddingY: number; // Top/bottom padding
@@ -1450,6 +1519,16 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
1450
1519
  // exposure to 0 and re-earns it — the exposure is hard-monotone within a
1451
1520
  // text lineage.
1452
1521
  #settledExposedText?: string;
1522
+ // Semantic source state that produced the most recent render. Unlike #text,
1523
+ // it does not advance when streaming updates arrive before the next paint.
1524
+ #lastRenderedText?: string;
1525
+ #lastRenderedTransientRenderCache = false;
1526
+ #lastRenderedHasMutableTrailingRow = false;
1527
+ #widthEpochBoundaries = new WeakMap<
1528
+ object,
1529
+ { text: string; transientRenderCache: boolean; hasMutableTrailingRow: boolean }
1530
+ >();
1531
+
1453
1532
  // True while #renderStreamingContentLines renders the frozen token range:
1454
1533
  // frozen code blocks highlight even in transient mode so their bytes match
1455
1534
  // the finalized render (they render once into the prefix line cache, so
@@ -1545,6 +1624,56 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
1545
1624
  return this.#lastRenderSettledRows;
1546
1625
  }
1547
1626
 
1627
+ captureNativeScrollbackWidthEpoch(): unknown {
1628
+ if (this.#lastRenderedText === undefined) return undefined;
1629
+ const marker = {};
1630
+ this.#widthEpochBoundaries.set(marker, {
1631
+ text: this.#lastRenderedText,
1632
+ transientRenderCache: this.#lastRenderedTransientRenderCache,
1633
+ hasMutableTrailingRow: this.#lastRenderedHasMutableTrailingRow,
1634
+ });
1635
+ return marker;
1636
+ }
1637
+
1638
+ resolveNativeScrollbackWidthEpoch(boundary: unknown): number | undefined {
1639
+ if (typeof boundary !== "object" || boundary === null || this.#cachedWidth === undefined) return undefined;
1640
+ const captured = this.#widthEpochBoundaries.get(boundary);
1641
+ if (captured === undefined) return undefined;
1642
+ const snapshot = new Markdown(
1643
+ captured.text,
1644
+ this.#paddingX,
1645
+ this.#paddingY,
1646
+ this.#theme,
1647
+ this.#defaultTextStyle,
1648
+ this.#codeBlockIndent,
1649
+ );
1650
+ snapshot.#ignoreTight = this.#ignoreTight;
1651
+ snapshot.#transientRenderCache = captured.transientRenderCache;
1652
+ return Math.max(
1653
+ 0,
1654
+ snapshot.render(this.#cachedWidth).length - this.#paddingY - (captured.hasMutableTrailingRow ? 1 : 0),
1655
+ );
1656
+ }
1657
+
1658
+ getNativeScrollbackWidthEpochRows(): number | undefined {
1659
+ return this.#cachedLines === undefined ? undefined : this.#widthEpochRows(this.#cachedLines.length);
1660
+ }
1661
+
1662
+ isNativeScrollbackWidthEpochAppendOnly(boundary: unknown): boolean {
1663
+ if (typeof boundary !== "object" || boundary === null) return true;
1664
+ return this.#widthEpochBoundaries.get(boundary)?.hasMutableTrailingRow !== true;
1665
+ }
1666
+
1667
+ #widthEpochRows(renderedRows: number): number {
1668
+ return Math.max(0, renderedRows - this.#paddingY - (this.#transientRenderCache ? 1 : 0));
1669
+ }
1670
+
1671
+ #recordLastRenderedState(hasContentRows: boolean): void {
1672
+ this.#lastRenderedText = this.#text;
1673
+ this.#lastRenderedTransientRenderCache = this.#transientRenderCache;
1674
+ this.#lastRenderedHasMutableTrailingRow = this.#transientRenderCache && hasContentRows;
1675
+ }
1676
+
1548
1677
  /**
1549
1678
  * Freeze every table whose first physical row is already part of the native
1550
1679
  * scrollback prefix. The recorded widths came from the exact frame that was
@@ -1644,6 +1773,7 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
1644
1773
  // Returning the cached reference is load-bearing: parents memoize their
1645
1774
  // concatenation on reference equality.
1646
1775
  if (this.#cachedLines && this.#cachedText === this.#text && this.#cachedWidth === width) {
1776
+ this.#recordLastRenderedState(this.#cachedLines.length > 0);
1647
1777
  return this.#cachedLines;
1648
1778
  }
1649
1779
 
@@ -1660,11 +1790,14 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
1660
1790
  this.#cachedText = this.#text;
1661
1791
  this.#cachedWidth = width;
1662
1792
  this.#cachedLines = EMPTY_RENDER_LINES;
1793
+ this.#recordLastRenderedState(false);
1663
1794
  return EMPTY_RENDER_LINES;
1664
1795
  }
1665
1796
 
1666
1797
  // Replace tabs with 3 spaces for consistent rendering
1667
- const normalizedText = replaceTabs(this.#text);
1798
+ const normalizedText = this.transientRenderCache
1799
+ ? replaceTabs(this.#text)
1800
+ : repairOrphanClosingFence(replaceTabs(this.#text));
1668
1801
  const signature = this.#renderSignature(width, paddingX);
1669
1802
 
1670
1803
  // L2: module-level LRU — survives component disposal/recreation across
@@ -1695,6 +1828,7 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
1695
1828
  this.#cachedText = this.#text;
1696
1829
  this.#cachedWidth = width;
1697
1830
  this.#cachedLines = cached.lines;
1831
+ this.#recordLastRenderedState(cached.lines.length > 0);
1698
1832
  return cached.lines;
1699
1833
  }
1700
1834
  }
@@ -1738,6 +1872,7 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
1738
1872
  })),
1739
1873
  });
1740
1874
  }
1875
+ this.#recordLastRenderedState(contentLines.length > 0);
1741
1876
 
1742
1877
  return result;
1743
1878
  }
@@ -25,11 +25,14 @@ export class Text implements Component {
25
25
  #paddingY: number; // Top/bottom padding
26
26
  #customBgFn?: (text: string) => string;
27
27
  #styleFn?: (text: string) => string;
28
+ #widthEpochRevision = 0;
28
29
 
29
30
  #ignoreTight = false;
30
31
 
31
32
  setIgnoreTight(ignore: boolean): this {
33
+ if (this.#ignoreTight === ignore) return this;
32
34
  this.#ignoreTight = ignore;
35
+ this.#widthEpochRevision++;
33
36
  this.invalidate();
34
37
  return this;
35
38
  }
@@ -60,15 +63,21 @@ export class Text implements Component {
60
63
  this.#cachedWidth = undefined;
61
64
  this.#cachedWidthEpoch = undefined;
62
65
  this.#cachedLines = undefined;
66
+ this.#widthEpochRevision++;
63
67
  return true;
64
68
  }
65
69
 
70
+ getNativeScrollbackWidthEpochRevision(): number {
71
+ return this.#widthEpochRevision;
72
+ }
73
+
66
74
  setCustomBgFn(customBgFn?: (text: string) => string): void {
67
75
  this.#customBgFn = customBgFn;
68
76
  this.#cachedText = undefined;
69
77
  this.#cachedWidth = undefined;
70
78
  this.#cachedWidthEpoch = undefined;
71
79
  this.#cachedLines = undefined;
80
+ this.#widthEpochRevision++;
72
81
  }
73
82
 
74
83
  /**
@@ -83,6 +92,7 @@ export class Text implements Component {
83
92
  this.#cachedWidth = undefined;
84
93
  this.#cachedWidthEpoch = undefined;
85
94
  this.#cachedLines = undefined;
95
+ this.#widthEpochRevision++;
86
96
  return this;
87
97
  }
88
98
 
@@ -146,6 +146,16 @@ const HBRACE_COMMANDS: Record<string, HBraceSpec> = {
146
146
  underparen: { left: "╰", mid: "─", center: "─", right: "╯", over: false },
147
147
  };
148
148
 
149
+ /**
150
+ * Number of required arguments each display command consumes. Shared by
151
+ * {@link readArg} and {@link splitLines} so nested command atoms consume exactly
152
+ * their own arguments while preserving any outer command's pending arity.
153
+ */
154
+ const COMMAND_ARITY: Record<string, number> = { overset: 2, underset: 2, stackrel: 2, sqrt: 1 };
155
+ for (const name in FRAC_COMMANDS) COMMAND_ARITY[name] = 2;
156
+ for (const name in BINOM_COMMANDS) COMMAND_ARITY[name] = 2;
157
+ for (const name in HBRACE_COMMANDS) COMMAND_ARITY[name] = 1;
158
+
149
159
  // Vertical delimiter piece characters: `only` for single-line content, then
150
160
  // top/mid/bot columns for stretched forms; `axis` replaces `mid` at the
151
161
  // baseline row (the brace point).
@@ -516,11 +526,13 @@ function readBraceGroup(src: string, i: number): Span {
516
526
 
517
527
  /**
518
528
  * Read one command argument: a `{…}` group, a single char, or a `\command`
519
- * together with its attached `[…]`/`{…}` arguments (or whole `\begin…\end`
520
- * block), so e.g. `\frac\sqrt{a}{b}` reads `\sqrt{a}` as the numerator.
529
+ * together with its arguments (or whole `\begin…\end` block). Commands whose
530
+ * arity is known consume exactly that many arguments, including across source
531
+ * whitespace, so `\frac\sqrt {a} {b}` reads `\sqrt {a}` as the numerator and
532
+ * leaves `{b}` for the denominator.
521
533
  */
522
534
  function readArg(src: string, i: number): Span {
523
- while (src[i] === " ") i++;
535
+ while (src[i] === " " || src[i] === "\t" || src[i] === "\n") i++;
524
536
  if (i >= src.length) return { text: "", end: i };
525
537
  if (src[i] === "{") return readBraceGroup(src, i);
526
538
  if (src[i] !== "\\") return { text: src[i], end: i + 1 };
@@ -535,6 +547,22 @@ function readArg(src: string, i: number): Span {
535
547
  if (env) return env;
536
548
  }
537
549
  if (!name) return { text: src.slice(i, i + 2), end: i + 2 }; // non-letter command (\,, \{, …)
550
+
551
+ const arity = COMMAND_ARITY[name];
552
+ if (arity !== undefined) {
553
+ let end = j;
554
+ // Optional command arguments (e.g. the degree in `\sqrt[3]{x}`) do not
555
+ // consume a required-argument slot.
556
+ for (;;) {
557
+ while (src[end] === " " || src[end] === "\t" || src[end] === "\n") end++;
558
+ if (src[end] !== "[") break;
559
+ const close = src.indexOf("]", end);
560
+ end = close === -1 ? src.length : close + 1;
561
+ }
562
+ for (let arg = 0; arg < arity; arg++) end = readArg(src, end).end;
563
+ return { text: src.slice(i, end), end };
564
+ }
565
+
538
566
  let end = j;
539
567
  while (src[end] === "[" || src[end] === "{") {
540
568
  if (src[end] === "{") end = readBraceGroup(src, end).end;
@@ -1271,6 +1299,80 @@ function parseExpr(src: string, ctx: Ctx = ROOT_CTX): Box {
1271
1299
  return hconcat(boxes);
1272
1300
  }
1273
1301
 
1302
+ /**
1303
+ * Count the command arguments still owed at the end of `seg` — non-zero when
1304
+ * the row ends mid-construct (`\frac{a}` awaiting its denominator, or
1305
+ * `\frac`/`x^` awaiting any argument). Pending arities form a stack: an
1306
+ * unbraced nested command consumes one outer argument, then retains its own
1307
+ * pending arguments without discarding the outer command's remaining arity.
1308
+ * Used to keep a command joined to an argument written on the next source line
1309
+ * while still treating an ordinary next row (`a\n{b+c}`) as a real row break.
1310
+ */
1311
+ function bracesOwed(seg: string): number {
1312
+ const pending: number[] = [];
1313
+ const consumeArg = (): void => {
1314
+ const top = pending.length - 1;
1315
+ if (top < 0) return;
1316
+ if (pending[top] === 1) pending.pop();
1317
+ else pending[top]--;
1318
+ };
1319
+
1320
+ let i = 0;
1321
+ while (i < seg.length) {
1322
+ const c = seg[i];
1323
+ if (c === "\\") {
1324
+ let j = i + 1;
1325
+ let name = "";
1326
+ while (j < seg.length && /[A-Za-z]/.test(seg[j])) name += seg[j++];
1327
+ // A command plus its immediately attached `[…]`/`{…}` groups is one
1328
+ // atom for an enclosing argument, matching readArg. Consume that outer
1329
+ // argument first, then retain only the command's own missing arguments
1330
+ // in a nested frame. Attached groups beyond the known arity still stay
1331
+ // part of the atom and cannot consume another outer argument.
1332
+ consumeArg();
1333
+ const arity = name ? (COMMAND_ARITY[name] ?? 0) : 0;
1334
+ let attached = 0;
1335
+ if (name) {
1336
+ while (seg[j] === "[" || seg[j] === "{") {
1337
+ if (seg[j] === "{") {
1338
+ j = readBraceGroup(seg, j).end;
1339
+ if (attached < arity) attached++;
1340
+ } else {
1341
+ const close = seg.indexOf("]", j);
1342
+ j = close === -1 ? seg.length : close + 1;
1343
+ }
1344
+ }
1345
+ } else {
1346
+ j = i + 2; // non-letter command (`\,`, `\{`, …)
1347
+ }
1348
+ const missing = arity - attached;
1349
+ if (missing > 0) pending.push(missing);
1350
+ i = j;
1351
+ continue;
1352
+ }
1353
+ if (c === "{") {
1354
+ i = readBraceGroup(seg, i).end;
1355
+ consumeArg();
1356
+ continue;
1357
+ }
1358
+ if (c === "^" || c === "_") {
1359
+ pending.push(1);
1360
+ i++;
1361
+ continue;
1362
+ }
1363
+ if (c === " " || c === "\t" || c === "\n") {
1364
+ i++;
1365
+ continue;
1366
+ }
1367
+ consumeArg(); // a bare atom satisfies one pending argument
1368
+ i++;
1369
+ }
1370
+
1371
+ let owed = 0;
1372
+ for (const remaining of pending) owed += remaining;
1373
+ return owed;
1374
+ }
1375
+
1274
1376
  /** Split on top-level `\n` and `\\` row separators (outside braces and environments). */
1275
1377
  function splitLines(src: string): string[] {
1276
1378
  const lines: string[] = [];
@@ -1308,8 +1410,16 @@ function splitLines(src: string): string[] {
1308
1410
  if (c === "{") braceDepth++;
1309
1411
  else if (c === "}") braceDepth--;
1310
1412
  else if (c === "\n" && braceDepth === 0 && envDepth === 0) {
1311
- lines.push(src.slice(last, i));
1312
- last = i + 1;
1413
+ // A top-level newline is a row break UNLESS the current row ends with a
1414
+ // command still awaiting an argument (e.g. `\frac{num}\n{den}`,
1415
+ // `\frac{num}\n\sqrt{x}`, or `x^\n2`). Splitting there would sever the
1416
+ // command from its argument, so keep both in one segment; latexToBlock
1417
+ // collapses the interior newline to a space before parsing. A row that
1418
+ // merely opens with a braced group (`a\n{b+c}`) stays a break.
1419
+ if (bracesOwed(src.slice(last, i)) === 0) {
1420
+ lines.push(src.slice(last, i));
1421
+ last = i + 1;
1422
+ }
1313
1423
  }
1314
1424
  i++;
1315
1425
  }
@@ -1327,7 +1437,7 @@ function splitLines(src: string): string[] {
1327
1437
  export function latexToBlock(src: string): string[] {
1328
1438
  if (typeof src !== "string" || src.trim() === "") return [];
1329
1439
  const rows = splitLines(src.trim())
1330
- .map(line => line.trim())
1440
+ .map(line => line.replace(/[ \t]*\n[ \t]*/g, " ").trim())
1331
1441
  .filter(line => line !== "")
1332
1442
  .map(line => parseExpr(line));
1333
1443
  if (rows.length === 0) return [];
@@ -731,6 +731,80 @@ export function encodeKittyPlacement(options: {
731
731
  return wrapTmuxPassthroughIfNeeded(`\x1b_G${params.join(",")}\x1b\\`);
732
732
  }
733
733
 
734
+ /**
735
+ * Exact shape of the direct-placement line {@link Image} emits as its block's
736
+ * last row: optional `ESC 7` + `CUU(rows-1)` prefix, the {@link encodeKittyPlacement}
737
+ * APC, optional `ESC 8` suffix. tmux-passthrough-wrapped lines deliberately do
738
+ * not match (passthrough placements stay untouched).
739
+ */
740
+ const KITTY_DIRECT_PLACEMENT_LINE =
741
+ /^(?:\x1b7(?:\x1b\[(\d+)A)?)?\x1b_Ga=p,q=2,C=1,i=(\d+)(?:,p=(\d+))?(?:,c=(\d+))?(?:,r=(\d+))?\x1b\\(?:\x1b8)?$/;
742
+
743
+ export interface ParsedKittyPlacementLine {
744
+ imageId: number;
745
+ placementId: number | undefined;
746
+ columns: number;
747
+ rows: number;
748
+ }
749
+
750
+ /**
751
+ * Parse a frame line that consists solely of a Kitty direct placement (the
752
+ * last line of an {@link Image} block). Returns null for anything else —
753
+ * placeholder grids, tmux-wrapped placements, sixel/iTerm2 payloads — so
754
+ * callers fall back to writing the line verbatim.
755
+ */
756
+ export function parseKittyDirectPlacementLine(line: string): ParsedKittyPlacementLine | null {
757
+ const m = KITTY_DIRECT_PLACEMENT_LINE.exec(line);
758
+ if (!m) return null;
759
+ const columns = m[4] !== undefined ? Number(m[4]) : 0;
760
+ const rows = m[5] !== undefined ? Number(m[5]) : 0;
761
+ if (columns <= 0 || rows <= 0) return null;
762
+ return {
763
+ imageId: Number(m[2]),
764
+ placementId: m[3] !== undefined ? Number(m[3]) : undefined,
765
+ columns,
766
+ rows,
767
+ };
768
+ }
769
+
770
+ /**
771
+ * Rebuild an {@link Image} direct-placement line for the viewport row it is
772
+ * written at. The component-rendered line encodes `CUU(rows-1)`, which clamps
773
+ * at the viewport top once the block's leading rows have scrolled out — the
774
+ * placement then re-anchors the full image shifted down over foreign rows.
775
+ * Anchor at the block's first *visible* row instead, clipping the source
776
+ * rectangle (`y=`/`h=`, image pixels) to the visible bottom slice.
777
+ */
778
+ export function encodeKittyPlacementLine(options: {
779
+ imageId: number;
780
+ placementId: number;
781
+ columns: number;
782
+ /** Total cell rows of the image block. */
783
+ rows: number;
784
+ /** Viewport row the block's last line is being written at. */
785
+ screenRow: number;
786
+ /** Source image height in pixels, for the clipped source rectangle. */
787
+ imageHeightPx: number;
788
+ }): string {
789
+ // Without a source pixel height the slice cannot be expressed — emit the
790
+ // component's own full form (status quo) rather than squashing the whole
791
+ // image into the reduced row count.
792
+ const clippable = options.imageHeightPx > 0;
793
+ const hiddenRows = clippable ? Math.max(0, options.rows - 1 - options.screenRow) : 0;
794
+ const visibleRows = options.rows - hiddenRows;
795
+ const params: string[] = ["a=p", "q=2", "C=1", `i=${options.imageId}`, `p=${options.placementId}`];
796
+ params.push(`c=${options.columns}`, `r=${visibleRows}`);
797
+ if (hiddenRows > 0) {
798
+ const srcY = Math.floor((options.imageHeightPx * hiddenRows) / options.rows);
799
+ params.push(`y=${srcY}`, `h=${Math.max(1, options.imageHeightPx - srcY)}`);
800
+ }
801
+ // No tmux passthrough: inside tmux the component's own line arrives
802
+ // wrapped, never parses, and never reaches this rewrite.
803
+ const apc = `\x1b_G${params.join(",")}\x1b\\`;
804
+ const cuu = visibleRows - 1;
805
+ return cuu > 0 ? `\x1b7\x1b[${cuu}A${apc}\x1b8` : apc;
806
+ }
807
+
734
808
  /**
735
809
  * Kitty graphics delete command for a single image id. Uses `d=I` (capital)
736
810
  * which removes the image and every one of its placements — on screen *and* in
@@ -742,6 +816,16 @@ export function encodeKittyDeleteImage(imageId: number): string {
742
816
  return wrapTmuxPassthroughIfNeeded(`\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`);
743
817
  }
744
818
 
819
+ /**
820
+ * Delete a single placement of an image (`d=i`, lowercase): removes its cells
821
+ * and registry entry but keeps the transmitted data, so a later `a=p` under a
822
+ * fresh placement id needs no retransmit. Used to clear stale placement-epoch
823
+ * entries after a destructive history clear.
824
+ */
825
+ export function encodeKittyDeletePlacement(imageId: number, placementId: number): string {
826
+ return wrapTmuxPassthroughIfNeeded(`\x1b_Ga=d,d=i,i=${imageId},p=${placementId},q=2\x1b\\`);
827
+ }
828
+
745
829
  export function encodeITerm2(
746
830
  base64Data: string,
747
831
  options: {