ispbills-icli 6.0.0 → 6.1.1

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/README.md CHANGED
@@ -93,6 +93,7 @@ icli --reasoning "audit the config on olt#1" # stream reasoning inline
93
93
  | `icli logout` | Log out and clear saved credentials |
94
94
  | `icli update` | Update iCli to the latest version |
95
95
  | `icli whoami` | Show current login info |
96
+ | `icli --version` | Show the installed iCli version |
96
97
  | `icli --help` | Show help |
97
98
 
98
99
  ### Flags
@@ -101,6 +102,10 @@ icli --reasoning "audit the config on olt#1" # stream reasoning inline
101
102
  |---|---|
102
103
  | `--reasoning` | Stream the model's reasoning inline |
103
104
  | `--loader-style STYLE` | Thinking indicator: `spinner` (default), `gradient`, or `minimal` |
105
+ | `--ascii` | Force ASCII-safe glyphs (use if symbols show as boxes) |
106
+ | `--unicode` | Force full Unicode glyphs |
107
+
108
+ > On classic Windows `cmd.exe` iCli auto-switches to ASCII-safe symbols. If it guesses wrong, force it with `icli --ascii` (or set the `ICLI_ASCII=1` environment variable). For the full Unicode look, use **Windows Terminal** or pass `--unicode`.
104
109
 
105
110
  ### Slash commands & keys (REPL)
106
111
 
@@ -133,6 +138,7 @@ iCli connects to the same AI engine as the in-browser terminal:
133
138
  - **Streaming output** — answers stream live with a thinking indicator, reasoning, and step-by-step server progress
134
139
  - **Plans & connect cards** — proposed fixes render as a reviewable plan (`apply fix` to confirm); device connections show as a connect card
135
140
  - **Zero dependencies** — pure Node.js (built-ins only), so it installs instantly and runs the same on Windows, macOS, and Linux
141
+ - **Terminal-aware rendering** — full-width separators, and automatic ASCII-safe glyphs on classic Windows consoles (`cmd.exe` / legacy PowerShell) so nothing shows as broken boxes
136
142
 
137
143
  ---
138
144
 
package/bin/icli.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  SLASH_COMMANDS, slashCompleter, slashToQuestion, printSlashMenu, printHistory,
17
17
  } from '../src/commands.js';
18
18
  import {
19
- RESET, BOLD, DIM, GRAY, WHITE, ACCENT, GREEN, YELLOW, RED, CYAN, rule,
19
+ RESET, BOLD, DIM, GRAY, WHITE, ACCENT, GREEN, YELLOW, RED, CYAN, rule, glyphs,
20
20
  } from '../src/ui.js';
21
21
 
22
22
  const PKG_NAME = 'ispbills-icli';
@@ -48,6 +48,11 @@ for (let i = 0; i < rawArgs.length; i++) {
48
48
 
49
49
  const cmd = positional[0] ?? '';
50
50
 
51
+ // Glyph overrides — set the env vars the (lazy) glyph resolver in ui.js reads,
52
+ // before anything is rendered. Useful when auto-detection guesses wrong.
53
+ if (has('--ascii')) process.env.ICLI_ASCII = '1';
54
+ if (has('--unicode')) process.env.ICLI_UNICODE = '1';
55
+
51
56
  const display = {
52
57
  reasoning: has('--reasoning'),
53
58
  loader: {
@@ -78,10 +83,10 @@ function selfUpdate() {
78
83
  });
79
84
 
80
85
  if (res.status === 0) {
81
- process.stdout.write(`\n ${GREEN}✓${RESET} iCli updated. ${DIM}Restart iCli to use the new version.${RESET}\n\n`);
86
+ process.stdout.write(`\n ${GREEN}${glyphs.check}${RESET} iCli updated. ${DIM}Restart iCli to use the new version.${RESET}\n\n`);
82
87
  return true;
83
88
  }
84
- process.stdout.write(`\n ${RED}✖${RESET} Update failed. Try manually:\n`);
89
+ process.stdout.write(`\n ${RED}${glyphs.cross}${RESET} Update failed. Try manually:\n`);
85
90
  process.stdout.write(` ${ACCENT}npm install -g ${PKG_NAME}@latest${RESET}\n`);
86
91
  process.stdout.write(` ${DIM}On some systems this needs elevated privileges (sudo / Administrator).${RESET}\n\n`);
87
92
  return false;
@@ -98,6 +103,7 @@ function printHelp() {
98
103
  ['icli logout', 'Log out and clear saved credentials'],
99
104
  ['icli update', 'Update iCli to the latest version'],
100
105
  ['icli whoami', 'Show current session info'],
106
+ ['icli --version', 'Show the installed iCli version'],
101
107
  ['icli --help', 'Show this help'],
102
108
  ].forEach(([c, d]) => console.log(` ${ACCENT}${c.padEnd(20)}${RESET}${DIM}${d}${RESET}`));
103
109
 
@@ -105,6 +111,8 @@ function printHelp() {
105
111
  [
106
112
  ['--reasoning', 'Stream the model\'s reasoning inline'],
107
113
  ['--loader-style STYLE', 'spinner | gradient | minimal'],
114
+ ['--ascii', 'Force ASCII-safe glyphs (classic Windows cmd.exe)'],
115
+ ['--unicode', 'Force full Unicode glyphs'],
108
116
  ].forEach(([c, d]) => console.log(` ${ACCENT}${c.padEnd(24)}${RESET}${DIM}${d}${RESET}`));
109
117
 
110
118
  console.log(`\n ${BOLD}Slash commands${RESET}`);
@@ -132,7 +140,7 @@ async function ensureAuth() {
132
140
  if (!cfg) {
133
141
  process.stdout.write(`\n ${YELLOW}No saved credentials. Setting up iCli.${RESET}\n\n`);
134
142
  cfg = await loginFlow();
135
- process.stdout.write(`\n ${GREEN}✓${RESET} Logged in as ${WHITE}${cfg.user?.name ?? cfg.user?.email}${RESET}` +
143
+ process.stdout.write(`\n ${GREEN}${glyphs.check}${RESET} Logged in as ${WHITE}${cfg.user?.name ?? cfg.user?.email}${RESET}` +
136
144
  `${DIM} · ${cfg.user?.role ?? 'operator'}${RESET}\n\n`);
137
145
  }
138
146
  return cfg;
@@ -140,7 +148,7 @@ async function ensureAuth() {
140
148
 
141
149
  // ── One conversation turn ───────────────────────────────────────────────────────
142
150
  async function runTurn(cfg, history, renderer, loader, question, { echo = false } = {}) {
143
- if (echo) console.log(`\n ${GRAY}❯${RESET} ${DIM}${question}${RESET}`);
151
+ if (echo) console.log(`\n ${GRAY}${glyphs.prompt}${RESET} ${DIM}${question}${RESET}`);
144
152
  history.push({ role: 'user', content: question });
145
153
 
146
154
  const abort = new AbortController();
@@ -168,7 +176,7 @@ async function runTurn(cfg, history, renderer, loader, question, { echo = false
168
176
  if (abort.signal.aborted) {
169
177
  console.log(`\n ${DIM}Interrupted.${RESET}\n`);
170
178
  } else {
171
- console.log(`\n ${RED}✖${RESET} ${WHITE}${e.message}${RESET}\n`);
179
+ console.log(`\n ${RED}${glyphs.cross}${RESET} ${WHITE}${e.message}${RESET}\n`);
172
180
  }
173
181
  } finally {
174
182
  process.removeListener('SIGINT', onSigint);
@@ -187,7 +195,7 @@ async function interactiveRepl(cfg) {
187
195
  const history = [];
188
196
  const renderer = new TuiRenderer({ display });
189
197
  const loader = new Loader(display.loader);
190
- const PROMPT = () => ` ${ACCENT}❯${RESET} `;
198
+ const PROMPT = () => ` ${ACCENT}${glyphs.prompt}${RESET} `;
191
199
  let busy = false;
192
200
 
193
201
  printBanner(cfg, { model: cfg._model });
@@ -227,7 +235,7 @@ async function interactiveRepl(cfg) {
227
235
  }
228
236
  if (q === '/new') {
229
237
  history.length = 0;
230
- console.log(`\n ${GREEN}✓${RESET} ${DIM}New conversation started.${RESET}\n`); rl.prompt(); return;
238
+ console.log(`\n ${GREEN}${glyphs.check}${RESET} ${DIM}New conversation started.${RESET}\n`); rl.prompt(); return;
231
239
  }
232
240
  if (q === '/help' || q === 'help') { printHelp(); rl.prompt(); return; }
233
241
  if (q === '/history' || q === 'history') { printHistory(history); rl.prompt(); return; }
@@ -237,7 +245,7 @@ async function interactiveRepl(cfg) {
237
245
  }
238
246
  if (q === '/logout' || q === 'logout') {
239
247
  const cleared = clearConfig();
240
- console.log(`\n ${cleared ? GREEN + '✓' + RESET + ' Logged out — credentials cleared.' : YELLOW + 'Already logged out.' + RESET}`);
248
+ console.log(`\n ${cleared ? GREEN + glyphs.check + RESET + ' Logged out — credentials cleared.' : YELLOW + 'Already logged out.' + RESET}`);
241
249
  console.log(` ${DIM}Run ${RESET}${ACCENT}icli login${RESET}${DIM} to sign in again.${RESET}\n`);
242
250
  rl.close(); return;
243
251
  }
@@ -260,11 +268,12 @@ async function interactiveRepl(cfg) {
260
268
 
261
269
  // ── Main ─────────────────────────────────────────────────────────────────────────
262
270
  async function main() {
263
- if (cmd === '--help' || cmd === '-h' || cmd === 'help') { printHelp(); return; }
271
+ if (has('--help') || has('-h') || cmd === 'help') { printHelp(); return; }
272
+ if (has('--version') || has('-v') || cmd === 'version') { console.log(pkgVersion()); return; }
264
273
 
265
274
  if (cmd === 'login') {
266
275
  const cfg = await loginFlow();
267
- console.log(`\n ${GREEN}✓${RESET} Logged in as ${WHITE}${cfg.user?.name ?? cfg.user?.email}${RESET}${DIM} · ${cfg.user?.role ?? 'operator'}${RESET}`);
276
+ console.log(`\n ${GREEN}${glyphs.check}${RESET} Logged in as ${WHITE}${cfg.user?.name ?? cfg.user?.email}${RESET}${DIM} · ${cfg.user?.role ?? 'operator'}${RESET}`);
268
277
  console.log(` ${DIM}Config: ${configPath()}${RESET}\n`);
269
278
  return;
270
279
  }
@@ -274,7 +283,7 @@ async function main() {
274
283
  if (cmd === 'logout') {
275
284
  const cleared = clearConfig();
276
285
  if (cleared) {
277
- console.log(`\n ${GREEN}✓${RESET} Logged out — credentials cleared.`);
286
+ console.log(`\n ${GREEN}${glyphs.check}${RESET} Logged out — credentials cleared.`);
278
287
  console.log(` ${DIM}Run ${RESET}${ACCENT}icli login${RESET}${DIM} to sign in again.${RESET}\n`);
279
288
  } else {
280
289
  console.log(`\n ${YELLOW}Not logged in.${RESET} Nothing to clear.\n`);
@@ -305,6 +314,6 @@ async function main() {
305
314
  }
306
315
 
307
316
  main().catch((e) => {
308
- console.log(`\n ${RED}✖${RESET} ${WHITE}${e.message}${RESET}\n`);
317
+ console.log(`\n ${RED}${glyphs.cross}${RESET} ${WHITE}${e.message}${RESET}\n`);
309
318
  exit(1);
310
319
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ispbills-icli",
3
- "version": "6.0.0",
3
+ "version": "6.1.1",
4
4
  "description": "iCli — IspBills AI network engineer in your terminal",
5
5
  "keywords": [
6
6
  "ispbills",
package/src/banner.js CHANGED
@@ -1,4 +1,4 @@
1
- import { RESET, BOLD, DIM, CYAN, WHITE, ACCENT, GRAY, cols, shortModel, rule } from './ui.js';
1
+ import { RESET, BOLD, DIM, CYAN, WHITE, ACCENT, GRAY, cols, shortModel, rule, hr } from './ui.js';
2
2
 
3
3
  const LOGO = [
4
4
  ' ██╗ ██████╗ ██████╗ ██████╗ ██╗██╗ ██████╗ ████████╗',
@@ -9,8 +9,12 @@ const LOGO = [
9
9
  ' ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝ ',
10
10
  ];
11
11
 
12
+ // Widest visible logo line — used to decide when to fall back to a compact header.
13
+ const LOGO_WIDTH = Math.max(...LOGO.map((l) => l.length));
14
+
12
15
  /**
13
16
  * Full iCopilot banner. On narrow terminals falls back to a compact header.
17
+ * A full-width rule frames the header so it uses the whole window.
14
18
  *
15
19
  * @param {object} cfg Loaded config ({ url, user, _model }).
16
20
  * @param {object} opts { model }
@@ -20,13 +24,11 @@ export function printBanner(cfg = {}, opts = {}) {
20
24
  const model = opts.model || cfg._model || 'IspBills AI';
21
25
 
22
26
  console.log();
23
- if (width >= 62) {
27
+ if (width >= LOGO_WIDTH) {
24
28
  for (const line of LOGO) console.log(ACCENT + BOLD + line + RESET);
25
29
  console.log(` ${DIM}iCopilot — network engineer in your terminal${RESET}`);
26
30
  } else {
27
- console.log(rule());
28
31
  console.log(` ${BOLD}iCopilot${RESET} ${DIM}— IspBills AI terminal${RESET}`);
29
- console.log(rule());
30
32
  }
31
33
  console.log();
32
34
 
@@ -39,4 +41,6 @@ export function printBanner(cfg = {}, opts = {}) {
39
41
  console.log();
40
42
  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
43
  console.log();
44
+ hr();
45
+ console.log();
42
46
  }
package/src/loader.js CHANGED
@@ -1,6 +1,4 @@
1
- import { DIM, RESET } from './ui.js';
2
-
3
- const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1
+ import { DIM, RESET, getSpinner } from './ui.js';
4
2
 
5
3
  const GRADIENT_COLORS = [
6
4
  '\x1b[38;5;240m',
@@ -68,7 +66,8 @@ export class Loader {
68
66
  }
69
67
  case 'spinner':
70
68
  default: {
71
- const char = SPINNER_FRAMES[this.frame % SPINNER_FRAMES.length];
69
+ const frames = getSpinner();
70
+ const char = frames[this.frame % frames.length];
72
71
  process.stdout.write(`\r ${DIM}${char} ${text}…${RESET}`);
73
72
  break;
74
73
  }
package/src/renderer.js CHANGED
@@ -1,19 +1,19 @@
1
1
  import {
2
2
  RESET, BOLD, DIM, ITALIC, CYAN, GREEN, YELLOW, RED, GRAY, WHITE, ACCENT,
3
- cols, shortModel,
3
+ cols, shortModel, glyphs, hr,
4
4
  } from './ui.js';
5
5
 
6
6
  /** Pick a status icon from the content of a server progress line. */
7
7
  function statusIcon(line) {
8
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}`;
9
+ if (/unreachable|skip|fail|error|denied|timeout/.test(l)) return `${RED}${glyphs.cross}${RESET}`;
10
+ if (/done|success|found|complet|online|ready|ok\b/.test(l)) return `${GREEN}${glyphs.check}${RESET}`;
11
+ if (/batch|sub.?agent|fleet|parallel/.test(l)) return `${ACCENT}${glyphs.ring}${RESET}`;
12
+ if (/\[\d+\/\d+\]/.test(l)) return `${ACCENT}${glyphs.diamond}${RESET}`;
13
+ if (/probe|reachab|ping/.test(l)) return `${YELLOW}${glyphs.target}${RESET}`;
14
+ if (/connect|ssh|telnet|api|session/.test(l)) return `${ACCENT}${glyphs.arrow}${RESET}`;
15
+ if (/read|fetch|get|query|look|search/.test(l)) return `${GRAY}${glyphs.down}${RESET}`;
16
+ return `${GRAY}${glyphs.bullet}${RESET}`;
17
17
  }
18
18
 
19
19
  /**
@@ -67,7 +67,7 @@ export class TuiRenderer {
67
67
  let out = line
68
68
  .replace(/\*\*(.+?)\*\*/g, `${BOLD}$1${RESET}`)
69
69
  .replace(/`([^`]+)`/g, `${CYAN}$1${RESET}`)
70
- .replace(/^(\s*)[-*]\s+/, `$1${ACCENT}·${RESET} `);
70
+ .replace(/^(\s*)[-*]\s+/, `$1${ACCENT}${glyphs.bullet}${RESET} `);
71
71
  return ' ' + out;
72
72
  }
73
73
 
@@ -89,7 +89,7 @@ export class TuiRenderer {
89
89
  if (!this.display.reasoning) return;
90
90
  this.endStreaming();
91
91
  if (!this.reasoningOpen) {
92
- process.stdout.write(`\n ${ACCENT}◆${RESET} ${DIM}Reasoning${RESET}\n ${GRAY}`);
92
+ process.stdout.write(`\n ${ACCENT}${glyphs.reasoning}${RESET} ${DIM}Reasoning${RESET}\n ${GRAY}`);
93
93
  this.reasoningOpen = true;
94
94
  }
95
95
  process.stdout.write(delta.replace(/\n/g, `\n ${GRAY}`));
@@ -126,16 +126,16 @@ export class TuiRenderer {
126
126
  }
127
127
 
128
128
  renderPlan(reply) {
129
- console.log(`\n ${ACCENT}${BOLD} Plan${RESET}`);
129
+ console.log(`\n ${ACCENT}${BOLD}${glyphs.diamond} Plan${RESET}`);
130
130
  if (reply.summary) console.log(` ${DIM}${reply.summary}${RESET}`);
131
131
  const steps = reply.steps || [];
132
132
  steps.forEach((s, i) => {
133
133
  const last = i === steps.length - 1;
134
- const branch = `${GRAY}${last ? '└─' : '├─'}${RESET}`;
134
+ const branch = `${GRAY}${last ? glyphs.branchEnd : glyphs.branchMid}${RESET}`;
135
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}` : '';
136
+ (s.risk === 'destructive' || s.risk === 'high') ? ` ${RED}${glyphs.warn} destructive${RESET}` :
137
+ (s.risk === 'caution' || s.risk === 'medium') ? ` ${YELLOW}${glyphs.bolt} caution${RESET}` : '';
138
+ const blocked = s.blocked ? ` ${RED}${glyphs.block} blocked${RESET}` : '';
139
139
  console.log(` ${branch} ${DIM}${i + 1}.${RESET} ${YELLOW}${s.cmd || ''}${RESET}${risk}${blocked}`);
140
140
  const why = s.why || s.purpose;
141
141
  if (why) console.log(` ${GRAY}${why}${RESET}`);
@@ -147,7 +147,7 @@ export class TuiRenderer {
147
147
  const d = reply.device ?? reply ?? {};
148
148
  const proto = d.proto ? ` ${DIM}via ${d.proto}${RESET}` : '';
149
149
  console.log(
150
- `\n ${GREEN}🔌${RESET} ${BOLD}${(d.kind ?? '?').toUpperCase()} #${d.id ?? '?'}${RESET}${proto}`
150
+ `\n ${GREEN}${glyphs.plug}${RESET} ${BOLD}${(d.kind ?? '?').toUpperCase()} #${d.id ?? '?'}${RESET}${proto}`
151
151
  );
152
152
  if (reply.note) console.log(` ${DIM}${reply.note}${RESET}`);
153
153
  console.log(` ${DIM}Establishing session…${RESET}`);
@@ -155,8 +155,10 @@ export class TuiRenderer {
155
155
 
156
156
  renderFooter(meta) {
157
157
  const parts = [meta.model ? shortModel(meta.model) : null, meta.user, meta.role].filter(Boolean);
158
+ console.log();
159
+ hr();
158
160
  if (parts.length) {
159
- console.log(`\n ${GRAY}${parts.join(' · ')}${RESET}`);
161
+ console.log(` ${GRAY}${parts.join(' · ')}${RESET}`);
160
162
  }
161
163
  }
162
164
  }
package/src/ui.js CHANGED
@@ -53,6 +53,55 @@ export function rule(color = GRAY) {
53
53
  return paint(color, '─'.repeat(cols()));
54
54
  }
55
55
 
56
+ // ── Glyphs ─────────────────────────────────────────────────────────────────
57
+ // Classic Windows consoles (cmd.exe / legacy PowerShell using the Consolas or
58
+ // raster font) lack many Unicode dingbats, geometric shapes, arrows, braille
59
+ // and emoji — they render as "tofu" boxes. Windows Terminal sets WT_SESSION,
60
+ // so we fall back to ASCII on a classic Windows console. This can be forced
61
+ // either way with the ICLI_ASCII / ICLI_UNICODE env vars (or --ascii/--unicode).
62
+ //
63
+ // Selection is evaluated lazily (per access) so the CLI can set the env vars
64
+ // from command-line flags before the first glyph is rendered.
65
+ function truthy(v) {
66
+ return v === '1' || v === 'true' || v === 'yes' || v === 'on';
67
+ }
68
+ export function asciiSafe() {
69
+ if (truthy(process.env.ICLI_UNICODE)) return false;
70
+ if (truthy(process.env.ICLI_ASCII)) return true;
71
+ return process.platform === 'win32' && !process.env.WT_SESSION;
72
+ }
73
+
74
+ const ASCII_GLYPHS = {
75
+ prompt: '>', bullet: '-', diamond: '*', ring: '*', target: 'o',
76
+ arrow: '->', down: 'v', check: 'OK', cross: 'XX', warn: '!!',
77
+ bolt: '!', block: 'X', plug: '>>', reasoning: '~',
78
+ branchMid: '|-', branchEnd: '`-', vbar: '|',
79
+ };
80
+ const UNICODE_GLYPHS = {
81
+ prompt: '❯', bullet: '·', diamond: '◆', ring: '◈', target: '◎',
82
+ arrow: '⇢', down: '↓', check: '✓', cross: '✗', warn: '⚠',
83
+ bolt: '⚡', block: '⛔', plug: '🔌', reasoning: '◆',
84
+ branchMid: '├─', branchEnd: '└─', vbar: '│',
85
+ };
86
+
87
+ // Proxy resolves each glyph at access time, honouring runtime env overrides.
88
+ export const glyphs = new Proxy({}, {
89
+ get: (_t, key) => (asciiSafe() ? ASCII_GLYPHS : UNICODE_GLYPHS)[key],
90
+ });
91
+
92
+ const ASCII_SPINNER = ['|', '/', '-', '\\'];
93
+ const UNICODE_SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
94
+
95
+ /** Spinner frames — braille on modern terminals, ASCII on classic Windows. */
96
+ export function getSpinner() {
97
+ return asciiSafe() ? ASCII_SPINNER : UNICODE_SPINNER;
98
+ }
99
+
100
+ /** Print a full-width horizontal rule (dim by default). */
101
+ export function hr(color = GRAY) {
102
+ process.stdout.write(paint(color, '─'.repeat(cols())) + '\n');
103
+ }
104
+
56
105
  /** Shorten a fully-qualified model id for display (e.g. "anthropic/claude-…"). */
57
106
  export function shortModel(m) {
58
107
  if (!m) return '';