@vincemakes/kiso-code 0.1.13

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.
@@ -0,0 +1,105 @@
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 type { Event } from "@vincemakes/kiso-core";
6
+ import { canonicalTargetPath } from "@vincemakes/kiso-tools-node";
7
+ /**
8
+ * v2a — the palette, centralized (no hard-coded codes elsewhere): ONE
9
+ * accent — blue, ANSI 256 color 75 — for the identity accents (the you>
10
+ * prompt, the banner tagline, ✓ marks, slash-command names); red for
11
+ * errors; dim for metadata. NO_COLOR set, or a non-TTY output → every
12
+ * code is empty, so pipes and CI carry ZERO ANSI (the existing byte-level
13
+ * e2e assertions guard it). Everything not listed here is plain.
14
+ */
15
+ export interface Palette {
16
+ readonly blue: string;
17
+ readonly dim: string;
18
+ readonly red: string;
19
+ readonly reset: string;
20
+ }
21
+ export declare const COLOR_ON: Palette;
22
+ export declare const COLOR_OFF: Palette;
23
+ export declare function palette(): Palette;
24
+ /**
25
+ * E 组/八: strip terminal-injection vectors from MODEL/TOOL text before it
26
+ * reaches the terminal — ESC, C0 (except \t \n), C1, CR, backspace, and
27
+ * bidi overrides. The kiso colors are applied by render, not by the data.
28
+ * EVERY externally-sourced string must pass through this before any output.
29
+ */
30
+ export declare function escapeTerminal(text: string): string;
31
+ /**
32
+ * 八/十: the path the human is asked to approve is the CANONICAL one the
33
+ * tool will actually touch — the tools' OWN resolution (deepest existing
34
+ * ancestor realpath'd, the not-yet-existing tail re-appended), so a file
35
+ * to be created under a symlinked directory shows the REAL target, and
36
+ * the UI and the tool share ONE resolution (canonicalTargetPath).
37
+ */
38
+ export declare const canonicalPath: typeof canonicalTargetPath;
39
+ export interface RenderResult {
40
+ readonly text: string;
41
+ readonly newline: boolean;
42
+ readonly prompt: boolean;
43
+ }
44
+ /**
45
+ * Render one event. `text` may be a continuation (text_delta appends to the
46
+ * current line); `newline` says whether the line is complete.
47
+ *
48
+ * 自举 P1: `prevThinking` marks a thinking delta that continues the SAME
49
+ * block — it renders appended to the segment, without the … prefix. The
50
+ * consumer closes the segment with a newline at the next non-thinking
51
+ * event.
52
+ */
53
+ /**
54
+ * v2b — one thinking BLOCK folds to ONE dim line: the first 100 chars, a
55
+ * " (… /think shows full)" marker when the block is longer. The consumer
56
+ * buffers the block's deltas, renders this at the block's end, and keeps
57
+ * the full text for /think. Pipes get the same fold — the content
58
+ * strategy is presentation-independent.
59
+ */
60
+ export declare function foldThinking(block: string): string;
61
+ /** v2b — the [result] echo truncates at 160 chars + a /last hint. */
62
+ export declare function foldResult(content: string): string;
63
+ export declare function renderEvent(ev: Event, prevThinking?: boolean): RenderResult;
64
+ /**
65
+ * B 区: one-line summary of a completed tool call, e.g.
66
+ * ✓ edit src/foo.ts (+12 -3) ✓ read src/bar.ts (140 lines)
67
+ * ✗ shell npm test (exit 1)
68
+ * edit/write show +/- line counts, read shows lines, shell shows the exit
69
+ * code; failures (isError) are ✗. Pure and deterministic.
70
+ */
71
+ export declare function renderToolSummary(name: string, input: Record<string, unknown>, result: {
72
+ content: string;
73
+ isError: boolean;
74
+ }): string;
75
+ /** B 区: usage data gathered from the run's usage events. */
76
+ export interface RunUsage {
77
+ readonly in: number | null;
78
+ readonly out: number | null;
79
+ readonly cache: number | null;
80
+ readonly known: boolean;
81
+ }
82
+ /**
83
+ * B 区/v2a: the one-line status bar after a terminal, e.g.
84
+ * [turn 3 · in 12.4k out 1.8k · cache 9.2k · ctx ~14%]
85
+ * 降噪: unknown fields are OMITTED ENTIRELY (有什么显什么); a fully unknown
86
+ * usage → null (the caller prints nothing); faux mode → [turn N · faux].
87
+ * All data comes from usage events; ctx is the approximate estimate
88
+ * passed in (chars/4 vs the window), marked with ~.
89
+ */
90
+ export declare function renderStatusLine(turn: number, usage: RunUsage, ctxRatio: number, faux?: boolean): string | null;
91
+ /**
92
+ * v2a rhythm — the exact bytes after a terminal event: the status line
93
+ * hugs the terminal (有什么显什么 — omitted when there is nothing to show),
94
+ * then EXACTLY one blank line before the next prompt. The consumer prints
95
+ * this verbatim; the render tests pin the sequence.
96
+ */
97
+ export declare function renderTerminalGap(statusLine: string | null): string;
98
+ /** One-line summary of a session, for `kiso sessions`. */
99
+ export declare function renderSessionLine(meta: {
100
+ id: string;
101
+ title: string;
102
+ events: number;
103
+ runs: number;
104
+ updatedAt: number;
105
+ }): string;
package/dist/render.js ADDED
@@ -0,0 +1,265 @@
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", reset: "\x1b[0m" };
7
+ export const COLOR_OFF = { blue: "", dim: "", red: "", 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 ? " (… /think shows full)" : ""}${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
+ 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
+ /** One-line summary of a session, for `kiso sessions`. */
261
+ export function renderSessionLine(meta) {
262
+ const when = meta.updatedAt ? new Date(meta.updatedAt).toISOString().slice(0, 16) : "—";
263
+ // 八: the title is the user's first prompt — model/user text, escaped.
264
+ return `${meta.id.padEnd(24)} ${meta.runs} runs ${String(meta.events).padStart(5)} events ${when} ${escapeTerminal(meta.title)}`;
265
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@vincemakes/kiso-code",
3
+ "version": "0.1.13",
4
+ "description": "kiso CLI — the coding-agent reference product: kiso chat / kiso resume / kiso sessions.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "kiso": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc -p tsconfig.build.json",
17
+ "typecheck": "tsc -p tsconfig.json",
18
+ "test": "vitest run"
19
+ },
20
+ "dependencies": {
21
+ "@vincemakes/kiso-core": "0.1.13",
22
+ "@vincemakes/kiso-evals": "0.1.13",
23
+ "@vincemakes/kiso-provider-anthropic": "0.1.13",
24
+ "@vincemakes/kiso-provider-openai": "0.1.13",
25
+ "@vincemakes/kiso-runtime": "0.1.13",
26
+ "@vincemakes/kiso-tools-node": "0.1.13"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^26.1.2",
30
+ "typescript": "^5.7.2",
31
+ "vitest": "^3.0.0"
32
+ },
33
+ "engines": {
34
+ "node": ">=22"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "https://github.com/vincemakes/kiso.git",
39
+ "directory": "apps/cli"
40
+ },
41
+ "bugs": {
42
+ "url": "https://github.com/vincemakes/kiso/issues"
43
+ },
44
+ "homepage": "https://github.com/vincemakes/kiso/tree/main/apps/cli#readme"
45
+ }