ispbills-icli 7.0.0 → 8.1.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
@@ -109,20 +108,80 @@ icli --reasoning "audit the config on olt#1" # stream reasoning inline
109
108
 
110
109
  ### Slash commands & keys (REPL)
111
110
 
112
- Inside the REPL, type `/` to see the menu. Shortcuts:
111
+ Type `/` to open the command menu. It filters as you type, shows an inline
112
+ suggestion (accept with **Tab** or **→**), and runs the highlighted command on
113
+ **Enter**.
114
+
115
+ **Devices & diagnostics**
116
+
117
+ | Slash | Description |
118
+ |---|---|
119
+ | `/check [device]` | Run full diagnostics on a device |
120
+ | `/status [device]` | Health summary across the fleet |
121
+ | `/reachable` | Which devices are up / unreachable |
122
+ | `/uptime [device]` | Device uptime and last reboot |
123
+ | `/cpu [device]` | CPU, memory and temperature load |
124
+ | `/interfaces [device]` | Interface / port status and errors |
125
+ | `/config [device]` | Show the running configuration |
126
+ | `/audit [device]` | Audit config and suggest fixes |
127
+ | `/backup [device]` | Back up the device configuration |
128
+ | `/reboot [device]` | Reboot a device (asks to confirm) |
129
+ | `/alarms` | Active alarms and recent faults |
130
+
131
+ **Access network**
132
+
133
+ | Slash | Description |
134
+ |---|---|
135
+ | `/onu [id]` | ONU status and signal levels |
136
+ | `/optical [device]` | Optical RX/TX power (dBm) |
137
+ | `/vlan [id]` | VLAN usage — free or in use |
138
+ | `/pppoe [user]` | PPPoE session detail |
139
+ | `/online` · `/offline` | Online / offline customers & ONUs |
140
+ | `/bandwidth [device]` | Traffic / bandwidth usage |
141
+
142
+ **Lookups**
143
+
144
+ | Slash | Description |
145
+ |---|---|
146
+ | `/mac [addr]` | Locate a MAC address on the network |
147
+ | `/arp [device]` · `/route [device]` | ARP / routing tables |
148
+ | `/dhcp` | DHCP leases and pool usage |
149
+ | `/ping [host]` · `/trace [host]` | Ping / traceroute a host |
150
+ | `/customer [name/IP]` | Look up a customer |
151
+
152
+ **Session**
113
153
 
114
154
  | Slash | Description |
115
155
  |---|---|
116
- | `/check`, `/vlan`, `/onu`, `/customer`, `/ping`, `/online` | Common NOC queries |
117
- | `/new` | Start a fresh conversation |
118
- | `/history` | Show conversation history |
119
- | `/clear` | Clear the screen |
120
- | `/update` | Update iCli |
121
- | `/logout` | Log out |
122
- | `/help` | Show help |
123
- | `/exit` | Quit |
156
+ | `/autopilot` | Toggle autopilot proposed fixes are applied automatically (also **Shift+Tab**) |
157
+ | `/model [name]` | Show or set the AI model |
158
+ | `/reasoning` | Toggle reasoning display (also **Ctrl+T**) |
159
+ | `/new` · `/history` · `/clear` | Reset conversation · show history · clear screen |
160
+ | `/update` · `/logout` · `/help` · `/exit` | Update · log out · help · quit |
124
161
 
125
- Function keys: **F1** help · **F2** history · **F3** rollback last action · **F4** bulk device sweep. **Tab** autocompletes slash commands; **↑/↓** navigate input history.
162
+ **Keys**
163
+
164
+ | Key | Action |
165
+ |---|---|
166
+ | **Enter** | Send message (or run the highlighted slash command) |
167
+ | **/** then **↑/↓** | Open the slash menu and move the selection; **Tab** / **→** completes, **Enter** runs |
168
+ | **↑/↓** (empty input) | Navigate input history |
169
+ | **Shift+Tab** | Toggle autopilot mode |
170
+ | **Ctrl+T** | Toggle reasoning display |
171
+ | **Ctrl+L** | Clear the transcript |
172
+ | **Esc** | Cancel the in-flight request |
173
+ | **Ctrl+C** | Cancel a running request; press again on an empty prompt to exit |
174
+ | **Ctrl+A/E** | Jump to start / end of the line |
175
+ | **Ctrl+U/K/W** | Delete to start / to end / previous word |
176
+
177
+ **Autopilot** — when on (⚡ shown in the footer), any fix the AI proposes as a
178
+ plan is applied automatically without waiting for you to type `apply fix`.
179
+ Toggle it with **Shift+Tab** or `/autopilot`.
180
+
181
+ The REPL runs as a full-height app in the terminal's alternate screen: the banner
182
+ stays pinned at the top, the transcript fills the middle, and the input box is
183
+ pinned to the bottom — the same layout as GitHub Copilot CLI. On exit, the session
184
+ transcript is reprinted to your normal scrollback.
126
185
 
127
186
  ---
128
187
 
@@ -137,7 +196,7 @@ iCli connects to the same AI engine as the in-browser terminal:
137
196
  - **Web search** — looks up vendor CLI docs when it needs to
138
197
  - **Streaming output** — answers stream live with a thinking indicator, reasoning, and step-by-step server progress
139
198
  - **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
199
+ - **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
200
  - **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
201
 
143
202
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ispbills-icli",
3
- "version": "7.0.0",
3
+ "version": "8.1.0",
4
4
  "description": "iCli — IspBills AI network engineer in your terminal",
5
5
  "keywords": [
6
6
  "ispbills",
package/src/commands.js CHANGED
@@ -1,23 +1,57 @@
1
1
  import { RESET, BOLD, DIM, GRAY, WHITE, ACCENT, GREEN } from './ui.js';
2
2
 
3
- /** All slash commands, used for the menu, help and Tab completion. */
3
+ /**
4
+ * All slash commands, used for the menu, help and Tab completion.
5
+ *
6
+ * `group` is only used to organise the /help output. `local: true` marks
7
+ * commands the REPL handles itself (they never hit the AI backend).
8
+ */
4
9
  export const SLASH_COMMANDS = [
5
- { cmd: '/check', hint: '[device]', desc: 'Run diagnostics on a device' },
6
- { cmd: '/vlan', hint: '[id]', desc: 'Check VLAN usage' },
7
- { cmd: '/onu', hint: '[id]', desc: 'Show ONU status and signal' },
8
- { cmd: '/customer', hint: '[name/IP]', desc: 'Look up a customer' },
9
- { cmd: '/ping', hint: '[host]', desc: 'Ping a host' },
10
- { cmd: '/online', hint: '', desc: 'List online sessions' },
11
- { cmd: '/new', hint: '', desc: 'Start a fresh conversation' },
12
- { cmd: '/history', hint: '', desc: 'Show conversation history' },
13
- { cmd: '/clear', hint: '', desc: 'Clear the screen' },
14
- { cmd: '/update', hint: '', desc: 'Update iCli to the latest version' },
15
- { cmd: '/logout', hint: '', desc: 'Log out and clear credentials' },
16
- { cmd: '/help', hint: '', desc: 'Show help' },
17
- { cmd: '/exit', hint: '', desc: 'Exit iCli' },
10
+ // ── Devices & diagnostics ──
11
+ { cmd: '/check', hint: '[device]', desc: 'Run full diagnostics on a device', group: 'Devices' },
12
+ { cmd: '/status', hint: '[device]', desc: 'Health summary across the fleet', group: 'Devices' },
13
+ { cmd: '/reachable', hint: '', desc: 'Which devices are up / unreachable', group: 'Devices' },
14
+ { cmd: '/uptime', hint: '[device]', desc: 'Device uptime and last reboot', group: 'Devices' },
15
+ { cmd: '/cpu', hint: '[device]', desc: 'CPU, memory and temperature load', group: 'Devices' },
16
+ { cmd: '/interfaces', hint: '[device]', desc: 'Interface / port status and errors', group: 'Devices' },
17
+ { cmd: '/config', hint: '[device]', desc: 'Show the running configuration', group: 'Devices' },
18
+ { cmd: '/audit', hint: '[device]', desc: 'Audit config and suggest fixes', group: 'Devices' },
19
+ { cmd: '/backup', hint: '[device]', desc: 'Back up the device configuration', group: 'Devices' },
20
+ { cmd: '/reboot', hint: '[device]', desc: 'Reboot a device (asks to confirm)', group: 'Devices' },
21
+ { cmd: '/alarms', hint: '', desc: 'Active alarms and recent faults', group: 'Devices' },
22
+
23
+ // ── Optical / access network ──
24
+ { cmd: '/onu', hint: '[id]', desc: 'ONU status and signal levels', group: 'Access' },
25
+ { cmd: '/optical', hint: '[device]', desc: 'Optical RX/TX power (dBm)', group: 'Access' },
26
+ { cmd: '/vlan', hint: '[id]', desc: 'VLAN usage — free or in use', group: 'Access' },
27
+ { cmd: '/pppoe', hint: '[user]', desc: 'PPPoE session detail', group: 'Access' },
28
+ { cmd: '/online', hint: '', desc: 'List online PPPoE sessions', group: 'Access' },
29
+ { cmd: '/offline', hint: '', desc: 'List offline customers / ONUs', group: 'Access' },
30
+ { cmd: '/bandwidth', hint: '[device]', desc: 'Traffic / bandwidth usage', group: 'Access' },
31
+
32
+ // ── Layer 2/3 lookups ──
33
+ { cmd: '/mac', hint: '[addr]', desc: 'Locate a MAC address on the network', group: 'Lookup' },
34
+ { cmd: '/arp', hint: '[device]', desc: 'ARP table for a device', group: 'Lookup' },
35
+ { cmd: '/route', hint: '[device]', desc: 'Routing table for a device', group: 'Lookup' },
36
+ { cmd: '/dhcp', hint: '', desc: 'DHCP leases and pool usage', group: 'Lookup' },
37
+ { cmd: '/ping', hint: '[host]', desc: 'Ping a host through the network', group: 'Lookup' },
38
+ { cmd: '/trace', hint: '[host]', desc: 'Traceroute the path to a host', group: 'Lookup' },
39
+ { cmd: '/customer', hint: '[name/IP]', desc: 'Look up a customer', group: 'Lookup' },
40
+
41
+ // ── Session & app ──
42
+ { cmd: '/autopilot', hint: '', desc: 'Toggle autopilot (auto-apply fixes)', group: 'Session', local: true },
43
+ { cmd: '/model', hint: '[name]', desc: 'Show or set the AI model', group: 'Session', local: true },
44
+ { cmd: '/reasoning', hint: '', desc: 'Toggle reasoning display (Ctrl+T)', group: 'Session', local: true },
45
+ { cmd: '/new', hint: '', desc: 'Start a fresh conversation', group: 'Session', local: true },
46
+ { cmd: '/history', hint: '', desc: 'Show conversation history', group: 'Session', local: true },
47
+ { cmd: '/clear', hint: '', desc: 'Clear the screen', group: 'Session', local: true },
48
+ { cmd: '/update', hint: '', desc: 'Update iCli to the latest version', group: 'Session', local: true },
49
+ { cmd: '/logout', hint: '', desc: 'Log out and clear credentials', group: 'Session', local: true },
50
+ { cmd: '/help', hint: '', desc: 'Show help', group: 'Session', local: true },
51
+ { cmd: '/exit', hint: '', desc: 'Exit iCli', group: 'Session', local: true },
18
52
  ];
19
53
 
20
- /** readline completer for slash commands. */
54
+ /** readline completer for slash commands (used by the non-Ink fallback REPL). */
21
55
  export function slashCompleter(line) {
22
56
  if (line.startsWith('/')) {
23
57
  const hits = SLASH_COMMANDS.map((s) => s.cmd + ' ').filter((s) => s.startsWith(line));
@@ -27,28 +61,56 @@ export function slashCompleter(line) {
27
61
  }
28
62
 
29
63
  /**
30
- * Map an IspBills AI shortcut command to a natural-language question.
64
+ * Map a NOC shortcut command to a natural-language question for the AI.
31
65
  * Returns null for commands that are handled locally by the REPL.
32
66
  */
33
67
  export function slashToQuestion(line) {
34
68
  const [sc, ...rest] = line.trim().split(/\s+/);
35
69
  const arg = rest.join(' ');
70
+ const on = (withArg, without) => (arg ? withArg : without);
36
71
  switch (sc) {
37
- case '/check': return arg ? `run diagnostics on ${arg}` : 'check status of all devices';
38
- case '/vlan': return arg ? `is vlan ${arg} in use or free?` : 'list all vlans in use';
39
- case '/onu': return arg ? `show ONU ${arg} status and signal level` : 'list all ONUs';
40
- case '/customer': return arg ? `look up customer ${arg}` : 'show recent customers';
41
- case '/ping': return arg ? `ping ${arg} through the network` : 'which devices are reachable?';
42
- case '/online': return 'list currently online PPPoE sessions';
43
- default: return null;
72
+ // Devices & diagnostics
73
+ case '/check': return on(`run full diagnostics on ${arg} and summarise any problems`, 'run diagnostics across all devices and flag anything unhealthy');
74
+ case '/status': return on(`give a health summary for ${arg}`, 'give a health summary of every device in the fleet');
75
+ case '/reachable': return 'which devices are reachable right now and which are down?';
76
+ case '/uptime': return on(`what is the uptime and last reboot time of ${arg}?`, 'show uptime for all devices and flag any that rebooted recently');
77
+ case '/cpu': return on(`show CPU, memory and temperature load on ${arg}`, 'show CPU and memory load across all devices and flag anything overloaded');
78
+ case '/interfaces': return on(`show interface/port status and error counters on ${arg}`, 'show interface status across devices and flag ports with errors or that are down');
79
+ case '/config': return on(`show the running configuration of ${arg}`, 'which device config do you want to see? list the devices');
80
+ case '/audit': return on(`audit the configuration of ${arg} and suggest concrete fixes`, 'audit the fleet configuration and suggest concrete improvements');
81
+ case '/backup': return on(`back up the running configuration of ${arg}`, 'back up the running configuration of all devices');
82
+ case '/reboot': return on(`reboot ${arg} safely`, 'which device should I reboot? list the devices first');
83
+ case '/alarms': return 'show active alarms and recent faults across the network';
84
+
85
+ // Optical / access
86
+ case '/onu': return on(`show ONU ${arg} status and optical signal level`, 'list all ONUs with their status and signal levels');
87
+ case '/optical': return on(`show optical RX/TX power in dBm for ${arg} and flag weak signals`, 'show optical RX/TX power across ONUs and flag any weak or out-of-range signals');
88
+ case '/vlan': return on(`is VLAN ${arg} in use or free across the network?`, 'list all VLANs and show which are in use or free');
89
+ case '/pppoe': return on(`show PPPoE session detail for ${arg}`, 'summarise current PPPoE sessions');
90
+ case '/online': return 'list currently online PPPoE sessions';
91
+ case '/offline': return 'list customers or ONUs that are currently offline';
92
+ case '/bandwidth': return on(`show traffic and bandwidth usage on ${arg}`, 'show current bandwidth usage across the network and flag the top talkers');
93
+
94
+ // Layer 2/3 lookups
95
+ case '/mac': return on(`locate MAC address ${arg} — which device and port is it on?`, 'how do I locate a MAC address? ask me for the address');
96
+ case '/arp': return on(`show the ARP table for ${arg}`, 'show ARP tables and flag any duplicate or suspicious entries');
97
+ case '/route': return on(`show the routing table for ${arg}`, 'show routing tables across the network');
98
+ case '/dhcp': return 'show DHCP leases and pool usage, and flag pools that are nearly exhausted';
99
+ case '/ping': return on(`ping ${arg} through the network and report latency and loss`, 'which hosts should I ping? list reachable devices');
100
+ case '/trace': return on(`traceroute the path to ${arg} and show each hop`, 'what host should I traceroute?');
101
+ case '/customer': return on(`look up customer ${arg} with their device, plan and session status`, 'show recently active customers');
102
+
103
+ default: return null; // locally handled command
44
104
  }
45
105
  }
46
106
 
47
107
  export function printSlashMenu() {
48
108
  console.log(`\n ${BOLD}Slash commands${RESET}`);
49
- for (const { cmd, hint, desc } of SLASH_COMMANDS) {
50
- const key = (cmd + (hint ? ' ' + hint : '')).padEnd(20);
51
- console.log(` ${ACCENT}${key}${RESET}${DIM}${desc}${RESET}`);
109
+ let group = null;
110
+ for (const { cmd, hint, desc, group: g } of SLASH_COMMANDS) {
111
+ if (g !== group) { group = g; console.log(` ${DIM}${group}${RESET}`); }
112
+ const key = (cmd + (hint ? ' ' + hint : '')).padEnd(22);
113
+ console.log(` ${ACCENT}${key}${RESET}${DIM}${desc}${RESET}`);
52
114
  }
53
115
  console.log();
54
116
  }
package/src/tui/app.js CHANGED
@@ -1,8 +1,10 @@
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';
5
- import { C, glyphs, midDot } from './theme.js';
6
+ import { Box, Text, useStdout, useApp, useInput } from 'ink';
7
+ import { C, glyphs, midDot, dash } from './theme.js';
6
8
  import {
7
9
  Banner, UserMessage, AssistantMessage, StatusLines, Reasoning,
8
10
  Plan, Connect, Notice, Thinking,
@@ -10,82 +12,184 @@ import {
10
12
  import { Composer } from './composer.js';
11
13
  import { streamChat } from '../client.js';
12
14
  import { slashToQuestion, SLASH_COMMANDS } from '../commands.js';
15
+ import { saveConfig } from '../config.js';
13
16
  import { shortModel } from '../ui.js';
14
17
 
15
- function useTerminalWidth() {
18
+ function useTerminalSize() {
16
19
  const { stdout } = useStdout();
17
- const [width, setWidth] = useState(stdout?.columns || 80);
20
+ const [size, setSize] = useState({
21
+ cols: stdout?.columns || 80,
22
+ rows: stdout?.rows || 24,
23
+ });
18
24
  useEffect(() => {
19
25
  if (!stdout) return;
20
- const on = () => setWidth(stdout.columns || 80);
26
+ const on = () => setSize({ cols: stdout.columns || 80, rows: stdout.rows || 24 });
21
27
  stdout.on('resize', on);
22
28
  return () => stdout.off('resize', on);
23
29
  }, [stdout]);
24
- return width;
30
+ return size;
25
31
  }
26
32
 
27
33
  let itemId = 0;
28
34
  const nextId = () => ++itemId;
29
35
 
30
- export function App({ cfg, display = {}, onExternal, initialQuestion }) {
36
+ // Rough rendered-height estimates so we can tail-slice the transcript to fit
37
+ // the available middle region (biased high → under-fill, never overflow/clip).
38
+ const wrapLines = (text, cols) =>
39
+ String(text || '').split('\n').reduce((n, l) => n + Math.max(1, Math.ceil(l.length / Math.max(1, cols))), 0);
40
+
41
+ function itemHeight(it, cols) {
42
+ switch (it.type) {
43
+ case 'user': return 1 + wrapLines(it.text, cols);
44
+ case 'assistant': return 1 + wrapLines(it.text, cols);
45
+ case 'notice': return 1 + wrapLines(it.text, cols);
46
+ case 'plan': { const s = it.reply?.steps || []; return 2 + (it.reply?.summary ? 1 : 0) + s.length * 2 + 2; }
47
+ case 'connect': return 3 + (it.reply?.note ? 1 : 0);
48
+ default: return 1;
49
+ }
50
+ }
51
+
52
+ function bannerHeight(cols, compact) {
53
+ if (compact) return 2;
54
+ return cols >= 60 ? 11 : 5;
55
+ }
56
+
57
+ function liveHeight(live, cols, showReasoning) {
58
+ if (!live) return 0;
59
+ let h = 2; // thinking line + margin
60
+ if (live.status?.length) h += 1 + live.status.length;
61
+ if (showReasoning && live.reasoning) h += 1 + wrapLines(live.reasoning, cols);
62
+ if (live.text) h += 1 + wrapLines(live.text, cols);
63
+ return h;
64
+ }
65
+
66
+ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion }) {
31
67
  const { exit } = useApp();
32
- const width = useTerminalWidth();
68
+ const { cols, rows } = useTerminalSize();
33
69
 
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.
70
+ const [items, setItems] = useState([]);
37
71
  const [live, setLive] = useState(null); // { status:[], reasoning:'', text:'' }
38
72
  const [busy, setBusy] = useState(false);
39
73
  const [model, setModel] = useState(cfg._model);
40
- const convo = useRef([]); // [{role, content}] sent to backend
74
+ const [showReasoning, setShowReasoning] = useState(!!display.reasoning);
75
+ const [autopilot, setAutopilot] = useState(false);
76
+ const [hint, setHint] = useState('');
77
+
78
+ const convo = useRef([]); // [{role, content}] for the backend
79
+ const plain = useRef([]); // plain-text transcript for exit reprint
80
+ const abortRef = useRef(null); // AbortController of the in-flight turn
81
+ const ctrlC = useRef(0); // timestamp of the last lone Ctrl+C
82
+ const apRef = useRef(false); // live autopilot flag (read inside runTurn)
83
+ const modelRef = useRef(cfg._model); // preferred model to request
84
+
85
+ const setAutopilotMode = useCallback((next) => {
86
+ apRef.current = next;
87
+ setAutopilot(next);
88
+ }, []);
41
89
 
42
90
  const push = useCallback((item) => setItems((xs) => [...xs, { id: nextId(), ...item }]), []);
91
+ const log = (line) => plain.current.push(line);
43
92
 
44
- const runTurn = useCallback(async (question) => {
93
+ const runTurn = useCallback(async (question, depth = 0) => {
45
94
  push({ type: 'user', text: question });
95
+ log(`\n${glyphs.prompt} ${question}\n`);
46
96
  convo.current.push({ role: 'user', content: question });
47
97
  setBusy(true);
48
- const liveState = { status: [], reasoning: '', text: '' };
49
- setLive({ ...liveState });
98
+ setHint('');
99
+ const s = { status: [], reasoning: '', text: '' };
100
+ setLive({ ...s });
101
+
102
+ const abort = new AbortController();
103
+ abortRef.current = abort;
104
+
105
+ const context = { autopilot: apRef.current };
106
+ if (modelRef.current) context.model = modelRef.current;
50
107
 
51
108
  try {
52
109
  const { reply, text, meta } = await streamChat(cfg, [...convo.current], {
110
+ signal: abort.signal,
111
+ context,
53
112
  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 });
113
+ if (ev.type === 'status') s.status = [...s.status, ev.line];
114
+ else if (ev.type === 'reasoning') s.reasoning += ev.delta;
115
+ else if (ev.type === 'text') s.text += ev.delta;
116
+ setLive({ ...s });
58
117
  },
59
118
  });
60
119
  if (meta.model) setModel(meta.model);
61
120
  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)' });
121
+ if (type === 'plan') { push({ type: 'plan', reply }); log('[plan] ' + (reply.summary || '')); }
122
+ else if (type === 'connect') { push({ type: 'connect', reply }); log('[connect] ' + (reply.device?.kind || '')); }
123
+ else { push({ type: 'assistant', text: text || '(no response)' }); log(text || ''); }
65
124
  if (text) convo.current.push({ role: 'assistant', content: text });
125
+
126
+ // Autopilot: auto-confirm a proposed plan without waiting for the user.
127
+ if (type === 'plan' && apRef.current && depth < 3) {
128
+ push({ type: 'notice', text: `${glyphs.bolt} autopilot ${dash()} applying fix…`, color: C.yellow });
129
+ setLive(null);
130
+ setBusy(false);
131
+ abortRef.current = null;
132
+ return runTurn('apply fix', depth + 1);
133
+ }
66
134
  } catch (e) {
67
- push({ type: 'notice', text: `${glyphs.cross} ${e.message}`, color: C.red });
135
+ if (abort.signal.aborted) push({ type: 'notice', text: `${glyphs.cross} Cancelled.`, color: C.yellow });
136
+ else push({ type: 'notice', text: `${glyphs.cross} ${e.message}`, color: C.red });
68
137
  } finally {
138
+ abortRef.current = null;
69
139
  setLive(null);
70
140
  setBusy(false);
71
141
  }
72
- }, [cfg, display.reasoning, push]);
142
+ }, [cfg, push]);
143
+
144
+ const doExit = useCallback(() => { onExit?.(plain.current); exit(); }, [exit, onExit]);
73
145
 
74
146
  const onSubmit = useCallback((raw) => {
75
147
  const q = raw.trim();
76
148
  if (!q) return;
149
+ setHint('');
77
150
 
78
- // Locally-handled commands.
79
- if (q === '/exit' || q === 'exit' || q === 'quit') { exit(); return; }
80
- if (q === '/clear') { setItems([{ id: nextId(), type: 'banner' }]); return; }
151
+ if (q === '/exit' || q === 'exit' || q === 'quit') { doExit(); return; }
152
+ if (q === '/clear') { setItems([]); return; }
81
153
  if (q === '/new') {
82
154
  convo.current = [];
155
+ setItems([]);
83
156
  push({ type: 'notice', text: `${glyphs.check} New conversation started.`, color: C.green });
84
157
  return;
85
158
  }
159
+ if (q === '/reasoning') {
160
+ setShowReasoning((v) => { push({ type: 'notice', text: `Reasoning display ${!v ? 'on' : 'off'}.` }); return !v; });
161
+ return;
162
+ }
163
+ if (q === '/autopilot' || q === '/auto') {
164
+ const next = !apRef.current;
165
+ setAutopilotMode(next);
166
+ push({ type: 'notice',
167
+ text: next
168
+ ? `${glyphs.bolt} Autopilot ON ${dash()} proposed fixes will be applied automatically. Toggle with Shift+Tab.`
169
+ : `Autopilot OFF ${dash()} fixes will wait for your confirmation.`,
170
+ color: next ? C.yellow : C.gray });
171
+ return;
172
+ }
173
+ if (q === '/model' || q.startsWith('/model ')) {
174
+ const name = q.slice('/model'.length).trim();
175
+ if (!name) {
176
+ push({ type: 'notice', text: `Current model: ${model || modelRef.current || 'server default'}.\nUse /model <name> to request a different model.` });
177
+ } else {
178
+ modelRef.current = name;
179
+ setModel(name);
180
+ cfg._model = name;
181
+ try { saveConfig(cfg); } catch {}
182
+ push({ type: 'notice', text: `${glyphs.check} Model set to ${name}. It applies to your next message.`, color: C.green });
183
+ }
184
+ return;
185
+ }
86
186
  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 });
187
+ let group = null;
188
+ const lines = SLASH_COMMANDS.map((c) => {
189
+ const head = c.group !== group ? ((group = c.group), `\n ${c.group}\n`) : '';
190
+ return head + ` ${(c.cmd + (c.hint ? ' ' + c.hint : '')).padEnd(22)}${c.desc}`;
191
+ }).join('\n');
192
+ push({ type: 'notice', text: 'Commands:' + lines + '\n\n Keys: Shift+Tab autopilot · Ctrl+T reasoning · Ctrl+L clear · Ctrl+C twice to exit' });
89
193
  return;
90
194
  }
91
195
  if (q === '/history') {
@@ -93,10 +197,9 @@ export function App({ cfg, display = {}, onExternal, initialQuestion }) {
93
197
  push({ type: 'notice', text: us.length ? us.map((m, i) => ` ${i + 1}. ${m.content.slice(0, 80)}`).join('\n') : 'No history yet.' });
94
198
  return;
95
199
  }
96
- if (q === '/logout') { onExternal?.('logout'); exit(); return; }
97
- if (q === '/update') { onExternal?.('update'); exit(); return; }
200
+ if (q === '/logout') { onExternal?.('logout'); doExit(); return; }
201
+ if (q === '/update') { onExternal?.('update'); doExit(); return; }
98
202
 
99
- // Shortcut slash → natural-language question.
100
203
  let question = q;
101
204
  if (q.startsWith('/')) {
102
205
  const mapped = slashToQuestion(q);
@@ -104,20 +207,37 @@ export function App({ cfg, display = {}, onExternal, initialQuestion }) {
104
207
  else { push({ type: 'notice', text: `Unknown command: ${q}. Type /help.` }); return; }
105
208
  }
106
209
  runTurn(question);
107
- }, [exit, push, runTurn, onExternal]);
210
+ }, [doExit, push, runTurn, onExternal, setAutopilotMode, model, cfg]);
211
+
212
+ // Global keys (Composer handles text editing; here we handle app-level keys).
213
+ useInput((input, key) => {
214
+ if (key.ctrl && input === 'c') {
215
+ if (busy) { abortRef.current?.abort(); setHint(''); return; }
216
+ const now = Date.now();
217
+ if (now - ctrlC.current < 1500) { doExit(); return; }
218
+ ctrlC.current = now;
219
+ setHint('Press Ctrl+C again to exit');
220
+ return;
221
+ }
222
+ if (key.escape && busy) { abortRef.current?.abort(); return; }
223
+ if (key.tab && key.shift && !busy) {
224
+ const next = !apRef.current;
225
+ setAutopilotMode(next);
226
+ setHint(next ? 'Autopilot ON' : 'Autopilot OFF');
227
+ return;
228
+ }
229
+ if (key.ctrl && input === 't') { setShowReasoning((v) => !v); return; }
230
+ if (key.ctrl && input === 'l' && !busy) { setItems([]); return; }
231
+ });
108
232
 
109
- // Optionally auto-run a first question (e.g. launched with a query).
233
+ // Auto-run a first question if launched with one.
110
234
  const started = useRef(false);
111
235
  useEffect(() => {
112
- if (initialQuestion && !started.current) {
113
- started.current = true;
114
- onSubmit(initialQuestion);
115
- }
236
+ if (initialQuestion && !started.current) { started.current = true; onSubmit(initialQuestion); }
116
237
  }, [initialQuestion, onSubmit]);
117
238
 
118
239
  const renderItem = (it) => {
119
240
  switch (it.type) {
120
- case 'banner': return html`<${Banner} key=${it.id} cfg=${cfg} model=${model} width=${width} />`;
121
241
  case 'user': return html`<${UserMessage} key=${it.id} text=${it.text} />`;
122
242
  case 'assistant': return html`<${AssistantMessage} key=${it.id} text=${it.text} />`;
123
243
  case 'plan': return html`<${Plan} key=${it.id} reply=${it.reply} />`;
@@ -128,26 +248,52 @@ export function App({ cfg, display = {}, onExternal, initialQuestion }) {
128
248
  };
129
249
 
130
250
  const ctx = [model ? shortModel(model) : null, cfg.user?.name, cfg.user?.role].filter(Boolean).join(midDot());
251
+ const modeTag = autopilot ? `${glyphs.bolt} autopilot ${midDot()} ` : '';
252
+ const footerRight = hint || `${modeTag}/help ${midDot()} Shift+Tab autopilot ${midDot()} Ctrl+C exit`;
253
+
254
+ // Tail-slice the transcript to what fits the middle region so nothing is
255
+ // clipped mid-line on small terminals.
256
+ const compact = rows < 30 || cols < 60;
257
+ const budget = Math.max(
258
+ 3,
259
+ rows - bannerHeight(cols, compact) - 3 /*composer*/ - 1 /*footer*/ - 1 /*safety*/
260
+ - liveHeight(live, cols, showReasoning) - 1 /*earlier indicator*/,
261
+ );
262
+ const chosen = [];
263
+ let used = 0;
264
+ for (let i = items.length - 1; i >= 0; i--) {
265
+ const h = itemHeight(items[i], cols);
266
+ if (chosen.length && used + h > budget) break;
267
+ chosen.unshift(items[i]);
268
+ used += h;
269
+ }
270
+ const hidden = items.length - chosen.length;
131
271
 
132
272
  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} />
273
+ <${Box} flexDirection="column" width=${cols} height=${rows}>
274
+ <${Box} flexShrink=${0} flexDirection="column" alignItems="center">
275
+ <${Banner} cfg=${cfg} model=${model} width=${cols} compact=${compact} />
147
276
  <//>
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<//>
277
+
278
+ <${Box} flexGrow=${1} flexDirection="column" justifyContent="flex-end" overflow="hidden">
279
+ ${hidden > 0 ? html`<${Text} color=${C.gray}>${glyphs.up} ${hidden} earlier message${hidden > 1 ? 's' : ''}<//>` : null}
280
+ ${chosen.map(renderItem)}
281
+ ${live
282
+ ? html`<${Box} flexDirection="column">
283
+ <${StatusLines} lines=${live.status} />
284
+ ${showReasoning && live.reasoning ? html`<${Reasoning} text=${live.reasoning} />` : null}
285
+ ${live.text ? html`<${Box} marginTop=${1}><${AssistantMessage} text=${live.text} /><//>` : null}
286
+ <${Thinking} label=${live.text ? 'Responding' : 'Thinking'} />
287
+ <//>`
288
+ : null}
289
+ <//>
290
+
291
+ <${Box} flexShrink=${0} flexDirection="column">
292
+ <${Composer} onSubmit=${onSubmit} busy=${busy} width=${cols} />
293
+ <${Box} paddingX=${1} width=${cols}>
294
+ <${Box} flexGrow=${1}><${Text} color=${C.gray} wrap="truncate-end">${ctx}<//><//>
295
+ <${Text} color=${hint || autopilot ? C.yellow : C.gray} wrap="truncate-end">${footerRight}<//>
296
+ <//>
151
297
  <//>
152
298
  <//>`;
153
299
  }
@@ -15,20 +15,32 @@ 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
- <${Box} flexDirection="column" marginBottom=${1}>
39
+ <${Box} flexDirection="column" marginBottom=${1} alignItems="center">
28
40
  ${wide
29
- ? html`<${Box} flexDirection="column">
41
+ ? html`<${Box} flexDirection="column" alignItems="center">
30
42
  ${LOGO.map((l, i) => html`<${Text} key=${'l' + i} color=${C.accent} bold>${l}<//>`)}
31
- <${Text} color=${C.gray}> iCopilot ${dash()} network engineer in your terminal<//>
43
+ <${Text} color=${C.gray}>iCopilot ${dash()} network engineer in your terminal<//>
32
44
  <//>`
33
45
  : html`<${Text} color=${C.accent} bold>iCopilot ${html`<${Text} color=${C.gray}>${dash()} IspBills AI terminal<//>`}<//>`}
34
46
  <${Box} marginTop=${1}>
@@ -39,7 +51,7 @@ export function Banner({ cfg = {}, model, width = 80 }) {
39
51
  <${Text} color=${label === 'model' ? C.cyan : C.white}>${val}<//>
40
52
  <//>`)}
41
53
  <//>
42
- ${cfg.url ? html`<${Text} color=${C.gray}>url ${html`<${Text} color=${C.accent}>${cfg.url}<//>`}<//>` : null}
54
+ ${cfg.url ? html`<${Text} color=${C.gray}>${cfg.url}<//>` : null}
43
55
  <//>`;
44
56
  }
45
57
 
@@ -1,85 +1,105 @@
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
- if (key.tab) {
43
- if (slashMatches.length) {
44
- const c = slashMatches[0].cmd + ' ';
45
- setValue(c); setCursor(c.length);
46
- }
49
+ if (key.tab && !key.shift) {
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) {
79
+ // At end of a slash token, Right accepts the highlighted completion.
80
+ if (menuOpen && cursor >= value.length) { const c = matches[sel].cmd + ' '; setText(c, c.length); return; }
81
+ setCursor((c) => Math.min(value.length, c + 1));
82
+ return;
83
+ }
84
+ if (key.ctrl && input === 'a') { setCursor(0); return; } // line start
85
+ if (key.ctrl && input === 'e') { setCursor(value.length); return; } // line end
86
+
87
+ // ── Deletion ──
74
88
  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
- }
89
+ if (cursor > 0) { setValue((v) => v.slice(0, cursor - 1) + v.slice(cursor)); setCursor((c) => c - 1); }
90
+ return;
91
+ }
92
+ if (key.ctrl && input === 'u') { setValue(value.slice(cursor)); setCursor(0); return; } // to start
93
+ if (key.ctrl && input === 'k') { setValue(value.slice(0, cursor)); return; } // to end
94
+ if (key.ctrl && input === 'w') { // prev word
95
+ let i = cursor;
96
+ while (i > 0 && value[i - 1] === ' ') i--;
97
+ while (i > 0 && value[i - 1] !== ' ') i--;
98
+ setValue(value.slice(0, i) + value.slice(cursor)); setCursor(i);
79
99
  return;
80
100
  }
81
101
 
82
- // Printable input (ignore control/meta chords; ctrl+c handled by the app).
102
+ // ── Printable input ──
83
103
  if (input && !key.ctrl && !key.meta) {
84
104
  const printable = input.replace(/[\x00-\x1f\x7f]/g, '');
85
105
  if (!printable) return;
@@ -88,28 +108,46 @@ export function Composer({ onSubmit, busy, cfg = {}, width = 80 }) {
88
108
  }
89
109
  });
90
110
 
91
- // Render the value with an inverse cursor block.
92
111
  const before = value.slice(0, cursor);
93
112
  const at = value[cursor] ?? ' ';
94
113
  const after = value.slice(cursor + 1);
95
- const showPlaceholder = value.length === 0;
114
+ const empty = value.length === 0;
115
+ const sug = menuOpen && matches[sel] ? matches[sel].cmd : '';
116
+ const ghost = sug && sug.startsWith(value) && cursor >= value.length ? sug.slice(value.length) : '';
96
117
 
97
118
  return html`
98
119
  <${Box} flexDirection="column" width=${width}>
120
+ ${menuOpen
121
+ ? html`<${Box} flexDirection="column" paddingX=${1} marginBottom=${0}>
122
+ ${(() => {
123
+ const MAX = 8;
124
+ let start = 0;
125
+ if (matches.length > MAX) start = Math.min(Math.max(0, sel - Math.floor(MAX / 2)), matches.length - MAX);
126
+ const window = matches.slice(start, start + MAX);
127
+ return window.map((s, wi) => {
128
+ const i = start + wi;
129
+ const on = i === sel;
130
+ const label = (s.cmd + (s.hint ? ' ' + s.hint : '')).padEnd(21);
131
+ return html`<${Box} key=${'m' + i}>
132
+ <${Text} color=${on ? C.accent : C.gray}>${on ? glyphs.prompt + ' ' : ' '}<//>
133
+ <${Text} color=${on ? C.accent : C.white} bold=${on}>${label}<//>
134
+ <${Text} color=${C.gray}>${s.desc}<//>
135
+ <//>`;
136
+ });
137
+ })()}
138
+ <//>`
139
+ : null}
140
+
99
141
  <${Box} width=${width} borderStyle=${borderStyle()} borderColor=${busy ? C.gray : C.accent} paddingX=${1}>
100
142
  <${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}<//><//>`}
143
+ ${empty
144
+ ? html`<${Box} flexGrow=${1}>
145
+ <${Text} color=${C.gray} inverse> <//>
146
+ <${Text} color=${C.gray}>${busy ? 'Working…' : 'Ask about your network, or type / for commands'}<//>
147
+ <//>`
148
+ : html`<${Box} flexGrow=${1}>
149
+ <${Text}>${before}<//><${Text} inverse>${at}<//><${Text}>${after}<//><${Text} color=${C.gray}>${ghost}<//>
150
+ <//>`}
104
151
  <//>
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
152
  <//>`;
115
153
  }
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
  };