@vincemakes/kiso-code 0.1.30 → 0.1.32

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