ispbills-icli 6.1.0 → 7.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/README.md +7 -2
- package/bin/icli.js +25 -1
- package/package.json +7 -2
- package/src/loader.js +3 -4
- package/src/tui/app.js +153 -0
- package/src/tui/components.js +155 -0
- package/src/tui/composer.js +115 -0
- package/src/tui/dom.js +9 -0
- package/src/tui/markdown.js +76 -0
- package/src/tui/run.js +25 -0
- package/src/tui/theme.js +41 -0
- package/src/ui.js +37 -19
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
|
|
|
@@ -132,8 +137,8 @@ iCli connects to the same AI engine as the in-browser terminal:
|
|
|
132
137
|
- **Web search** — looks up vendor CLI docs when it needs to
|
|
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
|
-
- **
|
|
136
|
-
- **Terminal-aware rendering** —
|
|
140
|
+
- **Full-screen terminal UI** — an interactive TUI (built on [Ink](https://github.com/vadimdemedes/ink)) with a bordered input, slash-command menu, input history, and a scrolling transcript — the same style as GitHub Copilot CLI / Gemini CLI. Piped/non-interactive use falls back to plain streaming output
|
|
141
|
+
- **Terminal-aware rendering** — the layout reflows to your terminal width and resizes live; on classic Windows consoles (`cmd.exe` / legacy PowerShell) it auto-switches to ASCII-safe glyphs and borders so nothing shows as broken boxes
|
|
137
142
|
|
|
138
143
|
---
|
|
139
144
|
|
package/bin/icli.js
CHANGED
|
@@ -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: {
|
|
@@ -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}`);
|
|
@@ -260,7 +268,8 @@ async function interactiveRepl(cfg) {
|
|
|
260
268
|
|
|
261
269
|
// ── Main ─────────────────────────────────────────────────────────────────────────
|
|
262
270
|
async function main() {
|
|
263
|
-
if (
|
|
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();
|
|
@@ -301,6 +310,21 @@ async function main() {
|
|
|
301
310
|
return;
|
|
302
311
|
}
|
|
303
312
|
|
|
313
|
+
// Interactive: use the rich Ink TUI on a real terminal; fall back to the
|
|
314
|
+
// plain readline REPL when stdout/stdin is not a TTY (pipes, dumb terminals).
|
|
315
|
+
if (output.isTTY && input.isTTY) {
|
|
316
|
+
const { runTui } = await import('../src/tui/run.js');
|
|
317
|
+
const action = await runTui(cfg, { display });
|
|
318
|
+
if (action === 'logout') {
|
|
319
|
+
const cleared = clearConfig();
|
|
320
|
+
console.log(`\n ${cleared ? GREEN + glyphs.check + RESET + ' Logged out — credentials cleared.' : YELLOW + 'Already logged out.' + RESET}`);
|
|
321
|
+
console.log(` ${DIM}Run ${RESET}${ACCENT}icli login${RESET}${DIM} to sign in again.${RESET}\n`);
|
|
322
|
+
} else if (action === 'update') {
|
|
323
|
+
selfUpdate();
|
|
324
|
+
}
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
304
328
|
await interactiveRepl(cfg);
|
|
305
329
|
}
|
|
306
330
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ispbills-icli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.0",
|
|
4
4
|
"description": "iCli — IspBills AI network engineer in your terminal",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ispbills",
|
|
@@ -23,5 +23,10 @@
|
|
|
23
23
|
"src/",
|
|
24
24
|
"README.md"
|
|
25
25
|
],
|
|
26
|
-
"dependencies": {
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"htm": "^3.1.1",
|
|
28
|
+
"ink": "^6.8.0",
|
|
29
|
+
"ink-spinner": "^5.0.0",
|
|
30
|
+
"react": "^19.2.7"
|
|
31
|
+
}
|
|
27
32
|
}
|
package/src/loader.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import { DIM, RESET,
|
|
2
|
-
|
|
3
|
-
const SPINNER_FRAMES = SPINNER;
|
|
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
|
|
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/tui/app.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Root Ink application: manages conversation state, streams from the IspBills
|
|
2
|
+
// backend, and lays out a scrollable history (Static) above a pinned input.
|
|
3
|
+
import { html, useState, useEffect, useRef, useCallback } from './dom.js';
|
|
4
|
+
import { Box, Text, Static, useStdout, useApp } from 'ink';
|
|
5
|
+
import { C, glyphs, midDot } from './theme.js';
|
|
6
|
+
import {
|
|
7
|
+
Banner, UserMessage, AssistantMessage, StatusLines, Reasoning,
|
|
8
|
+
Plan, Connect, Notice, Thinking,
|
|
9
|
+
} from './components.js';
|
|
10
|
+
import { Composer } from './composer.js';
|
|
11
|
+
import { streamChat } from '../client.js';
|
|
12
|
+
import { slashToQuestion, SLASH_COMMANDS } from '../commands.js';
|
|
13
|
+
import { shortModel } from '../ui.js';
|
|
14
|
+
|
|
15
|
+
function useTerminalWidth() {
|
|
16
|
+
const { stdout } = useStdout();
|
|
17
|
+
const [width, setWidth] = useState(stdout?.columns || 80);
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (!stdout) return;
|
|
20
|
+
const on = () => setWidth(stdout.columns || 80);
|
|
21
|
+
stdout.on('resize', on);
|
|
22
|
+
return () => stdout.off('resize', on);
|
|
23
|
+
}, [stdout]);
|
|
24
|
+
return width;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let itemId = 0;
|
|
28
|
+
const nextId = () => ++itemId;
|
|
29
|
+
|
|
30
|
+
export function App({ cfg, display = {}, onExternal, initialQuestion }) {
|
|
31
|
+
const { exit } = useApp();
|
|
32
|
+
const width = useTerminalWidth();
|
|
33
|
+
|
|
34
|
+
// Finished, scrolled-away items (rendered once via <Static>).
|
|
35
|
+
const [items, setItems] = useState(() => [{ id: nextId(), type: 'banner' }]);
|
|
36
|
+
// Live streaming state for the in-flight turn.
|
|
37
|
+
const [live, setLive] = useState(null); // { status:[], reasoning:'', text:'' }
|
|
38
|
+
const [busy, setBusy] = useState(false);
|
|
39
|
+
const [model, setModel] = useState(cfg._model);
|
|
40
|
+
const convo = useRef([]); // [{role, content}] sent to backend
|
|
41
|
+
|
|
42
|
+
const push = useCallback((item) => setItems((xs) => [...xs, { id: nextId(), ...item }]), []);
|
|
43
|
+
|
|
44
|
+
const runTurn = useCallback(async (question) => {
|
|
45
|
+
push({ type: 'user', text: question });
|
|
46
|
+
convo.current.push({ role: 'user', content: question });
|
|
47
|
+
setBusy(true);
|
|
48
|
+
const liveState = { status: [], reasoning: '', text: '' };
|
|
49
|
+
setLive({ ...liveState });
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const { reply, text, meta } = await streamChat(cfg, [...convo.current], {
|
|
53
|
+
onEvent: (ev) => {
|
|
54
|
+
if (ev.type === 'status') liveState.status = [...liveState.status, ev.line];
|
|
55
|
+
else if (ev.type === 'reasoning' && display.reasoning) liveState.reasoning += ev.delta;
|
|
56
|
+
else if (ev.type === 'text') liveState.text += ev.delta;
|
|
57
|
+
setLive({ ...liveState });
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
if (meta.model) setModel(meta.model);
|
|
61
|
+
const type = reply.type ?? 'message';
|
|
62
|
+
if (type === 'plan') push({ type: 'plan', reply });
|
|
63
|
+
else if (type === 'connect') push({ type: 'connect', reply });
|
|
64
|
+
else push({ type: 'assistant', text: text || '(no response)' });
|
|
65
|
+
if (text) convo.current.push({ role: 'assistant', content: text });
|
|
66
|
+
} catch (e) {
|
|
67
|
+
push({ type: 'notice', text: `${glyphs.cross} ${e.message}`, color: C.red });
|
|
68
|
+
} finally {
|
|
69
|
+
setLive(null);
|
|
70
|
+
setBusy(false);
|
|
71
|
+
}
|
|
72
|
+
}, [cfg, display.reasoning, push]);
|
|
73
|
+
|
|
74
|
+
const onSubmit = useCallback((raw) => {
|
|
75
|
+
const q = raw.trim();
|
|
76
|
+
if (!q) return;
|
|
77
|
+
|
|
78
|
+
// Locally-handled commands.
|
|
79
|
+
if (q === '/exit' || q === 'exit' || q === 'quit') { exit(); return; }
|
|
80
|
+
if (q === '/clear') { setItems([{ id: nextId(), type: 'banner' }]); return; }
|
|
81
|
+
if (q === '/new') {
|
|
82
|
+
convo.current = [];
|
|
83
|
+
push({ type: 'notice', text: `${glyphs.check} New conversation started.`, color: C.green });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (q === '/help' || q === 'help') {
|
|
87
|
+
const lines = SLASH_COMMANDS.map((s) => ` ${(s.cmd + (s.hint ? ' ' + s.hint : '')).padEnd(18)}${s.desc}`).join('\n');
|
|
88
|
+
push({ type: 'notice', text: 'Commands:\n' + lines });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (q === '/history') {
|
|
92
|
+
const us = convo.current.filter((m) => m.role === 'user');
|
|
93
|
+
push({ type: 'notice', text: us.length ? us.map((m, i) => ` ${i + 1}. ${m.content.slice(0, 80)}`).join('\n') : 'No history yet.' });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (q === '/logout') { onExternal?.('logout'); exit(); return; }
|
|
97
|
+
if (q === '/update') { onExternal?.('update'); exit(); return; }
|
|
98
|
+
|
|
99
|
+
// Shortcut slash → natural-language question.
|
|
100
|
+
let question = q;
|
|
101
|
+
if (q.startsWith('/')) {
|
|
102
|
+
const mapped = slashToQuestion(q);
|
|
103
|
+
if (mapped) question = mapped;
|
|
104
|
+
else { push({ type: 'notice', text: `Unknown command: ${q}. Type /help.` }); return; }
|
|
105
|
+
}
|
|
106
|
+
runTurn(question);
|
|
107
|
+
}, [exit, push, runTurn, onExternal]);
|
|
108
|
+
|
|
109
|
+
// Optionally auto-run a first question (e.g. launched with a query).
|
|
110
|
+
const started = useRef(false);
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (initialQuestion && !started.current) {
|
|
113
|
+
started.current = true;
|
|
114
|
+
onSubmit(initialQuestion);
|
|
115
|
+
}
|
|
116
|
+
}, [initialQuestion, onSubmit]);
|
|
117
|
+
|
|
118
|
+
const renderItem = (it) => {
|
|
119
|
+
switch (it.type) {
|
|
120
|
+
case 'banner': return html`<${Banner} key=${it.id} cfg=${cfg} model=${model} width=${width} />`;
|
|
121
|
+
case 'user': return html`<${UserMessage} key=${it.id} text=${it.text} />`;
|
|
122
|
+
case 'assistant': return html`<${AssistantMessage} key=${it.id} text=${it.text} />`;
|
|
123
|
+
case 'plan': return html`<${Plan} key=${it.id} reply=${it.reply} />`;
|
|
124
|
+
case 'connect': return html`<${Connect} key=${it.id} reply=${it.reply} />`;
|
|
125
|
+
case 'notice': return html`<${Notice} key=${it.id} text=${it.text} color=${it.color} />`;
|
|
126
|
+
default: return null;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const ctx = [model ? shortModel(model) : null, cfg.user?.name, cfg.user?.role].filter(Boolean).join(midDot());
|
|
131
|
+
|
|
132
|
+
return html`
|
|
133
|
+
<${Box} flexDirection="column" width=${width}>
|
|
134
|
+
<${Static} items=${items}>${renderItem}<//>
|
|
135
|
+
|
|
136
|
+
${live
|
|
137
|
+
? html`<${Box} flexDirection="column">
|
|
138
|
+
<${StatusLines} lines=${live.status} />
|
|
139
|
+
${live.reasoning ? html`<${Reasoning} text=${live.reasoning} />` : null}
|
|
140
|
+
${live.text ? html`<${Box} marginTop=${1}><${AssistantMessage} text=${live.text} /><//>` : null}
|
|
141
|
+
<${Thinking} label=${live.text ? 'Responding' : 'Thinking'} />
|
|
142
|
+
<//>`
|
|
143
|
+
: null}
|
|
144
|
+
|
|
145
|
+
<${Box} marginTop=${1}>
|
|
146
|
+
<${Composer} onSubmit=${onSubmit} busy=${busy} cfg=${cfg} width=${width} />
|
|
147
|
+
<//>
|
|
148
|
+
<${Box} paddingX=${1} width=${width}>
|
|
149
|
+
<${Box} flexGrow=${1}><${Text} color=${C.gray} wrap="truncate-end">${ctx}<//><//>
|
|
150
|
+
<${Text} color=${C.gray} wrap="truncate-end">/help ${midDot()} history ${midDot()} Ctrl+C to exit<//>
|
|
151
|
+
<//>
|
|
152
|
+
<//>`;
|
|
153
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Presentational components for the iCli Ink TUI.
|
|
2
|
+
import { html } from './dom.js';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import Spinner from 'ink-spinner';
|
|
5
|
+
import { C, glyphs, spinnerType, borderStyle, midDot, dash } from './theme.js';
|
|
6
|
+
import { Markdown } from './markdown.js';
|
|
7
|
+
import { shortModel } from '../ui.js';
|
|
8
|
+
|
|
9
|
+
const LOGO = [
|
|
10
|
+
'██╗ ██████╗ ██████╗ ██████╗ ██╗██╗ ██████╗ ████████╗',
|
|
11
|
+
'██║██╔════╝██╔═══██╗██╔══██╗██║██║ ██╔═══██╗╚══██╔══╝',
|
|
12
|
+
'██║██║ ██║ ██║██████╔╝██║██║ ██║ ██║ ██║ ',
|
|
13
|
+
'██║██║ ██║ ██║██╔═══╝ ██║██║ ██║ ██║ ██║ ',
|
|
14
|
+
'██║╚██████╗╚██████╔╝██║ ██║███████╗╚██████╔╝ ██║ ',
|
|
15
|
+
'╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝ ',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
/** Header banner — full logo on wide terminals, compact title otherwise. */
|
|
19
|
+
export function Banner({ cfg = {}, model, width = 80 }) {
|
|
20
|
+
const m = shortModel(model || cfg._model || 'IspBills AI');
|
|
21
|
+
const wide = width >= 60;
|
|
22
|
+
const meta = [
|
|
23
|
+
['model', m], ['user', cfg.user?.name], ['role', cfg.user?.role],
|
|
24
|
+
].filter(([, v]) => v);
|
|
25
|
+
|
|
26
|
+
return html`
|
|
27
|
+
<${Box} flexDirection="column" marginBottom=${1}>
|
|
28
|
+
${wide
|
|
29
|
+
? html`<${Box} flexDirection="column">
|
|
30
|
+
${LOGO.map((l, i) => html`<${Text} key=${'l' + i} color=${C.accent} bold>${l}<//>`)}
|
|
31
|
+
<${Text} color=${C.gray}> iCopilot ${dash()} network engineer in your terminal<//>
|
|
32
|
+
<//>`
|
|
33
|
+
: html`<${Text} color=${C.accent} bold>iCopilot ${html`<${Text} color=${C.gray}>${dash()} IspBills AI terminal<//>`}<//>`}
|
|
34
|
+
<${Box} marginTop=${1}>
|
|
35
|
+
${meta.map(([label, val], i) => html`
|
|
36
|
+
<${Box} key=${'m' + i}>
|
|
37
|
+
${i > 0 ? html`<${Text} color=${C.gray}>${midDot()}<//>` : null}
|
|
38
|
+
<${Text} color=${C.gray}>${label} <//>
|
|
39
|
+
<${Text} color=${label === 'model' ? C.cyan : C.white}>${val}<//>
|
|
40
|
+
<//>`)}
|
|
41
|
+
<//>
|
|
42
|
+
${cfg.url ? html`<${Text} color=${C.gray}>url ${html`<${Text} color=${C.accent}>${cfg.url}<//>`}<//>` : null}
|
|
43
|
+
<//>`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The user's prompt line. */
|
|
47
|
+
export function UserMessage({ text }) {
|
|
48
|
+
return html`
|
|
49
|
+
<${Box} marginTop=${1}>
|
|
50
|
+
<${Text} color=${C.accent} bold>${glyphs.prompt} <//>
|
|
51
|
+
<${Text} color=${C.white}>${text}<//>
|
|
52
|
+
<//>`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A finished assistant answer (markdown). */
|
|
56
|
+
export function AssistantMessage({ text }) {
|
|
57
|
+
return html`
|
|
58
|
+
<${Box} flexDirection="column" marginTop=${1}>
|
|
59
|
+
<${Markdown} content=${text} />
|
|
60
|
+
<//>`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Icon for a server progress/status line. */
|
|
64
|
+
function statusIcon(line) {
|
|
65
|
+
const l = String(line).toLowerCase();
|
|
66
|
+
if (/unreachable|skip|fail|error|denied|timeout/.test(l)) return html`<${Text} color=${C.red}>${glyphs.cross}<//>`;
|
|
67
|
+
if (/done|success|found|complet|online|ready|ok\b/.test(l)) return html`<${Text} color=${C.green}>${glyphs.check}<//>`;
|
|
68
|
+
if (/batch|sub.?agent|fleet|parallel/.test(l)) return html`<${Text} color=${C.accent}>${glyphs.ring}<//>`;
|
|
69
|
+
if (/\[\d+\/\d+\]/.test(l)) return html`<${Text} color=${C.accent}>${glyphs.diamond}<//>`;
|
|
70
|
+
if (/probe|reachab|ping/.test(l)) return html`<${Text} color=${C.yellow}>${glyphs.target}<//>`;
|
|
71
|
+
if (/connect|ssh|telnet|api|session/.test(l)) return html`<${Text} color=${C.accent}>${glyphs.arrow}<//>`;
|
|
72
|
+
return html`<${Text} color=${C.gray}>${glyphs.bullet}<//>`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Live server activity (tool calls happening backend-side). */
|
|
76
|
+
export function StatusLines({ lines = [] }) {
|
|
77
|
+
if (!lines.length) return null;
|
|
78
|
+
return html`
|
|
79
|
+
<${Box} flexDirection="column" marginTop=${1}>
|
|
80
|
+
${lines.map((line, i) => html`
|
|
81
|
+
<${Box} key=${'s' + i}>
|
|
82
|
+
${statusIcon(line)}
|
|
83
|
+
<${Text} color=${C.gray}> ${line}<//>
|
|
84
|
+
<//>`)}
|
|
85
|
+
<//>`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Reasoning trace (only when enabled). */
|
|
89
|
+
export function Reasoning({ text }) {
|
|
90
|
+
if (!text) return null;
|
|
91
|
+
return html`
|
|
92
|
+
<${Box} flexDirection="column" marginTop=${1}>
|
|
93
|
+
<${Text} color=${C.accent}>${glyphs.reasoning} ${html`<${Text} color=${C.gray}>Reasoning<//>`}<//>
|
|
94
|
+
<${Text} color=${C.gray}>${text}<//>
|
|
95
|
+
<//>`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A proposed remediation plan. */
|
|
99
|
+
export function Plan({ reply = {} }) {
|
|
100
|
+
const steps = reply.steps || [];
|
|
101
|
+
return html`
|
|
102
|
+
<${Box} flexDirection="column" marginTop=${1}>
|
|
103
|
+
<${Text} color=${C.accent} bold>${glyphs.diamond} Plan<//>
|
|
104
|
+
${reply.summary ? html`<${Text} color=${C.gray}>${reply.summary}<//>` : null}
|
|
105
|
+
${steps.map((s, i) => {
|
|
106
|
+
const last = i === steps.length - 1;
|
|
107
|
+
const risk = (s.risk === 'destructive' || s.risk === 'high')
|
|
108
|
+
? html`<${Text} color=${C.red}> ${glyphs.warn} destructive<//>`
|
|
109
|
+
: (s.risk === 'caution' || s.risk === 'medium')
|
|
110
|
+
? html`<${Text} color=${C.yellow}> ${glyphs.bolt} caution<//>` : null;
|
|
111
|
+
return html`
|
|
112
|
+
<${Box} flexDirection="column" key=${'p' + i}>
|
|
113
|
+
<${Box}>
|
|
114
|
+
<${Text} color=${C.gray}>${last ? glyphs.branchEnd : glyphs.branchMid} <//>
|
|
115
|
+
<${Text} color=${C.gray}>${i + 1}. <//>
|
|
116
|
+
<${Text} color=${C.yellow}>${s.cmd || ''}<//>
|
|
117
|
+
${risk}
|
|
118
|
+
${s.blocked ? html`<${Text} color=${C.red}> ${glyphs.block} blocked<//>` : null}
|
|
119
|
+
<//>
|
|
120
|
+
${(s.why || s.purpose) ? html`<${Text} color=${C.gray}> ${s.why || s.purpose}<//>` : null}
|
|
121
|
+
<//>`;
|
|
122
|
+
})}
|
|
123
|
+
<${Box} marginTop=${1}>
|
|
124
|
+
<${Text} color=${C.gray}>Type ${html`<${Text} color=${C.white}>apply fix<//>`} to confirm.<//>
|
|
125
|
+
<//>
|
|
126
|
+
<//>`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A device-connect card. */
|
|
130
|
+
export function Connect({ reply = {} }) {
|
|
131
|
+
const d = reply.device ?? reply ?? {};
|
|
132
|
+
return html`
|
|
133
|
+
<${Box} flexDirection="column" marginTop=${1}>
|
|
134
|
+
<${Box}>
|
|
135
|
+
<${Text} color=${C.green}>${glyphs.plug} <//>
|
|
136
|
+
<${Text} bold>${(d.kind ?? '?').toUpperCase()} #${d.id ?? '?'}<//>
|
|
137
|
+
${d.proto ? html`<${Text} color=${C.gray}> via ${d.proto}<//>` : null}
|
|
138
|
+
<//>
|
|
139
|
+
${reply.note ? html`<${Text} color=${C.gray}>${reply.note}<//>` : null}
|
|
140
|
+
<//>`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A local notice (help hint, errors, etc.). */
|
|
144
|
+
export function Notice({ text, color = C.gray }) {
|
|
145
|
+
return html`<${Box} marginTop=${1}><${Text} color=${color}>${text}<//><//>`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The active "thinking" line with a spinner. */
|
|
149
|
+
export function Thinking({ label = 'Thinking' }) {
|
|
150
|
+
return html`
|
|
151
|
+
<${Box} marginTop=${1}>
|
|
152
|
+
<${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /><//>
|
|
153
|
+
<${Text} color=${C.gray}> ${label}…<//>
|
|
154
|
+
<//>`;
|
|
155
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Bordered input composer with cursor editing, input history and slash-command
|
|
2
|
+
// completion — the pinned bottom control of the TUI.
|
|
3
|
+
import { html, useState, useCallback } from './dom.js';
|
|
4
|
+
import { Box, Text, useInput } from 'ink';
|
|
5
|
+
import { C, glyphs, borderStyle } from './theme.js';
|
|
6
|
+
import { SLASH_COMMANDS } from '../commands.js';
|
|
7
|
+
|
|
8
|
+
export function Composer({ onSubmit, busy, cfg = {}, width = 80 }) {
|
|
9
|
+
const [value, setValue] = useState('');
|
|
10
|
+
const [cursor, setCursor] = useState(0);
|
|
11
|
+
const [history, setHistory] = useState([]);
|
|
12
|
+
const [histIdx, setHistIdx] = useState(-1); // -1 = current (editing) line
|
|
13
|
+
|
|
14
|
+
const slashMatches = value.startsWith('/')
|
|
15
|
+
? SLASH_COMMANDS.filter((s) => s.cmd.startsWith(value.trim().split(/\s+/)[0]))
|
|
16
|
+
: [];
|
|
17
|
+
|
|
18
|
+
const submit = useCallback((text) => {
|
|
19
|
+
const q = text.trim();
|
|
20
|
+
setValue('');
|
|
21
|
+
setCursor(0);
|
|
22
|
+
setHistIdx(-1);
|
|
23
|
+
if (q) {
|
|
24
|
+
setHistory((h) => (h[h.length - 1] === q ? h : [...h, q]));
|
|
25
|
+
onSubmit(q);
|
|
26
|
+
}
|
|
27
|
+
}, [onSubmit]);
|
|
28
|
+
|
|
29
|
+
useInput((input, key) => {
|
|
30
|
+
if (busy) return; // ignore edits while a turn is streaming
|
|
31
|
+
|
|
32
|
+
if (key.return) { submit(value); return; }
|
|
33
|
+
|
|
34
|
+
// Pasted/batched input may arrive as one chunk containing a newline —
|
|
35
|
+
// insert the text before it, then submit (mirrors typing + Enter).
|
|
36
|
+
if (input && /[\r\n]/.test(input)) {
|
|
37
|
+
const first = input.split(/[\r\n]/)[0];
|
|
38
|
+
submit(value.slice(0, cursor) + first + value.slice(cursor));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (key.tab) {
|
|
43
|
+
if (slashMatches.length) {
|
|
44
|
+
const c = slashMatches[0].cmd + ' ';
|
|
45
|
+
setValue(c); setCursor(c.length);
|
|
46
|
+
}
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (key.leftArrow) { setCursor((c) => Math.max(0, c - 1)); return; }
|
|
51
|
+
if (key.rightArrow) { setCursor((c) => Math.min(value.length, c + 1)); return; }
|
|
52
|
+
if (key.upArrow) {
|
|
53
|
+
setHistory((h) => {
|
|
54
|
+
if (!h.length) return h;
|
|
55
|
+
const idx = histIdx === -1 ? h.length - 1 : Math.max(0, histIdx - 1);
|
|
56
|
+
setHistIdx(idx); setValue(h[idx]); setCursor(h[idx].length);
|
|
57
|
+
return h;
|
|
58
|
+
});
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (key.downArrow) {
|
|
62
|
+
setHistory((h) => {
|
|
63
|
+
if (histIdx === -1) return h;
|
|
64
|
+
const idx = histIdx + 1;
|
|
65
|
+
if (idx >= h.length) { setHistIdx(-1); setValue(''); setCursor(0); }
|
|
66
|
+
else { setHistIdx(idx); setValue(h[idx]); setCursor(h[idx].length); }
|
|
67
|
+
return h;
|
|
68
|
+
});
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ink maps both Backspace and Delete here across platforms; treat as
|
|
73
|
+
// "delete char before cursor" so Windows conhost behaves correctly.
|
|
74
|
+
if (key.backspace || key.delete) {
|
|
75
|
+
if (cursor > 0) {
|
|
76
|
+
setValue((v) => v.slice(0, cursor - 1) + v.slice(cursor));
|
|
77
|
+
setCursor((c) => c - 1);
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Printable input (ignore control/meta chords; ctrl+c handled by the app).
|
|
83
|
+
if (input && !key.ctrl && !key.meta) {
|
|
84
|
+
const printable = input.replace(/[\x00-\x1f\x7f]/g, '');
|
|
85
|
+
if (!printable) return;
|
|
86
|
+
setValue((v) => v.slice(0, cursor) + printable + v.slice(cursor));
|
|
87
|
+
setCursor((c) => c + printable.length);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Render the value with an inverse cursor block.
|
|
92
|
+
const before = value.slice(0, cursor);
|
|
93
|
+
const at = value[cursor] ?? ' ';
|
|
94
|
+
const after = value.slice(cursor + 1);
|
|
95
|
+
const showPlaceholder = value.length === 0;
|
|
96
|
+
|
|
97
|
+
return html`
|
|
98
|
+
<${Box} flexDirection="column" width=${width}>
|
|
99
|
+
<${Box} width=${width} borderStyle=${borderStyle()} borderColor=${busy ? C.gray : C.accent} paddingX=${1}>
|
|
100
|
+
<${Text} color=${busy ? C.gray : C.accent}>${glyphs.prompt} <//>
|
|
101
|
+
${showPlaceholder
|
|
102
|
+
? html`<${Box} flexGrow=${1}><${Text} color=${C.gray} inverse> <//><${Text} color=${C.gray}>${busy ? 'Working…' : 'Ask about your network, or type / for commands'}<//><//>`
|
|
103
|
+
: html`<${Box} flexGrow=${1}><${Text}>${before}<//><${Text} inverse>${at}<//><${Text}>${after}<//><//>`}
|
|
104
|
+
<//>
|
|
105
|
+
${slashMatches.length && !busy
|
|
106
|
+
? html`<${Box} flexDirection="column" paddingX=${1}>
|
|
107
|
+
${slashMatches.slice(0, 6).map((s, i) => html`
|
|
108
|
+
<${Box} key=${'sc' + i}>
|
|
109
|
+
<${Text} color=${C.accent}>${(s.cmd + (s.hint ? ' ' + s.hint : '')).padEnd(21)}<//>
|
|
110
|
+
<${Text} color=${C.gray}>${s.desc}<//>
|
|
111
|
+
<//>`)}
|
|
112
|
+
<//>`
|
|
113
|
+
: null}
|
|
114
|
+
<//>`;
|
|
115
|
+
}
|
package/src/tui/dom.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// JSX-free React/Ink binding via htm — lets us author components with
|
|
2
|
+
// tagged-template markup and ship plain ESM (no build step, works the same
|
|
3
|
+
// on Windows and Linux).
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import htm from 'htm';
|
|
6
|
+
|
|
7
|
+
export { React };
|
|
8
|
+
export const html = htm.bind(React.createElement);
|
|
9
|
+
export const { useState, useEffect, useRef, useCallback, useMemo } = React;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Minimal, dependency-free markdown → Ink renderer. Supports headings,
|
|
2
|
+
// bold, inline code, bullet/numbered lists and fenced code blocks. Ink
|
|
3
|
+
// handles wrapping to the available width automatically.
|
|
4
|
+
import { html, React } from './dom.js';
|
|
5
|
+
import { Box, Text } from 'ink';
|
|
6
|
+
import { C, glyphs } from './theme.js';
|
|
7
|
+
|
|
8
|
+
let keyId = 0;
|
|
9
|
+
const k = () => `md${keyId++}`;
|
|
10
|
+
|
|
11
|
+
/** Parse a single line into styled Ink <Text> spans (bold, `code`). */
|
|
12
|
+
function renderInline(line) {
|
|
13
|
+
const spans = [];
|
|
14
|
+
// Tokenise on **bold** and `code`.
|
|
15
|
+
const re = /(\*\*([^*]+)\*\*|`([^`]+)`)/g;
|
|
16
|
+
let last = 0;
|
|
17
|
+
let m;
|
|
18
|
+
while ((m = re.exec(line)) !== null) {
|
|
19
|
+
if (m.index > last) spans.push(html`<${Text} key=${k()}>${line.slice(last, m.index)}<//>`);
|
|
20
|
+
if (m[2] !== undefined) spans.push(html`<${Text} key=${k()} bold>${m[2]}<//>`);
|
|
21
|
+
else if (m[3] !== undefined) spans.push(html`<${Text} key=${k()} color=${C.cyan}>${m[3]}<//>`);
|
|
22
|
+
last = m.index + m[0].length;
|
|
23
|
+
}
|
|
24
|
+
if (last < line.length) spans.push(html`<${Text} key=${k()}>${line.slice(last)}<//>`);
|
|
25
|
+
return spans.length ? spans : [html`<${Text} key=${k()}> <//>`];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Render a markdown string as a column of Ink lines. */
|
|
29
|
+
export function Markdown({ content = '' }) {
|
|
30
|
+
const rows = [];
|
|
31
|
+
let inCode = false;
|
|
32
|
+
const lines = String(content).replace(/\s+$/, '').split('\n');
|
|
33
|
+
|
|
34
|
+
for (const raw of lines) {
|
|
35
|
+
const line = raw.replace(/\t/g, ' ');
|
|
36
|
+
|
|
37
|
+
if (line.trim().startsWith('```')) {
|
|
38
|
+
inCode = !inCode;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (inCode) {
|
|
42
|
+
rows.push(html`<${Text} key=${k()} color=${C.gray}> ${line}<//>`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const h = line.match(/^(#{1,3})\s+(.*)$/);
|
|
47
|
+
if (h) {
|
|
48
|
+
rows.push(html`<${Text} key=${k()} bold color=${C.white}>${h[2]}<//>`);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const bullet = line.match(/^(\s*)[-*]\s+(.*)$/);
|
|
53
|
+
if (bullet) {
|
|
54
|
+
rows.push(html`
|
|
55
|
+
<${Box} key=${k()}>
|
|
56
|
+
<${Text} color=${C.accent}>${bullet[1]}${glyphs.bullet} <//>
|
|
57
|
+
<${Text}>${renderInline(bullet[2])}<//>
|
|
58
|
+
<//>`);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const num = line.match(/^(\s*)(\d+)\.\s+(.*)$/);
|
|
63
|
+
if (num) {
|
|
64
|
+
rows.push(html`
|
|
65
|
+
<${Box} key=${k()}>
|
|
66
|
+
<${Text} color=${C.accent}>${num[1]}${num[2]}. <//>
|
|
67
|
+
<${Text}>${renderInline(num[3])}<//>
|
|
68
|
+
<//>`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
rows.push(html`<${Text} key=${k()}>${renderInline(line)}<//>`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return html`<${Box} flexDirection="column">${rows}<//>`;
|
|
76
|
+
}
|
package/src/tui/run.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Ink render entrypoint for the interactive TUI.
|
|
2
|
+
import { html } from './dom.js';
|
|
3
|
+
import { render } from 'ink';
|
|
4
|
+
import { App } from './app.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Launch the interactive Ink TUI.
|
|
8
|
+
*
|
|
9
|
+
* @param {object} cfg Loaded config ({ url, token, user, _model, ... }).
|
|
10
|
+
* @param {object} opts { display: { reasoning }, initialQuestion, onExternal }
|
|
11
|
+
* @returns {Promise<string|undefined>} An external action ('logout'|'update') if requested.
|
|
12
|
+
*/
|
|
13
|
+
export async function runTui(cfg, opts = {}) {
|
|
14
|
+
const { display = {}, onExternal, initialQuestion } = opts;
|
|
15
|
+
let externalAction;
|
|
16
|
+
const record = (action) => { externalAction = action; };
|
|
17
|
+
|
|
18
|
+
const instance = render(
|
|
19
|
+
html`<${App} cfg=${cfg} display=${display} initialQuestion=${initialQuestion} onExternal=${(a) => { record(a); onExternal?.(a); }} />`,
|
|
20
|
+
{ exitOnCtrlC: true },
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
await instance.waitUntilExit();
|
|
24
|
+
return externalAction;
|
|
25
|
+
}
|
package/src/tui/theme.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Colours + glyphs for the Ink TUI. Colours are Ink-friendly names/hex;
|
|
2
|
+
// glyphs and the spinner reuse the ASCII-safe logic from ui.js so classic
|
|
3
|
+
// Windows consoles never render "tofu" boxes.
|
|
4
|
+
import { glyphs, asciiSafe } from '../ui.js';
|
|
5
|
+
|
|
6
|
+
export { glyphs, asciiSafe };
|
|
7
|
+
|
|
8
|
+
// Soft-blue brand accent (approx. xterm-256 colour 111).
|
|
9
|
+
export const ACCENT = '#87afff';
|
|
10
|
+
export const C = {
|
|
11
|
+
accent: ACCENT,
|
|
12
|
+
gray: 'gray',
|
|
13
|
+
white: 'white',
|
|
14
|
+
cyan: 'cyan',
|
|
15
|
+
green: 'green',
|
|
16
|
+
yellow: 'yellow',
|
|
17
|
+
red: 'red',
|
|
18
|
+
magenta: 'magenta',
|
|
19
|
+
blue: 'blue',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// ink-spinner uses cli-spinners; 'line' is pure ASCII (|/-\), 'dots' is braille.
|
|
23
|
+
export function spinnerType() {
|
|
24
|
+
return asciiSafe() ? 'line' : 'dots';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Border style — rounded corners (╭╮╰╯) are NOT in CP437, so fall back to a
|
|
28
|
+
// single-line border on classic Windows consoles; rounded elsewhere.
|
|
29
|
+
export function borderStyle() {
|
|
30
|
+
return asciiSafe() ? 'single' : 'round';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Middle-dot separator, ASCII-safe on classic Windows consoles.
|
|
34
|
+
export function midDot() {
|
|
35
|
+
return asciiSafe() ? ' - ' : ' · ';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Em-dash, ASCII-safe on classic Windows consoles.
|
|
39
|
+
export function dash() {
|
|
40
|
+
return asciiSafe() ? '-' : '—';
|
|
41
|
+
}
|
package/src/ui.js
CHANGED
|
@@ -57,27 +57,45 @@ export function rule(color = GRAY) {
|
|
|
57
57
|
// Classic Windows consoles (cmd.exe / legacy PowerShell using the Consolas or
|
|
58
58
|
// raster font) lack many Unicode dingbats, geometric shapes, arrows, braille
|
|
59
59
|
// and emoji — they render as "tofu" boxes. Windows Terminal sets WT_SESSION,
|
|
60
|
-
// so we
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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 = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
76
94
|
|
|
77
95
|
/** Spinner frames — braille on modern terminals, ASCII on classic Windows. */
|
|
78
|
-
export
|
|
79
|
-
?
|
|
80
|
-
|
|
96
|
+
export function getSpinner() {
|
|
97
|
+
return asciiSafe() ? ASCII_SPINNER : UNICODE_SPINNER;
|
|
98
|
+
}
|
|
81
99
|
|
|
82
100
|
/** Print a full-width horizontal rule (dim by default). */
|
|
83
101
|
export function hr(color = GRAY) {
|