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 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
- IspBills AI · Admin · group_admin
73
-
74
- ❯ /exit
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
- Function keys: **F1** help · **F2** history · **F3** rollback last action · **F4** bulk device sweep. **Tab** autocompletes slash commands; **↑/↓** navigate input history.
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** — 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
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ispbills-icli",
3
- "version": "7.0.0",
3
+ "version": "8.0.0",
4
4
  "description": "iCli — IspBills AI network engineer in your terminal",
5
5
  "keywords": [
6
6
  "ispbills",
package/src/tui/app.js CHANGED
@@ -1,7 +1,9 @@
1
- // Root Ink application: manages conversation state, streams from the IspBills
2
- // backend, and lays out a scrollable history (Static) above a pinned input.
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, Static, useStdout, useApp } from 'ink';
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 useTerminalWidth() {
17
+ function useTerminalSize() {
16
18
  const { stdout } = useStdout();
17
- const [width, setWidth] = useState(stdout?.columns || 80);
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 = () => setWidth(stdout.columns || 80);
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 width;
29
+ return size;
25
30
  }
26
31
 
27
32
  let itemId = 0;
28
33
  const nextId = () => ++itemId;
29
34
 
30
- export function App({ cfg, display = {}, onExternal, initialQuestion }) {
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 width = useTerminalWidth();
67
+ const { cols, rows } = useTerminalSize();
33
68
 
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.
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 convo = useRef([]); // [{role, content}] sent to backend
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
- const liveState = { status: [], reasoning: '', text: '' };
49
- setLive({ ...liveState });
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') 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 });
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} ${e.message}`, color: C.red });
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, display.reasoning, push]);
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
- // Locally-handled commands.
79
- if (q === '/exit' || q === 'exit' || q === 'quit') { exit(); return; }
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((s) => ` ${(s.cmd + (s.hint ? ' ' + s.hint : '')).padEnd(18)}${s.desc}`).join('\n');
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'); exit(); return; }
97
- if (q === '/update') { onExternal?.('update'); exit(); return; }
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
- }, [exit, push, runTurn, onExternal]);
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
- // Optionally auto-run a first question (e.g. launched with a query).
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=${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} />
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
- <${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<//>
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
  }
@@ -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
@@ -1,85 +1,100 @@
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';
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, cfg = {}, width = 80 }) {
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); // -1 = current (editing) line
13
+ const [histIdx, setHistIdx] = useState(-1);
14
+ const [sel, setSel] = useState(0);
13
15
 
14
- const slashMatches = value.startsWith('/')
15
- ? SLASH_COMMANDS.filter((s) => s.cmd.startsWith(value.trim().split(/\s+/)[0]))
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
- useInput((input, key) => {
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
- if (key.return) { submit(value); return; }
35
+ useInput((input, key) => {
36
+ if (busy) return; // ignore edits while a turn streams (App handles Ctrl+C/Esc)
33
37
 
34
- // Pasted/batched input may arrive as one chunk containing a newline —
35
- // insert the text before it, then submit (mirrors typing + Enter).
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 (slashMatches.length) {
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
- if (key.leftArrow) { setCursor((c) => Math.max(0, c - 1)); return; }
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); setValue(h[idx]); setCursor(h[idx].length);
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); setValue(''); setCursor(0); }
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
- // ink maps both Backspace and Delete here across platforms; treat as
73
- // "delete char before cursor" so Windows conhost behaves correctly.
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
- setValue((v) => v.slice(0, cursor - 1) + v.slice(cursor));
77
- setCursor((c) => c - 1);
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 (ignore control/meta chords; ctrl+c handled by the app).
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 showPlaceholder = value.length === 0;
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
- ${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}<//><//>`}
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 for the interactive TUI.
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
- * 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
- */
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
- const record = (action) => { externalAction = action; };
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} cfg=${cfg} display=${display} initialQuestion=${initialQuestion} onExternal=${(a) => { record(a); onExternal?.(a); }} />`,
20
- { exitOnCtrlC: true },
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
- await instance.waitUntilExit();
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
  };