@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.
@@ -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
- export declare function visibleWidth(line: string): number;
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;
@@ -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
- export function visibleWidth(line) {
99
- let w = 0;
100
- for (let i = 0; i < line.length;) {
101
- if (line[i] === "\x1b") {
102
- const m = /^\x1b\[[0-9;?]*[A-Za-z]/.exec(line.slice(i));
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
- /** A code point's display width: 2 for the wide ranges, 1 otherwise. */
62
- export function charWidth(cp) {
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 2; // hangul jamo
72
+ return true; // hangul jamo
65
73
  if (cp >= 0x2e80 && cp <= 0x303e)
66
- return 2; // radicals .. CJK punctuation
74
+ return true; // radicals .. CJK punctuation
67
75
  if (cp >= 0x3041 && cp <= 0x33ff)
68
- return 2; // kana, CJK compat
76
+ return true; // kana, CJK compat
69
77
  if (cp >= 0x3400 && cp <= 0x4dbf)
70
- return 2; // CJK ext A
78
+ return true; // CJK ext A
71
79
  if (cp >= 0x4e00 && cp <= 0x9fff)
72
- return 2; // CJK unified
80
+ return true; // CJK unified
73
81
  if (cp >= 0xa000 && cp <= 0xa4cf)
74
- return 2; // yi
82
+ return true; // yi
75
83
  if (cp >= 0xa960 && cp <= 0xa97f)
76
- return 2; // hangul jamo ext
84
+ return true; // hangul jamo ext
77
85
  if (cp >= 0xac00 && cp <= 0xd7a3)
78
- return 2; // hangul syllables
86
+ return true; // hangul syllables
79
87
  if (cp >= 0xf900 && cp <= 0xfaff)
80
- return 2; // CJK compat ideographs
88
+ return true; // CJK compat ideographs
81
89
  if (cp >= 0xfe10 && cp <= 0xfe19)
82
- return 2; // vertical forms
90
+ return true; // vertical forms
83
91
  if (cp >= 0xfe30 && cp <= 0xfe6f)
84
- return 2; // CJK compat forms
92
+ return true; // CJK compat forms
85
93
  if (cp >= 0xff00 && cp <= 0xff60)
86
- return 2; // fullwidth forms
94
+ return true; // fullwidth forms
87
95
  if (cp >= 0xffe0 && cp <= 0xffe6)
88
- return 2; // fullwidth signs
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 2; // mahjong red dragon, joker
107
+ return true; // mahjong red dragon, joker
95
108
  if (cp >= 0x1f18e && cp <= 0x1f19a)
96
- return 2; // enclosed alphanumerics
109
+ return true; // enclosed alphanumerics
97
110
  if (cp >= 0x1f200 && cp <= 0x1f251)
98
- return 2; // enclosed ideographic
111
+ return true; // enclosed ideographic
99
112
  if (cp >= 0x1f300 && cp <= 0x1f64f)
100
- return 2; // emoji (misc + emoticons)
113
+ return true; // emoji (misc + emoticons)
101
114
  if (cp >= 0x1f680 && cp <= 0x1f6ff)
102
- return 2; // transport + map
115
+ return true; // transport + map
103
116
  if (cp >= 0x1f7e0 && cp <= 0x1f7eb)
104
- return 2; // colored circles + squares
117
+ return true; // colored circles + squares
105
118
  if (cp >= 0x1f900 && cp <= 0x1f9ff)
106
- return 2; // supplemental emoji
119
+ return true; // supplemental emoji
107
120
  if (cp >= 0x1fa70 && cp <= 0x1faff)
108
- return 2; // symbols + pictographs ext-A
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 2;
125
+ return true;
115
126
  }
116
- return 1;
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.10.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",