ispbills-icli 5.3.0 → 6.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ispbills-icli",
3
- "version": "5.3.0",
3
+ "version": "6.0.0",
4
4
  "description": "iCli — IspBills AI network engineer in your terminal",
5
5
  "keywords": [
6
6
  "ispbills",
@@ -23,10 +23,5 @@
23
23
  "src/",
24
24
  "README.md"
25
25
  ],
26
- "dependencies": {
27
- "chalk": "^5.3.0",
28
- "marked": "^15.0.12",
29
- "marked-terminal": "^7.3.0",
30
- "ora": "^9.4.1"
31
- }
26
+ "dependencies": {}
32
27
  }
package/src/banner.js ADDED
@@ -0,0 +1,42 @@
1
+ import { RESET, BOLD, DIM, CYAN, WHITE, ACCENT, GRAY, cols, shortModel, rule } from './ui.js';
2
+
3
+ const LOGO = [
4
+ ' ██╗ ██████╗ ██████╗ ██████╗ ██╗██╗ ██████╗ ████████╗',
5
+ ' ██║██╔════╝██╔═══██╗██╔══██╗██║██║ ██╔═══██╗╚══██╔══╝',
6
+ ' ██║██║ ██║ ██║██████╔╝██║██║ ██║ ██║ ██║ ',
7
+ ' ██║██║ ██║ ██║██╔═══╝ ██║██║ ██║ ██║ ██║ ',
8
+ ' ██║╚██████╗╚██████╔╝██║ ██║███████╗╚██████╔╝ ██║ ',
9
+ ' ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝ ',
10
+ ];
11
+
12
+ /**
13
+ * Full iCopilot banner. On narrow terminals falls back to a compact header.
14
+ *
15
+ * @param {object} cfg Loaded config ({ url, user, _model }).
16
+ * @param {object} opts { model }
17
+ */
18
+ export function printBanner(cfg = {}, opts = {}) {
19
+ const width = cols();
20
+ const model = opts.model || cfg._model || 'IspBills AI';
21
+
22
+ console.log();
23
+ if (width >= 62) {
24
+ for (const line of LOGO) console.log(ACCENT + BOLD + line + RESET);
25
+ console.log(` ${DIM}iCopilot — network engineer in your terminal${RESET}`);
26
+ } else {
27
+ console.log(rule());
28
+ console.log(` ${BOLD}iCopilot${RESET} ${DIM}— IspBills AI terminal${RESET}`);
29
+ console.log(rule());
30
+ }
31
+ console.log();
32
+
33
+ const parts = [];
34
+ parts.push(`${DIM}model${RESET} ${CYAN}${shortModel(model)}${RESET}`);
35
+ if (cfg.user?.name) parts.push(`${DIM}user${RESET} ${WHITE}${cfg.user.name}${RESET}`);
36
+ if (cfg.user?.role) parts.push(`${DIM}role${RESET} ${WHITE}${cfg.user.role}${RESET}`);
37
+ console.log(' ' + parts.join(` ${GRAY}·${RESET} `));
38
+ if (cfg.url) console.log(` ${DIM}url${RESET} ${ACCENT}${cfg.url}${RESET}`);
39
+ console.log();
40
+ console.log(` ${DIM}Type a message to start. ${RESET}${ACCENT}/help${RESET}${DIM} for commands · ${RESET}${ACCENT}Ctrl+C${RESET}${DIM} to exit${RESET}`);
41
+ console.log();
42
+ }
package/src/client.js ADDED
@@ -0,0 +1,100 @@
1
+ import { refreshToken } from './auth.js';
2
+
3
+ /**
4
+ * Incrementally extract the answer text from the raw JSON the backend streams
5
+ * in `delta` events (the reply object is built up as a JSON string).
6
+ */
7
+ function parseStreamText(raw) {
8
+ const m = raw.match(/"text"\s*:\s*"((?:[^"\\]|\\.)*)/);
9
+ if (!m) return raw.trimStart().startsWith('{') ? null : raw;
10
+ return m[1]
11
+ .replace(/\\n/g, '\n')
12
+ .replace(/\\t/g, '\t')
13
+ .replace(/\\r/g, '')
14
+ .replace(/\\"/g, '"')
15
+ .replace(/\\\\/g, '\\');
16
+ }
17
+
18
+ /**
19
+ * Stream a chat turn from the IspBills Terminal AI backend.
20
+ *
21
+ * Emits UI-agnostic events via `onEvent` and resolves with the final reply.
22
+ *
23
+ * @param {object} cfg Loaded config ({ url, token, refresh_token, ... }).
24
+ * @param {Array} messages Conversation history [{role, content}].
25
+ * @param {object} [options] { onEvent(event), signal, context }
26
+ * @returns {Promise<{reply: object, text: string, meta: object}>}
27
+ */
28
+ export async function streamChat(cfg, messages, options = {}) {
29
+ const { onEvent = () => {}, signal, context = {} } = options;
30
+ const body = JSON.stringify({ messages, context });
31
+
32
+ const doFetch = (token) =>
33
+ fetch(`${cfg.url}/api/v1/ai/chat/stream`, {
34
+ method: 'POST',
35
+ headers: {
36
+ 'Content-Type': 'application/json',
37
+ Accept: 'text/event-stream',
38
+ Authorization: 'Bearer ' + token,
39
+ },
40
+ body,
41
+ signal,
42
+ });
43
+
44
+ let res = await doFetch(cfg.token);
45
+ if (res.status === 401 && cfg.refresh_token) {
46
+ const updated = await refreshToken(cfg);
47
+ if (!updated) throw new Error('Session expired. Run `icli login`.');
48
+ Object.assign(cfg, updated);
49
+ res = await doFetch(cfg.token);
50
+ }
51
+ if (!res.ok) {
52
+ const txt = await res.text().catch(() => '');
53
+ throw new Error(`AI request failed (HTTP ${res.status}): ${txt.slice(0, 200)}`);
54
+ }
55
+
56
+ let rawAnswer = '';
57
+ let answerText = '';
58
+ let finalResult = null;
59
+
60
+ const decoder = new TextDecoder();
61
+ let sseBuffer = '';
62
+
63
+ for await (const chunk of res.body) {
64
+ if (signal?.aborted) break;
65
+ sseBuffer += decoder.decode(chunk, { stream: true });
66
+ const lines = sseBuffer.split('\n');
67
+ sseBuffer = lines.pop();
68
+
69
+ for (const line of lines) {
70
+ const t = line.replace(/\r$/, '');
71
+ if (!t.startsWith('data: ')) continue;
72
+ let ev;
73
+ try {
74
+ ev = JSON.parse(t.slice(6));
75
+ } catch {
76
+ continue;
77
+ }
78
+
79
+ if (ev.status_delta !== undefined) {
80
+ onEvent({ type: 'status', line: ev.status_delta });
81
+ } else if (ev.reason_delta !== undefined) {
82
+ onEvent({ type: 'reasoning', delta: ev.reason_delta });
83
+ } else if (ev.delta !== undefined) {
84
+ rawAnswer += ev.delta;
85
+ const txt = parseStreamText(rawAnswer);
86
+ if (txt !== null && txt.length > answerText.length) {
87
+ onEvent({ type: 'text', delta: txt.slice(answerText.length) });
88
+ answerText = txt;
89
+ }
90
+ } else if (ev.done) {
91
+ finalResult = ev.result;
92
+ }
93
+ }
94
+ }
95
+
96
+ const reply = finalResult?.reply ?? {};
97
+ const meta = finalResult?._meta ?? {};
98
+ const text = reply.text || answerText || '';
99
+ return { reply, text, meta };
100
+ }
@@ -0,0 +1,67 @@
1
+ import { RESET, BOLD, DIM, GRAY, WHITE, ACCENT, GREEN } from './ui.js';
2
+
3
+ /** All slash commands, used for the menu, help and Tab completion. */
4
+ export const SLASH_COMMANDS = [
5
+ { cmd: '/check', hint: '[device]', desc: 'Run diagnostics on a device' },
6
+ { cmd: '/vlan', hint: '[id]', desc: 'Check VLAN usage' },
7
+ { cmd: '/onu', hint: '[id]', desc: 'Show ONU status and signal' },
8
+ { cmd: '/customer', hint: '[name/IP]', desc: 'Look up a customer' },
9
+ { cmd: '/ping', hint: '[host]', desc: 'Ping a host' },
10
+ { cmd: '/online', hint: '', desc: 'List online sessions' },
11
+ { cmd: '/new', hint: '', desc: 'Start a fresh conversation' },
12
+ { cmd: '/history', hint: '', desc: 'Show conversation history' },
13
+ { cmd: '/clear', hint: '', desc: 'Clear the screen' },
14
+ { cmd: '/update', hint: '', desc: 'Update iCli to the latest version' },
15
+ { cmd: '/logout', hint: '', desc: 'Log out and clear credentials' },
16
+ { cmd: '/help', hint: '', desc: 'Show help' },
17
+ { cmd: '/exit', hint: '', desc: 'Exit iCli' },
18
+ ];
19
+
20
+ /** readline completer for slash commands. */
21
+ export function slashCompleter(line) {
22
+ if (line.startsWith('/')) {
23
+ const hits = SLASH_COMMANDS.map((s) => s.cmd + ' ').filter((s) => s.startsWith(line));
24
+ return [hits.length ? hits : SLASH_COMMANDS.map((s) => s.cmd + ' '), line];
25
+ }
26
+ return [[], line];
27
+ }
28
+
29
+ /**
30
+ * Map an IspBills AI shortcut command to a natural-language question.
31
+ * Returns null for commands that are handled locally by the REPL.
32
+ */
33
+ export function slashToQuestion(line) {
34
+ const [sc, ...rest] = line.trim().split(/\s+/);
35
+ const arg = rest.join(' ');
36
+ switch (sc) {
37
+ case '/check': return arg ? `run diagnostics on ${arg}` : 'check status of all devices';
38
+ case '/vlan': return arg ? `is vlan ${arg} in use or free?` : 'list all vlans in use';
39
+ case '/onu': return arg ? `show ONU ${arg} status and signal level` : 'list all ONUs';
40
+ case '/customer': return arg ? `look up customer ${arg}` : 'show recent customers';
41
+ case '/ping': return arg ? `ping ${arg} through the network` : 'which devices are reachable?';
42
+ case '/online': return 'list currently online PPPoE sessions';
43
+ default: return null;
44
+ }
45
+ }
46
+
47
+ export function printSlashMenu() {
48
+ console.log(`\n ${BOLD}Slash commands${RESET}`);
49
+ for (const { cmd, hint, desc } of SLASH_COMMANDS) {
50
+ const key = (cmd + (hint ? ' ' + hint : '')).padEnd(20);
51
+ console.log(` ${ACCENT}${key}${RESET}${DIM}${desc}${RESET}`);
52
+ }
53
+ console.log();
54
+ }
55
+
56
+ export function printHistory(history) {
57
+ const turns = history.filter((m) => m.role === 'user');
58
+ if (!turns.length) {
59
+ console.log(`\n ${GRAY}No history yet.${RESET}\n`);
60
+ return;
61
+ }
62
+ console.log(`\n ${BOLD}History${RESET}`);
63
+ turns.forEach((m, i) =>
64
+ console.log(` ${DIM}${String(i + 1).padStart(2)}.${RESET} ${WHITE}${m.content.replace(/\s+/g, ' ').slice(0, 80)}${RESET}`)
65
+ );
66
+ console.log();
67
+ }
package/src/loader.js ADDED
@@ -0,0 +1,77 @@
1
+ import { DIM, RESET } from './ui.js';
2
+
3
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
4
+
5
+ const GRADIENT_COLORS = [
6
+ '\x1b[38;5;240m',
7
+ '\x1b[38;5;245m',
8
+ '\x1b[38;5;250m',
9
+ '\x1b[38;5;255m',
10
+ '\x1b[38;5;250m',
11
+ '\x1b[38;5;245m',
12
+ ];
13
+
14
+ /**
15
+ * A single-line loader shown while the backend is streaming.
16
+ * Styles: 'spinner' (default), 'gradient', 'minimal'.
17
+ */
18
+ export class Loader {
19
+ constructor(config = { text: 'Thinking', style: 'spinner' }) {
20
+ this.config = config;
21
+ this.frame = 0;
22
+ this.interval = null;
23
+ }
24
+
25
+ setText(text) {
26
+ this.config = { ...this.config, text };
27
+ }
28
+
29
+ start() {
30
+ if (this.interval) return;
31
+ this.frame = 0;
32
+ const { style } = this.config;
33
+ const ms = style === 'gradient' ? 150 : style === 'spinner' ? 80 : 300;
34
+ this.interval = setInterval(() => this.draw(), ms);
35
+ this.draw();
36
+ }
37
+
38
+ stop() {
39
+ if (this.interval) {
40
+ clearInterval(this.interval);
41
+ this.interval = null;
42
+ process.stdout.write('\r\x1b[K');
43
+ }
44
+ }
45
+
46
+ get running() {
47
+ return this.interval !== null;
48
+ }
49
+
50
+ draw() {
51
+ const { text, style } = this.config;
52
+ this.frame++;
53
+ switch (style) {
54
+ case 'minimal': {
55
+ const dots = ['·', '··', '···'];
56
+ process.stdout.write(`\r ${DIM}${text}${dots[this.frame % 3]}${RESET}`);
57
+ break;
58
+ }
59
+ case 'gradient': {
60
+ const len = GRADIENT_COLORS.length;
61
+ let out = '\r ';
62
+ for (let i = 0; i < text.length; i++) {
63
+ out += GRADIENT_COLORS[(this.frame + i) % len] + text[i];
64
+ }
65
+ out += RESET;
66
+ process.stdout.write(out);
67
+ break;
68
+ }
69
+ case 'spinner':
70
+ default: {
71
+ const char = SPINNER_FRAMES[this.frame % SPINNER_FRAMES.length];
72
+ process.stdout.write(`\r ${DIM}${char} ${text}…${RESET}`);
73
+ break;
74
+ }
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,162 @@
1
+ import {
2
+ RESET, BOLD, DIM, ITALIC, CYAN, GREEN, YELLOW, RED, GRAY, WHITE, ACCENT,
3
+ cols, shortModel,
4
+ } from './ui.js';
5
+
6
+ /** Pick a status icon from the content of a server progress line. */
7
+ function statusIcon(line) {
8
+ const l = line.toLowerCase();
9
+ if (/unreachable|skip|fail|error|denied|timeout/.test(l)) return `${RED}✗${RESET}`;
10
+ if (/done|success|found|complet|online|ready|ok\b/.test(l)) return `${GREEN}✓${RESET}`;
11
+ if (/batch|sub.?agent|fleet|parallel/.test(l)) return `${ACCENT}◈${RESET}`;
12
+ if (/\[\d+\/\d+\]/.test(l)) return `${ACCENT}◉${RESET}`;
13
+ if (/probe|reachab|ping/.test(l)) return `${YELLOW}◎${RESET}`;
14
+ if (/connect|ssh|telnet|api|session/.test(l)) return `${ACCENT}⇢${RESET}`;
15
+ if (/read|fetch|get|query|look|search/.test(l)) return `${GRAY}↓${RESET}`;
16
+ return `${GRAY}·${RESET}`;
17
+ }
18
+
19
+ /**
20
+ * Streaming + final-reply renderer for the iCopilot TUI. Consumes the
21
+ * UI-agnostic events emitted by client.streamChat and draws them.
22
+ */
23
+ export class TuiRenderer {
24
+ constructor(opts = {}) {
25
+ this.display = { reasoning: false, ...(opts.display || {}) };
26
+ this.streaming = false;
27
+ this.lineBuf = '';
28
+ this.inCodeBlock = false;
29
+ this.reasoningOpen = false;
30
+ this.sawStatus = false;
31
+ }
32
+
33
+ handle(event) {
34
+ switch (event.type) {
35
+ case 'text': return this.renderText(event.delta);
36
+ case 'status': return this.renderStatus(event.line);
37
+ case 'reasoning': return this.renderReasoning(event.delta);
38
+ }
39
+ }
40
+
41
+ // ── Streaming answer text ──────────────────────────────────────────────────
42
+ renderText(delta) {
43
+ this.endReasoning();
44
+ this.streaming = true;
45
+ this.lineBuf += delta;
46
+ const lines = this.lineBuf.split('\n');
47
+ this.lineBuf = lines.pop();
48
+ for (const line of lines) {
49
+ process.stdout.write(this.formatMarkdownLine(line) + '\n');
50
+ }
51
+ }
52
+
53
+ flushLineBuf() {
54
+ if (this.lineBuf) {
55
+ process.stdout.write(this.formatMarkdownLine(this.lineBuf) + '\n');
56
+ this.lineBuf = '';
57
+ }
58
+ }
59
+
60
+ formatMarkdownLine(line) {
61
+ if (line.startsWith('```')) {
62
+ this.inCodeBlock = !this.inCodeBlock;
63
+ return this.inCodeBlock ? ` ${DIM}` : RESET;
64
+ }
65
+ if (this.inCodeBlock) return ` ${DIM}${line}${RESET}`;
66
+ if (/^#{1,3}\s/.test(line)) return `\n ${BOLD}${line.replace(/^#+\s*/, '')}${RESET}`;
67
+ let out = line
68
+ .replace(/\*\*(.+?)\*\*/g, `${BOLD}$1${RESET}`)
69
+ .replace(/`([^`]+)`/g, `${CYAN}$1${RESET}`)
70
+ .replace(/^(\s*)[-*]\s+/, `$1${ACCENT}·${RESET} `);
71
+ return ' ' + out;
72
+ }
73
+
74
+ // ── Server progress (tool activity happening backend-side) ─────────────────
75
+ renderStatus(line) {
76
+ this.endStreaming();
77
+ this.endReasoning();
78
+ if (!this.sawStatus) {
79
+ process.stdout.write(`\n ${DIM}${ITALIC}working${RESET}\n`);
80
+ this.sawStatus = true;
81
+ }
82
+ const text = line.trimStart();
83
+ const indent = /^[\s·•⌁]/.test(line) ? ' ' : ' ';
84
+ process.stdout.write(`${indent}${statusIcon(line)} ${DIM}${text}${RESET}\n`);
85
+ }
86
+
87
+ // ── Reasoning ──────────────────────────────────────────────────────────────
88
+ renderReasoning(delta) {
89
+ if (!this.display.reasoning) return;
90
+ this.endStreaming();
91
+ if (!this.reasoningOpen) {
92
+ process.stdout.write(`\n ${ACCENT}◆${RESET} ${DIM}Reasoning${RESET}\n ${GRAY}`);
93
+ this.reasoningOpen = true;
94
+ }
95
+ process.stdout.write(delta.replace(/\n/g, `\n ${GRAY}`));
96
+ }
97
+
98
+ endReasoning() {
99
+ if (this.reasoningOpen) {
100
+ process.stdout.write(RESET + '\n');
101
+ this.reasoningOpen = false;
102
+ }
103
+ }
104
+
105
+ endStreaming() {
106
+ if (this.streaming) {
107
+ this.flushLineBuf();
108
+ this.inCodeBlock = false;
109
+ process.stdout.write(RESET);
110
+ this.streaming = false;
111
+ }
112
+ }
113
+
114
+ // ── Final reply ─────────────────────────────────────────────────────────────
115
+ renderReply(reply = {}, meta = {}) {
116
+ this.endStreaming();
117
+ this.endReasoning();
118
+ const type = reply.type ?? 'message';
119
+
120
+ if (type === 'plan') this.renderPlan(reply);
121
+ else if (type === 'connect') this.renderConnect(reply);
122
+ // 'message' text already streamed live via renderText; nothing more to draw.
123
+
124
+ this.renderFooter(meta);
125
+ this.sawStatus = false;
126
+ }
127
+
128
+ renderPlan(reply) {
129
+ console.log(`\n ${ACCENT}${BOLD}◆ Plan${RESET}`);
130
+ if (reply.summary) console.log(` ${DIM}${reply.summary}${RESET}`);
131
+ const steps = reply.steps || [];
132
+ steps.forEach((s, i) => {
133
+ const last = i === steps.length - 1;
134
+ const branch = `${GRAY}${last ? '└─' : '├─'}${RESET}`;
135
+ const risk =
136
+ (s.risk === 'destructive' || s.risk === 'high') ? ` ${RED}⚠ destructive${RESET}` :
137
+ (s.risk === 'caution' || s.risk === 'medium') ? ` ${YELLOW}⚡ caution${RESET}` : '';
138
+ const blocked = s.blocked ? ` ${RED}⛔ blocked${RESET}` : '';
139
+ console.log(` ${branch} ${DIM}${i + 1}.${RESET} ${YELLOW}${s.cmd || ''}${RESET}${risk}${blocked}`);
140
+ const why = s.why || s.purpose;
141
+ if (why) console.log(` ${GRAY}${why}${RESET}`);
142
+ });
143
+ console.log(`\n ${DIM}Type ${RESET}${WHITE}apply fix${RESET}${DIM} to confirm.${RESET}`);
144
+ }
145
+
146
+ renderConnect(reply) {
147
+ const d = reply.device ?? reply ?? {};
148
+ const proto = d.proto ? ` ${DIM}via ${d.proto}${RESET}` : '';
149
+ console.log(
150
+ `\n ${GREEN}🔌${RESET} ${BOLD}${(d.kind ?? '?').toUpperCase()} #${d.id ?? '?'}${RESET}${proto}`
151
+ );
152
+ if (reply.note) console.log(` ${DIM}${reply.note}${RESET}`);
153
+ console.log(` ${DIM}Establishing session…${RESET}`);
154
+ }
155
+
156
+ renderFooter(meta) {
157
+ const parts = [meta.model ? shortModel(meta.model) : null, meta.user, meta.role].filter(Boolean);
158
+ if (parts.length) {
159
+ console.log(`\n ${GRAY}${parts.join(' · ')}${RESET}`);
160
+ }
161
+ }
162
+ }
package/src/session.js ADDED
@@ -0,0 +1,41 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+
5
+ const SESSION_DIR = join(homedir(), '.config', 'ispbills-icli', 'sessions');
6
+
7
+ export function sessionDir() {
8
+ return SESSION_DIR;
9
+ }
10
+
11
+ export function initSessionDir() {
12
+ if (!existsSync(SESSION_DIR)) mkdirSync(SESSION_DIR, { recursive: true });
13
+ }
14
+
15
+ export function newSessionPath() {
16
+ const id = new Date().toISOString().replace(/[:.]/g, '-');
17
+ return join(SESSION_DIR, `${id}.jsonl`);
18
+ }
19
+
20
+ /** Append a single message to the JSONL session log (best-effort). */
21
+ export function saveMessage(sessionPath, message) {
22
+ try {
23
+ appendFileSync(sessionPath, JSON.stringify({ timestamp: new Date().toISOString(), message }) + '\n');
24
+ } catch {
25
+ /* non-fatal — session logging is a convenience only */
26
+ }
27
+ }
28
+
29
+ export function listSessions() {
30
+ if (!existsSync(SESSION_DIR)) return [];
31
+ return readdirSync(SESSION_DIR).filter((f) => f.endsWith('.jsonl')).sort();
32
+ }
33
+
34
+ export function loadSession(sessionPath) {
35
+ if (!existsSync(sessionPath)) return [];
36
+ return readFileSync(sessionPath, 'utf-8')
37
+ .split('\n')
38
+ .filter(Boolean)
39
+ .map((line) => { try { return JSON.parse(line).message; } catch { return null; } })
40
+ .filter(Boolean);
41
+ }
package/src/ui.js ADDED
@@ -0,0 +1,61 @@
1
+ // Zero-dependency ANSI helpers shared across the iCopilot TUI.
2
+ // Raw escape codes keep the package dependency-free and portable to
3
+ // Windows terminals (Windows Terminal, PowerShell 7, cmd via VT mode).
4
+
5
+ export const RESET = '\x1b[0m';
6
+ export const BOLD = '\x1b[1m';
7
+ export const DIM = '\x1b[2m';
8
+ export const ITALIC = '\x1b[3m';
9
+ export const UNDERLINE = '\x1b[4m';
10
+
11
+ export const GRAY = '\x1b[90m';
12
+ export const WHITE = '\x1b[97m';
13
+ export const CYAN = '\x1b[36m';
14
+ export const GREEN = '\x1b[32m';
15
+ export const YELLOW = '\x1b[33m';
16
+ export const RED = '\x1b[31m';
17
+ export const MAGENTA = '\x1b[35m';
18
+ export const BLUE = '\x1b[34m';
19
+
20
+ // Accent used throughout the iCopilot brand (soft blue).
21
+ export const ACCENT = '\x1b[38;5;111m';
22
+
23
+ /** Terminal width, clamped to a sensible range. */
24
+ export function cols() {
25
+ const c = process.stdout.columns || 80;
26
+ return Math.max(20, c);
27
+ }
28
+
29
+ /** Wrap text in an ANSI color and always reset. */
30
+ export function paint(color, text) {
31
+ return `${color}${text}${RESET}`;
32
+ }
33
+
34
+ /** Length of a string ignoring ANSI escape codes. */
35
+ export function visLen(s) {
36
+ return s.replace(/\x1b\[[0-9;]*m/g, '').length;
37
+ }
38
+
39
+ /** Truncate to `max` visible chars with an ellipsis. */
40
+ export function trunc(s, max = 50) {
41
+ return s.length > max ? s.slice(0, max) + '…' : s;
42
+ }
43
+
44
+ /** Naive pluralisation used by grouped tool output. */
45
+ export function plural(n, noun) {
46
+ if (n === 1) return `1 ${noun}`;
47
+ if (noun.endsWith('y')) return `${n} ${noun.slice(0, -1)}ies`;
48
+ return `${n} ${noun}s`;
49
+ }
50
+
51
+ /** A horizontal rule spanning the terminal width. */
52
+ export function rule(color = GRAY) {
53
+ return paint(color, '─'.repeat(cols()));
54
+ }
55
+
56
+ /** Shorten a fully-qualified model id for display (e.g. "anthropic/claude-…"). */
57
+ export function shortModel(m) {
58
+ if (!m) return '';
59
+ const parts = String(m).split('/');
60
+ return parts[parts.length - 1];
61
+ }