ispbills-icli 7.0.0 → 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 +23 -7
- package/package.json +1 -1
- package/src/tui/app.js +146 -56
- package/src/tui/components.js +15 -3
- package/src/tui/composer.js +77 -46
- package/src/tui/run.js +31 -12
- 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,7 +153,7 @@ 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
|
-
- **Full-screen terminal UI** —
|
|
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
|
|
141
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
|
---
|
package/package.json
CHANGED
package/src/tui/app.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
// Root Ink application
|
|
2
|
-
//
|
|
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.
|
|
3
5
|
import { html, useState, useEffect, useRef, useCallback } from './dom.js';
|
|
4
|
-
import { Box, Text,
|
|
6
|
+
import { Box, Text, useStdout, useApp, useInput } from 'ink';
|
|
5
7
|
import { C, glyphs, midDot } from './theme.js';
|
|
6
8
|
import {
|
|
7
9
|
Banner, UserMessage, AssistantMessage, StatusLines, Reasoning,
|
|
@@ -12,80 +14,133 @@ import { streamChat } from '../client.js';
|
|
|
12
14
|
import { slashToQuestion, SLASH_COMMANDS } from '../commands.js';
|
|
13
15
|
import { shortModel } from '../ui.js';
|
|
14
16
|
|
|
15
|
-
function
|
|
17
|
+
function useTerminalSize() {
|
|
16
18
|
const { stdout } = useStdout();
|
|
17
|
-
const [
|
|
19
|
+
const [size, setSize] = useState({
|
|
20
|
+
cols: stdout?.columns || 80,
|
|
21
|
+
rows: stdout?.rows || 24,
|
|
22
|
+
});
|
|
18
23
|
useEffect(() => {
|
|
19
24
|
if (!stdout) return;
|
|
20
|
-
const on = () =>
|
|
25
|
+
const on = () => setSize({ cols: stdout.columns || 80, rows: stdout.rows || 24 });
|
|
21
26
|
stdout.on('resize', on);
|
|
22
27
|
return () => stdout.off('resize', on);
|
|
23
28
|
}, [stdout]);
|
|
24
|
-
return
|
|
29
|
+
return size;
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
let itemId = 0;
|
|
28
33
|
const nextId = () => ++itemId;
|
|
29
34
|
|
|
30
|
-
|
|
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 }) {
|
|
31
66
|
const { exit } = useApp();
|
|
32
|
-
const
|
|
67
|
+
const { cols, rows } = useTerminalSize();
|
|
33
68
|
|
|
34
|
-
|
|
35
|
-
const [items, setItems] = useState(() => [{ id: nextId(), type: 'banner' }]);
|
|
36
|
-
// Live streaming state for the in-flight turn.
|
|
69
|
+
const [items, setItems] = useState([]);
|
|
37
70
|
const [live, setLive] = useState(null); // { status:[], reasoning:'', text:'' }
|
|
38
71
|
const [busy, setBusy] = useState(false);
|
|
39
72
|
const [model, setModel] = useState(cfg._model);
|
|
40
|
-
const
|
|
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
|
|
41
80
|
|
|
42
81
|
const push = useCallback((item) => setItems((xs) => [...xs, { id: nextId(), ...item }]), []);
|
|
82
|
+
const log = (line) => plain.current.push(line);
|
|
43
83
|
|
|
44
84
|
const runTurn = useCallback(async (question) => {
|
|
45
85
|
push({ type: 'user', text: question });
|
|
86
|
+
log(`\n${glyphs.prompt} ${question}\n`);
|
|
46
87
|
convo.current.push({ role: 'user', content: question });
|
|
47
88
|
setBusy(true);
|
|
48
|
-
|
|
49
|
-
|
|
89
|
+
setHint('');
|
|
90
|
+
const s = { status: [], reasoning: '', text: '' };
|
|
91
|
+
setLive({ ...s });
|
|
92
|
+
|
|
93
|
+
const abort = new AbortController();
|
|
94
|
+
abortRef.current = abort;
|
|
50
95
|
|
|
51
96
|
try {
|
|
52
97
|
const { reply, text, meta } = await streamChat(cfg, [...convo.current], {
|
|
98
|
+
signal: abort.signal,
|
|
53
99
|
onEvent: (ev) => {
|
|
54
|
-
if (ev.type === 'status')
|
|
55
|
-
else if (ev.type === 'reasoning'
|
|
56
|
-
else if (ev.type === 'text')
|
|
57
|
-
setLive({ ...
|
|
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 });
|
|
58
104
|
},
|
|
59
105
|
});
|
|
60
106
|
if (meta.model) setModel(meta.model);
|
|
61
107
|
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)' });
|
|
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 || ''); }
|
|
65
111
|
if (text) convo.current.push({ role: 'assistant', content: text });
|
|
66
112
|
} catch (e) {
|
|
67
|
-
push({ type: 'notice', text: `${glyphs.cross}
|
|
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 });
|
|
68
115
|
} finally {
|
|
116
|
+
abortRef.current = null;
|
|
69
117
|
setLive(null);
|
|
70
118
|
setBusy(false);
|
|
71
119
|
}
|
|
72
|
-
}, [cfg,
|
|
120
|
+
}, [cfg, push]);
|
|
121
|
+
|
|
122
|
+
const doExit = useCallback(() => { onExit?.(plain.current); exit(); }, [exit, onExit]);
|
|
73
123
|
|
|
74
124
|
const onSubmit = useCallback((raw) => {
|
|
75
125
|
const q = raw.trim();
|
|
76
126
|
if (!q) return;
|
|
127
|
+
setHint('');
|
|
77
128
|
|
|
78
|
-
|
|
79
|
-
if (q === '/
|
|
80
|
-
if (q === '/clear') { setItems([{ id: nextId(), type: 'banner' }]); return; }
|
|
129
|
+
if (q === '/exit' || q === 'exit' || q === 'quit') { doExit(); return; }
|
|
130
|
+
if (q === '/clear') { setItems([]); return; }
|
|
81
131
|
if (q === '/new') {
|
|
82
132
|
convo.current = [];
|
|
133
|
+
setItems([]);
|
|
83
134
|
push({ type: 'notice', text: `${glyphs.check} New conversation started.`, color: C.green });
|
|
84
135
|
return;
|
|
85
136
|
}
|
|
137
|
+
if (q === '/reasoning') {
|
|
138
|
+
setShowReasoning((v) => { push({ type: 'notice', text: `Reasoning display ${!v ? 'on' : 'off'}.` }); return !v; });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
86
141
|
if (q === '/help' || q === 'help') {
|
|
87
|
-
const lines = SLASH_COMMANDS.map((
|
|
88
|
-
push({ type: 'notice', text: 'Commands:\n' + lines });
|
|
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' });
|
|
89
144
|
return;
|
|
90
145
|
}
|
|
91
146
|
if (q === '/history') {
|
|
@@ -93,10 +148,9 @@ export function App({ cfg, display = {}, onExternal, initialQuestion }) {
|
|
|
93
148
|
push({ type: 'notice', text: us.length ? us.map((m, i) => ` ${i + 1}. ${m.content.slice(0, 80)}`).join('\n') : 'No history yet.' });
|
|
94
149
|
return;
|
|
95
150
|
}
|
|
96
|
-
if (q === '/logout') { onExternal?.('logout');
|
|
97
|
-
if (q === '/update') { onExternal?.('update');
|
|
151
|
+
if (q === '/logout') { onExternal?.('logout'); doExit(); return; }
|
|
152
|
+
if (q === '/update') { onExternal?.('update'); doExit(); return; }
|
|
98
153
|
|
|
99
|
-
// Shortcut slash → natural-language question.
|
|
100
154
|
let question = q;
|
|
101
155
|
if (q.startsWith('/')) {
|
|
102
156
|
const mapped = slashToQuestion(q);
|
|
@@ -104,20 +158,31 @@ export function App({ cfg, display = {}, onExternal, initialQuestion }) {
|
|
|
104
158
|
else { push({ type: 'notice', text: `Unknown command: ${q}. Type /help.` }); return; }
|
|
105
159
|
}
|
|
106
160
|
runTurn(question);
|
|
107
|
-
}, [
|
|
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
|
+
});
|
|
108
177
|
|
|
109
|
-
//
|
|
178
|
+
// Auto-run a first question if launched with one.
|
|
110
179
|
const started = useRef(false);
|
|
111
180
|
useEffect(() => {
|
|
112
|
-
if (initialQuestion && !started.current) {
|
|
113
|
-
started.current = true;
|
|
114
|
-
onSubmit(initialQuestion);
|
|
115
|
-
}
|
|
181
|
+
if (initialQuestion && !started.current) { started.current = true; onSubmit(initialQuestion); }
|
|
116
182
|
}, [initialQuestion, onSubmit]);
|
|
117
183
|
|
|
118
184
|
const renderItem = (it) => {
|
|
119
185
|
switch (it.type) {
|
|
120
|
-
case 'banner': return html`<${Banner} key=${it.id} cfg=${cfg} model=${model} width=${width} />`;
|
|
121
186
|
case 'user': return html`<${UserMessage} key=${it.id} text=${it.text} />`;
|
|
122
187
|
case 'assistant': return html`<${AssistantMessage} key=${it.id} text=${it.text} />`;
|
|
123
188
|
case 'plan': return html`<${Plan} key=${it.id} reply=${it.reply} />`;
|
|
@@ -128,26 +193,51 @@ export function App({ cfg, display = {}, onExternal, initialQuestion }) {
|
|
|
128
193
|
};
|
|
129
194
|
|
|
130
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;
|
|
131
215
|
|
|
132
216
|
return html`
|
|
133
|
-
<${Box} flexDirection="column" width=${
|
|
134
|
-
<${
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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}
|
|
147
233
|
<//>
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
<${
|
|
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
|
+
<//>
|
|
151
241
|
<//>
|
|
152
242
|
<//>`;
|
|
153
243
|
}
|
package/src/tui/components.js
CHANGED
|
@@ -15,14 +15,26 @@ const LOGO = [
|
|
|
15
15
|
'╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝ ',
|
|
16
16
|
];
|
|
17
17
|
|
|
18
|
-
/** Header banner — full logo on wide terminals, compact title otherwise. */
|
|
19
|
-
export function Banner({ cfg = {}, model, width = 80 }) {
|
|
18
|
+
/** Header banner — full logo on wide/tall terminals, compact title otherwise. */
|
|
19
|
+
export function Banner({ cfg = {}, model, width = 80, compact = false }) {
|
|
20
20
|
const m = shortModel(model || cfg._model || 'IspBills AI');
|
|
21
|
-
const wide = width >= 60;
|
|
21
|
+
const wide = !compact && width >= 60;
|
|
22
22
|
const meta = [
|
|
23
23
|
['model', m], ['user', cfg.user?.name], ['role', cfg.user?.role],
|
|
24
24
|
].filter(([, v]) => v);
|
|
25
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
|
+
|
|
26
38
|
return html`
|
|
27
39
|
<${Box} flexDirection="column" marginBottom=${1}>
|
|
28
40
|
${wide
|
package/src/tui/composer.js
CHANGED
|
@@ -1,85 +1,100 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
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';
|
|
4
5
|
import { Box, Text, useInput } from 'ink';
|
|
5
6
|
import { C, glyphs, borderStyle } from './theme.js';
|
|
6
7
|
import { SLASH_COMMANDS } from '../commands.js';
|
|
7
8
|
|
|
8
|
-
export function Composer({ onSubmit, busy,
|
|
9
|
+
export function Composer({ onSubmit, busy, width = 80 }) {
|
|
9
10
|
const [value, setValue] = useState('');
|
|
10
11
|
const [cursor, setCursor] = useState(0);
|
|
11
12
|
const [history, setHistory] = useState([]);
|
|
12
|
-
const [histIdx, setHistIdx] = useState(-1);
|
|
13
|
+
const [histIdx, setHistIdx] = useState(-1);
|
|
14
|
+
const [sel, setSel] = useState(0);
|
|
13
15
|
|
|
14
|
-
|
|
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))
|
|
16
19
|
: [];
|
|
20
|
+
const menuOpen = matches.length > 0;
|
|
21
|
+
|
|
22
|
+
useEffect(() => { setSel((i) => Math.min(i, Math.max(0, matches.length - 1))); }, [matches.length]);
|
|
17
23
|
|
|
18
24
|
const submit = useCallback((text) => {
|
|
19
25
|
const q = text.trim();
|
|
20
|
-
setValue('');
|
|
21
|
-
setCursor(0);
|
|
22
|
-
setHistIdx(-1);
|
|
26
|
+
setValue(''); setCursor(0); setHistIdx(-1); setSel(0);
|
|
23
27
|
if (q) {
|
|
24
28
|
setHistory((h) => (h[h.length - 1] === q ? h : [...h, q]));
|
|
25
29
|
onSubmit(q);
|
|
26
30
|
}
|
|
27
31
|
}, [onSubmit]);
|
|
28
32
|
|
|
29
|
-
|
|
30
|
-
if (busy) return; // ignore edits while a turn is streaming
|
|
33
|
+
const setText = (v, c) => { setValue(v); setCursor(c ?? v.length); };
|
|
31
34
|
|
|
32
|
-
|
|
35
|
+
useInput((input, key) => {
|
|
36
|
+
if (busy) return; // ignore edits while a turn streams (App handles Ctrl+C/Esc)
|
|
33
37
|
|
|
34
|
-
//
|
|
35
|
-
|
|
38
|
+
// ── Submit / complete ──
|
|
39
|
+
if (key.return) {
|
|
40
|
+
if (menuOpen) { submit(matches[sel].cmd); return; }
|
|
41
|
+
submit(value);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
36
44
|
if (input && /[\r\n]/.test(input)) {
|
|
37
45
|
const first = input.split(/[\r\n]/)[0];
|
|
38
46
|
submit(value.slice(0, cursor) + first + value.slice(cursor));
|
|
39
47
|
return;
|
|
40
48
|
}
|
|
41
|
-
|
|
42
49
|
if (key.tab) {
|
|
43
|
-
if (
|
|
44
|
-
const c = slashMatches[0].cmd + ' ';
|
|
45
|
-
setValue(c); setCursor(c.length);
|
|
46
|
-
}
|
|
50
|
+
if (menuOpen) { const c = matches[sel].cmd + ' '; setText(c, c.length); }
|
|
47
51
|
return;
|
|
48
52
|
}
|
|
49
53
|
|
|
50
|
-
|
|
51
|
-
if (key.rightArrow) { setCursor((c) => Math.min(value.length, c + 1)); return; }
|
|
54
|
+
// ── Menu / history navigation ──
|
|
52
55
|
if (key.upArrow) {
|
|
56
|
+
if (menuOpen) { setSel((i) => Math.max(0, i - 1)); return; }
|
|
53
57
|
setHistory((h) => {
|
|
54
58
|
if (!h.length) return h;
|
|
55
59
|
const idx = histIdx === -1 ? h.length - 1 : Math.max(0, histIdx - 1);
|
|
56
|
-
setHistIdx(idx);
|
|
60
|
+
setHistIdx(idx); setText(h[idx]);
|
|
57
61
|
return h;
|
|
58
62
|
});
|
|
59
63
|
return;
|
|
60
64
|
}
|
|
61
65
|
if (key.downArrow) {
|
|
66
|
+
if (menuOpen) { setSel((i) => Math.min(matches.length - 1, i + 1)); return; }
|
|
62
67
|
setHistory((h) => {
|
|
63
68
|
if (histIdx === -1) return h;
|
|
64
69
|
const idx = histIdx + 1;
|
|
65
|
-
if (idx >= h.length) { setHistIdx(-1);
|
|
66
|
-
else { setHistIdx(idx); setValue(h[idx]); setCursor(h[idx].length); }
|
|
70
|
+
if (idx >= h.length) { setHistIdx(-1); setText(''); } else { setHistIdx(idx); setText(h[idx]); }
|
|
67
71
|
return h;
|
|
68
72
|
});
|
|
69
73
|
return;
|
|
70
74
|
}
|
|
71
75
|
|
|
72
|
-
//
|
|
73
|
-
|
|
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 ──
|
|
74
83
|
if (key.backspace || key.delete) {
|
|
75
|
-
if (cursor > 0) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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);
|
|
79
94
|
return;
|
|
80
95
|
}
|
|
81
96
|
|
|
82
|
-
// Printable input
|
|
97
|
+
// ── Printable input ──
|
|
83
98
|
if (input && !key.ctrl && !key.meta) {
|
|
84
99
|
const printable = input.replace(/[\x00-\x1f\x7f]/g, '');
|
|
85
100
|
if (!printable) return;
|
|
@@ -88,28 +103,44 @@ export function Composer({ onSubmit, busy, cfg = {}, width = 80 }) {
|
|
|
88
103
|
}
|
|
89
104
|
});
|
|
90
105
|
|
|
91
|
-
// Render the value with an inverse cursor block.
|
|
92
106
|
const before = value.slice(0, cursor);
|
|
93
107
|
const at = value[cursor] ?? ' ';
|
|
94
108
|
const after = value.slice(cursor + 1);
|
|
95
|
-
const
|
|
109
|
+
const empty = value.length === 0;
|
|
96
110
|
|
|
97
111
|
return html`
|
|
98
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
|
+
|
|
99
134
|
<${Box} width=${width} borderStyle=${borderStyle()} borderColor=${busy ? C.gray : C.accent} paddingX=${1}>
|
|
100
135
|
<${Text} color=${busy ? C.gray : C.accent}>${glyphs.prompt} <//>
|
|
101
|
-
${
|
|
102
|
-
? html`<${Box} flexGrow=${1}
|
|
103
|
-
|
|
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
|
+
<//>`}
|
|
104
144
|
<//>
|
|
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
145
|
<//>`;
|
|
115
146
|
}
|
package/src/tui/run.js
CHANGED
|
@@ -1,25 +1,44 @@
|
|
|
1
|
-
// Ink render entrypoint
|
|
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.
|
|
2
6
|
import { html } from './dom.js';
|
|
3
7
|
import { render } from 'ink';
|
|
4
8
|
import { App } from './app.js';
|
|
5
9
|
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
*/
|
|
10
|
+
const ALT_ENTER = '\x1b[?1049h\x1b[2J\x1b[H';
|
|
11
|
+
const ALT_LEAVE = '\x1b[?1049l';
|
|
12
|
+
|
|
13
13
|
export async function runTui(cfg, opts = {}) {
|
|
14
14
|
const { display = {}, onExternal, initialQuestion } = opts;
|
|
15
15
|
let externalAction;
|
|
16
|
-
|
|
16
|
+
let transcript = null;
|
|
17
|
+
|
|
18
|
+
const isTTY = process.stdout.isTTY;
|
|
19
|
+
if (isTTY) process.stdout.write(ALT_ENTER);
|
|
17
20
|
|
|
18
21
|
const instance = render(
|
|
19
|
-
html`<${App}
|
|
20
|
-
|
|
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 },
|
|
21
30
|
);
|
|
22
31
|
|
|
23
|
-
|
|
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
|
+
|
|
24
43
|
return externalAction;
|
|
25
44
|
}
|
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
|
};
|