@vincemakes/kiso-tui 0.1.19

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/render.js ADDED
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Event rendering for the terminal. Pure (testable): given events, produce
3
+ * the lines a human sees. Colors are raw ANSI — no dependencies.
4
+ */
5
+ export const COLOR_ON = { blue: "\x1b[38;5;75m", dim: "\x1b[2m", red: "\x1b[31m", green: "\x1b[32m", bg: "\x1b[48;5;237m", reset: "\x1b[0m" };
6
+ export const COLOR_OFF = { blue: "", dim: "", red: "", green: "", bg: "", reset: "" };
7
+ export function palette() {
8
+ return process.env.NO_COLOR === undefined && process.stdout.isTTY ? COLOR_ON : COLOR_OFF;
9
+ }
10
+ /**
11
+ * E 组/八: strip terminal-injection vectors from MODEL/TOOL text before it
12
+ * reaches the terminal — ESC, C0 (except \t \n), C1, CR, backspace, and
13
+ * bidi overrides. The kiso colors are applied by render, not by the data.
14
+ * EVERY externally-sourced string must pass through this before any output.
15
+ */
16
+ export function escapeTerminal(text) {
17
+ // eslint-disable-next-line no-control-regex
18
+ return text
19
+ .replace(/[\u0000-\u0008\u000d\u000e-\u001f\u007f]/g, "") // C0 (keeps only \t and \n)
20
+ .replace(/\u001b/g, "") // ESC
21
+ .replace(/[\u0080-\u009f]/g, "") // C1
22
+ .replace(/[\u202a-\u202e\u2066-\u2069]/g, ""); // bidi
23
+ }
24
+ /**
25
+ * Render one event. `text` may be a continuation (text_delta appends to the
26
+ * current line); `newline` says whether the line is complete.
27
+ *
28
+ * 自举 P1: `prevThinking` marks a thinking delta that continues the SAME
29
+ * block — it renders appended to the segment, without the … prefix. The
30
+ * consumer closes the segment with a newline at the next non-thinking
31
+ * event.
32
+ */
33
+ /**
34
+ * v2b — one thinking BLOCK folds to ONE dim line: the first 100 chars, a
35
+ * " (… /think shows full)" marker when the block is longer. The consumer
36
+ * buffers the block's deltas, renders this at the block's end, and keeps
37
+ * the full text for /think. Pipes get the same fold — the content
38
+ * strategy is presentation-independent.
39
+ */
40
+ export function foldThinking(block) {
41
+ const p = palette();
42
+ const trimmed = escapeTerminal(block.trim());
43
+ const truncated = trimmed.length > 100;
44
+ return `${p.dim}…${trimmed.slice(0, 100)}${truncated ? ` (${block.length} chars · /think)` : ""}${p.reset}\n`;
45
+ }
46
+ /** v2b — the [result] echo truncates at 160 chars + a /last hint. */
47
+ export function foldResult(content) {
48
+ const flat = content.replaceAll("\n", " ");
49
+ const truncated = flat.length > 160;
50
+ return `${escapeTerminal(flat.slice(0, 160))}${truncated ? " (/last for full)" : ""}`;
51
+ }
52
+ export function renderEvent(ev, prevThinking = false, resolvePath = (p) => p) {
53
+ const p = palette();
54
+ switch (ev.type) {
55
+ case "user_input":
56
+ // v2a: blue (the identity accent — the interactive prompt echoes
57
+ // itself; this render is the REPLAY path).
58
+ return { text: `${p.blue}you> ${escapeTerminal(typeof ev.content === "string" ? ev.content : "(content)")}${p.reset}\n`, newline: true, prompt: false };
59
+ case "text_delta":
60
+ return { text: escapeTerminal(ev.text), newline: false, prompt: false };
61
+ case "text_end":
62
+ return { text: "\n", newline: true, prompt: false };
63
+ case "thinking":
64
+ // 自举 P1/v2b: the CONSUMER buffers each thinking block and folds
65
+ // it to ONE dim line (foldThinking); this render is the generic
66
+ // path for tests. The full block goes to /think.
67
+ return {
68
+ text: foldThinking(ev.text),
69
+ newline: true,
70
+ prompt: false,
71
+ };
72
+ case "tool_call_end":
73
+ // v2a: plain — the call line is information, not decoration.
74
+ return {
75
+ text: `→ ${escapeTerminal(ev.name)}(${ev.input ? escapeTerminal(JSON.stringify(ev.input).slice(0, 200)) : ""})\n`,
76
+ newline: true,
77
+ prompt: false,
78
+ };
79
+ case "tool_execution_started":
80
+ return { text: `${p.dim} running…${p.reset}\n`, newline: true, prompt: false };
81
+ case "tool_execution_succeeded":
82
+ return { text: ` ok\n`, newline: true, prompt: false }; // v2a: plain — success is not an accent
83
+ case "tool_execution_failed":
84
+ return { text: `${p.red} failed: ${escapeTerminal(ev.error.slice(0, 160))}${p.reset}\n`, newline: true, prompt: false };
85
+ case "tool_result": {
86
+ const content = typeof ev.content === "string" ? ev.content : ev.content.map((b) => (b.type === "text" ? b.text : "(image)")).join("");
87
+ return {
88
+ // v2b: the echo truncates at 160 chars + a /last hint — the
89
+ // full content stays in the event stream.
90
+ text: `${p.dim}${ev.isError ? p.red : p.dim} [result${ev.isError ? " ✗" : ""}] ${foldResult(content)}${p.reset}\n`,
91
+ newline: true,
92
+ prompt: false,
93
+ };
94
+ }
95
+ case "permission_requested":
96
+ // 八: the tool NAME is model text — escaped like everything else.
97
+ return {
98
+ text: `⏸ ${escapeTerminal(ev.name)} needs approval ${p.dim}${approvalDetail(ev.name, ev.input, resolvePath)}${p.reset} `,
99
+ newline: false,
100
+ prompt: true,
101
+ };
102
+ case "permission_decided":
103
+ // v2a: verdicts are plain — neither success accents nor errors.
104
+ return {
105
+ text: ` ${ev.decision === "approved" ? "approved" : "denied"}${ev.reason ? `: ${escapeTerminal(ev.reason)}` : ""}\n`,
106
+ newline: true,
107
+ prompt: false,
108
+ };
109
+ case "terminal": {
110
+ const outcome = ev.outcome;
111
+ const label = outcome.kind === "completed"
112
+ ? `done` // v2a: plain — the ✓ mark is the success accent
113
+ : outcome.kind === "aborted"
114
+ ? `aborted (${outcome.by})`
115
+ : `${p.red}${outcome.kind}${p.reset}${"error" in outcome && "message" in outcome.error ? `: ${escapeTerminal(outcome.error.message.slice(0, 200))}` : ""}`;
116
+ return { text: `\n${label}\n`, newline: true, prompt: false };
117
+ }
118
+ case "compacted":
119
+ return { text: `${p.dim} [compacted ${ev.cleared.length} results]${p.reset}\n`, newline: true, prompt: false };
120
+ case "uncertain_pending":
121
+ return {
122
+ text: `${p.red}⚠ ${escapeTerminal(ev.name)} failed (${ev.executionId}): ${escapeTerminal(ev.error.slice(0, 160))}${p.reset}\n`,
123
+ newline: true,
124
+ prompt: false,
125
+ };
126
+ default:
127
+ return { text: "", newline: false, prompt: false };
128
+ }
129
+ }
130
+ /**
131
+ * The approval prompt detail (Area 5/八): the human must be able to see
132
+ * EVERYTHING they are approving. The shell command is shown in full; the
133
+ * path is the CANONICAL one the tool will touch; write/edit show the FULL
134
+ * content (never a truncated tail that hides a dangerous payload). The
135
+ * decision is bound to the complete input via the decisionId.
136
+ */
137
+ function approvalDetail(name, input, resolvePath) {
138
+ if (name === "shell") {
139
+ return `\n $ ${escapeTerminal(String(input.command ?? ""))}`;
140
+ }
141
+ if (name === "write_file") {
142
+ const content = String(input.content ?? "");
143
+ return `\n ${escapeTerminal(resolvePath(String(input.path ?? "?")))}\n ${escapeTerminal(content)}`;
144
+ }
145
+ if (name === "edit_file") {
146
+ return `\n ${escapeTerminal(resolvePath(String(input.path ?? "?")))}\n replace: ${escapeTerminal(String(input.search ?? ""))}\n with: ${escapeTerminal(String(input.replace ?? ""))}`;
147
+ }
148
+ return `\n ${escapeTerminal(JSON.stringify(input))}`;
149
+ }
150
+ /**
151
+ * B 区: one-line summary of a completed tool call, e.g.
152
+ * ✓ edit src/foo.ts (+12 -3) ✓ read src/bar.ts (140 lines)
153
+ * ✗ shell npm test (exit 1)
154
+ * edit/write show +/- line counts, read shows lines, shell shows the exit
155
+ * code; failures (isError) are ✗. Pure and deterministic.
156
+ */
157
+ export function renderToolSummary(name, input, result) {
158
+ // v2a: ✓ is a blue identity accent; ✗ stays red.
159
+ const p = palette();
160
+ const mark = result.isError ? `${p.red}✗${p.reset}` : `${p.blue}✓${p.reset}`;
161
+ const shortName = name.replace("_file", "");
162
+ const detail = toolSummaryDetail(name, input, result);
163
+ return `${mark} ${escapeTerminal(`${shortName} ${detail}`)}`;
164
+ }
165
+ function toolSummaryDetail(name, input, result) {
166
+ // Line count without the phantom empty line after a trailing newline.
167
+ const lines = (text) => {
168
+ if (text === "")
169
+ return 0;
170
+ const parts = text.split("\n");
171
+ return parts[parts.length - 1] === "" ? parts.length - 1 : parts.length;
172
+ };
173
+ switch (name) {
174
+ case "read_file": {
175
+ const path = String(input.path ?? "?");
176
+ const count = lines(String(result.content));
177
+ return `${path} (${count} line${count === 1 ? "" : "s"})`;
178
+ }
179
+ case "write_file": {
180
+ const path = String(input.path ?? "?");
181
+ const count = lines(String(input.content ?? ""));
182
+ return `${path} (+${count})`;
183
+ }
184
+ case "edit_file": {
185
+ const path = String(input.path ?? "?");
186
+ const removed = lines(String(input.search ?? ""));
187
+ const added = lines(String(input.replace ?? ""));
188
+ return `${path} (+${added} -${removed})`;
189
+ }
190
+ case "shell": {
191
+ const command = String(input.command ?? "?");
192
+ const exit = exitCodeOf(result);
193
+ return `${command} (exit ${exit})`;
194
+ }
195
+ case "list_dir":
196
+ return String(input.path ?? "(root)");
197
+ default:
198
+ return String(input.path ?? input.command ?? "");
199
+ }
200
+ }
201
+ /** The exit code of a shell result: parsed from the failure text, 0 on success. */
202
+ function exitCodeOf(result) {
203
+ if (!result.isError)
204
+ return 0;
205
+ const m = /exit (\d+)/.exec(result.content);
206
+ return m !== null ? Number(m[1]) : 1;
207
+ }
208
+ /** k-units for the status line: 12345 → 12.3k, 800 → 800, null → ?. */
209
+ export function kUnit(value) {
210
+ if (value === null)
211
+ return "?";
212
+ if (value >= 1000)
213
+ return `${(value / 1000).toFixed(1).replace(/\.0$/, "")}k`;
214
+ return String(value);
215
+ }
216
+ /**
217
+ * B 区/v2a: the one-line status bar after a terminal, e.g.
218
+ * [turn 3 · in 12.4k out 1.8k · cache 9.2k · ctx ~14%]
219
+ * 降噪: unknown fields are OMITTED ENTIRELY (有什么显什么); a fully unknown
220
+ * usage → null (the caller prints nothing); faux mode → [turn N · faux].
221
+ * All data comes from usage events; ctx is the approximate estimate
222
+ * passed in (chars/4 vs the window), marked with ~.
223
+ */
224
+ export function renderStatusLine(turn, usage, ctxRatio, faux = false) {
225
+ if (faux)
226
+ return `[turn ${turn} · faux]`;
227
+ if (!usage.known)
228
+ return null; // everything unknown — nothing worth showing
229
+ const parts = [];
230
+ if (usage.in !== null || usage.out !== null) {
231
+ const seg = `${usage.in !== null ? `in ${kUnit(usage.in)}` : ""}${usage.in !== null && usage.out !== null ? " " : ""}${usage.out !== null ? `out ${kUnit(usage.out)}` : ""}`;
232
+ parts.push(seg);
233
+ }
234
+ if (usage.cache !== null)
235
+ parts.push(`cache ${kUnit(usage.cache)}`);
236
+ if (Number.isFinite(ctxRatio))
237
+ parts.push(`ctx ~${Math.round(ctxRatio * 100)}%`);
238
+ if (parts.length === 0)
239
+ return null;
240
+ return `[turn ${turn} · ${parts.join(" · ")}]`;
241
+ }
242
+ /**
243
+ * v2a rhythm — the exact bytes after a terminal event: the status line
244
+ * hugs the terminal (有什么显什么 — omitted when there is nothing to show),
245
+ * then EXACTLY one blank line before the next prompt. The consumer prints
246
+ * this verbatim; the render tests pin the sequence.
247
+ */
248
+ export function renderTerminalGap(statusLine) {
249
+ return `${statusLine === null ? "" : `${statusLine}\n`}\n`;
250
+ }
251
+ /**
252
+ * v3 §01 — the banner, block-split. The logo is three INDEPENDENT rows
253
+ * (TOP / the tagline / BOTTOM), then TWO info rows (version,
254
+ * extensions). Every row truncates at the terminal width with a " (+N)"
255
+ * marker (N = the hidden display width); a window narrower than 40
256
+ * columns skips the logo entirely — only the info rows. Pure.
257
+ */
258
+ export const TAGLINE = "the coding agent that survives kill -9";
259
+ const LOGO_ROWS = ["█ █ ▀█▀ █▀▀ █▀█", TAGLINE, "▀ ▀ ▀▀▀ ▀▀▀ ▀▀▀"];
260
+ /** Display width of a row (1-cell ASCII; 2-cell CJK/wide). */
261
+ function displayW(row) {
262
+ let w = 0;
263
+ for (let i = 0; i < row.length; i += 1) {
264
+ const cp = row.codePointAt(i);
265
+ w += cp > 0xff ? 2 : 1;
266
+ }
267
+ return w;
268
+ }
269
+ /** v3 §01: truncate a row at `width`, marking the hidden span " (+N)". */
270
+ export function truncateRow(row, width) {
271
+ if (displayW(row) <= width)
272
+ return row;
273
+ const cut = Math.max(0, width - 4);
274
+ let w = 0;
275
+ let i = 0;
276
+ for (; i < row.length; i += 1) {
277
+ const cw = row.codePointAt(i) > 0xff ? 2 : 1;
278
+ if (w + cw > cut)
279
+ break;
280
+ w += cw;
281
+ }
282
+ return `${row.slice(0, i)} (+${displayW(row) - w})`;
283
+ }
284
+ /** v3 §01: the banner lines for a width W — logo (skipped under 40
285
+ * columns) + version + extensions. */
286
+ export function bannerLines(W, version, extensionsText) {
287
+ const rows = [];
288
+ if (W >= 40)
289
+ for (const r of LOGO_ROWS)
290
+ rows.push(truncateRow(r, W));
291
+ rows.push(truncateRow(`v${version}`, W));
292
+ if (extensionsText !== "")
293
+ rows.push(truncateRow(extensionsText, W));
294
+ return rows;
295
+ }
296
+ export function renderRecap(s) {
297
+ const p = palette();
298
+ const parts = [`${s.seconds}s`, `${s.tools} tool${s.tools === 1 ? "" : "s"}${s.edits > 0 ? ` (${s.edits} edit${s.edits === 1 ? "" : "s"})` : ""}`];
299
+ if (s.usage.known) {
300
+ const seg = `${s.usage.in !== null ? `in ${kUnit(s.usage.in)}` : ""}${s.usage.in !== null && s.usage.out !== null ? " " : ""}${s.usage.out !== null ? `out ${kUnit(s.usage.out)}` : ""}`;
301
+ if (seg !== "")
302
+ parts.push(seg);
303
+ if (s.usage.cache !== null && s.usage.in !== null && s.usage.in > 0) {
304
+ parts.push(`cache ${Math.round((s.usage.cache / s.usage.in) * 100)}%`);
305
+ }
306
+ }
307
+ if (s.ctxLeftPct !== null)
308
+ parts.push(`ctx left ~${Math.round(s.ctxLeftPct)}%`);
309
+ return `${p.blue}▞${p.reset} ${parts.join(" · ")}\n`;
310
+ }
311
+ /** One-line summary of a session, for `kiso sessions`. */
312
+ export function renderSessionLine(meta) {
313
+ const when = meta.updatedAt ? new Date(meta.updatedAt).toISOString().slice(0, 16) : "—";
314
+ // 八: the title is the user's first prompt — model/user text, escaped.
315
+ return `${meta.id.padEnd(24)} ${meta.runs} runs ${String(meta.events).padStart(5)} events ${when} ${escapeTerminal(meta.title)}`;
316
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@vincemakes/kiso-tui",
3
+ "version": "0.1.19",
4
+ "description": "kiso tui — the pure terminal layer (cell renderer, dock, raw editor, diff, palette). Zero runtime dependencies: input is data, output is bytes.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.build.json",
20
+ "typecheck": "tsc -p tsconfig.json",
21
+ "test": "vitest run"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^26.1.2",
25
+ "typescript": "^5.7.2",
26
+ "vitest": "^3.0.0"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://github.com/vincemakes/kiso.git",
31
+ "directory": "packages/tui"
32
+ },
33
+ "bugs": {
34
+ "url": "https://github.com/vincemakes/kiso/issues"
35
+ },
36
+ "homepage": "https://github.com/vincemakes/kiso/tree/main/packages/tui#readme"
37
+ }