ispbills-icli 6.1.1 → 8.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 +24 -8
- package/bin/icli.js +15 -0
- package/package.json +7 -2
- package/src/tui/app.js +243 -0
- package/src/tui/components.js +167 -0
- package/src/tui/composer.js +146 -0
- package/src/tui/dom.js +9 -0
- package/src/tui/markdown.js +76 -0
- package/src/tui/run.js +44 -0
- package/src/tui/theme.js +41 -0
- package/src/ui.js +2 -2
package/README.md
CHANGED
|
@@ -57,8 +57,6 @@ $ icli
|
|
|
57
57
|
model IspBills AI · user Admin · role group_admin
|
|
58
58
|
url https://app.myisp.com
|
|
59
59
|
|
|
60
|
-
Type a message to start. /help for commands · Ctrl+C to exit
|
|
61
|
-
|
|
62
60
|
❯ is vlan 82 free on all devices?
|
|
63
61
|
|
|
64
62
|
working
|
|
@@ -69,9 +67,10 @@ $ icli
|
|
|
69
67
|
|
|
70
68
|
VLAN 82 is free across all 3 reachable devices in your fleet.
|
|
71
69
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
70
|
+
╭──────────────────────────────────────────────────────────╮
|
|
71
|
+
│ ❯ Ask about your network, or type / for commands │
|
|
72
|
+
╰──────────────────────────────────────────────────────────╯
|
|
73
|
+
IspBills AI · Admin · group_admin /help · Ctrl+T reasoning · Ctrl+C exit
|
|
75
74
|
```
|
|
76
75
|
|
|
77
76
|
### Single-shot query
|
|
@@ -122,7 +121,24 @@ Inside the REPL, type `/` to see the menu. Shortcuts:
|
|
|
122
121
|
| `/help` | Show help |
|
|
123
122
|
| `/exit` | Quit |
|
|
124
123
|
|
|
125
|
-
|
|
124
|
+
**Keys**
|
|
125
|
+
|
|
126
|
+
| Key | Action |
|
|
127
|
+
|---|---|
|
|
128
|
+
| **Enter** | Send message (or run the highlighted slash command) |
|
|
129
|
+
| **/** then **↑/↓** | Open the slash menu and move the selection; **Tab** completes, **Enter** runs |
|
|
130
|
+
| **↑/↓** (empty input) | Navigate input history |
|
|
131
|
+
| **Ctrl+T** | Toggle reasoning display |
|
|
132
|
+
| **Ctrl+L** | Clear the transcript |
|
|
133
|
+
| **Esc** | Cancel the in-flight request |
|
|
134
|
+
| **Ctrl+C** | Cancel a running request; press again on an empty prompt to exit |
|
|
135
|
+
| **Ctrl+A/E** | Jump to start / end of the line |
|
|
136
|
+
| **Ctrl+U/K/W** | Delete to start / to end / previous word |
|
|
137
|
+
|
|
138
|
+
The REPL runs as a full-height app in the terminal's alternate screen: the banner
|
|
139
|
+
stays pinned at the top, the transcript fills the middle, and the input box is
|
|
140
|
+
pinned to the bottom — the same layout as GitHub Copilot CLI. On exit, the session
|
|
141
|
+
transcript is reprinted to your normal scrollback.
|
|
126
142
|
|
|
127
143
|
---
|
|
128
144
|
|
|
@@ -137,8 +153,8 @@ iCli connects to the same AI engine as the in-browser terminal:
|
|
|
137
153
|
- **Web search** — looks up vendor CLI docs when it needs to
|
|
138
154
|
- **Streaming output** — answers stream live with a thinking indicator, reasoning, and step-by-step server progress
|
|
139
155
|
- **Plans & connect cards** — proposed fixes render as a reviewable plan (`apply fix` to confirm); device connections show as a connect card
|
|
140
|
-
- **
|
|
141
|
-
- **Terminal-aware rendering** —
|
|
156
|
+
- **Full-screen terminal UI** — a full-height alternate-screen TUI (built on [Ink](https://github.com/vadimdemedes/ink)) with the banner pinned to the top, a bottom-pinned bordered input, a selectable slash-command menu, input history, and a bottom-anchored transcript — the same layout as GitHub Copilot CLI / Gemini CLI. Piped/non-interactive use falls back to plain streaming output
|
|
157
|
+
- **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
|
|
142
158
|
|
|
143
159
|
---
|
|
144
160
|
|
package/bin/icli.js
CHANGED
|
@@ -310,6 +310,21 @@ async function main() {
|
|
|
310
310
|
return;
|
|
311
311
|
}
|
|
312
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
|
+
|
|
313
328
|
await interactiveRepl(cfg);
|
|
314
329
|
}
|
|
315
330
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ispbills-icli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "8.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/tui/app.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Root Ink application. Renders a full-height, alt-screen chat UI (Copilot-CLI
|
|
2
|
+
// style): banner pinned to the top, transcript filling the middle, input
|
|
3
|
+
// pinned to the bottom. Manages conversation state and streams from the
|
|
4
|
+
// IspBills backend.
|
|
5
|
+
import { html, useState, useEffect, useRef, useCallback } from './dom.js';
|
|
6
|
+
import { Box, Text, useStdout, useApp, useInput } from 'ink';
|
|
7
|
+
import { C, glyphs, midDot } from './theme.js';
|
|
8
|
+
import {
|
|
9
|
+
Banner, UserMessage, AssistantMessage, StatusLines, Reasoning,
|
|
10
|
+
Plan, Connect, Notice, Thinking,
|
|
11
|
+
} from './components.js';
|
|
12
|
+
import { Composer } from './composer.js';
|
|
13
|
+
import { streamChat } from '../client.js';
|
|
14
|
+
import { slashToQuestion, SLASH_COMMANDS } from '../commands.js';
|
|
15
|
+
import { shortModel } from '../ui.js';
|
|
16
|
+
|
|
17
|
+
function useTerminalSize() {
|
|
18
|
+
const { stdout } = useStdout();
|
|
19
|
+
const [size, setSize] = useState({
|
|
20
|
+
cols: stdout?.columns || 80,
|
|
21
|
+
rows: stdout?.rows || 24,
|
|
22
|
+
});
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!stdout) return;
|
|
25
|
+
const on = () => setSize({ cols: stdout.columns || 80, rows: stdout.rows || 24 });
|
|
26
|
+
stdout.on('resize', on);
|
|
27
|
+
return () => stdout.off('resize', on);
|
|
28
|
+
}, [stdout]);
|
|
29
|
+
return size;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let itemId = 0;
|
|
33
|
+
const nextId = () => ++itemId;
|
|
34
|
+
|
|
35
|
+
// Rough rendered-height estimates so we can tail-slice the transcript to fit
|
|
36
|
+
// the available middle region (biased high → under-fill, never overflow/clip).
|
|
37
|
+
const wrapLines = (text, cols) =>
|
|
38
|
+
String(text || '').split('\n').reduce((n, l) => n + Math.max(1, Math.ceil(l.length / Math.max(1, cols))), 0);
|
|
39
|
+
|
|
40
|
+
function itemHeight(it, cols) {
|
|
41
|
+
switch (it.type) {
|
|
42
|
+
case 'user': return 1 + wrapLines(it.text, cols);
|
|
43
|
+
case 'assistant': return 1 + wrapLines(it.text, cols);
|
|
44
|
+
case 'notice': return 1 + wrapLines(it.text, cols);
|
|
45
|
+
case 'plan': { const s = it.reply?.steps || []; return 2 + (it.reply?.summary ? 1 : 0) + s.length * 2 + 2; }
|
|
46
|
+
case 'connect': return 3 + (it.reply?.note ? 1 : 0);
|
|
47
|
+
default: return 1;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function bannerHeight(cols, compact) {
|
|
52
|
+
if (compact) return 2;
|
|
53
|
+
return cols >= 60 ? 11 : 5;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function liveHeight(live, cols, showReasoning) {
|
|
57
|
+
if (!live) return 0;
|
|
58
|
+
let h = 2; // thinking line + margin
|
|
59
|
+
if (live.status?.length) h += 1 + live.status.length;
|
|
60
|
+
if (showReasoning && live.reasoning) h += 1 + wrapLines(live.reasoning, cols);
|
|
61
|
+
if (live.text) h += 1 + wrapLines(live.text, cols);
|
|
62
|
+
return h;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function App({ cfg, display = {}, onExternal, onExit, initialQuestion }) {
|
|
66
|
+
const { exit } = useApp();
|
|
67
|
+
const { cols, rows } = useTerminalSize();
|
|
68
|
+
|
|
69
|
+
const [items, setItems] = useState([]);
|
|
70
|
+
const [live, setLive] = useState(null); // { status:[], reasoning:'', text:'' }
|
|
71
|
+
const [busy, setBusy] = useState(false);
|
|
72
|
+
const [model, setModel] = useState(cfg._model);
|
|
73
|
+
const [showReasoning, setShowReasoning] = useState(!!display.reasoning);
|
|
74
|
+
const [hint, setHint] = useState('');
|
|
75
|
+
|
|
76
|
+
const convo = useRef([]); // [{role, content}] for the backend
|
|
77
|
+
const plain = useRef([]); // plain-text transcript for exit reprint
|
|
78
|
+
const abortRef = useRef(null); // AbortController of the in-flight turn
|
|
79
|
+
const ctrlC = useRef(0); // timestamp of the last lone Ctrl+C
|
|
80
|
+
|
|
81
|
+
const push = useCallback((item) => setItems((xs) => [...xs, { id: nextId(), ...item }]), []);
|
|
82
|
+
const log = (line) => plain.current.push(line);
|
|
83
|
+
|
|
84
|
+
const runTurn = useCallback(async (question) => {
|
|
85
|
+
push({ type: 'user', text: question });
|
|
86
|
+
log(`\n${glyphs.prompt} ${question}\n`);
|
|
87
|
+
convo.current.push({ role: 'user', content: question });
|
|
88
|
+
setBusy(true);
|
|
89
|
+
setHint('');
|
|
90
|
+
const s = { status: [], reasoning: '', text: '' };
|
|
91
|
+
setLive({ ...s });
|
|
92
|
+
|
|
93
|
+
const abort = new AbortController();
|
|
94
|
+
abortRef.current = abort;
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const { reply, text, meta } = await streamChat(cfg, [...convo.current], {
|
|
98
|
+
signal: abort.signal,
|
|
99
|
+
onEvent: (ev) => {
|
|
100
|
+
if (ev.type === 'status') s.status = [...s.status, ev.line];
|
|
101
|
+
else if (ev.type === 'reasoning') s.reasoning += ev.delta;
|
|
102
|
+
else if (ev.type === 'text') s.text += ev.delta;
|
|
103
|
+
setLive({ ...s });
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
if (meta.model) setModel(meta.model);
|
|
107
|
+
const type = reply.type ?? 'message';
|
|
108
|
+
if (type === 'plan') { push({ type: 'plan', reply }); log('[plan] ' + (reply.summary || '')); }
|
|
109
|
+
else if (type === 'connect') { push({ type: 'connect', reply }); log('[connect] ' + (reply.device?.kind || '')); }
|
|
110
|
+
else { push({ type: 'assistant', text: text || '(no response)' }); log(text || ''); }
|
|
111
|
+
if (text) convo.current.push({ role: 'assistant', content: text });
|
|
112
|
+
} catch (e) {
|
|
113
|
+
if (abort.signal.aborted) push({ type: 'notice', text: `${glyphs.cross} Cancelled.`, color: C.yellow });
|
|
114
|
+
else push({ type: 'notice', text: `${glyphs.cross} ${e.message}`, color: C.red });
|
|
115
|
+
} finally {
|
|
116
|
+
abortRef.current = null;
|
|
117
|
+
setLive(null);
|
|
118
|
+
setBusy(false);
|
|
119
|
+
}
|
|
120
|
+
}, [cfg, push]);
|
|
121
|
+
|
|
122
|
+
const doExit = useCallback(() => { onExit?.(plain.current); exit(); }, [exit, onExit]);
|
|
123
|
+
|
|
124
|
+
const onSubmit = useCallback((raw) => {
|
|
125
|
+
const q = raw.trim();
|
|
126
|
+
if (!q) return;
|
|
127
|
+
setHint('');
|
|
128
|
+
|
|
129
|
+
if (q === '/exit' || q === 'exit' || q === 'quit') { doExit(); return; }
|
|
130
|
+
if (q === '/clear') { setItems([]); return; }
|
|
131
|
+
if (q === '/new') {
|
|
132
|
+
convo.current = [];
|
|
133
|
+
setItems([]);
|
|
134
|
+
push({ type: 'notice', text: `${glyphs.check} New conversation started.`, color: C.green });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (q === '/reasoning') {
|
|
138
|
+
setShowReasoning((v) => { push({ type: 'notice', text: `Reasoning display ${!v ? 'on' : 'off'}.` }); return !v; });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (q === '/help' || q === 'help') {
|
|
142
|
+
const lines = SLASH_COMMANDS.map((c) => ` ${(c.cmd + (c.hint ? ' ' + c.hint : '')).padEnd(20)}${c.desc}`).join('\n');
|
|
143
|
+
push({ type: 'notice', text: 'Commands:\n' + lines + '\n\n Keys: Ctrl+T reasoning · Ctrl+L clear · Ctrl+C cancel · Ctrl+C twice to exit' });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (q === '/history') {
|
|
147
|
+
const us = convo.current.filter((m) => m.role === 'user');
|
|
148
|
+
push({ type: 'notice', text: us.length ? us.map((m, i) => ` ${i + 1}. ${m.content.slice(0, 80)}`).join('\n') : 'No history yet.' });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (q === '/logout') { onExternal?.('logout'); doExit(); return; }
|
|
152
|
+
if (q === '/update') { onExternal?.('update'); doExit(); return; }
|
|
153
|
+
|
|
154
|
+
let question = q;
|
|
155
|
+
if (q.startsWith('/')) {
|
|
156
|
+
const mapped = slashToQuestion(q);
|
|
157
|
+
if (mapped) question = mapped;
|
|
158
|
+
else { push({ type: 'notice', text: `Unknown command: ${q}. Type /help.` }); return; }
|
|
159
|
+
}
|
|
160
|
+
runTurn(question);
|
|
161
|
+
}, [doExit, push, runTurn, onExternal]);
|
|
162
|
+
|
|
163
|
+
// Global keys (Composer handles text editing; here we handle app-level keys).
|
|
164
|
+
useInput((input, key) => {
|
|
165
|
+
if (key.ctrl && input === 'c') {
|
|
166
|
+
if (busy) { abortRef.current?.abort(); setHint(''); return; }
|
|
167
|
+
const now = Date.now();
|
|
168
|
+
if (now - ctrlC.current < 1500) { doExit(); return; }
|
|
169
|
+
ctrlC.current = now;
|
|
170
|
+
setHint('Press Ctrl+C again to exit');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (key.escape && busy) { abortRef.current?.abort(); return; }
|
|
174
|
+
if (key.ctrl && input === 't') { setShowReasoning((v) => !v); return; }
|
|
175
|
+
if (key.ctrl && input === 'l' && !busy) { setItems([]); return; }
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Auto-run a first question if launched with one.
|
|
179
|
+
const started = useRef(false);
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
if (initialQuestion && !started.current) { started.current = true; onSubmit(initialQuestion); }
|
|
182
|
+
}, [initialQuestion, onSubmit]);
|
|
183
|
+
|
|
184
|
+
const renderItem = (it) => {
|
|
185
|
+
switch (it.type) {
|
|
186
|
+
case 'user': return html`<${UserMessage} key=${it.id} text=${it.text} />`;
|
|
187
|
+
case 'assistant': return html`<${AssistantMessage} key=${it.id} text=${it.text} />`;
|
|
188
|
+
case 'plan': return html`<${Plan} key=${it.id} reply=${it.reply} />`;
|
|
189
|
+
case 'connect': return html`<${Connect} key=${it.id} reply=${it.reply} />`;
|
|
190
|
+
case 'notice': return html`<${Notice} key=${it.id} text=${it.text} color=${it.color} />`;
|
|
191
|
+
default: return null;
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const ctx = [model ? shortModel(model) : null, cfg.user?.name, cfg.user?.role].filter(Boolean).join(midDot());
|
|
196
|
+
const footerRight = hint || `/help ${midDot()} Ctrl+T reasoning ${midDot()} Ctrl+C exit`;
|
|
197
|
+
|
|
198
|
+
// Tail-slice the transcript to what fits the middle region so nothing is
|
|
199
|
+
// clipped mid-line on small terminals.
|
|
200
|
+
const compact = rows < 30 || cols < 60;
|
|
201
|
+
const budget = Math.max(
|
|
202
|
+
3,
|
|
203
|
+
rows - bannerHeight(cols, compact) - 3 /*composer*/ - 1 /*footer*/ - 1 /*safety*/
|
|
204
|
+
- liveHeight(live, cols, showReasoning) - 1 /*earlier indicator*/,
|
|
205
|
+
);
|
|
206
|
+
const chosen = [];
|
|
207
|
+
let used = 0;
|
|
208
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
209
|
+
const h = itemHeight(items[i], cols);
|
|
210
|
+
if (chosen.length && used + h > budget) break;
|
|
211
|
+
chosen.unshift(items[i]);
|
|
212
|
+
used += h;
|
|
213
|
+
}
|
|
214
|
+
const hidden = items.length - chosen.length;
|
|
215
|
+
|
|
216
|
+
return html`
|
|
217
|
+
<${Box} flexDirection="column" width=${cols} height=${rows}>
|
|
218
|
+
<${Box} flexShrink=${0} flexDirection="column">
|
|
219
|
+
<${Banner} cfg=${cfg} model=${model} width=${cols} compact=${compact} />
|
|
220
|
+
<//>
|
|
221
|
+
|
|
222
|
+
<${Box} flexGrow=${1} flexDirection="column" justifyContent="flex-end" overflow="hidden">
|
|
223
|
+
${hidden > 0 ? html`<${Text} color=${C.gray}>${glyphs.up} ${hidden} earlier message${hidden > 1 ? 's' : ''}<//>` : null}
|
|
224
|
+
${chosen.map(renderItem)}
|
|
225
|
+
${live
|
|
226
|
+
? html`<${Box} flexDirection="column">
|
|
227
|
+
<${StatusLines} lines=${live.status} />
|
|
228
|
+
${showReasoning && live.reasoning ? html`<${Reasoning} text=${live.reasoning} />` : null}
|
|
229
|
+
${live.text ? html`<${Box} marginTop=${1}><${AssistantMessage} text=${live.text} /><//>` : null}
|
|
230
|
+
<${Thinking} label=${live.text ? 'Responding' : 'Thinking'} />
|
|
231
|
+
<//>`
|
|
232
|
+
: null}
|
|
233
|
+
<//>
|
|
234
|
+
|
|
235
|
+
<${Box} flexShrink=${0} flexDirection="column">
|
|
236
|
+
<${Composer} onSubmit=${onSubmit} busy=${busy} width=${cols} />
|
|
237
|
+
<${Box} paddingX=${1} width=${cols}>
|
|
238
|
+
<${Box} flexGrow=${1}><${Text} color=${C.gray} wrap="truncate-end">${ctx}<//><//>
|
|
239
|
+
<${Text} color=${hint ? C.yellow : C.gray} wrap="truncate-end">${footerRight}<//>
|
|
240
|
+
<//>
|
|
241
|
+
<//>
|
|
242
|
+
<//>`;
|
|
243
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
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/tall terminals, compact title otherwise. */
|
|
19
|
+
export function Banner({ cfg = {}, model, width = 80, compact = false }) {
|
|
20
|
+
const m = shortModel(model || cfg._model || 'IspBills AI');
|
|
21
|
+
const wide = !compact && width >= 60;
|
|
22
|
+
const meta = [
|
|
23
|
+
['model', m], ['user', cfg.user?.name], ['role', cfg.user?.role],
|
|
24
|
+
].filter(([, v]) => v);
|
|
25
|
+
|
|
26
|
+
if (compact) {
|
|
27
|
+
return html`
|
|
28
|
+
<${Box} marginBottom=${1}>
|
|
29
|
+
<${Text} color=${C.accent} bold>iCopilot <//>
|
|
30
|
+
${meta.map(([label, val], i) => html`
|
|
31
|
+
<${Box} key=${'m' + i}>
|
|
32
|
+
<${Text} color=${C.gray}>${midDot()}<//>
|
|
33
|
+
<${Text} color=${label === 'model' ? C.cyan : C.white}>${val}<//>
|
|
34
|
+
<//>`)}
|
|
35
|
+
<//>`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return html`
|
|
39
|
+
<${Box} flexDirection="column" marginBottom=${1}>
|
|
40
|
+
${wide
|
|
41
|
+
? html`<${Box} flexDirection="column">
|
|
42
|
+
${LOGO.map((l, i) => html`<${Text} key=${'l' + i} color=${C.accent} bold>${l}<//>`)}
|
|
43
|
+
<${Text} color=${C.gray}> iCopilot ${dash()} network engineer in your terminal<//>
|
|
44
|
+
<//>`
|
|
45
|
+
: html`<${Text} color=${C.accent} bold>iCopilot ${html`<${Text} color=${C.gray}>${dash()} IspBills AI terminal<//>`}<//>`}
|
|
46
|
+
<${Box} marginTop=${1}>
|
|
47
|
+
${meta.map(([label, val], i) => html`
|
|
48
|
+
<${Box} key=${'m' + i}>
|
|
49
|
+
${i > 0 ? html`<${Text} color=${C.gray}>${midDot()}<//>` : null}
|
|
50
|
+
<${Text} color=${C.gray}>${label} <//>
|
|
51
|
+
<${Text} color=${label === 'model' ? C.cyan : C.white}>${val}<//>
|
|
52
|
+
<//>`)}
|
|
53
|
+
<//>
|
|
54
|
+
${cfg.url ? html`<${Text} color=${C.gray}>url ${html`<${Text} color=${C.accent}>${cfg.url}<//>`}<//>` : null}
|
|
55
|
+
<//>`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The user's prompt line. */
|
|
59
|
+
export function UserMessage({ text }) {
|
|
60
|
+
return html`
|
|
61
|
+
<${Box} marginTop=${1}>
|
|
62
|
+
<${Text} color=${C.accent} bold>${glyphs.prompt} <//>
|
|
63
|
+
<${Text} color=${C.white}>${text}<//>
|
|
64
|
+
<//>`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A finished assistant answer (markdown). */
|
|
68
|
+
export function AssistantMessage({ text }) {
|
|
69
|
+
return html`
|
|
70
|
+
<${Box} flexDirection="column" marginTop=${1}>
|
|
71
|
+
<${Markdown} content=${text} />
|
|
72
|
+
<//>`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Icon for a server progress/status line. */
|
|
76
|
+
function statusIcon(line) {
|
|
77
|
+
const l = String(line).toLowerCase();
|
|
78
|
+
if (/unreachable|skip|fail|error|denied|timeout/.test(l)) return html`<${Text} color=${C.red}>${glyphs.cross}<//>`;
|
|
79
|
+
if (/done|success|found|complet|online|ready|ok\b/.test(l)) return html`<${Text} color=${C.green}>${glyphs.check}<//>`;
|
|
80
|
+
if (/batch|sub.?agent|fleet|parallel/.test(l)) return html`<${Text} color=${C.accent}>${glyphs.ring}<//>`;
|
|
81
|
+
if (/\[\d+\/\d+\]/.test(l)) return html`<${Text} color=${C.accent}>${glyphs.diamond}<//>`;
|
|
82
|
+
if (/probe|reachab|ping/.test(l)) return html`<${Text} color=${C.yellow}>${glyphs.target}<//>`;
|
|
83
|
+
if (/connect|ssh|telnet|api|session/.test(l)) return html`<${Text} color=${C.accent}>${glyphs.arrow}<//>`;
|
|
84
|
+
return html`<${Text} color=${C.gray}>${glyphs.bullet}<//>`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Live server activity (tool calls happening backend-side). */
|
|
88
|
+
export function StatusLines({ lines = [] }) {
|
|
89
|
+
if (!lines.length) return null;
|
|
90
|
+
return html`
|
|
91
|
+
<${Box} flexDirection="column" marginTop=${1}>
|
|
92
|
+
${lines.map((line, i) => html`
|
|
93
|
+
<${Box} key=${'s' + i}>
|
|
94
|
+
${statusIcon(line)}
|
|
95
|
+
<${Text} color=${C.gray}> ${line}<//>
|
|
96
|
+
<//>`)}
|
|
97
|
+
<//>`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Reasoning trace (only when enabled). */
|
|
101
|
+
export function Reasoning({ text }) {
|
|
102
|
+
if (!text) return null;
|
|
103
|
+
return html`
|
|
104
|
+
<${Box} flexDirection="column" marginTop=${1}>
|
|
105
|
+
<${Text} color=${C.accent}>${glyphs.reasoning} ${html`<${Text} color=${C.gray}>Reasoning<//>`}<//>
|
|
106
|
+
<${Text} color=${C.gray}>${text}<//>
|
|
107
|
+
<//>`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A proposed remediation plan. */
|
|
111
|
+
export function Plan({ reply = {} }) {
|
|
112
|
+
const steps = reply.steps || [];
|
|
113
|
+
return html`
|
|
114
|
+
<${Box} flexDirection="column" marginTop=${1}>
|
|
115
|
+
<${Text} color=${C.accent} bold>${glyphs.diamond} Plan<//>
|
|
116
|
+
${reply.summary ? html`<${Text} color=${C.gray}>${reply.summary}<//>` : null}
|
|
117
|
+
${steps.map((s, i) => {
|
|
118
|
+
const last = i === steps.length - 1;
|
|
119
|
+
const risk = (s.risk === 'destructive' || s.risk === 'high')
|
|
120
|
+
? html`<${Text} color=${C.red}> ${glyphs.warn} destructive<//>`
|
|
121
|
+
: (s.risk === 'caution' || s.risk === 'medium')
|
|
122
|
+
? html`<${Text} color=${C.yellow}> ${glyphs.bolt} caution<//>` : null;
|
|
123
|
+
return html`
|
|
124
|
+
<${Box} flexDirection="column" key=${'p' + i}>
|
|
125
|
+
<${Box}>
|
|
126
|
+
<${Text} color=${C.gray}>${last ? glyphs.branchEnd : glyphs.branchMid} <//>
|
|
127
|
+
<${Text} color=${C.gray}>${i + 1}. <//>
|
|
128
|
+
<${Text} color=${C.yellow}>${s.cmd || ''}<//>
|
|
129
|
+
${risk}
|
|
130
|
+
${s.blocked ? html`<${Text} color=${C.red}> ${glyphs.block} blocked<//>` : null}
|
|
131
|
+
<//>
|
|
132
|
+
${(s.why || s.purpose) ? html`<${Text} color=${C.gray}> ${s.why || s.purpose}<//>` : null}
|
|
133
|
+
<//>`;
|
|
134
|
+
})}
|
|
135
|
+
<${Box} marginTop=${1}>
|
|
136
|
+
<${Text} color=${C.gray}>Type ${html`<${Text} color=${C.white}>apply fix<//>`} to confirm.<//>
|
|
137
|
+
<//>
|
|
138
|
+
<//>`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A device-connect card. */
|
|
142
|
+
export function Connect({ reply = {} }) {
|
|
143
|
+
const d = reply.device ?? reply ?? {};
|
|
144
|
+
return html`
|
|
145
|
+
<${Box} flexDirection="column" marginTop=${1}>
|
|
146
|
+
<${Box}>
|
|
147
|
+
<${Text} color=${C.green}>${glyphs.plug} <//>
|
|
148
|
+
<${Text} bold>${(d.kind ?? '?').toUpperCase()} #${d.id ?? '?'}<//>
|
|
149
|
+
${d.proto ? html`<${Text} color=${C.gray}> via ${d.proto}<//>` : null}
|
|
150
|
+
<//>
|
|
151
|
+
${reply.note ? html`<${Text} color=${C.gray}>${reply.note}<//>` : null}
|
|
152
|
+
<//>`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A local notice (help hint, errors, etc.). */
|
|
156
|
+
export function Notice({ text, color = C.gray }) {
|
|
157
|
+
return html`<${Box} marginTop=${1}><${Text} color=${color}>${text}<//><//>`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The active "thinking" line with a spinner. */
|
|
161
|
+
export function Thinking({ label = 'Thinking' }) {
|
|
162
|
+
return html`
|
|
163
|
+
<${Box} marginTop=${1}>
|
|
164
|
+
<${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /><//>
|
|
165
|
+
<${Text} color=${C.gray}> ${label}…<//>
|
|
166
|
+
<//>`;
|
|
167
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Bottom-pinned input composer with cursor editing, input history, readline
|
|
2
|
+
// key bindings and a Copilot-style slash-command palette that appears ABOVE
|
|
3
|
+
// the input and is navigable with the arrow keys.
|
|
4
|
+
import { html, useState, useEffect, useCallback } from './dom.js';
|
|
5
|
+
import { Box, Text, useInput } from 'ink';
|
|
6
|
+
import { C, glyphs, borderStyle } from './theme.js';
|
|
7
|
+
import { SLASH_COMMANDS } from '../commands.js';
|
|
8
|
+
|
|
9
|
+
export function Composer({ onSubmit, busy, width = 80 }) {
|
|
10
|
+
const [value, setValue] = useState('');
|
|
11
|
+
const [cursor, setCursor] = useState(0);
|
|
12
|
+
const [history, setHistory] = useState([]);
|
|
13
|
+
const [histIdx, setHistIdx] = useState(-1);
|
|
14
|
+
const [sel, setSel] = useState(0);
|
|
15
|
+
|
|
16
|
+
// The slash palette is open while typing the command token (no space yet).
|
|
17
|
+
const matches = value.startsWith('/') && !value.includes(' ')
|
|
18
|
+
? SLASH_COMMANDS.filter((s) => s.cmd.startsWith(value))
|
|
19
|
+
: [];
|
|
20
|
+
const menuOpen = matches.length > 0;
|
|
21
|
+
|
|
22
|
+
useEffect(() => { setSel((i) => Math.min(i, Math.max(0, matches.length - 1))); }, [matches.length]);
|
|
23
|
+
|
|
24
|
+
const submit = useCallback((text) => {
|
|
25
|
+
const q = text.trim();
|
|
26
|
+
setValue(''); setCursor(0); setHistIdx(-1); setSel(0);
|
|
27
|
+
if (q) {
|
|
28
|
+
setHistory((h) => (h[h.length - 1] === q ? h : [...h, q]));
|
|
29
|
+
onSubmit(q);
|
|
30
|
+
}
|
|
31
|
+
}, [onSubmit]);
|
|
32
|
+
|
|
33
|
+
const setText = (v, c) => { setValue(v); setCursor(c ?? v.length); };
|
|
34
|
+
|
|
35
|
+
useInput((input, key) => {
|
|
36
|
+
if (busy) return; // ignore edits while a turn streams (App handles Ctrl+C/Esc)
|
|
37
|
+
|
|
38
|
+
// ── Submit / complete ──
|
|
39
|
+
if (key.return) {
|
|
40
|
+
if (menuOpen) { submit(matches[sel].cmd); return; }
|
|
41
|
+
submit(value);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (input && /[\r\n]/.test(input)) {
|
|
45
|
+
const first = input.split(/[\r\n]/)[0];
|
|
46
|
+
submit(value.slice(0, cursor) + first + value.slice(cursor));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (key.tab) {
|
|
50
|
+
if (menuOpen) { const c = matches[sel].cmd + ' '; setText(c, c.length); }
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Menu / history navigation ──
|
|
55
|
+
if (key.upArrow) {
|
|
56
|
+
if (menuOpen) { setSel((i) => Math.max(0, i - 1)); return; }
|
|
57
|
+
setHistory((h) => {
|
|
58
|
+
if (!h.length) return h;
|
|
59
|
+
const idx = histIdx === -1 ? h.length - 1 : Math.max(0, histIdx - 1);
|
|
60
|
+
setHistIdx(idx); setText(h[idx]);
|
|
61
|
+
return h;
|
|
62
|
+
});
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (key.downArrow) {
|
|
66
|
+
if (menuOpen) { setSel((i) => Math.min(matches.length - 1, i + 1)); return; }
|
|
67
|
+
setHistory((h) => {
|
|
68
|
+
if (histIdx === -1) return h;
|
|
69
|
+
const idx = histIdx + 1;
|
|
70
|
+
if (idx >= h.length) { setHistIdx(-1); setText(''); } else { setHistIdx(idx); setText(h[idx]); }
|
|
71
|
+
return h;
|
|
72
|
+
});
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Cursor movement ──
|
|
77
|
+
if (key.leftArrow) { setCursor((c) => Math.max(0, c - 1)); return; }
|
|
78
|
+
if (key.rightArrow) { setCursor((c) => Math.min(value.length, c + 1)); return; }
|
|
79
|
+
if (key.ctrl && input === 'a') { setCursor(0); return; } // line start
|
|
80
|
+
if (key.ctrl && input === 'e') { setCursor(value.length); return; } // line end
|
|
81
|
+
|
|
82
|
+
// ── Deletion ──
|
|
83
|
+
if (key.backspace || key.delete) {
|
|
84
|
+
if (cursor > 0) { setValue((v) => v.slice(0, cursor - 1) + v.slice(cursor)); setCursor((c) => c - 1); }
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (key.ctrl && input === 'u') { setValue(value.slice(cursor)); setCursor(0); return; } // to start
|
|
88
|
+
if (key.ctrl && input === 'k') { setValue(value.slice(0, cursor)); return; } // to end
|
|
89
|
+
if (key.ctrl && input === 'w') { // prev word
|
|
90
|
+
let i = cursor;
|
|
91
|
+
while (i > 0 && value[i - 1] === ' ') i--;
|
|
92
|
+
while (i > 0 && value[i - 1] !== ' ') i--;
|
|
93
|
+
setValue(value.slice(0, i) + value.slice(cursor)); setCursor(i);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Printable input ──
|
|
98
|
+
if (input && !key.ctrl && !key.meta) {
|
|
99
|
+
const printable = input.replace(/[\x00-\x1f\x7f]/g, '');
|
|
100
|
+
if (!printable) return;
|
|
101
|
+
setValue((v) => v.slice(0, cursor) + printable + v.slice(cursor));
|
|
102
|
+
setCursor((c) => c + printable.length);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const before = value.slice(0, cursor);
|
|
107
|
+
const at = value[cursor] ?? ' ';
|
|
108
|
+
const after = value.slice(cursor + 1);
|
|
109
|
+
const empty = value.length === 0;
|
|
110
|
+
|
|
111
|
+
return html`
|
|
112
|
+
<${Box} flexDirection="column" width=${width}>
|
|
113
|
+
${menuOpen
|
|
114
|
+
? html`<${Box} flexDirection="column" paddingX=${1} marginBottom=${0}>
|
|
115
|
+
${(() => {
|
|
116
|
+
const MAX = 8;
|
|
117
|
+
let start = 0;
|
|
118
|
+
if (matches.length > MAX) start = Math.min(Math.max(0, sel - Math.floor(MAX / 2)), matches.length - MAX);
|
|
119
|
+
const window = matches.slice(start, start + MAX);
|
|
120
|
+
return window.map((s, wi) => {
|
|
121
|
+
const i = start + wi;
|
|
122
|
+
const on = i === sel;
|
|
123
|
+
const label = (s.cmd + (s.hint ? ' ' + s.hint : '')).padEnd(21);
|
|
124
|
+
return html`<${Box} key=${'m' + i}>
|
|
125
|
+
<${Text} color=${on ? C.accent : C.gray}>${on ? glyphs.prompt + ' ' : ' '}<//>
|
|
126
|
+
<${Text} color=${on ? C.accent : C.white} bold=${on}>${label}<//>
|
|
127
|
+
<${Text} color=${C.gray}>${s.desc}<//>
|
|
128
|
+
<//>`;
|
|
129
|
+
});
|
|
130
|
+
})()}
|
|
131
|
+
<//>`
|
|
132
|
+
: null}
|
|
133
|
+
|
|
134
|
+
<${Box} width=${width} borderStyle=${borderStyle()} borderColor=${busy ? C.gray : C.accent} paddingX=${1}>
|
|
135
|
+
<${Text} color=${busy ? C.gray : C.accent}>${glyphs.prompt} <//>
|
|
136
|
+
${empty
|
|
137
|
+
? html`<${Box} flexGrow=${1}>
|
|
138
|
+
<${Text} color=${C.gray} inverse> <//>
|
|
139
|
+
<${Text} color=${C.gray}>${busy ? 'Working…' : 'Ask about your network, or type / for commands'}<//>
|
|
140
|
+
<//>`
|
|
141
|
+
: html`<${Box} flexGrow=${1}>
|
|
142
|
+
<${Text}>${before}<//><${Text} inverse>${at}<//><${Text}>${after}<//>
|
|
143
|
+
<//>`}
|
|
144
|
+
<//>
|
|
145
|
+
<//>`;
|
|
146
|
+
}
|
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,44 @@
|
|
|
1
|
+
// Ink render entrypoint. Runs the TUI as a full-height app inside the
|
|
2
|
+
// alternate screen buffer (like GitHub Copilot CLI): the banner stays pinned
|
|
3
|
+
// at the top, the transcript fills the middle, and the input is pinned to the
|
|
4
|
+
// bottom of the terminal. On exit we leave the alt screen and, optionally,
|
|
5
|
+
// reprint the transcript into the normal scrollback.
|
|
6
|
+
import { html } from './dom.js';
|
|
7
|
+
import { render } from 'ink';
|
|
8
|
+
import { App } from './app.js';
|
|
9
|
+
|
|
10
|
+
const ALT_ENTER = '\x1b[?1049h\x1b[2J\x1b[H';
|
|
11
|
+
const ALT_LEAVE = '\x1b[?1049l';
|
|
12
|
+
|
|
13
|
+
export async function runTui(cfg, opts = {}) {
|
|
14
|
+
const { display = {}, onExternal, initialQuestion } = opts;
|
|
15
|
+
let externalAction;
|
|
16
|
+
let transcript = null;
|
|
17
|
+
|
|
18
|
+
const isTTY = process.stdout.isTTY;
|
|
19
|
+
if (isTTY) process.stdout.write(ALT_ENTER);
|
|
20
|
+
|
|
21
|
+
const instance = render(
|
|
22
|
+
html`<${App}
|
|
23
|
+
cfg=${cfg}
|
|
24
|
+
display=${display}
|
|
25
|
+
initialQuestion=${initialQuestion}
|
|
26
|
+
onExternal=${(a) => { externalAction = a; onExternal?.(a); }}
|
|
27
|
+
onExit=${(t) => { transcript = t; }}
|
|
28
|
+
/>`,
|
|
29
|
+
{ exitOnCtrlC: false },
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
await instance.waitUntilExit();
|
|
34
|
+
} finally {
|
|
35
|
+
if (isTTY) process.stdout.write(ALT_LEAVE);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Reprint the conversation into the normal buffer so it survives exit.
|
|
39
|
+
if (transcript && transcript.length) {
|
|
40
|
+
process.stdout.write('\n' + transcript.join('\n') + '\n');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return externalAction;
|
|
44
|
+
}
|
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
|
@@ -73,13 +73,13 @@ export function asciiSafe() {
|
|
|
73
73
|
|
|
74
74
|
const ASCII_GLYPHS = {
|
|
75
75
|
prompt: '>', bullet: '-', diamond: '*', ring: '*', target: 'o',
|
|
76
|
-
arrow: '->', down: 'v', check: 'OK', cross: 'XX', warn: '!!',
|
|
76
|
+
arrow: '->', down: 'v', up: '^', check: 'OK', cross: 'XX', warn: '!!',
|
|
77
77
|
bolt: '!', block: 'X', plug: '>>', reasoning: '~',
|
|
78
78
|
branchMid: '|-', branchEnd: '`-', vbar: '|',
|
|
79
79
|
};
|
|
80
80
|
const UNICODE_GLYPHS = {
|
|
81
81
|
prompt: '❯', bullet: '·', diamond: '◆', ring: '◈', target: '◎',
|
|
82
|
-
arrow: '⇢', down: '↓', check: '✓', cross: '✗', warn: '⚠',
|
|
82
|
+
arrow: '⇢', down: '↓', up: '↑', check: '✓', cross: '✗', warn: '⚠',
|
|
83
83
|
bolt: '⚡', block: '⛔', plug: '🔌', reasoning: '◆',
|
|
84
84
|
branchMid: '├─', branchEnd: '└─', vbar: '│',
|
|
85
85
|
};
|