@vincemakes/kiso-tui-cells 0.10.0 → 0.12.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;
@@ -332,6 +350,34 @@ export declare function statusLine(status: string, tail: string, W: number, hint
332
350
  /** The display-width prefix of a plain (SGR-free) text. W21: exported
333
351
  * for the approval panel's option-2 rule-name cut. */
334
352
  export declare function widthCut(text: string, max: number): string;
353
+ /**
354
+ * TUI2-R3v2 ① — THE selection bar. One engine, every selection surface.
355
+ *
356
+ * The R1.5 ⑧ ruling settled the shape (a full-row reverse bar, not a
357
+ * two-cell marker you have to hunt for in eighty columns) and the @
358
+ * picker, the user chip and the R2 session picker each grew their own
359
+ * copy of the composition. The approval panel would have been the
360
+ * fourth, so the composition moves HERE and the surfaces call it.
361
+ *
362
+ * Two details are the whole reason this is a function and not four
363
+ * inlined string templates:
364
+ *
365
+ * - the inner `reset`s are rewritten to reset-then-reverse. A plain SGR
366
+ * 0 inside the bar punches a hole in it: the row goes back to normal
367
+ * video mid-span and the bar reads as two bars with a gap. The close
368
+ * is SGR 27 (rvEnd), never SGR 0, for the same reason — the bar
369
+ * composes INSIDE whatever span surrounds it.
370
+ * - the pad is computed from the caller's measured VISIBLE width, never
371
+ * from the styled string's length. A bar that stops short is not a
372
+ * bar, and one that runs past W crashes the compositor's invariant ①
373
+ * rather than truncating quietly — so the arithmetic is stated once,
374
+ * here, and proven once, in the sweep gates.
375
+ *
376
+ * The bar spends one cell of frame at each end, so callers build their
377
+ * spans against W−2 whether the row is selected or not — which is what
378
+ * keeps the columns from moving as the bar walks the list.
379
+ */
380
+ export declare function selectionBar(styled: string, visible: number, W: number): string;
335
381
  /** W6 — the box: the chrome's top rail. The two ╌ dotted rows become
336
382
  * a rounded box (the box already says "input lives here"); the rails
337
383
  * stay dim, the width is still the full W (the box is a rail with
@@ -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;
@@ -1401,6 +1411,38 @@ export function widthCut(text, max) {
1401
1411
  }
1402
1412
  return text.slice(0, i);
1403
1413
  }
1414
+ /**
1415
+ * TUI2-R3v2 ① — THE selection bar. One engine, every selection surface.
1416
+ *
1417
+ * The R1.5 ⑧ ruling settled the shape (a full-row reverse bar, not a
1418
+ * two-cell marker you have to hunt for in eighty columns) and the @
1419
+ * picker, the user chip and the R2 session picker each grew their own
1420
+ * copy of the composition. The approval panel would have been the
1421
+ * fourth, so the composition moves HERE and the surfaces call it.
1422
+ *
1423
+ * Two details are the whole reason this is a function and not four
1424
+ * inlined string templates:
1425
+ *
1426
+ * - the inner `reset`s are rewritten to reset-then-reverse. A plain SGR
1427
+ * 0 inside the bar punches a hole in it: the row goes back to normal
1428
+ * video mid-span and the bar reads as two bars with a gap. The close
1429
+ * is SGR 27 (rvEnd), never SGR 0, for the same reason — the bar
1430
+ * composes INSIDE whatever span surrounds it.
1431
+ * - the pad is computed from the caller's measured VISIBLE width, never
1432
+ * from the styled string's length. A bar that stops short is not a
1433
+ * bar, and one that runs past W crashes the compositor's invariant ①
1434
+ * rather than truncating quietly — so the arithmetic is stated once,
1435
+ * here, and proven once, in the sweep gates.
1436
+ *
1437
+ * The bar spends one cell of frame at each end, so callers build their
1438
+ * spans against W−2 whether the row is selected or not — which is what
1439
+ * keeps the columns from moving as the bar walks the list.
1440
+ */
1441
+ export function selectionBar(styled, visible, W) {
1442
+ const p = palette();
1443
+ const inner = styled.replaceAll(p.reset, `${p.reset}${p.rv}`);
1444
+ return `${p.rv} ${inner}${" ".repeat(Math.max(0, W - visible - 2))} ${p.rvEnd}`;
1445
+ }
1404
1446
  /** W6 — the box: the chrome's top rail. The two ╌ dotted rows become
1405
1447
  * a rounded box (the box already says "input lives here"); the rails
1406
1448
  * stay dim, the width is still the full W (the box is a rail with
package/dist/index.d.ts CHANGED
@@ -10,7 +10,7 @@ export { SPINNER, foldLine, foldWords, visibleWidth, bodySpacing, Container, cel
10
10
  export { editFileDiff, truncateDiff, writeFileDiff, type DiffLine, type DiffResult } from "./diff.js";
11
11
  export { pendingQueueRows } from "./components.js";
12
12
  export { charWidth, displayWidth, leadWidth, widthOf } from "./width.js";
13
- export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWidth, panelStatus, type PanelArgs, type PanelFlavor, type PanelPhase, type PanelSel, type PanelState, type PanelVerdict, type PanelView, type AskAnswer, type AskOption, type AskQuestion, type AskResult, type AskRuntime, type AskSpec, } from "./approval-panel.js";
13
+ export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWidth, panelStatus, type PanelArgs, type PanelFlavor, type PanelPhase, deletionRiskHint, SAFER_BACK, SAFER_DEGRADED, type SaferOption, type SaferRuntime, panelOptions, type PanelOption, type PanelOptionKind, type PanelState, type PanelVerdict, type PanelView, type AskAnswer, type AskOption, type AskQuestion, type AskResult, type AskRuntime, type AskSpec, } from "./approval-panel.js";
14
14
  export { interactivePrompt, projectTrustRows, projectTrustView, projectUntrustedNote, uncertainView, type TrustArtifact, } from "./strings.js";
15
15
  export { extensionsBannerText, helpRows, unansweredAskView, type BannerExtension } from "./strings.js";
16
16
  export { displayVerb } from "./strings.js";
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ export { charWidth, displayWidth, leadWidth, widthOf } from "./width.js";
17
17
  // that replaces the running tool's live window while a human-chain
18
18
  // approval is pending. Types + the row/lead/status renderers; the
19
19
  // verdict mapping lives in the cli, never here.
20
- export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWidth, panelStatus, } from "./approval-panel.js";
20
+ export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWidth, panelStatus, deletionRiskHint, SAFER_BACK, SAFER_DEGRADED, panelOptions, } from "./approval-panel.js";
21
21
  // KC3 slice 1 (the extraction): the human-facing strings the CLI's
22
22
  // trust-ui.ts used to build inline — the prompt, the project-trust
23
23
  // listing/view/note, the uncertain execution's view. The flow stays in
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[];