@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.
- package/CHANGELOG.md +48 -7
- package/README.md +1 -1
- package/package.json +67 -68
- package/src/components/editor.ts +64 -3
- package/src/components/image.ts +169 -3
- package/src/components/loader.ts +2 -3
- package/src/components/markdown.ts +146 -11
- package/src/components/text.ts +10 -0
- package/src/latex-block.ts +116 -6
- package/src/terminal-capabilities.ts +84 -0
- package/src/tui.ts +1109 -56
- package/src/utils.ts +28 -0
- package/dist/types/autocomplete.d.ts +0 -116
- package/dist/types/bracketed-paste.d.ts +0 -51
- package/dist/types/components/box.d.ts +0 -31
- package/dist/types/components/cancellable-loader.d.ts +0 -21
- package/dist/types/components/editor.d.ts +0 -162
- package/dist/types/components/image.d.ts +0 -112
- package/dist/types/components/input.d.ts +0 -25
- package/dist/types/components/loader.d.ts +0 -25
- package/dist/types/components/markdown.d.ts +0 -88
- package/dist/types/components/scroll-view.d.ts +0 -62
- package/dist/types/components/select-list.d.ts +0 -69
- package/dist/types/components/settings-list.d.ts +0 -123
- package/dist/types/components/spacer.d.ts +0 -11
- package/dist/types/components/tab-bar.d.ts +0 -89
- package/dist/types/components/text.d.ts +0 -27
- package/dist/types/components/truncated-text.d.ts +0 -10
- package/dist/types/deccara.d.ts +0 -49
- package/dist/types/desktop-notify.d.ts +0 -52
- package/dist/types/editor-component.d.ts +0 -38
- package/dist/types/fuzzy.d.ts +0 -48
- package/dist/types/index.d.ts +0 -32
- package/dist/types/keybindings.d.ts +0 -197
- package/dist/types/keys.d.ts +0 -210
- package/dist/types/kill-ring.d.ts +0 -20
- package/dist/types/kitty-graphics.d.ts +0 -76
- package/dist/types/latex-block.d.ts +0 -8
- package/dist/types/latex-to-unicode.d.ts +0 -50
- package/dist/types/loop-watchdog.d.ts +0 -44
- package/dist/types/mouse.d.ts +0 -67
- package/dist/types/stdin-buffer.d.ts +0 -60
- package/dist/types/symbols.d.ts +0 -25
- package/dist/types/terminal-capabilities.d.ts +0 -285
- package/dist/types/terminal.d.ts +0 -175
- package/dist/types/tmux.d.ts +0 -6
- package/dist/types/ttyid.d.ts +0 -9
- package/dist/types/tui.d.ts +0 -457
- 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 {
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
|
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 =
|
|
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
|
}
|
package/src/components/text.ts
CHANGED
|
@@ -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
|
|
package/src/latex-block.ts
CHANGED
|
@@ -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
|
|
520
|
-
*
|
|
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
|
-
|
|
1312
|
-
|
|
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: {
|