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 +75 -16
- package/package.json +1 -1
- package/src/commands.js +88 -26
- package/src/tui/app.js +204 -58
- package/src/tui/components.js +19 -7
- package/src/tui/composer.js +85 -47
- 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
|
|
@@ -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
|
-
|
|
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
|
-
| `/
|
|
117
|
-
| `/
|
|
118
|
-
| `/
|
|
119
|
-
| `/clear` |
|
|
120
|
-
| `/update` | Update
|
|
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
|
-
|
|
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** —
|
|
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
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
|
-
/**
|
|
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
|
-
|
|
6
|
-
{ cmd: '/
|
|
7
|
-
{ cmd: '/
|
|
8
|
-
{ cmd: '/
|
|
9
|
-
{ cmd: '/
|
|
10
|
-
{ cmd: '/
|
|
11
|
-
{ cmd: '/
|
|
12
|
-
{ cmd: '/
|
|
13
|
-
{ cmd: '/
|
|
14
|
-
{ cmd: '/
|
|
15
|
-
{ cmd: '/
|
|
16
|
-
{ cmd: '/
|
|
17
|
-
|
|
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
|
|
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
|
-
|
|
38
|
-
case '/
|
|
39
|
-
case '/
|
|
40
|
-
case '/
|
|
41
|
-
case '/
|
|
42
|
-
case '/
|
|
43
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
console.log(` ${
|
|
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
|
|
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,
|
|
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
|
|
18
|
+
function useTerminalSize() {
|
|
16
19
|
const { stdout } = useStdout();
|
|
17
|
-
const [
|
|
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 = () =>
|
|
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
|
|
30
|
+
return size;
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
let itemId = 0;
|
|
28
34
|
const nextId = () => ++itemId;
|
|
29
35
|
|
|
30
|
-
|
|
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
|
|
68
|
+
const { cols, rows } = useTerminalSize();
|
|
33
69
|
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
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')
|
|
55
|
-
else if (ev.type === 'reasoning'
|
|
56
|
-
else if (ev.type === 'text')
|
|
57
|
-
setLive({ ...
|
|
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}
|
|
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,
|
|
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
|
-
|
|
79
|
-
if (q === '/
|
|
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
|
-
|
|
88
|
-
|
|
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');
|
|
97
|
-
if (q === '/update') { onExternal?.('update');
|
|
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
|
-
}, [
|
|
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
|
-
//
|
|
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=${
|
|
134
|
-
<${
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
}
|
package/src/tui/components.js
CHANGED
|
@@ -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}>
|
|
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}
|
|
54
|
+
${cfg.url ? html`<${Text} color=${C.gray}>${cfg.url}<//>` : null}
|
|
43
55
|
<//>`;
|
|
44
56
|
}
|
|
45
57
|
|
package/src/tui/composer.js
CHANGED
|
@@ -1,85 +1,105 @@
|
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
77
|
-
|
|
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
|
|
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
|
|
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
|
-
${
|
|
102
|
-
? html`<${Box} flexGrow=${1}
|
|
103
|
-
|
|
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
|
|
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
|
};
|