@vincemakes/kiso-tui-cells 0.1.41

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/diff.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * v2e — the diff renderer: edit/write changes as inline ± lines, zero
3
+ * dependencies, no syntax highlighting (the spec's scope line). Shown at
4
+ * the approval moment ONLY — the frozen summary stays one line (v2d's
5
+ * anti-leak principle), /last has the full data.
6
+ *
7
+ * edit_file diffs IN PLACE (the search→replace windows are known — no
8
+ * general engine needed); write_file does a row-level LCS over the old
9
+ * file (small files are the target). Context: 2 rows each side. The
10
+ * RENDERER truncates (18 head + 18 tail + "… N lines"); the stats come
11
+ * from the full diff.
12
+ */
13
+ /** The diff block's per-row kind. */
14
+ export type DiffLine = {
15
+ kind: "-" | "+" | " ";
16
+ text: string;
17
+ };
18
+ export interface DiffResult {
19
+ /** The FULL diff (with context, not truncated) — the display truncates. */
20
+ lines: DiffLine[];
21
+ added: number;
22
+ removed: number;
23
+ }
24
+ /** The RENDERER's truncation: head + "… N lines (/last for full)" + tail. */
25
+ export declare function truncateDiff(diff: DiffLine[]): DiffLine[];
26
+ /** edit_file: the search→replace windows replace in place — the changed
27
+ * region is KNOWN, so the diff is the old window vs the new window,
28
+ * context from the surrounding file. */
29
+ export declare function editFileDiff(oldContent: string, search: string, replace: string): DiffResult;
30
+ /** write_file: a new file is all +; an existing file diffs row-level
31
+ * against its old content. */
32
+ export declare function writeFileDiff(oldContent: string | null, newContent: string): DiffResult;
package/dist/diff.js ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * v2e — the diff renderer: edit/write changes as inline ± lines, zero
3
+ * dependencies, no syntax highlighting (the spec's scope line). Shown at
4
+ * the approval moment ONLY — the frozen summary stays one line (v2d's
5
+ * anti-leak principle), /last has the full data.
6
+ *
7
+ * edit_file diffs IN PLACE (the search→replace windows are known — no
8
+ * general engine needed); write_file does a row-level LCS over the old
9
+ * file (small files are the target). Context: 2 rows each side. The
10
+ * RENDERER truncates (18 head + 18 tail + "… N lines"); the stats come
11
+ * from the full diff.
12
+ */
13
+ /** A line-level LCS diff — the classic two-row DP, ~small inputs. */
14
+ function lcsDiff(oldLines, newLines) {
15
+ const n = oldLines.length;
16
+ const m = newLines.length;
17
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
18
+ for (let i = n - 1; i >= 0; i -= 1) {
19
+ for (let j = m - 1; j >= 0; j -= 1) {
20
+ dp[i][j] = oldLines[i] === newLines[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
21
+ }
22
+ }
23
+ const out = [];
24
+ let i = 0;
25
+ let j = 0;
26
+ while (i < n && j < m) {
27
+ if (oldLines[i] === newLines[j]) {
28
+ out.push({ kind: " ", text: oldLines[i] });
29
+ i += 1;
30
+ j += 1;
31
+ }
32
+ else if (dp[i + 1][j] >= dp[i][j + 1]) {
33
+ out.push({ kind: "-", text: oldLines[i] });
34
+ i += 1;
35
+ }
36
+ else {
37
+ out.push({ kind: "+", text: newLines[j] });
38
+ j += 1;
39
+ }
40
+ }
41
+ while (i < n) {
42
+ out.push({ kind: "-", text: oldLines[i] });
43
+ i += 1;
44
+ }
45
+ while (j < m) {
46
+ out.push({ kind: "+", text: newLines[j] });
47
+ j += 1;
48
+ }
49
+ return out;
50
+ }
51
+ /** Keep 2 context rows around each change — the unified-style window. */
52
+ function withContext(diff) {
53
+ const out = [];
54
+ let lastAdded = -10;
55
+ for (let k = 0; k < diff.length; k += 1) {
56
+ if (diff[k].kind === " ")
57
+ continue;
58
+ const from = Math.max(0, k - 2);
59
+ const to = Math.min(diff.length - 1, k + 2);
60
+ for (let c = from; c <= to; c += 1) {
61
+ if (c > lastAdded) {
62
+ out.push(diff[c]);
63
+ lastAdded = c;
64
+ }
65
+ }
66
+ lastAdded = to;
67
+ }
68
+ return out;
69
+ }
70
+ const MAX_DIFF_LINES = 40; // the RENDERED cap
71
+ const TRUNCATE_KEEP = 18;
72
+ /** The RENDERER's truncation: head + "… N lines (/last for full)" + tail. */
73
+ export function truncateDiff(diff) {
74
+ if (diff.length <= MAX_DIFF_LINES)
75
+ return diff;
76
+ const omitted = diff.length - 2 * TRUNCATE_KEEP;
77
+ return [
78
+ ...diff.slice(0, TRUNCATE_KEEP),
79
+ { kind: " ", text: `… ${omitted} lines (/last for full)` },
80
+ ...diff.slice(diff.length - TRUNCATE_KEEP),
81
+ ];
82
+ }
83
+ function stats(diff) {
84
+ let added = 0;
85
+ let removed = 0;
86
+ for (const d of diff) {
87
+ if (d.kind === "+")
88
+ added += 1;
89
+ else if (d.kind === "-")
90
+ removed += 1;
91
+ }
92
+ return { added, removed };
93
+ }
94
+ /** edit_file: the search→replace windows replace in place — the changed
95
+ * region is KNOWN, so the diff is the old window vs the new window,
96
+ * context from the surrounding file. */
97
+ export function editFileDiff(oldContent, search, replace) {
98
+ const oldLines = oldContent.split("\n");
99
+ const searchLines = search.split("\n");
100
+ const replaceLines = replace.split("\n");
101
+ // Locate the search window (the first occurrence — the edit tool's own
102
+ // semantics); no occurrence → the whole file is the old side.
103
+ let at = -1;
104
+ for (let i = 0; i + searchLines.length <= oldLines.length; i += 1) {
105
+ if (oldLines.slice(i, i + searchLines.length).join("\n") === search) {
106
+ at = i;
107
+ break;
108
+ }
109
+ }
110
+ const lines = at < 0 ? withContext(lcsDiff(oldLines, replaceLines)) : withContext(lcsDiff(oldLines, [...oldLines.slice(0, at), ...replaceLines, ...oldLines.slice(at + searchLines.length)]));
111
+ return { lines, ...stats(lines) };
112
+ }
113
+ /** write_file: a new file is all +; an existing file diffs row-level
114
+ * against its old content. */
115
+ export function writeFileDiff(oldContent, newContent) {
116
+ if (oldContent === null) {
117
+ const lines = newContent.split("\n").map((text) => ({ kind: "+", text }));
118
+ return { lines, added: lines.length, removed: 0 };
119
+ }
120
+ const lines = withContext(lcsDiff(oldContent.split("\n"), newContent.split("\n")));
121
+ return { lines, ...stats(lines) };
122
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * kiso-tui-cells — the components cell renderer, extracted from the
3
+ * tui (ADR-0043 Amendment 4): components.ts, diff.ts, width.ts, and
4
+ * the render slice. ZERO runtime dependencies: input is data, output
5
+ * is bytes. The tui is the stable consumer (its shims re-export this
6
+ * package); the cli never imports it directly. Experimental — no
7
+ * API-stability promise yet.
8
+ */
9
+ export { SPINNER, foldLine, visibleWidth, bodySpacing, Container, cellComponent, ROLLUP_NOUN, turnFold, CAP_TASK_LIVE, formatDuration, statusLine, boxTop, boxBottom, terminalPipe, type FrameCtx, type RenderLine, type Component, type BodyCell, } from "./components.js";
10
+ export { editFileDiff, truncateDiff, writeFileDiff, type DiffLine, type DiffResult } from "./diff.js";
11
+ export { pendingQueueRows } from "./components.js";
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, } from "./approval-panel.js";
14
+ export { bannerLines, COLOR_OFF, COLOR_ON, colorInlineCode, escapeTerminal, foldResult, foldThinking, kUnit, palette, relativeTime, renderResumeList, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, type Palette, type ResumeMeta, } from "./render.js";
package/dist/index.js ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * kiso-tui-cells — the components cell renderer, extracted from the
3
+ * tui (ADR-0043 Amendment 4): components.ts, diff.ts, width.ts, and
4
+ * the render slice. ZERO runtime dependencies: input is data, output
5
+ * is bytes. The tui is the stable consumer (its shims re-export this
6
+ * package); the cli never imports it directly. Experimental — no
7
+ * API-stability promise yet.
8
+ */
9
+ export { SPINNER, foldLine, visibleWidth, bodySpacing, Container, cellComponent, ROLLUP_NOUN, turnFold, CAP_TASK_LIVE, formatDuration, statusLine, boxTop, boxBottom, terminalPipe, } from "./components.js";
10
+ export { editFileDiff, truncateDiff, writeFileDiff } from "./diff.js";
11
+ // W22 (the v8 input round): the pending-queue chips — the SAME
12
+ // UserMessage chip with the □ gutter, pre-rendered above the input
13
+ // row while turns wait in the queue.
14
+ export { pendingQueueRows } from "./components.js";
15
+ export { charWidth, displayWidth, leadWidth, widthOf } from "./width.js";
16
+ // W21 (the v8 approval round): the approval panel — the bounded block
17
+ // that replaces the running tool's live window while a human-chain
18
+ // approval is pending. Types + the row/lead/status renderers; the
19
+ // verdict mapping lives in the cli, never here.
20
+ export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWidth, panelStatus, } from "./approval-panel.js";
21
+ export { bannerLines, COLOR_OFF, COLOR_ON, colorInlineCode, escapeTerminal, foldResult, foldThinking, kUnit, palette, relativeTime, renderResumeList, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, } from "./render.js";
@@ -0,0 +1,129 @@
1
+ /**
2
+ * tui-cells — the render slice (ADR-0043 Amendment 4): the
3
+ * cell-rendering helpers components.ts imports — moved verbatim from
4
+ * the tui's render.ts, never duplicated. Pure (testable): given text,
5
+ * produce the bytes a human sees. Colors are raw ANSI — zero
6
+ * dependencies (the tui-cells package has none).
7
+ */
8
+ /**
9
+ * v2a — the palette, centralized (no hard-coded codes elsewhere); v5
10
+ * (TUI v5 #16e, the v4.1 design): the decorative blue (38;5;75) is
11
+ * RETIRED — the identity accents (the you> prompt, the banner tagline,
12
+ * ✓ marks, slash-command names, the input brick) are bright-white BOLD
13
+ * (SGR 1); the user message is the SGR-7 chip (the 2026-08-09 ruling
14
+ * retired the ▍ rail); `code` is the content semantic tint for
15
+ * inline code spans in assistant text (256-color 110 — the cube color
16
+ * nearest the design's #8fb4d8); red for errors, dim for metadata,
17
+ * green for the diff additions. NO_COLOR set, or a non-TTY output →
18
+ * every code is empty, so pipes and CI carry ZERO ANSI (the existing
19
+ * byte-level e2e assertions guard it). Everything not listed is plain.
20
+ */
21
+ export interface Palette {
22
+ readonly bold: string;
23
+ readonly dim: string;
24
+ readonly red: string;
25
+ readonly green: string;
26
+ readonly code: string;
27
+ readonly rv: string;
28
+ readonly rvEnd: string;
29
+ readonly reset: string;
30
+ }
31
+ export declare const COLOR_ON: Palette;
32
+ export declare const COLOR_OFF: Palette;
33
+ export declare function palette(): Palette;
34
+ /**
35
+ * E group/round 8: strip terminal-injection vectors from MODEL/TOOL text before it
36
+ * reaches the terminal — ESC, C0 (except \t \n), C1, CR, backspace, and
37
+ * bidi overrides. The kiso colors are applied by render, not by the data.
38
+ * EVERY externally-sourced string must pass through this before any output.
39
+ */
40
+ export declare function escapeTerminal(text: string): string;
41
+ /**
42
+ * TUI v5 #16e: the inline-code tint — backtick spans in ONE line of
43
+ * assistant body text get the `code` color. Deliberately NOT a markdown
44
+ * engine: single level only (`[^`]*` cannot nest), a span never matches
45
+ * across lines (the caller passes one line; an opener without a closer
46
+ * on the same line stays plain). NO_COLOR / pipes → the codes are empty
47
+ * strings → the line passes through byte-identical.
48
+ */
49
+ export declare function colorInlineCode(line: string): string;
50
+ /**
51
+ * v2b — one thinking BLOCK folds to ONE dim line: the first 100 chars, a
52
+ * " (… /think shows full)" marker when the block is longer. The consumer
53
+ * buffers the block's deltas, renders this at the block's end, and keeps
54
+ * the full text for /think. Pipes get the same fold — the content
55
+ * strategy is presentation-independent.
56
+ */
57
+ export declare function foldThinking(block: string): string;
58
+ /** v2b — the [result] echo truncates at 160 chars + a /last hint. */
59
+ export declare function foldResult(content: string): string;
60
+ /**
61
+ * B area: one-line summary of a completed tool call, e.g.
62
+ * ✓ edit src/foo.ts (+12 -3) ✓ read src/bar.ts (140 lines)
63
+ * ✗ shell npm test (exit 1)
64
+ * edit/write show +/- line counts, read shows lines, shell shows the exit
65
+ * code; failures (isError) are ✗. Pure and deterministic.
66
+ */
67
+ export declare function renderToolSummary(name: string, input: Record<string, unknown>, result: {
68
+ content: string;
69
+ isError: boolean;
70
+ }, reason?: string | null): string;
71
+ /** W15 — the expand header's target: the tool call's subject (the path
72
+ * for the *_file tools, the command for shell) — the same extraction
73
+ * the summary detail uses, WITHOUT the counts (the header names what
74
+ * was expanded, not its size). */
75
+ export declare function toolTarget(name: string, input: Record<string, unknown>): string;
76
+ /** k-units for the status line: 12345 → 12.3k, 800 → 800, null → ?. */
77
+ export declare function kUnit(value: number | null): string;
78
+ /**
79
+ * v2a rhythm — the exact bytes after a terminal event: the status line
80
+ * hugs the terminal (show what there is — omitted when there is nothing to show),
81
+ * then EXACTLY one blank line before the next prompt. The consumer prints
82
+ * this verbatim; the render tests pin the sequence.
83
+ */
84
+ export declare function renderTerminalGap(statusLine: string | null): string;
85
+ /**
86
+ * v3 §01 (V6-2) — the banner, block-split. The logo is THREE BRICK rows
87
+ * (the logo.svg pixel form — K I S O), then a BLANK, then the info rows:
88
+ * "kiso vX — tagline" + extensions. The tagline rides the version line
89
+ * (the old logo MIDDLE row was the tagline — a text row masquerading as
90
+ * the logo's centre). Every row truncates at the terminal width with a
91
+ * " (+N)" marker (N = the hidden display width); a window narrower than
92
+ * 40 columns skips the logo + the blank entirely — only the info rows.
93
+ * Pure.
94
+ */
95
+ export declare const TAGLINE = "the coding agent that survives kill -9";
96
+ /** v3 §01 (W1): truncate a row at `width`, marking the hidden span
97
+ * " (+N)". W1: the width math is the charWidth authority (the banner's
98
+ * brick glyphs are 1 cell — the art's 38 columns clear 40), and the
99
+ * marker's own cells are part of the row — the visible cut leaves room
100
+ * for it, so a truncated row never exceeds W (a cut row carries the
101
+ * marker INSIDE the width; a row that fits is returned untouched). */
102
+ export declare function truncateRow(row: string, width: number): string;
103
+ /** v3 §01 (V6-2) + W1: the banner lines for a width W and height H —
104
+ * the tier table (extends the existing "under 40 columns, skip the
105
+ * logo" rule with a HEIGHT input):
106
+ * W ≥ 40 and H ≥ 20 → BIG (the 36x6 wordmark, 2-column indent)
107
+ * W ≥ 40 and 14–19 rows → COMPACT (v6's LOGO_ROWS, byte-identical)
108
+ * anything smaller → text rows only
109
+ * then the blank, then "vX — tagline" — the art IS the wordmark, so the
110
+ * text row does not repeat the name — then extensions — then the W5
111
+ * resume list (BIG only, W5). Every row truncates at the terminal width
112
+ * with a " (+N)" marker. Pure. */
113
+ export declare function bannerLines(W: number, H: number, version: string, extensionsText: string, resume?: readonly ResumeMeta[], now?: number): string[];
114
+ /** W5 — the opening-screen resume list. Every field already exists
115
+ * behind renderSessionLine / `kiso sessions`: the relative time, the
116
+ * title, then the right-aligned "N events · M runs". The columns are
117
+ * fixed per W: 4 indent + 7 when + 1 + the title (the ONLY flexible
118
+ * field — cut with the ellipsis marker INSIDE the width) + 1 + the meta
119
+ * (padStart to metaW). The done-when: the meta's right edge lands at
120
+ * exactly W on every row. Returns PLAIN rows — the banner's uniform dim
121
+ * wrap styles them (no dim+bold SGR composition). */
122
+ export interface ResumeMeta {
123
+ readonly title: string;
124
+ readonly events: number;
125
+ readonly runs: number;
126
+ readonly updatedAt: number;
127
+ }
128
+ export declare function relativeTime(updatedAt: number, now: number): string;
129
+ export declare function renderResumeList(metas: readonly ResumeMeta[], W: number, now: number): string[];
package/dist/render.js ADDED
@@ -0,0 +1,295 @@
1
+ /**
2
+ * tui-cells — the render slice (ADR-0043 Amendment 4): the
3
+ * cell-rendering helpers components.ts imports — moved verbatim from
4
+ * the tui's render.ts, never duplicated. Pure (testable): given text,
5
+ * produce the bytes a human sees. Colors are raw ANSI — zero
6
+ * dependencies (the tui-cells package has none).
7
+ */
8
+ import { charWidth, displayWidth } from "./width.js";
9
+ export const COLOR_ON = { bold: "\x1b[1m", dim: "\x1b[2m", red: "\x1b[31m", green: "\x1b[32m", code: "\x1b[38;5;110m", rv: "\x1b[7m", rvEnd: "\x1b[27m", reset: "\x1b[0m" };
10
+ export const COLOR_OFF = { bold: "", dim: "", red: "", green: "", code: "", rv: "", rvEnd: "", reset: "" };
11
+ export function palette() {
12
+ return process.env.NO_COLOR === undefined && process.stdout.isTTY ? COLOR_ON : COLOR_OFF;
13
+ }
14
+ /**
15
+ * E group/round 8: strip terminal-injection vectors from MODEL/TOOL text before it
16
+ * reaches the terminal — ESC, C0 (except \t \n), C1, CR, backspace, and
17
+ * bidi overrides. The kiso colors are applied by render, not by the data.
18
+ * EVERY externally-sourced string must pass through this before any output.
19
+ */
20
+ export function escapeTerminal(text) {
21
+ // eslint-disable-next-line no-control-regex
22
+ return text
23
+ .replace(/[\u0000-\u0008\u000d\u000e-\u001f\u007f]/g, "") // C0 (keeps only \t and \n)
24
+ .replace(/\u001b/g, "") // ESC
25
+ .replace(/[\u0080-\u009f]/g, "") // C1
26
+ .replace(/[\u202a-\u202e\u2066-\u2069]/g, ""); // bidi
27
+ }
28
+ /**
29
+ * TUI v5 #16e: the inline-code tint — backtick spans in ONE line of
30
+ * assistant body text get the `code` color. Deliberately NOT a markdown
31
+ * engine: single level only (`[^`]*` cannot nest), a span never matches
32
+ * across lines (the caller passes one line; an opener without a closer
33
+ * on the same line stays plain). NO_COLOR / pipes → the codes are empty
34
+ * strings → the line passes through byte-identical.
35
+ */
36
+ export function colorInlineCode(line) {
37
+ const p = palette();
38
+ if (p.code === "" || p.reset === "")
39
+ return line;
40
+ return line.replace(/`([^`]*)`/g, `${p.code}\`$1\`${p.reset}`);
41
+ }
42
+ /**
43
+ * v2b — one thinking BLOCK folds to ONE dim line: the first 100 chars, a
44
+ * " (… /think shows full)" marker when the block is longer. The consumer
45
+ * buffers the block's deltas, renders this at the block's end, and keeps
46
+ * the full text for /think. Pipes get the same fold — the content
47
+ * strategy is presentation-independent.
48
+ */
49
+ export function foldThinking(block) {
50
+ const p = palette();
51
+ const trimmed = escapeTerminal(block.trim());
52
+ const truncated = trimmed.length > 100;
53
+ return `${p.dim}…${trimmed.slice(0, 100)}${truncated ? ` (${block.length} chars · /think)` : ""}${p.reset}\n`;
54
+ }
55
+ /** v2b — the [result] echo truncates at 160 chars + a /last hint. */
56
+ export function foldResult(content) {
57
+ const flat = content.replaceAll("\n", " ");
58
+ const truncated = flat.length > 160;
59
+ return `${escapeTerminal(flat.slice(0, 160))}${truncated ? " (/last for full)" : ""}`;
60
+ }
61
+ /**
62
+ * B area: one-line summary of a completed tool call, e.g.
63
+ * ✓ edit src/foo.ts (+12 -3) ✓ read src/bar.ts (140 lines)
64
+ * ✗ shell npm test (exit 1)
65
+ * edit/write show +/- line counts, read shows lines, shell shows the exit
66
+ * code; failures (isError) are ✗. Pure and deterministic.
67
+ */
68
+ export function renderToolSummary(name, input, result, reason = null) {
69
+ // v2a/v5: ✓ is a bold identity accent; ✗ stays red.
70
+ const p = palette();
71
+ // W19: a DENIED call (the "denied" tag) renders the pinned row — the
72
+ // FULL call name, the target, the reason in the W4 parentheses idiom,
73
+ // and NO timing metadata (the call never ran — (0.0s) would be noise).
74
+ // The same row in the interactive and pipe paths, byte-clean on a pipe.
75
+ if (reason !== null) {
76
+ return `${p.red}✗${p.reset} ${escapeTerminal(`${name} ${toolTarget(name, input)} (${reason})`)}`;
77
+ }
78
+ const mark = result.isError ? `${p.red}✗${p.reset}` : `${p.bold}✓${p.reset}`;
79
+ const shortName = name.replace("_file", "");
80
+ const detail = toolSummaryDetail(name, input, result);
81
+ return `${mark} ${escapeTerminal(`${shortName} ${detail}`)}`;
82
+ }
83
+ function toolSummaryDetail(name, input, result) {
84
+ // Line count without the phantom empty line after a trailing newline.
85
+ const lines = (text) => {
86
+ if (text === "")
87
+ return 0;
88
+ const parts = text.split("\n");
89
+ return parts[parts.length - 1] === "" ? parts.length - 1 : parts.length;
90
+ };
91
+ switch (name) {
92
+ case "read_file": {
93
+ const path = String(input.path ?? "?");
94
+ const count = lines(String(result.content));
95
+ return `${path} (${count} line${count === 1 ? "" : "s"})`;
96
+ }
97
+ case "write_file": {
98
+ const path = String(input.path ?? "?");
99
+ const count = lines(String(input.content ?? ""));
100
+ return `${path} (+${count})`;
101
+ }
102
+ case "edit_file": {
103
+ const path = String(input.path ?? "?");
104
+ const removed = lines(String(input.search ?? ""));
105
+ const added = lines(String(input.replace ?? ""));
106
+ return `${path} (+${added} -${removed})`;
107
+ }
108
+ case "shell": {
109
+ const command = String(input.command ?? "?");
110
+ const exit = exitCodeOf(result);
111
+ return `${command} (exit ${exit})`;
112
+ }
113
+ case "list_dir":
114
+ return String(input.path ?? "(root)");
115
+ default:
116
+ return String(input.path ?? input.command ?? "");
117
+ }
118
+ }
119
+ /** W15 — the expand header's target: the tool call's subject (the path
120
+ * for the *_file tools, the command for shell) — the same extraction
121
+ * the summary detail uses, WITHOUT the counts (the header names what
122
+ * was expanded, not its size). */
123
+ export function toolTarget(name, input) {
124
+ switch (name) {
125
+ case "read_file":
126
+ case "write_file":
127
+ case "edit_file":
128
+ return String(input.path ?? "?");
129
+ case "shell":
130
+ return String(input.command ?? "?");
131
+ case "list_dir":
132
+ return String(input.path ?? "(root)");
133
+ default:
134
+ return String(input.path ?? input.command ?? "");
135
+ }
136
+ }
137
+ /** The exit code of a shell result: parsed from the failure text, 0 on success. */
138
+ function exitCodeOf(result) {
139
+ if (!result.isError)
140
+ return 0;
141
+ const m = /exit (\d+)/.exec(result.content);
142
+ return m !== null ? Number(m[1]) : 1;
143
+ }
144
+ /** k-units for the status line: 12345 → 12.3k, 800 → 800, null → ?. */
145
+ export function kUnit(value) {
146
+ if (value === null)
147
+ return "?";
148
+ if (value >= 1000)
149
+ return `${(value / 1000).toFixed(1).replace(/\.0$/, "")}k`;
150
+ return String(value);
151
+ }
152
+ /**
153
+ * v2a rhythm — the exact bytes after a terminal event: the status line
154
+ * hugs the terminal (show what there is — omitted when there is nothing to show),
155
+ * then EXACTLY one blank line before the next prompt. The consumer prints
156
+ * this verbatim; the render tests pin the sequence.
157
+ */
158
+ export function renderTerminalGap(statusLine) {
159
+ return `${statusLine === null ? "" : `${statusLine}\n`}\n`;
160
+ }
161
+ /**
162
+ * v3 §01 (V6-2) — the banner, block-split. The logo is THREE BRICK rows
163
+ * (the logo.svg pixel form — K I S O), then a BLANK, then the info rows:
164
+ * "kiso vX — tagline" + extensions. The tagline rides the version line
165
+ * (the old logo MIDDLE row was the tagline — a text row masquerading as
166
+ * the logo's centre). Every row truncates at the terminal width with a
167
+ * " (+N)" marker (N = the hidden display width); a window narrower than
168
+ * 40 columns skips the logo + the blank entirely — only the info rows.
169
+ * Pure.
170
+ */
171
+ export const TAGLINE = "the coding agent that survives kill -9";
172
+ /** v6's existing logo — W1's COMPACT tier, byte-identical, no redraw. */
173
+ const LOGO_ROWS = ["█ █ ▀█▀ █▀▀ █▀█", "█▀▄ █ ▀▀█ █ █", "▀ ▀ ▀▀▀ ▀▀▀ ▀▀▀"];
174
+ /** W1's BIG tier (36x6, `█` and space only — no half-blocks, so there is
175
+ * no tile seam to lose in a font that renders ▀ ▄ at the wrong height).
176
+ * Each pixel is two cells wide on purpose (a terminal cell is ~1:2);
177
+ * the render indents two — 38 columns total, clears 40. */
178
+ const BIG_LOGO_ROWS = [
179
+ "██ ██ ██████ ████████ ████████",
180
+ "██ ██ ██ ██ ██ ██",
181
+ "████ ██ ████████ ██ ██",
182
+ "████ ██ ██ ██ ██",
183
+ "██ ██ ██ ██ ██ ██",
184
+ "██ ██ ██████ ████████ ████████",
185
+ ];
186
+ /** v3 §01 (W1): truncate a row at `width`, marking the hidden span
187
+ * " (+N)". W1: the width math is the charWidth authority (the banner's
188
+ * brick glyphs are 1 cell — the art's 38 columns clear 40), and the
189
+ * marker's own cells are part of the row — the visible cut leaves room
190
+ * for it, so a truncated row never exceeds W (a cut row carries the
191
+ * marker INSIDE the width; a row that fits is returned untouched). */
192
+ export function truncateRow(row, width) {
193
+ const total = displayWidth(row);
194
+ if (total <= width)
195
+ return row;
196
+ // iterate the marker to a fixpoint: the marker's width changes the
197
+ // cut, the cut changes the hidden count the marker reports
198
+ let marker = " (+0)";
199
+ for (;;) {
200
+ const cut = Math.max(0, width - displayWidth(marker));
201
+ let w = 0;
202
+ let i = 0;
203
+ while (i < row.length) {
204
+ const cp = row.codePointAt(i);
205
+ const cw = charWidth(cp);
206
+ if (w + cw > cut)
207
+ break;
208
+ w += cw;
209
+ i += cp > 0xffff ? 2 : 1; // code-point stepping — never split a pair
210
+ }
211
+ const next = ` (+${total - w})`;
212
+ if (next === marker)
213
+ return `${row.slice(0, i)}${next}`;
214
+ marker = next;
215
+ }
216
+ }
217
+ /** v3 §01 (V6-2) + W1: the banner lines for a width W and height H —
218
+ * the tier table (extends the existing "under 40 columns, skip the
219
+ * logo" rule with a HEIGHT input):
220
+ * W ≥ 40 and H ≥ 20 → BIG (the 36x6 wordmark, 2-column indent)
221
+ * W ≥ 40 and 14–19 rows → COMPACT (v6's LOGO_ROWS, byte-identical)
222
+ * anything smaller → text rows only
223
+ * then the blank, then "vX — tagline" — the art IS the wordmark, so the
224
+ * text row does not repeat the name — then extensions — then the W5
225
+ * resume list (BIG only, W5). Every row truncates at the terminal width
226
+ * with a " (+N)" marker. Pure. */
227
+ export function bannerLines(W, H, version, extensionsText, resume = [], now = Date.now()) {
228
+ const rows = [];
229
+ if (W >= 40) {
230
+ if (H >= 20) {
231
+ for (const r of BIG_LOGO_ROWS)
232
+ rows.push(truncateRow(` ${r}`, W));
233
+ }
234
+ else if (H >= 14) {
235
+ for (const r of LOGO_ROWS)
236
+ rows.push(truncateRow(r, W));
237
+ }
238
+ }
239
+ if (rows.length > 0)
240
+ rows.push("");
241
+ rows.push(truncateRow(`v${version} — ${TAGLINE}`, W));
242
+ if (extensionsText !== "")
243
+ rows.push(truncateRow(extensionsText, W));
244
+ if (W >= 40 && H >= 20 && resume.length > 0) {
245
+ rows.push("", ...renderResumeList(resume, W, now));
246
+ }
247
+ return rows;
248
+ }
249
+ export function relativeTime(updatedAt, now) {
250
+ const s = Math.max(0, now - updatedAt) / 1000;
251
+ if (s < 60)
252
+ return "now";
253
+ const m = Math.floor(s / 60);
254
+ if (m < 60)
255
+ return `${m}m ago`;
256
+ const h = Math.floor(m / 60);
257
+ if (h < 24)
258
+ return `${h}h ago`;
259
+ const d = Math.floor(h / 24);
260
+ if (d < 7)
261
+ return `${d}d ago`;
262
+ return `${Math.floor(d / 7)}w ago`;
263
+ }
264
+ function titleCut(text, max) {
265
+ if (displayWidth(text) <= max)
266
+ return text;
267
+ const room = max - displayWidth("…");
268
+ let w = 0;
269
+ let i = 0;
270
+ while (i < text.length) {
271
+ const cp = text.codePointAt(i);
272
+ const cw = charWidth(cp);
273
+ if (w + cw > room)
274
+ break;
275
+ w += cw;
276
+ i += cp > 0xffff ? 2 : 1;
277
+ }
278
+ return text.slice(0, i) + "…";
279
+ }
280
+ export function renderResumeList(metas, W, now) {
281
+ if (metas.length === 0)
282
+ return [];
283
+ const rows = [" ▞ resume"];
284
+ const whens = metas.map((m) => relativeTime(m.updatedAt, now));
285
+ const metaTexts = metas.map((m) => `${m.events} events · ${m.runs} runs`);
286
+ const metaW = Math.max(...metaTexts.map((t) => t.length));
287
+ const titleW = Math.max(1, W - 13 - metaW);
288
+ for (let i = 0; i < metas.length; i += 1) {
289
+ const title = escapeTerminal(metas[i].title);
290
+ const shown = titleCut(title, titleW);
291
+ const pad = titleW - displayWidth(shown);
292
+ rows.push(` ${whens[i].padEnd(7)} ${shown}${" ".repeat(pad)} ${metaTexts[i].padStart(metaW)}`);
293
+ }
294
+ return rows;
295
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The display-width primitives — the SINGLE width authority (TUI v5
3
+ * #16e: "charWidth is the width authority"). The eastAsianWidth table
4
+ * is a ~40-line subset (CJK ideographs/kana/hangul/fullwidth/common
5
+ * wide symbols = 2, everything else = 1 — the box-drawing/brick glyphs
6
+ * █▀▄▞▸ are narrow). Known limitation, documented in the README: emoji
7
+ * ZWJ clusters are not guaranteed perfect — each code point counts as
8
+ * its width. Zero dependencies (importable from any module).
9
+ */
10
+ /** A code point's display width: 2 for the wide ranges, 1 otherwise. */
11
+ export declare function charWidth(cp: number): number;
12
+ /** Display width of a code-point array (cursor math, scrolling). */
13
+ export declare function widthOf(chars: readonly number[]): number;
14
+ /** Display width of a string. */
15
+ export declare function displayWidth(text: string): number;
16
+ /** A LEAD's display width — the prompt / the panel's phase lead,
17
+ * ANSI-stripped. W23: the ONE width authority shared by the editor
18
+ * (selfRender, #reflow), the compositor's #inputRow, and editCol — a
19
+ * lead can never measure differently at two call sites (the frame-
20
+ * derived column contract: wallL + leadWidth(lead) + cells + 1). */
21
+ export declare function leadWidth(lead: string): number;