@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/approval-panel.d.ts +109 -0
- package/dist/approval-panel.js +144 -0
- package/dist/components.d.ts +298 -0
- package/dist/components.js +979 -0
- package/dist/diff.d.ts +32 -0
- package/dist/diff.js +122 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +21 -0
- package/dist/render.d.ts +129 -0
- package/dist/render.js +295 -0
- package/dist/width.d.ts +21 -0
- package/dist/width.js +67 -0
- package/package.json +57 -0
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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";
|
package/dist/render.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/width.d.ts
ADDED
|
@@ -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;
|