@vincemakes/kiso-tui-cells 0.10.0 → 0.11.0
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/dist/components.d.ts +20 -2
- package/dist/components.js +29 -19
- package/dist/md.d.ts +137 -0
- package/dist/md.js +720 -0
- package/dist/render.d.ts +10 -0
- package/dist/render.js +2 -2
- package/dist/width.d.ts +14 -0
- package/dist/width.js +74 -27
- package/package.json +1 -1
package/dist/components.d.ts
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* tint, fold wording).
|
|
19
19
|
*/
|
|
20
20
|
import { foldThinking, foldResult, renderToolSummary, type ResumeMeta } from "./render.js";
|
|
21
|
+
import { type MdBlock } from "./md.js";
|
|
22
|
+
export { MdStream, renderBlock, renderMarkdown, type MdBlock, type MdKind } from "./md.js";
|
|
21
23
|
/** The spinner glyphs, cycled by the compositor's on-demand tick. */
|
|
22
24
|
export declare const SPINNER: string[];
|
|
23
25
|
/** The frame context the compositor passes down — the pieces of time
|
|
@@ -43,8 +45,12 @@ export type RenderLine = string;
|
|
|
43
45
|
*/
|
|
44
46
|
export declare function foldLine(line: string, W: number): string[];
|
|
45
47
|
/** The visible width of a rendered line (SGR stripped — the invariant
|
|
46
|
-
* the compositor enforces on every emitted line).
|
|
47
|
-
|
|
48
|
+
* the compositor enforces on every emitted line). TUI2-MD ⑤: the body
|
|
49
|
+
* moved to width.ts (the width authority's own home) so the markdown
|
|
50
|
+
* renderer can measure without importing this module back — the
|
|
51
|
+
* re-export is verbatim, so every existing importer and the barrel see
|
|
52
|
+
* exactly what they saw. */
|
|
53
|
+
export { visibleWidth } from "./width.js";
|
|
48
54
|
/** A component: render the display lines for one piece of state. */
|
|
49
55
|
export interface Component {
|
|
50
56
|
render(width: number, ctx: FrameCtx): string[];
|
|
@@ -145,6 +151,18 @@ export type BodyCell = {
|
|
|
145
151
|
kind: "text";
|
|
146
152
|
text: string;
|
|
147
153
|
done: boolean;
|
|
154
|
+
}
|
|
155
|
+
/** TUI2-MD ⑤ — ONE markdown block of assistant body text. The cell is
|
|
156
|
+
* the commit unit the compositor already had, so block-freeze needs
|
|
157
|
+
* no new commit machinery: a CLOSED block is a DONE cell and the
|
|
158
|
+
* natural loop freezes it; the OPEN tail block is the one cell left
|
|
159
|
+
* live. `block` carries the block's SOURCE (never rendered rows), so
|
|
160
|
+
* a resize re-renders it at the new width exactly as every other
|
|
161
|
+
* cell does. */
|
|
162
|
+
| {
|
|
163
|
+
kind: "md";
|
|
164
|
+
block: MdBlock;
|
|
165
|
+
done: boolean;
|
|
148
166
|
} | {
|
|
149
167
|
kind: "notice";
|
|
150
168
|
text: string;
|
package/dist/components.js
CHANGED
|
@@ -17,12 +17,18 @@
|
|
|
17
17
|
* (untouched); render.ts supplies the original text (palette, escape,
|
|
18
18
|
* tint, fold wording).
|
|
19
19
|
*/
|
|
20
|
-
import { displayWidth } from "./width.js";
|
|
20
|
+
import { displayWidth, visibleWidth } from "./width.js";
|
|
21
21
|
// TUI2-R2pre ④: the ONE display-verb table (strings.ts, beside
|
|
22
22
|
// KEY_BINDINGS). strings.js imports only render/width here, so this edge
|
|
23
23
|
// adds no cycle.
|
|
24
24
|
import { displayVerb } from "./strings.js";
|
|
25
25
|
import { bannerLines, escapeTerminal, foldThinking, foldResult, colorInlineCode, renderTerminalGap, renderToolSummary, toolTarget, kUnit, palette, } from "./render.js";
|
|
26
|
+
// TUI2-MD: the markdown renderer's surface reaches the tui through this
|
|
27
|
+
// module (the tui's components shim re-exports it) — one import edge,
|
|
28
|
+
// and it points one way: md.ts measures with the width authority, never
|
|
29
|
+
// back through here.
|
|
30
|
+
import { renderBlock } from "./md.js";
|
|
31
|
+
export { MdStream, renderBlock, renderMarkdown } from "./md.js";
|
|
26
32
|
/** The spinner glyphs, cycled by the compositor's on-demand tick. */
|
|
27
33
|
export const SPINNER = ["▖", "▘", "▝", "▗"];
|
|
28
34
|
/**
|
|
@@ -94,24 +100,12 @@ export function foldLine(line, W) {
|
|
|
94
100
|
return out;
|
|
95
101
|
}
|
|
96
102
|
/** The visible width of a rendered line (SGR stripped — the invariant
|
|
97
|
-
* the compositor enforces on every emitted line).
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (m !== null) {
|
|
104
|
-
i += m[0].length;
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
i += 1;
|
|
108
|
-
continue;
|
|
109
|
-
}
|
|
110
|
-
w += displayWidth(line[i]);
|
|
111
|
-
i += 1;
|
|
112
|
-
}
|
|
113
|
-
return w;
|
|
114
|
-
}
|
|
103
|
+
* the compositor enforces on every emitted line). TUI2-MD ⑤: the body
|
|
104
|
+
* moved to width.ts (the width authority's own home) so the markdown
|
|
105
|
+
* renderer can measure without importing this module back — the
|
|
106
|
+
* re-export is verbatim, so every existing importer and the barrel see
|
|
107
|
+
* exactly what they saw. */
|
|
108
|
+
export { visibleWidth } from "./width.js";
|
|
115
109
|
/** The W11 spacing formula — "a row gets one blank line above it when
|
|
116
110
|
* the row is itself a block, or when the previous sibling was taller
|
|
117
111
|
* than one row". One-row siblings pack tight; anything multi-row
|
|
@@ -160,6 +154,8 @@ export function cellComponent(cell) {
|
|
|
160
154
|
return new ToolExecution(cell);
|
|
161
155
|
case "text":
|
|
162
156
|
return new AssistantMessage(cell);
|
|
157
|
+
case "md":
|
|
158
|
+
return new MarkdownBlock(cell);
|
|
163
159
|
case "notice":
|
|
164
160
|
return new ErrorLine(cell);
|
|
165
161
|
case "banner":
|
|
@@ -1190,6 +1186,20 @@ class AssistantMessage {
|
|
|
1190
1186
|
return wrapped.length > 0 ? wrapped.map((l) => colorInlineCode(l)) : [""];
|
|
1191
1187
|
}
|
|
1192
1188
|
}
|
|
1189
|
+
/** TUI2-MD ⑤ — one markdown block. Pure in (block, W): the same source
|
|
1190
|
+
* and the same width give the same bytes, which is the freeze property
|
|
1191
|
+
* the commit path relies on. The block carries its own leading blank
|
|
1192
|
+
* (the style table's rhythm), so the compositor's W11 join formula
|
|
1193
|
+
* steps aside between two of these. */
|
|
1194
|
+
class MarkdownBlock {
|
|
1195
|
+
cell;
|
|
1196
|
+
constructor(cell) {
|
|
1197
|
+
this.cell = cell;
|
|
1198
|
+
}
|
|
1199
|
+
render(W, _ctx) {
|
|
1200
|
+
return renderBlock(this.cell.block, W);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1193
1203
|
/** The ⚠ / notice lines — the error surface. */
|
|
1194
1204
|
class ErrorLine {
|
|
1195
1205
|
cell;
|
package/dist/md.d.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI2-MD — the markdown renderer. Hand-rolled, zero dependencies, and
|
|
3
|
+
* deliberately a SUBSET: the constructs assistant prose actually uses,
|
|
4
|
+
* rendered under the mono discipline (attributes over colours, zero
|
|
5
|
+
* syntax highlighting).
|
|
6
|
+
*
|
|
7
|
+
* THE BLOCK-FREEZE DISCIPLINE. kiso's committed bytes are never
|
|
8
|
+
* re-emitted (ADR-0046) — the terminal's own scrollback is the
|
|
9
|
+
* transcript. A renderer that re-lexes the whole message per delta (the
|
|
10
|
+
* shape a whole-text parser forces on you) can therefore not be used
|
|
11
|
+
* here at all: it would need to repaint lines that have already left
|
|
12
|
+
* the live region. So the scanner below IS the streaming state machine.
|
|
13
|
+
* It consumes appended text and yields two things:
|
|
14
|
+
*
|
|
15
|
+
* - CLOSED blocks: their source is final, so their render is final;
|
|
16
|
+
* the compositor commits them through the path it already had.
|
|
17
|
+
* - the OPEN TAIL block: the live region's occupant, re-rendered in
|
|
18
|
+
* place per delta, bounded by construction (one block).
|
|
19
|
+
*
|
|
20
|
+
* The freeze property — a closed block's rendered lines never change as
|
|
21
|
+
* more text arrives — is earned by ONE rule: block boundaries are
|
|
22
|
+
* decided only on COMPLETE lines. A trailing partial line renders
|
|
23
|
+
* eagerly but can never close anything, because a decision taken on an
|
|
24
|
+
* incomplete line can be wrong ("#" is a heading until it becomes
|
|
25
|
+
* "#hashtag") and a wrong decision here is a wrong commit.
|
|
26
|
+
*
|
|
27
|
+
* Everything else follows: an unclosed `**` renders literal and flips
|
|
28
|
+
* when it closes, but only ever inside the open block; a fence body
|
|
29
|
+
* line is line-local (no highlighting means no cross-line lexer state),
|
|
30
|
+
* so it closes the instant its newline arrives and long code blocks
|
|
31
|
+
* never bloat the live region; a table re-layouts as rows stream, and
|
|
32
|
+
* only inside the tail.
|
|
33
|
+
*
|
|
34
|
+
* The style table is the round's normative one (the owner's circled
|
|
35
|
+
* group D): the BLOCK half is `blockBody` below, the INLINE half is
|
|
36
|
+
* `inlineSpans`, and every entry is pinned by a fixture rather than
|
|
37
|
+
* described twice.
|
|
38
|
+
*/
|
|
39
|
+
/** The block kinds. `fence-open`/`fence-line` are separate kinds on
|
|
40
|
+
* purpose: a fence's rows must be able to freeze ONE AT A TIME. */
|
|
41
|
+
export type MdKind = "para" | "heading" | "list" | "table" | "quote" | "rule" | "fence-open" | "fence-line";
|
|
42
|
+
/** One block: its SOURCE lines, never a rendered form. The render is a
|
|
43
|
+
* pure function of (block, width), which is what makes the freeze
|
|
44
|
+
* property a property of the scanner alone. */
|
|
45
|
+
export interface MdBlock {
|
|
46
|
+
readonly kind: MdKind;
|
|
47
|
+
readonly lines: readonly string[];
|
|
48
|
+
/** a blank row precedes this block — the markdown rhythm, owned here
|
|
49
|
+
* rather than by the compositor's W11 join formula (which reads row
|
|
50
|
+
* COUNTS and so cannot express "no blank between two rows of one
|
|
51
|
+
* fence"). */
|
|
52
|
+
readonly gap: boolean;
|
|
53
|
+
/** the fence's language tag; "" everywhere else. */
|
|
54
|
+
readonly lang: string;
|
|
55
|
+
}
|
|
56
|
+
export declare class MdStream {
|
|
57
|
+
#private;
|
|
58
|
+
/** Append streamed text. Only COMPLETE lines reach the state machine. */
|
|
59
|
+
push(text: string): void;
|
|
60
|
+
/** The message ended: the trailing partial line is a complete line
|
|
61
|
+
* after all, and the open block closes. */
|
|
62
|
+
end(): void;
|
|
63
|
+
/** Every block so far: `closed()` of them are FINAL, and at most one
|
|
64
|
+
* open tail follows. Fresh objects — a closed block's source can
|
|
65
|
+
* never be reached through this. */
|
|
66
|
+
blocks(): readonly MdBlock[];
|
|
67
|
+
/** How many leading blocks are CLOSED — the commit-eligible count. */
|
|
68
|
+
closed(): number;
|
|
69
|
+
}
|
|
70
|
+
/** The whole message at once — the freeze property's oracle, and the
|
|
71
|
+
* path a non-streaming caller takes. */
|
|
72
|
+
export declare function renderMarkdown(text: string, W: number): string[];
|
|
73
|
+
/** One block's screen rows. Pure in (block, W) — this is the whole
|
|
74
|
+
* freeze guarantee: same source, same width, same bytes, forever. */
|
|
75
|
+
export declare function renderBlock(b: MdBlock, W: number): string[];
|
|
76
|
+
/**
|
|
77
|
+
* The inline pass — the mono style table applied to one block's text.
|
|
78
|
+
*
|
|
79
|
+
* Scoped to `**bold**`, `*italic*`, `` `code` ``, `[text](url)` and the
|
|
80
|
+
* backslash escape, with two rules that matter more than coverage:
|
|
81
|
+
*
|
|
82
|
+
* RAW UNTIL CLOSED — an opener with no closer in this text stays
|
|
83
|
+
* literal. That is what lets a half-streamed `**` show its asterisks
|
|
84
|
+
* and flip the instant the closer lands, inside the live block and
|
|
85
|
+
* nowhere else.
|
|
86
|
+
*
|
|
87
|
+
* CLOSE BACK TO `base` — the block's own style (a heading's bold, a
|
|
88
|
+
* quote's dim) is passed in, and every span reopens it on the way
|
|
89
|
+
* out, so a nested span can never strand it. Italic is the one span
|
|
90
|
+
* that closes surgically (SGR 23), because it can.
|
|
91
|
+
*
|
|
92
|
+
* Documented deviations from CommonMark: `_` never emphasizes (it is a
|
|
93
|
+
* character in identifiers far more often than a marker in prose);
|
|
94
|
+
* emphasis does not nest across a code span; `~~` is not a construct at
|
|
95
|
+
* all — the markers are content (the strict tokenizer both reference
|
|
96
|
+
* implementations converged on, taken to its honest conclusion, since
|
|
97
|
+
* SGR 9's terminal support is too fragmented to promise).
|
|
98
|
+
*/
|
|
99
|
+
export declare function inlineSpans(text: string, base: string): string;
|
|
100
|
+
/** Split a table line into cells. The `|` walls are found on the RAW
|
|
101
|
+
* line, but a pipe INSIDE a code span is content — two independent
|
|
102
|
+
* reference implementations both patched exactly this, because a
|
|
103
|
+
* command in a cell (`grep a | wc`) is common and splitting it puts
|
|
104
|
+
* the human's own text in the wrong column. A backslash-escaped pipe
|
|
105
|
+
* is content too. */
|
|
106
|
+
export declare function splitCells(line: string): string[];
|
|
107
|
+
export type MdAlign = "left" | "center" | "right";
|
|
108
|
+
export interface MdTable {
|
|
109
|
+
readonly header: readonly string[];
|
|
110
|
+
readonly align: readonly MdAlign[];
|
|
111
|
+
readonly rows: readonly (readonly string[])[];
|
|
112
|
+
}
|
|
113
|
+
/** The table shape, or null when these lines are NOT a table.
|
|
114
|
+
*
|
|
115
|
+
* Two rejections, both borrowed: a second line that is not a delimiter
|
|
116
|
+
* row means this is prose that contains pipes; and a body row carrying
|
|
117
|
+
* MORE columns than the header is malformed — rendering it would have
|
|
118
|
+
* to guess where the extra content belongs, and a guess printed into
|
|
119
|
+
* scrollback is indistinguishable from a fact. Rejected tables fall
|
|
120
|
+
* back to their own source bytes, which are still valid markdown. */
|
|
121
|
+
export declare function tableShape(lines: readonly string[]): MdTable | null;
|
|
122
|
+
/**
|
|
123
|
+
* Wrap styled text into rows of at most W columns, with a HANGING
|
|
124
|
+
* INDENT: `first` prefixes the first row, `hang` every later one, and
|
|
125
|
+
* the text column is what continuations align to.
|
|
126
|
+
*
|
|
127
|
+
* The SGR spans open at a break are closed at the row's end and
|
|
128
|
+
* reopened at the next row's start, so no style leaks into the padding
|
|
129
|
+
* and none is lost across the break. Italic's own close (23) is
|
|
130
|
+
* understood, so `\x1b[3m…\x1b[23m` inside a bold heading tracks
|
|
131
|
+
* correctly.
|
|
132
|
+
*
|
|
133
|
+
* Every emitted row measures ≤ W through the SAME width authority the
|
|
134
|
+
* compositor's invariant ① measures with — which is the only way the
|
|
135
|
+
* two can agree.
|
|
136
|
+
*/
|
|
137
|
+
export declare function mdWrap(text: string, W: number, first: string, hang: string): string[];
|
package/dist/md.js
ADDED
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI2-MD — the markdown renderer. Hand-rolled, zero dependencies, and
|
|
3
|
+
* deliberately a SUBSET: the constructs assistant prose actually uses,
|
|
4
|
+
* rendered under the mono discipline (attributes over colours, zero
|
|
5
|
+
* syntax highlighting).
|
|
6
|
+
*
|
|
7
|
+
* THE BLOCK-FREEZE DISCIPLINE. kiso's committed bytes are never
|
|
8
|
+
* re-emitted (ADR-0046) — the terminal's own scrollback is the
|
|
9
|
+
* transcript. A renderer that re-lexes the whole message per delta (the
|
|
10
|
+
* shape a whole-text parser forces on you) can therefore not be used
|
|
11
|
+
* here at all: it would need to repaint lines that have already left
|
|
12
|
+
* the live region. So the scanner below IS the streaming state machine.
|
|
13
|
+
* It consumes appended text and yields two things:
|
|
14
|
+
*
|
|
15
|
+
* - CLOSED blocks: their source is final, so their render is final;
|
|
16
|
+
* the compositor commits them through the path it already had.
|
|
17
|
+
* - the OPEN TAIL block: the live region's occupant, re-rendered in
|
|
18
|
+
* place per delta, bounded by construction (one block).
|
|
19
|
+
*
|
|
20
|
+
* The freeze property — a closed block's rendered lines never change as
|
|
21
|
+
* more text arrives — is earned by ONE rule: block boundaries are
|
|
22
|
+
* decided only on COMPLETE lines. A trailing partial line renders
|
|
23
|
+
* eagerly but can never close anything, because a decision taken on an
|
|
24
|
+
* incomplete line can be wrong ("#" is a heading until it becomes
|
|
25
|
+
* "#hashtag") and a wrong decision here is a wrong commit.
|
|
26
|
+
*
|
|
27
|
+
* Everything else follows: an unclosed `**` renders literal and flips
|
|
28
|
+
* when it closes, but only ever inside the open block; a fence body
|
|
29
|
+
* line is line-local (no highlighting means no cross-line lexer state),
|
|
30
|
+
* so it closes the instant its newline arrives and long code blocks
|
|
31
|
+
* never bloat the live region; a table re-layouts as rows stream, and
|
|
32
|
+
* only inside the tail.
|
|
33
|
+
*
|
|
34
|
+
* The style table is the round's normative one (the owner's circled
|
|
35
|
+
* group D): the BLOCK half is `blockBody` below, the INLINE half is
|
|
36
|
+
* `inlineSpans`, and every entry is pinned by a fixture rather than
|
|
37
|
+
* described twice.
|
|
38
|
+
*/
|
|
39
|
+
import { palette } from "./render.js";
|
|
40
|
+
import { breakable, charWidth, displayWidth } from "./width.js";
|
|
41
|
+
import { escapeTerminal } from "./render.js";
|
|
42
|
+
import { visibleWidth } from "./components.js";
|
|
43
|
+
// ---- line classification -------------------------------------------
|
|
44
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
|
|
45
|
+
/** ATX only, and the space is REQUIRED: `#hashtag` is prose. */
|
|
46
|
+
const HEADING = /^ {0,3}(#{1,6}) +(\S.*)$/;
|
|
47
|
+
const RULE = /^ {0,3}(?:-{3,}|\*{3,}|_{3,}) *$/;
|
|
48
|
+
const QUOTE = /^ {0,3}> ?(.*)$/;
|
|
49
|
+
const TABLE = /^ {0,3}\|/;
|
|
50
|
+
const ITEM = /^( *)([-*+]|\d{1,9}[.)])[ \t]+(.*)$/;
|
|
51
|
+
/** The kind a line would START. null = blank (a block separator). */
|
|
52
|
+
function classify(line) {
|
|
53
|
+
if (line.trim() === "")
|
|
54
|
+
return null;
|
|
55
|
+
if (FENCE.test(line))
|
|
56
|
+
return "fence-open";
|
|
57
|
+
if (RULE.test(line))
|
|
58
|
+
return "rule";
|
|
59
|
+
if (HEADING.test(line))
|
|
60
|
+
return "heading";
|
|
61
|
+
if (QUOTE.test(line))
|
|
62
|
+
return "quote";
|
|
63
|
+
if (TABLE.test(line))
|
|
64
|
+
return "table";
|
|
65
|
+
if (ITEM.test(line))
|
|
66
|
+
return "list";
|
|
67
|
+
return "para";
|
|
68
|
+
}
|
|
69
|
+
/** Can `line` JOIN an open block of this kind? Headings, rules and the
|
|
70
|
+
* fence rows are single-line blocks and never take a second line. */
|
|
71
|
+
function joins(kind, line) {
|
|
72
|
+
const c = classify(line);
|
|
73
|
+
if (c === null)
|
|
74
|
+
return false; // a blank closes everything
|
|
75
|
+
switch (kind) {
|
|
76
|
+
case "para":
|
|
77
|
+
return c === "para";
|
|
78
|
+
case "list":
|
|
79
|
+
// a list item's continuation must be INDENTED — an unindented
|
|
80
|
+
// paragraph after a list starts a paragraph (the lazy-continuation
|
|
81
|
+
// rule is a documented deviation: predictable beats compliant when
|
|
82
|
+
// the output is committed).
|
|
83
|
+
return c === "list" || (c === "para" && /^[ \t]/.test(line));
|
|
84
|
+
case "table":
|
|
85
|
+
return c === "table";
|
|
86
|
+
case "quote":
|
|
87
|
+
return c === "quote";
|
|
88
|
+
default:
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** The fence marker a line opens with (``` or ~~~, 3+). */
|
|
93
|
+
function fenceMark(line) {
|
|
94
|
+
return FENCE.exec(line)?.[1] ?? "```";
|
|
95
|
+
}
|
|
96
|
+
/** A line that CLOSES a fence: the same char, at least as long, alone. */
|
|
97
|
+
function closesFence(line, mark) {
|
|
98
|
+
const m = FENCE.exec(line);
|
|
99
|
+
return m !== null && m[1].startsWith(mark[0]) && m[1].length >= mark.length && m[2].trim() === "";
|
|
100
|
+
}
|
|
101
|
+
/** The partial line is nothing but the HEAD of a closing fence — the
|
|
102
|
+
* one visible flicker in fence-close streaming (a stray ``` `` ``` on
|
|
103
|
+
* screen for a frame). Both reference implementations trim it. */
|
|
104
|
+
function partialClose(partial, mark) {
|
|
105
|
+
const t = partial.trim();
|
|
106
|
+
return t !== "" && t.length <= mark.length && t.split("").every((c) => c === mark[0]);
|
|
107
|
+
}
|
|
108
|
+
function frozen(b, extra) {
|
|
109
|
+
return { kind: b.kind, lines: extra === undefined ? [...b.lines] : [...b.lines, extra], gap: b.gap, lang: b.lang };
|
|
110
|
+
}
|
|
111
|
+
export class MdStream {
|
|
112
|
+
#closed = [];
|
|
113
|
+
#open = null;
|
|
114
|
+
#partial = "";
|
|
115
|
+
/** the opener's marker while inside a fence; null outside one. */
|
|
116
|
+
#fence = null;
|
|
117
|
+
/** blocks STARTED so far — the gap rule's only input (the first block
|
|
118
|
+
* of a message opens tight; every later one carries its own blank). */
|
|
119
|
+
#started = 0;
|
|
120
|
+
/** Append streamed text. Only COMPLETE lines reach the state machine. */
|
|
121
|
+
push(text) {
|
|
122
|
+
// the renderer consumes already-scrubbed text and never re-introduces
|
|
123
|
+
// ESC from data: the styling below is applied by this module, never
|
|
124
|
+
// carried in the content.
|
|
125
|
+
this.#partial += escapeTerminal(text);
|
|
126
|
+
for (let at = this.#partial.indexOf("\n"); at >= 0; at = this.#partial.indexOf("\n")) {
|
|
127
|
+
this.#line(this.#partial.slice(0, at));
|
|
128
|
+
this.#partial = this.#partial.slice(at + 1);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** The message ended: the trailing partial line is a complete line
|
|
132
|
+
* after all, and the open block closes. */
|
|
133
|
+
end() {
|
|
134
|
+
if (this.#partial !== "") {
|
|
135
|
+
this.#line(this.#partial);
|
|
136
|
+
this.#partial = "";
|
|
137
|
+
}
|
|
138
|
+
this.#shut();
|
|
139
|
+
this.#fence = null;
|
|
140
|
+
}
|
|
141
|
+
/** Every block so far: `closed()` of them are FINAL, and at most one
|
|
142
|
+
* open tail follows. Fresh objects — a closed block's source can
|
|
143
|
+
* never be reached through this. */
|
|
144
|
+
blocks() {
|
|
145
|
+
const out = [...this.#closed];
|
|
146
|
+
const p = this.#partial;
|
|
147
|
+
if (this.#fence !== null) {
|
|
148
|
+
if (p !== "" && !partialClose(p, this.#fence))
|
|
149
|
+
out.push({ kind: "fence-line", lines: [p], gap: false, lang: "" });
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
if (this.#open !== null) {
|
|
153
|
+
out.push(p === "" ? frozen(this.#open) : frozen(this.#open, p));
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
const k = classify(p);
|
|
157
|
+
if (k !== null)
|
|
158
|
+
out.push({ kind: k, lines: [p], gap: this.#started > 0, lang: k === "fence-open" ? fenceLang(p) : "" });
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
/** How many leading blocks are CLOSED — the commit-eligible count. */
|
|
162
|
+
closed() {
|
|
163
|
+
return this.#closed.length;
|
|
164
|
+
}
|
|
165
|
+
#line(line) {
|
|
166
|
+
if (this.#fence !== null) {
|
|
167
|
+
// the closer emits no block: a bottom border is drawn only by an
|
|
168
|
+
// actual close, and under committed lines a phantom one would be
|
|
169
|
+
// a lie the force-commit path could freeze.
|
|
170
|
+
if (closesFence(line, this.#fence)) {
|
|
171
|
+
this.#fence = null;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
this.#push({ kind: "fence-line", lines: [line], gap: false, lang: "" });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const k = classify(line);
|
|
178
|
+
if (k === null) {
|
|
179
|
+
this.#shut();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (this.#open !== null && joins(this.#open.kind, line)) {
|
|
183
|
+
this.#open.lines.push(line);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
this.#shut();
|
|
187
|
+
if (k === "fence-open") {
|
|
188
|
+
this.#fence = fenceMark(line);
|
|
189
|
+
this.#push({ kind: k, lines: [line], gap: this.#started > 0, lang: fenceLang(line) });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (k === "heading" || k === "rule") {
|
|
193
|
+
this.#push({ kind: k, lines: [line], gap: this.#started > 0, lang: "" });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
this.#open = { kind: k, lines: [line], gap: this.#started > 0, lang: "" };
|
|
197
|
+
this.#started += 1;
|
|
198
|
+
}
|
|
199
|
+
/** A block that is final the moment its line is. */
|
|
200
|
+
#push(b) {
|
|
201
|
+
this.#closed.push(b);
|
|
202
|
+
this.#started += 1;
|
|
203
|
+
}
|
|
204
|
+
#shut() {
|
|
205
|
+
if (this.#open === null)
|
|
206
|
+
return;
|
|
207
|
+
this.#closed.push(frozen(this.#open));
|
|
208
|
+
this.#open = null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function fenceLang(line) {
|
|
212
|
+
return (FENCE.exec(line)?.[2] ?? "").trim();
|
|
213
|
+
}
|
|
214
|
+
// ---- rendering ------------------------------------------------------
|
|
215
|
+
/** The whole message at once — the freeze property's oracle, and the
|
|
216
|
+
* path a non-streaming caller takes. */
|
|
217
|
+
export function renderMarkdown(text, W) {
|
|
218
|
+
const s = new MdStream();
|
|
219
|
+
s.push(text);
|
|
220
|
+
s.end();
|
|
221
|
+
return s.blocks().flatMap((b) => renderBlock(b, W));
|
|
222
|
+
}
|
|
223
|
+
/** One block's screen rows. Pure in (block, W) — this is the whole
|
|
224
|
+
* freeze guarantee: same source, same width, same bytes, forever. */
|
|
225
|
+
export function renderBlock(b, W) {
|
|
226
|
+
const rows = blockBody(b, Math.max(1, W));
|
|
227
|
+
return b.gap ? ["", ...rows] : rows;
|
|
228
|
+
}
|
|
229
|
+
function blockBody(b, W) {
|
|
230
|
+
const p = palette();
|
|
231
|
+
switch (b.kind) {
|
|
232
|
+
case "heading": {
|
|
233
|
+
// the marker is stripped, the numbering kept, and the levels are
|
|
234
|
+
// NOT differentiated by colour — attributes only. A `**bold**`
|
|
235
|
+
// inside a heading is therefore a no-op, which is the mono
|
|
236
|
+
// discipline paying for itself: the nested-style restore machinery
|
|
237
|
+
// both reference implementations need for this exact input has
|
|
238
|
+
// nothing to restore here.
|
|
239
|
+
const m = HEADING.exec(b.lines[0] ?? "");
|
|
240
|
+
return wrap(`${p.bold}${inlineSpans(m?.[2] ?? b.lines[0] ?? "", p.bold)}${p.reset}`, W, "", "");
|
|
241
|
+
}
|
|
242
|
+
case "rule":
|
|
243
|
+
return [`${p.dim}${"─".repeat(Math.min(W, 28))}${p.reset}`];
|
|
244
|
+
case "fence-open":
|
|
245
|
+
// the dim gutter names the block; the language tag rides the
|
|
246
|
+
// opening row. Zero highlighting — which is exactly what makes a
|
|
247
|
+
// fence body line committable on its own.
|
|
248
|
+
return [`${p.dim}│${b.lang === "" ? "" : ` ${b.lang}`}${p.reset}`];
|
|
249
|
+
case "fence-line": {
|
|
250
|
+
// a fence body's INDENTATION is its content. The wrapper drops
|
|
251
|
+
// leading spaces \u2014 right for prose, a lie for code \u2014 so the indent
|
|
252
|
+
// rides as the row prefix instead, and a wrapped long line hangs
|
|
253
|
+
// under it rather than returning to the gutter.
|
|
254
|
+
const src = (b.lines[0] ?? "").replace(/\t/g, " ");
|
|
255
|
+
const indent = /^ */.exec(src)[0];
|
|
256
|
+
const gutter = `${p.dim}\u2502${p.reset} `;
|
|
257
|
+
return foldLineWidth(`${p.code}${src.slice(indent.length)}${p.reset}`, W - visibleWidth(gutter), indent).map((r) => `${gutter}${r}`);
|
|
258
|
+
}
|
|
259
|
+
case "quote": {
|
|
260
|
+
const text = b.lines.map((l) => QUOTE.exec(l)?.[1] ?? l).join(" ");
|
|
261
|
+
const gutter = `${p.dim}\u258f${p.reset} `;
|
|
262
|
+
return wrap(`${p.dim}${inlineSpans(text, p.dim)}${p.reset}`, W - visibleWidth(gutter), "", "").map((r) => `${gutter}${r}`);
|
|
263
|
+
}
|
|
264
|
+
case "list":
|
|
265
|
+
return listRows(b, W);
|
|
266
|
+
case "table":
|
|
267
|
+
return tableRows(b, W);
|
|
268
|
+
default:
|
|
269
|
+
// a paragraph's soft line breaks are spaces — the block reflows at
|
|
270
|
+
// the terminal's width, which is the whole point of rendering it.
|
|
271
|
+
return wrap(inlineSpans(b.lines.join(" "), ""), W, "", "");
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* The inline pass — the mono style table applied to one block's text.
|
|
276
|
+
*
|
|
277
|
+
* Scoped to `**bold**`, `*italic*`, `` `code` ``, `[text](url)` and the
|
|
278
|
+
* backslash escape, with two rules that matter more than coverage:
|
|
279
|
+
*
|
|
280
|
+
* RAW UNTIL CLOSED — an opener with no closer in this text stays
|
|
281
|
+
* literal. That is what lets a half-streamed `**` show its asterisks
|
|
282
|
+
* and flip the instant the closer lands, inside the live block and
|
|
283
|
+
* nowhere else.
|
|
284
|
+
*
|
|
285
|
+
* CLOSE BACK TO `base` — the block's own style (a heading's bold, a
|
|
286
|
+
* quote's dim) is passed in, and every span reopens it on the way
|
|
287
|
+
* out, so a nested span can never strand it. Italic is the one span
|
|
288
|
+
* that closes surgically (SGR 23), because it can.
|
|
289
|
+
*
|
|
290
|
+
* Documented deviations from CommonMark: `_` never emphasizes (it is a
|
|
291
|
+
* character in identifiers far more often than a marker in prose);
|
|
292
|
+
* emphasis does not nest across a code span; `~~` is not a construct at
|
|
293
|
+
* all — the markers are content (the strict tokenizer both reference
|
|
294
|
+
* implementations converged on, taken to its honest conclusion, since
|
|
295
|
+
* SGR 9's terminal support is too fragmented to promise).
|
|
296
|
+
*/
|
|
297
|
+
export function inlineSpans(text, base) {
|
|
298
|
+
const p = palette();
|
|
299
|
+
let out = "";
|
|
300
|
+
let i = 0;
|
|
301
|
+
while (i < text.length) {
|
|
302
|
+
const ch = text[i];
|
|
303
|
+
if (ch === "\\" && i + 1 < text.length && ESCAPABLE.test(text[i + 1])) {
|
|
304
|
+
out += text[i + 1];
|
|
305
|
+
i += 2;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (ch === "`") {
|
|
309
|
+
const end = text.indexOf("`", i + 1);
|
|
310
|
+
if (end > i) {
|
|
311
|
+
// a code span's content is LITERAL — no markers inside it mean
|
|
312
|
+
// anything, which is what makes `x | y` survive a table split
|
|
313
|
+
out += `${p.code}${text.slice(i + 1, end)}${p.reset}${base}`;
|
|
314
|
+
i = end + 1;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (text.startsWith("**", i)) {
|
|
319
|
+
const end = closerAt(text, i + 2, "**");
|
|
320
|
+
if (end >= 0) {
|
|
321
|
+
out += `${p.bold}${inlineSpans(text.slice(i + 2, end), `${base}${p.bold}`)}${p.reset}${base}`;
|
|
322
|
+
i = end + 2;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (ch === "*") {
|
|
327
|
+
const end = closerAt(text, i + 1, "*");
|
|
328
|
+
if (end >= 0) {
|
|
329
|
+
out += `${p.italic}${inlineSpans(text.slice(i + 1, end), `${base}${p.italic}`)}${p.italicEnd}`;
|
|
330
|
+
i = end + 1;
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (ch === "[") {
|
|
335
|
+
const link = LINK.exec(text.slice(i));
|
|
336
|
+
if (link !== null) {
|
|
337
|
+
// the text bright, the url dim in parentheses. NO OSC 8 this
|
|
338
|
+
// round: a hyperlink escape is bytes the human cannot see, and
|
|
339
|
+
// the byte discipline gets to decide that separately.
|
|
340
|
+
out += `${p.bold}${link[1]}${p.reset}${p.dim} (${link[2]})${p.reset}${base}`;
|
|
341
|
+
i += link[0].length;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
out += ch;
|
|
346
|
+
i += 1;
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
const ESCAPABLE = /[\\`*_~[\]()#|+.!>-]/;
|
|
351
|
+
const LINK = /^\[([^\]\n]*)\]\(([^)\s\n]*)\)/;
|
|
352
|
+
/** The closing delimiter for an emphasis opener at `from`, or −1.
|
|
353
|
+
* Strict on both edges: an opener followed by a space, or a closer
|
|
354
|
+
* preceded by one, is arithmetic or prose, not emphasis (`2 * 3 * 4`
|
|
355
|
+
* must survive). An empty span is not a span. */
|
|
356
|
+
function closerAt(text, from, delim) {
|
|
357
|
+
if (from >= text.length || text[from] === " ")
|
|
358
|
+
return -1;
|
|
359
|
+
for (let i = from; i >= 0;) {
|
|
360
|
+
const at = text.indexOf(delim, i);
|
|
361
|
+
if (at < 0)
|
|
362
|
+
return -1;
|
|
363
|
+
if (at > from && text[at - 1] !== " " && !(delim === "*" && text[at - 1] === "*"))
|
|
364
|
+
return at;
|
|
365
|
+
i = at + delim.length;
|
|
366
|
+
}
|
|
367
|
+
return -1;
|
|
368
|
+
}
|
|
369
|
+
// ---- the table tokenizer (the two convergent patches) ---------------
|
|
370
|
+
/** Split a table line into cells. The `|` walls are found on the RAW
|
|
371
|
+
* line, but a pipe INSIDE a code span is content — two independent
|
|
372
|
+
* reference implementations both patched exactly this, because a
|
|
373
|
+
* command in a cell (`grep a | wc`) is common and splitting it puts
|
|
374
|
+
* the human's own text in the wrong column. A backslash-escaped pipe
|
|
375
|
+
* is content too. */
|
|
376
|
+
export function splitCells(line) {
|
|
377
|
+
const cells = [];
|
|
378
|
+
let cur = "";
|
|
379
|
+
let code = false;
|
|
380
|
+
const body = line.trim();
|
|
381
|
+
for (let i = 0; i < body.length; i += 1) {
|
|
382
|
+
const ch = body[i];
|
|
383
|
+
if (ch === "\\" && i + 1 < body.length) {
|
|
384
|
+
cur += ch + body[i + 1];
|
|
385
|
+
i += 1;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (ch === "`")
|
|
389
|
+
code = !code;
|
|
390
|
+
if (ch === "|" && !code) {
|
|
391
|
+
cells.push(cur);
|
|
392
|
+
cur = "";
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
cur += ch;
|
|
396
|
+
}
|
|
397
|
+
cells.push(cur);
|
|
398
|
+
// the leading and trailing walls produce empty edge cells
|
|
399
|
+
if (cells.length > 0 && cells[0].trim() === "")
|
|
400
|
+
cells.shift();
|
|
401
|
+
if (cells.length > 0 && cells[cells.length - 1].trim() === "")
|
|
402
|
+
cells.pop();
|
|
403
|
+
return cells.map((c) => c.trim());
|
|
404
|
+
}
|
|
405
|
+
const DELIM_CELL = /^:?-{1,}:?$/;
|
|
406
|
+
/** The table shape, or null when these lines are NOT a table.
|
|
407
|
+
*
|
|
408
|
+
* Two rejections, both borrowed: a second line that is not a delimiter
|
|
409
|
+
* row means this is prose that contains pipes; and a body row carrying
|
|
410
|
+
* MORE columns than the header is malformed — rendering it would have
|
|
411
|
+
* to guess where the extra content belongs, and a guess printed into
|
|
412
|
+
* scrollback is indistinguishable from a fact. Rejected tables fall
|
|
413
|
+
* back to their own source bytes, which are still valid markdown. */
|
|
414
|
+
export function tableShape(lines) {
|
|
415
|
+
if (lines.length < 2)
|
|
416
|
+
return null;
|
|
417
|
+
const header = splitCells(lines[0]);
|
|
418
|
+
const delim = splitCells(lines[1]);
|
|
419
|
+
if (header.length === 0 || delim.length !== header.length)
|
|
420
|
+
return null;
|
|
421
|
+
if (!delim.every((c) => DELIM_CELL.test(c)))
|
|
422
|
+
return null;
|
|
423
|
+
const align = delim.map((c) => (c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : "left"));
|
|
424
|
+
const rows = [];
|
|
425
|
+
for (const line of lines.slice(2)) {
|
|
426
|
+
const cells = splitCells(line);
|
|
427
|
+
if (cells.length > header.length)
|
|
428
|
+
return null;
|
|
429
|
+
while (cells.length < header.length)
|
|
430
|
+
cells.push(""); // a short row is padded — nothing is invented
|
|
431
|
+
rows.push(cells);
|
|
432
|
+
}
|
|
433
|
+
return { header, align, rows };
|
|
434
|
+
}
|
|
435
|
+
/** The list: `•` normalization, numbers kept, 2 spaces per nesting
|
|
436
|
+
* level, and the HANGING INDENT — a wrapped item's continuation lines
|
|
437
|
+
* align to the text column, never back to the left margin. */
|
|
438
|
+
function listRows(b, W) {
|
|
439
|
+
const p = palette();
|
|
440
|
+
const out = [];
|
|
441
|
+
let lead = "";
|
|
442
|
+
let text = "";
|
|
443
|
+
const flush = () => {
|
|
444
|
+
if (lead === "")
|
|
445
|
+
return;
|
|
446
|
+
// the HANGING INDENT: the continuation prefix is the marker's own
|
|
447
|
+
// visible width, so a wrapped item's later rows align to the text
|
|
448
|
+
// column instead of returning to the margin.
|
|
449
|
+
out.push(...wrap(inlineSpans(text, ""), W, lead, " ".repeat(displayWidth(lead))));
|
|
450
|
+
};
|
|
451
|
+
for (const line of b.lines) {
|
|
452
|
+
const m = ITEM.exec(line);
|
|
453
|
+
if (m === null) {
|
|
454
|
+
text += ` ${line.trim()}`; // an indented continuation of the item
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
flush();
|
|
458
|
+
const depth = Math.min(5, Math.floor(m[1].length / 2));
|
|
459
|
+
// `•` normalization for bullets; a numbered list KEEPS its numbers
|
|
460
|
+
// (they are the author's meaning, not decoration).
|
|
461
|
+
const marker = /^\d/.test(m[2]) ? `${m[2]} ` : "• ";
|
|
462
|
+
lead = `${" ".repeat(depth + 1)}${marker}`;
|
|
463
|
+
text = m[3];
|
|
464
|
+
}
|
|
465
|
+
flush();
|
|
466
|
+
return out.length > 0 ? out : [""];
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* The table. Columns are measured at their NATURAL widths, on the
|
|
470
|
+
* inline-rendered text with the SGR stripped (a bold cell is four
|
|
471
|
+
* columns, not twelve). If the whole table fits, it is drawn aligned
|
|
472
|
+
* with the dim rails; if it does not, it does NOT shrink and it does
|
|
473
|
+
* NOT cut — every row becomes a record, and every cell survives.
|
|
474
|
+
*
|
|
475
|
+
* A rejected shape (no delimiter row, or a body row wider than the
|
|
476
|
+
* header) falls back to its own source lines, which are still valid
|
|
477
|
+
* markdown. That is the honest exit: a guess about where the extra
|
|
478
|
+
* content belongs would be indistinguishable, once committed, from a
|
|
479
|
+
* fact.
|
|
480
|
+
*/
|
|
481
|
+
function tableRows(b, W) {
|
|
482
|
+
const p = palette();
|
|
483
|
+
const t = tableShape(b.lines);
|
|
484
|
+
if (t === null)
|
|
485
|
+
return b.lines.flatMap((l) => wrap(l, W, "", ""));
|
|
486
|
+
const cols = t.header.map((h, i) => Math.max(cellWidth(h), ...t.rows.map((r) => cellWidth(r[i] ?? ""))));
|
|
487
|
+
// the drawn width: one rail, then each column as "│ cell " + its pad
|
|
488
|
+
const total = cols.reduce((n, w) => n + w + 3, 1);
|
|
489
|
+
if (total > W)
|
|
490
|
+
return recordRows(t, W);
|
|
491
|
+
const rail = `${p.dim}│${p.reset}`;
|
|
492
|
+
const row = (cells, bold) => `${rail}${cells.map((c, i) => ` ${pad(c, cols[i], t.align[i], bold)} `).join(rail)}${rail}`;
|
|
493
|
+
return [
|
|
494
|
+
row(t.header, true),
|
|
495
|
+
`${p.dim}├${cols.map((w) => "─".repeat(w + 2)).join("┼")}┤${p.reset}`,
|
|
496
|
+
...t.rows.map((r) => row(r, false)),
|
|
497
|
+
];
|
|
498
|
+
}
|
|
499
|
+
/** A cell's column count: what a human sees, styling removed. */
|
|
500
|
+
function cellWidth(cell) {
|
|
501
|
+
return visibleWidth(inlineSpans(cell, ""));
|
|
502
|
+
}
|
|
503
|
+
/** One padded cell — the styling goes on AFTER the measure, so it can
|
|
504
|
+
* never move a column. */
|
|
505
|
+
function pad(cell, w, align, bold) {
|
|
506
|
+
const p = palette();
|
|
507
|
+
const body = bold ? `${p.bold}${inlineSpans(cell, p.bold)}${p.reset}` : inlineSpans(cell, "");
|
|
508
|
+
const slack = Math.max(0, w - cellWidth(cell));
|
|
509
|
+
const left = align === "right" ? slack : align === "center" ? Math.floor(slack / 2) : 0;
|
|
510
|
+
return `${" ".repeat(left)}${body}${" ".repeat(slack - left)}`;
|
|
511
|
+
}
|
|
512
|
+
/** The narrow degradation: one record per row. The first column names
|
|
513
|
+
* the record (bold, with a dim colon); the rest is a dim `label:
|
|
514
|
+
* value` run joined by `·`, wrapped rather than cut. A blank row
|
|
515
|
+
* separates records — nothing is dropped at any width. */
|
|
516
|
+
function recordRows(t, W) {
|
|
517
|
+
const p = palette();
|
|
518
|
+
const out = [];
|
|
519
|
+
for (const r of t.rows) {
|
|
520
|
+
if (out.length > 0)
|
|
521
|
+
out.push("");
|
|
522
|
+
out.push(...wrap(`${p.bold}${inlineSpans(t.header[0] ?? "", p.bold)}${p.reset}${p.dim}:${p.reset} ${inlineSpans(r[0] ?? "", "")}`, W, "", ""));
|
|
523
|
+
const rest = t.header.slice(1).map((h, i) => `${h}: ${r[i + 1] ?? ""}`);
|
|
524
|
+
if (rest.length > 0)
|
|
525
|
+
out.push(...wrap(`${p.dim}${inlineSpans(rest.join(" · "), p.dim)}${p.reset}`, W, "", ""));
|
|
526
|
+
}
|
|
527
|
+
return out.length > 0 ? out : [row0(t)];
|
|
528
|
+
}
|
|
529
|
+
/** A table with a header and no body rows yet (the streaming case):
|
|
530
|
+
* the header alone, so the block still says what it is. */
|
|
531
|
+
function row0(t) {
|
|
532
|
+
const p = palette();
|
|
533
|
+
return `${p.bold}${t.header.join(" · ")}${p.reset}`;
|
|
534
|
+
}
|
|
535
|
+
// ---- the wrapper ----------------------------------------------------
|
|
536
|
+
const SGR_AT = /^\x1b\[[0-9;]*m/;
|
|
537
|
+
/** CJK closing punctuation — may not OPEN a row (a line that begins
|
|
538
|
+
* with a comma reads as broken). The smallest honest kinsoku set. */
|
|
539
|
+
const NO_START = "、。,.:;?!)」』】〕·…”’";
|
|
540
|
+
/** CJK opening punctuation — may not END one. */
|
|
541
|
+
const NO_END = "(「『【〔“‘";
|
|
542
|
+
/** May a row break between `prev` and `ch`? Only where a script allows
|
|
543
|
+
* it — and never so that a closing mark opens a row or an opening mark
|
|
544
|
+
* ends one. */
|
|
545
|
+
function breaks(prev, ch) {
|
|
546
|
+
if (NO_START.includes(ch) || NO_END.includes(prev))
|
|
547
|
+
return false;
|
|
548
|
+
return breakable(ch.codePointAt(0)) || breakable(prev.codePointAt(0));
|
|
549
|
+
}
|
|
550
|
+
/** Split styled text into break-eligible tokens. SGR sequences are
|
|
551
|
+
* zero-width and ride the token they precede; spaces are their own
|
|
552
|
+
* tokens (they vanish at a break); a CJK character is its own token,
|
|
553
|
+
* which is the whole fix — a space-free run stops being one word. */
|
|
554
|
+
function tokens(text) {
|
|
555
|
+
const out = [];
|
|
556
|
+
let cur = "";
|
|
557
|
+
let w = 0;
|
|
558
|
+
let prev = "";
|
|
559
|
+
const flush = () => {
|
|
560
|
+
if (cur === "")
|
|
561
|
+
return;
|
|
562
|
+
out.push({ text: cur, w, space: false });
|
|
563
|
+
cur = "";
|
|
564
|
+
w = 0;
|
|
565
|
+
};
|
|
566
|
+
for (let i = 0; i < text.length;) {
|
|
567
|
+
const m = SGR_AT.exec(text.slice(i));
|
|
568
|
+
if (m !== null) {
|
|
569
|
+
cur += m[0];
|
|
570
|
+
i += m[0].length;
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
// code POINT stepping — a surrogate pair is one character and can
|
|
574
|
+
// never be cut in half (the halves would measure one column each
|
|
575
|
+
// while the terminal draws two replacement glyphs)
|
|
576
|
+
const cp = text.codePointAt(i);
|
|
577
|
+
const ch = String.fromCodePoint(cp);
|
|
578
|
+
i += ch.length;
|
|
579
|
+
if (ch === " " || ch === "\t") {
|
|
580
|
+
flush();
|
|
581
|
+
out.push({ text: " ", w: 1, space: true });
|
|
582
|
+
prev = ch;
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (cur !== "" && prev !== "" && breaks(prev, ch))
|
|
586
|
+
flush();
|
|
587
|
+
cur += ch;
|
|
588
|
+
w += charWidth(cp);
|
|
589
|
+
prev = ch;
|
|
590
|
+
}
|
|
591
|
+
flush();
|
|
592
|
+
return out;
|
|
593
|
+
}
|
|
594
|
+
/** The SGR spans still open after `text`, given those open before it. */
|
|
595
|
+
function opensAfter(text, before) {
|
|
596
|
+
let open = [...before];
|
|
597
|
+
for (const m of text.matchAll(/\x1b\[[0-9;]*m/g)) {
|
|
598
|
+
if (m[0] === "\x1b[0m")
|
|
599
|
+
open = [];
|
|
600
|
+
else if (m[0] === "\x1b[23m")
|
|
601
|
+
open = open.filter((s) => s !== "\x1b[3m");
|
|
602
|
+
else
|
|
603
|
+
open.push(m[0]);
|
|
604
|
+
}
|
|
605
|
+
return open;
|
|
606
|
+
}
|
|
607
|
+
/** Break one over-wide token by code point — a long identifier or URL
|
|
608
|
+
* that cannot fit a whole row. Never a truncation: every piece is
|
|
609
|
+
* emitted. */
|
|
610
|
+
function pieces(text, room) {
|
|
611
|
+
const out = [];
|
|
612
|
+
let cur = "";
|
|
613
|
+
let w = 0;
|
|
614
|
+
for (let i = 0; i < text.length;) {
|
|
615
|
+
const m = SGR_AT.exec(text.slice(i));
|
|
616
|
+
if (m !== null) {
|
|
617
|
+
cur += m[0];
|
|
618
|
+
i += m[0].length;
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
const cp = text.codePointAt(i);
|
|
622
|
+
const ch = String.fromCodePoint(cp);
|
|
623
|
+
const cw = charWidth(cp);
|
|
624
|
+
if (w + cw > room && w > 0) {
|
|
625
|
+
out.push(cur);
|
|
626
|
+
cur = "";
|
|
627
|
+
w = 0;
|
|
628
|
+
}
|
|
629
|
+
cur += ch;
|
|
630
|
+
w += cw;
|
|
631
|
+
i += ch.length;
|
|
632
|
+
}
|
|
633
|
+
if (cur !== "")
|
|
634
|
+
out.push(cur);
|
|
635
|
+
return out;
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Wrap styled text into rows of at most W columns, with a HANGING
|
|
639
|
+
* INDENT: `first` prefixes the first row, `hang` every later one, and
|
|
640
|
+
* the text column is what continuations align to.
|
|
641
|
+
*
|
|
642
|
+
* The SGR spans open at a break are closed at the row's end and
|
|
643
|
+
* reopened at the next row's start, so no style leaks into the padding
|
|
644
|
+
* and none is lost across the break. Italic's own close (23) is
|
|
645
|
+
* understood, so `\x1b[3m…\x1b[23m` inside a bold heading tracks
|
|
646
|
+
* correctly.
|
|
647
|
+
*
|
|
648
|
+
* Every emitted row measures ≤ W through the SAME width authority the
|
|
649
|
+
* compositor's invariant ① measures with — which is the only way the
|
|
650
|
+
* two can agree.
|
|
651
|
+
*/
|
|
652
|
+
export function mdWrap(text, W, first, hang) {
|
|
653
|
+
const rows = [];
|
|
654
|
+
// a degenerate geometry (a deeply nested marker in a very narrow
|
|
655
|
+
// terminal) can make the PREFIX itself wider than the row. The prefix
|
|
656
|
+
// is chrome we generate, so it yields: it is cut to leave room for one
|
|
657
|
+
// WIDE character (two columns — a row that cannot hold one CJK glyph
|
|
658
|
+
// cannot hold the content it exists for), and the invariant holds
|
|
659
|
+
// instead of throwing on our own decoration.
|
|
660
|
+
const fit = (s) => (visibleWidth(s) <= W - 2 ? s : (pieces(s, Math.max(0, W - 2))[0] ?? ""));
|
|
661
|
+
let prefix = fit(first);
|
|
662
|
+
let room = Math.max(1, W - visibleWidth(prefix));
|
|
663
|
+
let line = "";
|
|
664
|
+
let w = 0;
|
|
665
|
+
let open = [];
|
|
666
|
+
let pend = "";
|
|
667
|
+
let pendW = 0;
|
|
668
|
+
const close = () => {
|
|
669
|
+
rows.push(`${prefix}${line}${open.length > 0 ? "\x1b[0m" : ""}`);
|
|
670
|
+
prefix = fit(hang);
|
|
671
|
+
room = Math.max(1, W - visibleWidth(prefix));
|
|
672
|
+
line = open.join("");
|
|
673
|
+
w = 0;
|
|
674
|
+
pend = "";
|
|
675
|
+
pendW = 0;
|
|
676
|
+
};
|
|
677
|
+
for (const t of tokens(text)) {
|
|
678
|
+
if (t.space) {
|
|
679
|
+
// a space at a break vanishes; inside a row it is held until the
|
|
680
|
+
// next word earns it
|
|
681
|
+
if (w > 0) {
|
|
682
|
+
pend += t.text;
|
|
683
|
+
pendW += t.w;
|
|
684
|
+
}
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
if (w > 0 && w + pendW + t.w > room)
|
|
688
|
+
close();
|
|
689
|
+
if (t.w > room) {
|
|
690
|
+
// too wide for any row: break it by code point, each piece its own
|
|
691
|
+
// row except the last, which carries on
|
|
692
|
+
const parts = pieces(t.text, room);
|
|
693
|
+
for (let k = 0; k < parts.length; k += 1) {
|
|
694
|
+
if (k > 0)
|
|
695
|
+
close();
|
|
696
|
+
line += parts[k];
|
|
697
|
+
w += k === parts.length - 1 ? visibleWidth(parts[k]) : room;
|
|
698
|
+
open = opensAfter(parts[k], open);
|
|
699
|
+
}
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
line += pend + t.text;
|
|
703
|
+
w += pendW + t.w;
|
|
704
|
+
open = opensAfter(pend + t.text, open);
|
|
705
|
+
pend = "";
|
|
706
|
+
pendW = 0;
|
|
707
|
+
}
|
|
708
|
+
rows.push(`${prefix}${line}${open.length > 0 ? "\x1b[0m" : ""}`);
|
|
709
|
+
return rows;
|
|
710
|
+
}
|
|
711
|
+
/** The block-level entry: wrap `text` under a first/hang prefix pair. */
|
|
712
|
+
function wrap(text, W, first, hang) {
|
|
713
|
+
return mdWrap(text, W, first, hang);
|
|
714
|
+
}
|
|
715
|
+
/** A fence body line: code, not prose — it still wraps rather than
|
|
716
|
+
* truncating (the no-silent-truncate ruling), every row carries the
|
|
717
|
+
* gutter, and the source line's own indent prefixes every row. */
|
|
718
|
+
function foldLineWidth(line, W, indent = "") {
|
|
719
|
+
return mdWrap(line, Math.max(1, W), indent, indent);
|
|
720
|
+
}
|
package/dist/render.d.ts
CHANGED
|
@@ -43,6 +43,16 @@ export interface Palette {
|
|
|
43
43
|
* missing member, not a fourth colour. */
|
|
44
44
|
readonly warn: string;
|
|
45
45
|
readonly code: string;
|
|
46
|
+
/** TUI2-MD (MD-1, the owner's circle) — the markdown round's ONE new
|
|
47
|
+
* member. `*italic*` needs a rendering, and under the mono discipline
|
|
48
|
+
* the answer cannot be a colour: SGR 3 is an ATTRIBUTE, it costs the
|
|
49
|
+
* alphabet nothing chromatic, and a terminal without italics simply
|
|
50
|
+
* draws the text — a harmless degradation rather than a lie.
|
|
51
|
+
* It ships with its own close (23) for the same reason `rv` does: an
|
|
52
|
+
* italic span inside a bold heading must be able to end WITHOUT the
|
|
53
|
+
* SGR-0 that would strand the heading's own style. */
|
|
54
|
+
readonly italic: string;
|
|
55
|
+
readonly italicEnd: string;
|
|
46
56
|
readonly rv: string;
|
|
47
57
|
readonly rvEnd: string;
|
|
48
58
|
readonly reset: string;
|
package/dist/render.js
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* dependencies (the tui-cells package has none).
|
|
7
7
|
*/
|
|
8
8
|
import { charWidth, displayWidth } from "./width.js";
|
|
9
|
-
export const COLOR_ON = { bold: "\x1b[1m", dim: "\x1b[2m", red: "\x1b[31m", green: "\x1b[32m", warn: "\x1b[33m", code: "\x1b[38;5;252m", rv: "\x1b[7m", rvEnd: "\x1b[27m", reset: "\x1b[0m" };
|
|
10
|
-
export const COLOR_OFF = { bold: "", dim: "", red: "", green: "", warn: "", code: "", rv: "", rvEnd: "", reset: "" };
|
|
9
|
+
export const COLOR_ON = { bold: "\x1b[1m", dim: "\x1b[2m", red: "\x1b[31m", green: "\x1b[32m", warn: "\x1b[33m", code: "\x1b[38;5;252m", italic: "\x1b[3m", italicEnd: "\x1b[23m", rv: "\x1b[7m", rvEnd: "\x1b[27m", reset: "\x1b[0m" };
|
|
10
|
+
export const COLOR_OFF = { bold: "", dim: "", red: "", green: "", warn: "", code: "", italic: "", italicEnd: "", rv: "", rvEnd: "", reset: "" };
|
|
11
11
|
export function palette() {
|
|
12
12
|
return process.env.NO_COLOR === undefined && process.stdout.isTTY ? COLOR_ON : COLOR_OFF;
|
|
13
13
|
}
|
package/dist/width.d.ts
CHANGED
|
@@ -19,10 +19,24 @@
|
|
|
19
19
|
*/
|
|
20
20
|
/** A code point's display width: 2 for the wide ranges, 1 otherwise. */
|
|
21
21
|
export declare function charWidth(cp: number): number;
|
|
22
|
+
/** TUI2-MD ③ — may a row break immediately before/after this code
|
|
23
|
+
* point? True for the CJK scripts (they break between any two
|
|
24
|
+
* characters), false for everything else INCLUDING the wide
|
|
25
|
+
* pictographs. The wrapper asks this; the width table answers it. */
|
|
26
|
+
export declare function breakable(cp: number): boolean;
|
|
22
27
|
/** Display width of a code-point array (cursor math, scrolling). */
|
|
23
28
|
export declare function widthOf(chars: readonly number[]): number;
|
|
24
29
|
/** Display width of a string. */
|
|
25
30
|
export declare function displayWidth(text: string): number;
|
|
31
|
+
/** The visible width of a RENDERED line — the same table, asked with
|
|
32
|
+
* the SGR/CSI sequences skipped. The compositor's invariant ① measures
|
|
33
|
+
* with this, so every producer of a screen row must measure with it
|
|
34
|
+
* too. TUI2-MD ⑤: moved here verbatim from components.ts, where it had
|
|
35
|
+
* lived since the extraction. The markdown renderer needs it and
|
|
36
|
+
* components.ts needs the markdown renderer — and a width question
|
|
37
|
+
* belongs to the width authority anyway. components.ts re-exports it,
|
|
38
|
+
* so every existing importer and the barrel are untouched. */
|
|
39
|
+
export declare function visibleWidth(line: string): number;
|
|
26
40
|
/** A LEAD's display width — the prompt / the panel's phase lead,
|
|
27
41
|
* ANSI-stripped. W23: the ONE width authority shared by the editor
|
|
28
42
|
* (selfRender, #reflow), the compositor's #inputRow, and editCol — a
|
package/dist/width.js
CHANGED
|
@@ -58,62 +58,84 @@ const EMOJI_PRESENTATION = [
|
|
|
58
58
|
[0x2b50, 0x2b50],
|
|
59
59
|
[0x2b55, 0x2b55],
|
|
60
60
|
];
|
|
61
|
-
/**
|
|
62
|
-
|
|
61
|
+
/** TUI2-MD ③ — the CJK half of the wide table, split out so the SAME
|
|
62
|
+
* ranges answer a SECOND question: may a line break here? The width
|
|
63
|
+
* authority stays one table; the break class is a view of it, never a
|
|
64
|
+
* fork (a second copy would drift, and a drifted width table is the
|
|
65
|
+
* composer clobber all over again).
|
|
66
|
+
*
|
|
67
|
+
* These scripts break between any two characters, which is why a
|
|
68
|
+
* space-free CJK run must not be treated as one unbreakable word: a
|
|
69
|
+
* whitespace-only wrapper cannot place it at all. */
|
|
70
|
+
function cjkWide(cp) {
|
|
63
71
|
if (cp >= 0x1100 && cp <= 0x115f)
|
|
64
|
-
return
|
|
72
|
+
return true; // hangul jamo
|
|
65
73
|
if (cp >= 0x2e80 && cp <= 0x303e)
|
|
66
|
-
return
|
|
74
|
+
return true; // radicals .. CJK punctuation
|
|
67
75
|
if (cp >= 0x3041 && cp <= 0x33ff)
|
|
68
|
-
return
|
|
76
|
+
return true; // kana, CJK compat
|
|
69
77
|
if (cp >= 0x3400 && cp <= 0x4dbf)
|
|
70
|
-
return
|
|
78
|
+
return true; // CJK ext A
|
|
71
79
|
if (cp >= 0x4e00 && cp <= 0x9fff)
|
|
72
|
-
return
|
|
80
|
+
return true; // CJK unified
|
|
73
81
|
if (cp >= 0xa000 && cp <= 0xa4cf)
|
|
74
|
-
return
|
|
82
|
+
return true; // yi
|
|
75
83
|
if (cp >= 0xa960 && cp <= 0xa97f)
|
|
76
|
-
return
|
|
84
|
+
return true; // hangul jamo ext
|
|
77
85
|
if (cp >= 0xac00 && cp <= 0xd7a3)
|
|
78
|
-
return
|
|
86
|
+
return true; // hangul syllables
|
|
79
87
|
if (cp >= 0xf900 && cp <= 0xfaff)
|
|
80
|
-
return
|
|
88
|
+
return true; // CJK compat ideographs
|
|
81
89
|
if (cp >= 0xfe10 && cp <= 0xfe19)
|
|
82
|
-
return
|
|
90
|
+
return true; // vertical forms
|
|
83
91
|
if (cp >= 0xfe30 && cp <= 0xfe6f)
|
|
84
|
-
return
|
|
92
|
+
return true; // CJK compat forms
|
|
85
93
|
if (cp >= 0xff00 && cp <= 0xff60)
|
|
86
|
-
return
|
|
94
|
+
return true; // fullwidth forms
|
|
87
95
|
if (cp >= 0xffe0 && cp <= 0xffe6)
|
|
88
|
-
return
|
|
96
|
+
return true; // fullwidth signs
|
|
97
|
+
return cp >= 0x20000 && cp <= 0x3fffd; // CJK ext B..G
|
|
98
|
+
}
|
|
99
|
+
/** The PICTOGRAPHIC half: wide, and deliberately NOT breakable — a
|
|
100
|
+
* ZWJ/variation sequence must survive a line break whole. */
|
|
101
|
+
function emojiWide(cp) {
|
|
89
102
|
// TUI2-R1.5 shipped only two of the pictographic ranges; the holes
|
|
90
103
|
// (transport, mahjong/cards, enclosed, colored shapes, the extended
|
|
91
104
|
// block) were scored ONE column while every terminal draws them in
|
|
92
105
|
// two — the composer clobber of the owner's field report (①).
|
|
93
106
|
if (cp === 0x1f004 || cp === 0x1f0cf)
|
|
94
|
-
return
|
|
107
|
+
return true; // mahjong red dragon, joker
|
|
95
108
|
if (cp >= 0x1f18e && cp <= 0x1f19a)
|
|
96
|
-
return
|
|
109
|
+
return true; // enclosed alphanumerics
|
|
97
110
|
if (cp >= 0x1f200 && cp <= 0x1f251)
|
|
98
|
-
return
|
|
111
|
+
return true; // enclosed ideographic
|
|
99
112
|
if (cp >= 0x1f300 && cp <= 0x1f64f)
|
|
100
|
-
return
|
|
113
|
+
return true; // emoji (misc + emoticons)
|
|
101
114
|
if (cp >= 0x1f680 && cp <= 0x1f6ff)
|
|
102
|
-
return
|
|
115
|
+
return true; // transport + map
|
|
103
116
|
if (cp >= 0x1f7e0 && cp <= 0x1f7eb)
|
|
104
|
-
return
|
|
117
|
+
return true; // colored circles + squares
|
|
105
118
|
if (cp >= 0x1f900 && cp <= 0x1f9ff)
|
|
106
|
-
return
|
|
119
|
+
return true; // supplemental emoji
|
|
107
120
|
if (cp >= 0x1fa70 && cp <= 0x1faff)
|
|
108
|
-
return
|
|
109
|
-
if (cp >= 0x20000 && cp <= 0x3fffd)
|
|
110
|
-
return 2; // CJK ext B..G
|
|
121
|
+
return true; // symbols + pictographs ext-A
|
|
111
122
|
if (cp >= 0x231a && cp <= 0x2b55) {
|
|
112
123
|
for (const [lo, hi] of EMOJI_PRESENTATION)
|
|
113
124
|
if (cp >= lo && cp <= hi)
|
|
114
|
-
return
|
|
125
|
+
return true;
|
|
115
126
|
}
|
|
116
|
-
return
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
/** A code point's display width: 2 for the wide ranges, 1 otherwise. */
|
|
130
|
+
export function charWidth(cp) {
|
|
131
|
+
return cjkWide(cp) || emojiWide(cp) ? 2 : 1;
|
|
132
|
+
}
|
|
133
|
+
/** TUI2-MD ③ — may a row break immediately before/after this code
|
|
134
|
+
* point? True for the CJK scripts (they break between any two
|
|
135
|
+
* characters), false for everything else INCLUDING the wide
|
|
136
|
+
* pictographs. The wrapper asks this; the width table answers it. */
|
|
137
|
+
export function breakable(cp) {
|
|
138
|
+
return cjkWide(cp);
|
|
117
139
|
}
|
|
118
140
|
/** Display width of a code-point array (cursor math, scrolling). */
|
|
119
141
|
export function widthOf(chars) {
|
|
@@ -129,6 +151,31 @@ export function displayWidth(text) {
|
|
|
129
151
|
w += charWidth(ch.codePointAt(0));
|
|
130
152
|
return w;
|
|
131
153
|
}
|
|
154
|
+
/** The visible width of a RENDERED line — the same table, asked with
|
|
155
|
+
* the SGR/CSI sequences skipped. The compositor's invariant ① measures
|
|
156
|
+
* with this, so every producer of a screen row must measure with it
|
|
157
|
+
* too. TUI2-MD ⑤: moved here verbatim from components.ts, where it had
|
|
158
|
+
* lived since the extraction. The markdown renderer needs it and
|
|
159
|
+
* components.ts needs the markdown renderer — and a width question
|
|
160
|
+
* belongs to the width authority anyway. components.ts re-exports it,
|
|
161
|
+
* so every existing importer and the barrel are untouched. */
|
|
162
|
+
export function visibleWidth(line) {
|
|
163
|
+
let w = 0;
|
|
164
|
+
for (let i = 0; i < line.length;) {
|
|
165
|
+
if (line[i] === "\x1b") {
|
|
166
|
+
const m = /^\x1b\[[0-9;?]*[A-Za-z]/.exec(line.slice(i));
|
|
167
|
+
if (m !== null) {
|
|
168
|
+
i += m[0].length;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
i += 1;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
w += displayWidth(line[i]);
|
|
175
|
+
i += 1;
|
|
176
|
+
}
|
|
177
|
+
return w;
|
|
178
|
+
}
|
|
132
179
|
/** A LEAD's display width — the prompt / the panel's phase lead,
|
|
133
180
|
* ANSI-stripped. W23: the ONE width authority shared by the editor
|
|
134
181
|
* (selfRender, #reflow), the compositor's #inputRow, and editCol — a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-tui-cells",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "kiso tui-cells — the components cell renderer (components, diff, width, the render slice). Zero runtime dependencies: input is data, output is bytes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|