kronk-cli 0.1.0

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/src/ui.js ADDED
@@ -0,0 +1,119 @@
1
+ const on = process.stdout.isTTY && !process.env.NO_COLOR;
2
+ const wrap = (code) => (s) => (on ? `\x1b[${code}m${s}\x1b[0m` : s);
3
+
4
+ export const c = {
5
+ dim: wrap('2'),
6
+ bold: wrap('1'),
7
+ red: wrap('31'),
8
+ green: wrap('32'),
9
+ yellow: wrap('33'),
10
+ blue: wrap('34'),
11
+ magenta: wrap('35'),
12
+ cyan: wrap('36'),
13
+ grey: wrap('90'),
14
+ };
15
+
16
+ export const banner = (model, url) => `
17
+ ${c.cyan(' ██ kronk-cli')} ${c.grey('· local agent, no network')}
18
+ ${c.grey('model')} ${c.bold(model)}
19
+ ${c.grey('server')} ${url}
20
+ ${c.grey('/help for commands · Ctrl-C to interrupt · /exit to quit')}
21
+ `;
22
+
23
+ const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(n));
24
+
25
+ /** `18k/131k 14% ▓▓░░░░░░░░` — how full the window is after this turn. */
26
+ export function fmtContext(used, window) {
27
+ if (!window || !used) return '';
28
+ const pct = used / window;
29
+ const filled = Math.min(10, Math.round(pct * 10));
30
+ const bar = '▓'.repeat(filled) + '░'.repeat(10 - filled);
31
+ const label = `${k(used)}/${k(window)} ${Math.round(pct * 100)}% ${bar}`;
32
+ if (pct >= 0.9) return c.red(label);
33
+ if (pct >= 0.7) return c.yellow(label);
34
+ return c.grey(label);
35
+ }
36
+
37
+ export function fmtUsage(u, window) {
38
+ if (!u) return '';
39
+ const bits = [`${u.prompt_tokens ?? 0}→${u.completion_tokens ?? 0} tok`];
40
+ if (u.tokens_per_second) bits.push(`${u.tokens_per_second.toFixed(1)} tok/s`);
41
+ if (u.time_to_first_token_ms) bits.push(`ttft ${Math.round(u.time_to_first_token_ms)}ms`);
42
+ const cached = u.prompt_tokens_details?.cached_tokens;
43
+ if (cached) bits.push(`${cached} cached`);
44
+ const reasoning = u.completion_tokens_details?.reasoning_tokens;
45
+ if (reasoning) bits.push(`${reasoning} thinking`);
46
+ const ctx = fmtContext((u.prompt_tokens ?? 0) + (u.completion_tokens ?? 0), window);
47
+ return `${c.grey(` ${bits.join(' · ')}`)}${ctx ? ` ${ctx}` : ''}`;
48
+ }
49
+
50
+ /**
51
+ * The line that sits directly above the prompt: what mode you are in, what is
52
+ * attached, and how full the window is. Rendered fresh before every prompt so
53
+ * it always reflects the current state rather than the state at startup.
54
+ */
55
+ export function statusLine({ model, auto, yes, noThink, mcp, steps, used, window }) {
56
+ const bits = [];
57
+ bits.push(c.grey(model.split('/').pop()));
58
+ if (auto) bits.push(c.magenta('auto'));
59
+ else if (yes) bits.push(c.yellow('yes'));
60
+ if (noThink) bits.push(c.grey('no-think'));
61
+ if (mcp) bits.push(c.cyan(`mcp ${mcp}`));
62
+ if (Number.isFinite(steps)) bits.push(c.grey(`steps ${steps}`));
63
+ const ctx = fmtContext(used, window);
64
+ if (ctx) bits.push(ctx);
65
+ return `${c.grey('⏵')} ${bits.join(c.grey(' · '))}`;
66
+ }
67
+
68
+ /**
69
+ * Live progress for a running command: elapsed time, line count, and the last
70
+ * line it printed. Redraws in place so a long build shows movement instead of
71
+ * looking like a hang.
72
+ */
73
+ export function liveLine() {
74
+ if (!process.stdout.isTTY) {
75
+ let warned = false;
76
+ return {
77
+ update({ seconds }) {
78
+ if (!warned && seconds > 10) { warned = true; process.stdout.write(c.grey(' …running\n')); }
79
+ },
80
+ done() {},
81
+ };
82
+ }
83
+ const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
84
+ let i = 0;
85
+ let last = { seconds: 0, lines: 0, kept: 0, capped: false, lastLine: '' };
86
+ const draw = () => {
87
+ const { seconds, lines, kept, capped, lastLine, window } = last;
88
+ const parts = [`${seconds.toFixed(0)}s`, `${lines.toLocaleString()} lines`];
89
+
90
+ // What this output will actually cost the conversation, live. ~4 chars per
91
+ // token is rough but the point is the order of magnitude, not the digit.
92
+ if (kept && window) {
93
+ const tok = Math.ceil(kept / 4);
94
+ const pct = Math.round((tok / window) * 100);
95
+ parts.push(`→${k(tok)} ctx ${pct}%${capped ? ' capped' : ''}`);
96
+ }
97
+ const head = ` ${c.cyan(frames[i++ % frames.length])} ${c.grey(parts.join(' · '))}`;
98
+ const tail = lastLine ? ` ${c.grey(lastLine)}` : '';
99
+ process.stdout.write(`\r${head}${tail}\x1b[K`);
100
+ };
101
+ const timer = setInterval(() => { last.seconds += 0.12; draw(); }, 120);
102
+ return {
103
+ update(info) { last = { ...last, ...info }; draw(); },
104
+ done() { clearInterval(timer); process.stdout.write('\r\x1b[K'); },
105
+ };
106
+ }
107
+
108
+ /** A one-line spinner that does not fight with streamed output. */
109
+ export function spinner(label) {
110
+ if (!process.stdout.isTTY) return { stop() {} };
111
+ const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
112
+ let i = 0;
113
+ const t = setInterval(() => {
114
+ process.stdout.write(`\r${c.cyan(frames[i++ % frames.length])} ${c.grey(label)}\x1b[K`);
115
+ }, 80);
116
+ return {
117
+ stop() { clearInterval(t); process.stdout.write('\r\x1b[K'); },
118
+ };
119
+ }