ispbills-icli 1.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/bin/icli.js +154 -86
  2. package/package.json +1 -1
  3. package/src/chat.js +200 -125
package/bin/icli.js CHANGED
@@ -1,174 +1,242 @@
1
1
  #!/usr/bin/env node
2
- import * as readline from 'readline/promises';
2
+ import { createInterface } from 'readline';
3
3
  import { stdin as input, stdout as output, argv, exit } from 'process';
4
4
  import chalk from 'chalk';
5
5
  import { loadConfig, configPath } from '../src/config.js';
6
6
  import { loginFlow } from '../src/auth.js';
7
- import { streamChat, printBanner } from '../src/chat.js';
7
+ import {
8
+ streamChat, printBanner, printSlashMenu,
9
+ SLASH_COMMANDS, slashCompleter, cols,
10
+ } from '../src/chat.js';
11
+
12
+ // ── Copilot CLI colour tokens ──────────────────────────────────────────────────
13
+ const dim = chalk.hex('#9198A1');
14
+ const tertiary = chalk.hex('#6e7781');
15
+ const accent = chalk.hex('#4493F8').bold;
16
+ const rule = () => chalk.hex('#3D444D')(' ' + '─'.repeat(Math.max(0, cols() - 4)));
8
17
 
9
- // ─── Parse args ─────────────────────────────────────────────────────────────
10
18
  const args = argv.slice(2);
11
19
  const cmd = args[0] ?? '';
12
20
 
13
- // ─── Help ────────────────────────────────────────────────────────────────────
21
+ // ── Help ───────────────────────────────────────────────────────────────────────
14
22
  function printHelp() {
15
- const line = ''.repeat(58);
16
- console.log('\n' + chalk.cyan('' + line + '╮'));
17
- console.log(chalk.cyan(' │ ') + chalk.bold.white('iCli') + chalk.dim(' — IspBills AI Terminal') + ' '.repeat(28) + chalk.cyan(' │'));
18
- console.log(chalk.cyan(' ╰' + line + '╯') + '\n');
19
-
20
- console.log(chalk.bold(' Usage\n'));
21
- console.log(' ' + chalk.cyan('icli') + ' ' + chalk.dim('Interactive REPL'));
22
- console.log(' ' + chalk.cyan('icli') + ' ' + chalk.yellow('"your question"') + ' ' + chalk.dim('Single-shot query'));
23
- console.log(' ' + chalk.cyan('icli login') + ' ' + chalk.dim('Authenticate'));
24
- console.log(' ' + chalk.cyan('icli whoami') + ' ' + chalk.dim('Show current login'));
25
- console.log(' ' + chalk.cyan('icli --help') + ' ' + chalk.dim('Show this help'));
26
-
27
- console.log('\n' + chalk.bold(' Examples\n'));
28
- console.log(' ' + chalk.dim('$') + ' icli "is vlan 82 in use?"');
29
- console.log(' ' + chalk.dim('$') + ' icli "show all ONUs on olt#1"');
30
- console.log(' ' + chalk.dim('$') + ' icli "which customer is using IP 192.168.1.50?"');
31
- console.log(' ' + chalk.dim('$') + ' icli' + chalk.dim(' # start interactive REPL'));
32
-
33
- console.log('\n' + chalk.bold(' Config\n'));
34
- console.log(' ' + chalk.dim(configPath()) + '\n');
23
+ process.stdout.write('\n');
24
+ process.stdout.write(' ' + chalk.bold.white('iCli') + dim(' · IspBills AI Terminal\n'));
25
+ process.stdout.write(rule() + '\n\n');
26
+
27
+ process.stdout.write(chalk.bold.white(' Usage\n\n'));
28
+ const cmds = [
29
+ ['icli', 'Interactive REPL with slash commands'],
30
+ ['icli "question"', 'Single-shot query then exit'],
31
+ ['icli login', 'Authenticate to your IspBills instance'],
32
+ ['icli whoami', 'Show current session info'],
33
+ ['icli --help', 'Show this help'],
34
+ ];
35
+ for (const [c, d] of cmds) {
36
+ process.stdout.write(' ' + accent(c.padEnd(20)) + dim(d) + '\n');
37
+ }
38
+
39
+ process.stdout.write('\n' + chalk.bold.white(' Slash commands\n\n'));
40
+ for (const { cmd: c, hint, desc } of SLASH_COMMANDS) {
41
+ const key = (c + (hint ? ' ' + hint : '')).padEnd(22);
42
+ process.stdout.write(' ' + accent(key) + dim(desc) + '\n');
43
+ }
44
+
45
+ process.stdout.write('\n' + chalk.bold.white(' Keyboard shortcuts\n\n'));
46
+ [
47
+ ['Tab', 'Autocomplete slash commands'],
48
+ ['↑ / ↓', 'Navigate input history'],
49
+ ['Ctrl+T', 'Toggle reasoning display'],
50
+ ['Ctrl+L', 'Clear screen'],
51
+ ['Ctrl+C', 'Cancel / exit'],
52
+ ].forEach(([k, d]) => {
53
+ process.stdout.write(' ' + chalk.yellow(k.padEnd(14)) + dim(d) + '\n');
54
+ });
55
+ process.stdout.write('\n');
35
56
  }
36
57
 
37
- // ─── Ensure authenticated ────────────────────────────────────────────────────
58
+ // ── Auth ───────────────────────────────────────────────────────────────────────
38
59
  async function ensureAuth() {
39
60
  let cfg = loadConfig();
40
61
  if (!cfg) {
41
- console.log(chalk.yellow('\n No saved login found. Let\'s connect iCli.\n'));
62
+ process.stdout.write(chalk.yellow('\n No saved credentials. Setting up iCli.\n\n'));
42
63
  cfg = await loginFlow();
43
- console.log('\n ' + chalk.green('✓') + ' Logged in as ' +
64
+ process.stdout.write(
65
+ '\n ' + chalk.green('✓') + ' Logged in as ' +
44
66
  chalk.white(cfg.user?.name ?? cfg.user?.email) +
45
- chalk.dim(' · ' + (cfg.user?.role ?? 'operator')));
46
- console.log(' ' + chalk.dim('Connected to: ') + chalk.cyan(cfg.url) + '\n');
67
+ dim(' · ' + (cfg.user?.role ?? 'operator')) + '\n\n'
68
+ );
47
69
  }
48
70
  return cfg;
49
71
  }
50
72
 
51
- // ─── Single-shot query ───────────────────────────────────────────────────────
73
+ // ── Slash question ───────────────────────────────────────────────────────────
74
+ function slashToQuestion(line) {
75
+ const [sc, ...rest] = line.trim().split(/\s+/);
76
+ const arg = rest.join(' ');
77
+ switch (sc) {
78
+ case '/check': return arg ? `run diagnostics on ${arg}` : 'check status of all devices';
79
+ case '/vlan': return arg ? `is vlan ${arg} in use or free?` : 'list all vlans in use';
80
+ case '/onu': return arg ? `show ONU ${arg} status and signal level` : 'list all ONUs';
81
+ case '/customer': return arg ? `look up customer ${arg}` : 'show recent customers';
82
+ case '/ping': return arg ? `ping ${arg} through the network` : 'which devices are reachable?';
83
+ case '/online': return 'list currently online PPPoE sessions';
84
+ default: return null;
85
+ }
86
+ }
87
+
88
+ // ── Single-shot ────────────────────────────────────────────────────────────────
52
89
  async function singleShot(cfg, question) {
53
- // Show the user's own message
54
- process.stdout.write('\n ' + chalk.cyan('You ') + chalk.white(question) + '\n');
55
- process.stdout.write(' ' + chalk.dim('─'.repeat(58)) + '\n');
56
- process.stdout.write(' ' + chalk.bold.cyan('iCopilot') + '\n');
90
+ // Copilot CLI style: role label then content, separated by blank lines
91
+ process.stdout.write('\n ' + chalk.hex('#4493F8').bold('You') + '\n');
92
+ process.stdout.write(' ' + chalk.white(question) + '\n\n');
93
+ process.stdout.write(rule() + '\n\n');
94
+ process.stdout.write(' ' + chalk.bold.white('iCopilot') + '\n');
57
95
 
58
- const messages = [{ role: 'user', content: question }];
59
96
  try {
60
- await streamChat(cfg, messages);
97
+ await streamChat(cfg, [{ role: 'user', content: question }]);
61
98
  } catch (e) {
62
- console.error('\n ' + chalk.red('✖ ' + e.message) + '\n');
99
+ process.stdout.write('\n ' + chalk.red('✖ ') + chalk.white(e.message) + '\n\n');
63
100
  exit(1);
64
101
  }
65
102
  }
66
103
 
67
- // ─── Interactive REPL ────────────────────────────────────────────────────────
104
+ // ── REPL ───────────────────────────────────────────────────────────────────────
68
105
  async function interactiveRepl(cfg) {
69
- const history = [];
106
+ const history = [];
107
+ let showReasoning = true;
70
108
 
71
109
  printBanner(cfg);
72
110
 
73
- const rl = readline.createInterface({
111
+ // Copilot CLI uses `> ` style prompt
112
+ const PROMPT = ' ' + chalk.hex('#4493F8').bold('>') + ' ';
113
+
114
+ const rl = createInterface({
74
115
  input, output,
75
- prompt: chalk.cyan(' You ') + chalk.white(''),
116
+ prompt: PROMPT,
117
+ completer: slashCompleter,
118
+ terminal: true,
76
119
  });
77
120
 
121
+ process.stdout.on('resize', () => rl.setPrompt(PROMPT));
122
+
78
123
  rl.prompt();
79
124
 
80
125
  rl.on('line', async (line) => {
81
126
  const q = line.trim();
82
127
  if (!q) { rl.prompt(); return; }
83
128
 
84
- if (q === 'exit' || q === 'quit') {
85
- console.log(chalk.dim('\n Goodbye.\n'));
86
- rl.close();
87
- exit(0);
129
+ // ── Built-in commands ──────────────────────────────────────────────────
130
+ if (q === '/exit' || q === 'exit' || q === 'quit') {
131
+ process.stdout.write(tertiary('\n Goodbye.\n\n')); rl.close(); exit(0);
88
132
  }
89
- if (q === 'clear') { console.clear(); printBanner(cfg); rl.prompt(); return; }
90
- if (q === 'history') {
91
- console.log('');
92
- history.filter(m => m.role === 'user').forEach((m, i) => {
93
- console.log(' ' + chalk.dim(String(i + 1) + '.') + ' ' + m.content.slice(0, 80));
94
- });
95
- console.log('');
96
- rl.prompt();
97
- return;
133
+ if (q === '/clear' || q === 'clear') {
134
+ process.stdout.write('\x1Bc'); printBanner(cfg); rl.prompt(); return;
135
+ }
136
+ if (q === '/help' || q === 'help') { printHelp(); rl.prompt(); return; }
137
+ if (q === '/history' || q === 'history') {
138
+ const turns = history.filter(m => m.role === 'user');
139
+ if (!turns.length) {
140
+ process.stdout.write(tertiary('\n No history yet.\n\n'));
141
+ } else {
142
+ process.stdout.write('\n');
143
+ turns.forEach((m, i) =>
144
+ process.stdout.write(' ' + dim(String(i+1).padStart(2)+'.') + ' ' + chalk.white(m.content.slice(0, cols()-8)) + '\n')
145
+ );
146
+ process.stdout.write('\n');
147
+ }
148
+ rl.prompt(); return;
149
+ }
150
+ if (q === '/') { printSlashMenu(); rl.prompt(); return; }
151
+
152
+ // ── Resolve slash command ──────────────────────────────────────────────
153
+ let question = q;
154
+ if (q.startsWith('/')) {
155
+ const resolved = slashToQuestion(q);
156
+ if (resolved) {
157
+ question = resolved;
158
+ process.stdout.write(' ' + tertiary('→ ') + dim(question) + '\n');
159
+ }
98
160
  }
99
- if (q === 'help') { printHelp(); rl.prompt(); return; }
100
161
 
101
162
  rl.pause();
102
- history.push({ role: 'user', content: q });
163
+ history.push({ role: 'user', content: question });
103
164
 
104
- // Show iCopilot label before streaming response
105
- process.stdout.write(' ' + chalk.dim('─'.repeat(58)) + '\n');
106
- process.stdout.write(' ' + chalk.bold.cyan('iCopilot') + '\n');
165
+ // Role separator Copilot CLI style blank-line + label pattern
166
+ process.stdout.write('\n' + rule() + '\n\n');
167
+ process.stdout.write(' ' + chalk.bold.white('iCopilot') + '\n');
107
168
 
108
169
  try {
109
- const answer = await streamChat(cfg, [...history]);
170
+ const answer = await streamChat(cfg, [...history], {}, { thinking: showReasoning });
110
171
  if (answer) history.push({ role: 'assistant', content: answer });
111
172
  } catch (e) {
112
- console.error('\n ' + chalk.red('✖ ' + e.message) + '\n');
173
+ process.stdout.write('\n ' + chalk.red('✖ ') + chalk.white(e.message) + '\n\n');
113
174
  }
114
175
 
115
- // Prompt for next message with label
116
- process.stdout.write(' ' + chalk.dim('─'.repeat(58)) + '\n');
117
- rl.setPrompt(chalk.cyan(' You ') + chalk.white(''));
176
+ process.stdout.write(rule() + '\n\n');
118
177
  rl.resume();
119
178
  rl.prompt();
120
179
  });
121
180
 
122
- rl.on('close', () => {
123
- console.log(chalk.dim('\n Goodbye.\n'));
124
- exit(0);
125
- });
181
+ // Ctrl+T toggles reasoning (matches Copilot CLI Ctrl+T)
182
+ if (input.setRawMode) {
183
+ process.stdin.on('keypress', (str, key) => {
184
+ if (key?.ctrl && key?.name === 't') {
185
+ showReasoning = !showReasoning;
186
+ process.stdout.write(tertiary(`\n Reasoning ${showReasoning ? 'on' : 'off'}\n\n`));
187
+ rl.prompt();
188
+ }
189
+ });
190
+ }
191
+
192
+ rl.on('close', () => { process.stdout.write(tertiary('\n Goodbye.\n\n')); exit(0); });
126
193
  }
127
194
 
128
- // ─── Main ────────────────────────────────────────────────────────────────────
195
+ // ── Main ───────────────────────────────────────────────────────────────────────
129
196
  async function main() {
130
- if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
131
- printHelp(); return;
132
- }
197
+ if (cmd === '--help' || cmd === '-h' || cmd === 'help') { printHelp(); return; }
133
198
 
134
199
  if (cmd === 'login') {
135
200
  const cfg = await loginFlow();
136
- console.log('\n ' + chalk.green('✓') + ' Logged in as ' +
201
+ process.stdout.write(
202
+ '\n ' + chalk.green('✓') + ' Logged in as ' +
137
203
  chalk.white(cfg.user?.name ?? cfg.user?.email) +
138
- chalk.dim(' · ' + (cfg.user?.role ?? 'operator')));
139
- console.log(' ' + chalk.dim('Config saved: ') + chalk.dim(configPath()) + '\n');
204
+ dim(' · ' + (cfg.user?.role ?? 'operator')) + '\n'
205
+ );
206
+ process.stdout.write(' ' + dim('Config: ' + configPath()) + '\n\n');
140
207
  return;
141
208
  }
142
209
 
143
210
  if (cmd === 'whoami') {
144
211
  const cfg = loadConfig();
145
212
  if (!cfg) {
146
- console.log('\n ' + chalk.yellow('Not logged in.') + ' Run ' + chalk.cyan('icli login') + '\n');
213
+ process.stdout.write('\n ' + chalk.yellow('Not logged in.') + ' Run ' + accent('icli login') + '\n\n');
147
214
  return;
148
215
  }
149
- const line = ''.repeat(58);
150
- console.log('\n' + chalk.dim(' ' + line));
151
- console.log(' ' + chalk.dim('URL ') + chalk.cyan(cfg.url));
152
- console.log(' ' + chalk.dim('Name ') + chalk.white(cfg.user?.name ?? '—'));
153
- console.log(' ' + chalk.dim('Email ') + chalk.white(cfg.user?.email ?? '—'));
154
- console.log(' ' + chalk.dim('Role ') + chalk.white(cfg.user?.role ?? '—'));
155
- console.log(chalk.dim(' ' + line) + '\n');
216
+ process.stdout.write('\n' + rule() + '\n');
217
+ [
218
+ ['url', cfg.url],
219
+ ['name', cfg.user?.name],
220
+ ['email', cfg.user?.email],
221
+ ['role', cfg.user?.role],
222
+ ].forEach(([k, v]) => {
223
+ if (v) process.stdout.write(' ' + dim(k.padEnd(7)) + chalk.white(v) + '\n');
224
+ });
225
+ process.stdout.write(rule() + '\n\n');
156
226
  return;
157
227
  }
158
228
 
159
229
  const cfg = await ensureAuth();
160
230
 
161
- // Single-shot: icli "question here"
162
231
  if (cmd && !cmd.startsWith('-')) {
163
232
  await singleShot(cfg, args.join(' '));
164
233
  return;
165
234
  }
166
235
 
167
- // Interactive REPL
168
236
  await interactiveRepl(cfg);
169
237
  }
170
238
 
171
239
  main().catch(e => {
172
- console.error('\n ' + chalk.red('✖ ' + e.message) + '\n');
240
+ process.stdout.write('\n ' + chalk.red('✖ ') + chalk.white(e.message) + '\n\n');
173
241
  exit(1);
174
242
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ispbills-icli",
3
- "version": "1.1.0",
3
+ "version": "3.0.0",
4
4
  "description": "iCli — IspBills AI network engineer in your terminal",
5
5
  "keywords": [
6
6
  "ispbills",
package/src/chat.js CHANGED
@@ -4,119 +4,209 @@ import { marked } from 'marked';
4
4
  import TerminalRenderer from 'marked-terminal';
5
5
  import { refreshToken } from './auth.js';
6
6
 
7
- // Configure markdown renderer
7
+ // ── Copilot CLI colour palette (matched from app.js theme tokens) ─────────────
8
+ const C = {
9
+ textPrimary: chalk.white,
10
+ textSecondary: chalk.hex('#9198A1'),
11
+ textTertiary: chalk.hex('#6e7781'),
12
+ accent: chalk.hex('#4493F8'),
13
+ border: chalk.hex('#3D444D'),
14
+ userLabel: chalk.hex('#4493F8').bold, // same as selected/accent
15
+ assistantLabel: chalk.white.bold,
16
+ statusOk: chalk.green,
17
+ statusErr: chalk.red,
18
+ statusWarn: chalk.yellow,
19
+ code: chalk.hex('#79c0ff'), // syntaxString dark
20
+ codeBg: chalk.bgHex('#161b22').hex('#c9d1d9'),
21
+ };
22
+
23
+ // ── Layout ────────────────────────────────────────────────────────────────────
24
+ export const cols = () => Math.min((process.stdout.columns || 80), 88);
25
+ const rule = (ch = '─') => C.border(' ' + ch.repeat(Math.max(0, cols() - 4)));
26
+
27
+ // ── Markdown (matched to Copilot CLI syntax colours) ──────────────────────────
8
28
  marked.setOptions({ renderer: new TerminalRenderer({
9
- firstHeading: chalk.bold.cyan,
10
- heading: chalk.bold.cyan,
29
+ firstHeading: chalk.bold.white,
30
+ heading: chalk.bold.white,
11
31
  strong: chalk.bold.white,
12
- em: chalk.italic.dim,
13
- codespan: (code) => chalk.bgGray.white(` ${code} `),
14
- code: (code) => code.split('\n').map(l => chalk.green(' ' + l)).join('\n'),
15
- blockquote: chalk.dim,
16
- listitem: (text) => ` ${chalk.cyan('')} ${text}`,
17
- hr: () => chalk.dim(' ' + '─'.repeat(56)) + '\n',
18
- link: (_href, _title, text) => chalk.cyan.underline(text || _href),
32
+ em: chalk.italic.hex('#9198A1'),
33
+ codespan: (t) => chalk.bgHex('#161b22').hex('#c9d1d9')(` ${t} `),
34
+ code: (t) => '\n' + t.split('\n').map(l => ' ' + chalk.hex('#c9d1d9')(l)).join('\n') + '\n',
35
+ blockquote: (t) => C.textSecondary(' ▌ ' + t.trim()),
36
+ listitem: (t) => ` ${C.accent('·')} ${t.trimEnd()}`,
37
+ hr: () => rule() + '\n',
38
+ link: (_h, _t, t) => chalk.hex('#58a6ff').underline(t || _h),
19
39
  }) });
20
40
 
21
41
  function renderMarkdown(text) {
22
- try {
23
- return marked(text).trimEnd();
24
- } catch {
25
- return text;
42
+ try { return marked(text).trimEnd(); } catch { return text; }
43
+ }
44
+
45
+ // ── Status icon (same pattern, cleaner) ───────────────────────────────────────
46
+ function statusIcon(line) {
47
+ const l = line.toLowerCase();
48
+ if (/unreachable|skip|fail|error/.test(l)) return C.statusErr('✗');
49
+ if (/done|success|found|complet|online/.test(l)) return C.statusOk('✓');
50
+ if (/batch|sub.?agent|fleet|parallel/.test(l)) return C.accent('◈');
51
+ if (/\[\d+\/\d+\]/.test(l)) return C.accent('◉');
52
+ if (/probe|reachab|ping/.test(l)) return C.statusWarn('◎');
53
+ if (/read|fetch|get|query/.test(l)) return C.textSecondary('↓');
54
+ if (/connect|ssh|telnet|api/.test(l)) return C.accent('⇢');
55
+ if (/⌁/.test(line)) return C.border('⌁');
56
+ return C.border('›');
57
+ }
58
+
59
+ // ── Banner (Copilot CLI style: label · info · shortcuts) ─────────────────────
60
+ export function printBanner(cfg, meta = {}) {
61
+ const model = meta.model ? shortModel(meta.model) : (cfg?._model ?? null);
62
+ process.stdout.write('\n');
63
+
64
+ // Title row — bold label, dim subtitle
65
+ process.stdout.write(
66
+ ' ' + chalk.bold.white('iCopilot') +
67
+ C.textSecondary(' · IspBills AI Network Engineer') + '\n'
68
+ );
69
+
70
+ // Session info row (model · user · role) — matches Copilot CLI footer format
71
+ const parts = [];
72
+ if (model) parts.push(C.textSecondary('model ') + chalk.white(model));
73
+ if (cfg?.user?.name) parts.push(C.textSecondary('user ') + chalk.white(cfg.user.name));
74
+ if (cfg?.user?.role) parts.push(C.textSecondary('role ') + chalk.white(cfg.user.role));
75
+ if (cfg?.url) parts.push(C.textSecondary('url ') + C.accent(cfg.url));
76
+ if (parts.length) {
77
+ process.stdout.write(' ' + parts.join(C.textSecondary(' · ')) + '\n');
26
78
  }
79
+
80
+ // Hint row — matches Copilot CLI shortcut hints
81
+ process.stdout.write(
82
+ C.textTertiary(' / commands Tab autocomplete ↑↓ history Ctrl+C exit') + '\n'
83
+ );
84
+ process.stdout.write(rule() + '\n\n');
85
+ }
86
+
87
+ function shortModel(m) {
88
+ return m.replace('openai/', '').replace(/^claude-/, '').replace(/^gpt-/, 'gpt-');
27
89
  }
28
90
 
29
- /**
30
- * Stream a single chat turn to stdout.
31
- *
32
- * @param {object} cfg - { url, token, refresh_token, user }
33
- * @param {Array} messages - [{ role, content }]
34
- * @param {object} [context] - optional device context
35
- * @param {object} [opts] - { thinking: bool }
36
- * @returns {string} The final answer text
37
- */
91
+ // ── Slash commands ─────────────────────────────────────────────────────────────
92
+ export const SLASH_COMMANDS = [
93
+ { cmd: '/check', hint: '[device]', desc: 'Run diagnostics on a device' },
94
+ { cmd: '/vlan', hint: '[id]', desc: 'Check VLAN usage' },
95
+ { cmd: '/onu', hint: '[id]', desc: 'Show ONU status and signal' },
96
+ { cmd: '/customer', hint: '[name/IP]', desc: 'Look up a customer' },
97
+ { cmd: '/ping', hint: '[host]', desc: 'Ping a host' },
98
+ { cmd: '/online', hint: '', desc: 'List online sessions' },
99
+ { cmd: '/history', hint: '', desc: 'Show conversation history' },
100
+ { cmd: '/clear', hint: '', desc: 'Clear the screen' },
101
+ { cmd: '/help', hint: '', desc: 'Show help' },
102
+ { cmd: '/exit', hint: '', desc: 'Exit iCli' },
103
+ ];
104
+
105
+ export function printSlashMenu() {
106
+ process.stdout.write('\n');
107
+ process.stdout.write(chalk.bold.white(' Commands') + C.textTertiary(' (Tab to autocomplete)\n\n'));
108
+ for (const { cmd: c, hint, desc } of SLASH_COMMANDS) {
109
+ const key = (c + (hint ? ' ' + hint : '')).padEnd(22);
110
+ process.stdout.write(' ' + C.accent(key) + C.textSecondary(desc) + '\n');
111
+ }
112
+ process.stdout.write('\n');
113
+ }
114
+
115
+ export function slashCompleter(line) {
116
+ if (line.startsWith('/')) {
117
+ const hits = SLASH_COMMANDS.map(s => s.cmd + ' ').filter(s => s.startsWith(line));
118
+ return [hits.length ? hits : SLASH_COMMANDS.map(s => s.cmd + ' '), line];
119
+ }
120
+ return [[], line];
121
+ }
122
+
123
+ // ── streamChat ────────────────────────────────────────────────────────────────
38
124
  export async function streamChat(cfg, messages, context = {}, opts = {}) {
39
125
  const showThinking = opts.thinking !== false;
40
126
  const body = JSON.stringify({ messages, context });
41
127
 
42
- let res = await fetch(`${cfg.url}/api/v1/ai/chat/stream`, {
43
- method: 'POST',
44
- headers: {
45
- 'Content-Type': 'application/json',
46
- 'Accept': 'text/event-stream',
47
- 'Authorization': `Bearer ${cfg.token}`,
48
- },
49
- body,
50
- });
51
-
52
- // Token expired — try refresh once
53
- if (res.status === 401 && cfg.refresh_token) {
54
- const updated = await refreshToken(cfg);
55
- if (!updated) throw new Error('Session expired. Run `icli login` to re-authenticate.');
56
- Object.assign(cfg, updated);
57
- res = await fetch(`${cfg.url}/api/v1/ai/chat/stream`, {
58
- method: 'POST',
128
+ async function doFetch(token) {
129
+ return fetch(`${cfg.url}/api/v1/ai/chat/stream`, {
130
+ method: 'POST',
59
131
  headers: {
60
132
  'Content-Type': 'application/json',
61
133
  'Accept': 'text/event-stream',
62
- 'Authorization': `Bearer ${cfg.token}`,
134
+ 'Authorization': `Bearer ${token}`,
63
135
  },
64
136
  body,
65
137
  });
66
138
  }
67
139
 
140
+ let res = await doFetch(cfg.token);
141
+ if (res.status === 401 && cfg.refresh_token) {
142
+ const updated = await refreshToken(cfg);
143
+ if (!updated) throw new Error('Session expired. Run `icli login`.');
144
+ Object.assign(cfg, updated);
145
+ res = await doFetch(cfg.token);
146
+ }
68
147
  if (!res.ok) {
69
148
  const txt = await res.text().catch(() => '');
70
149
  throw new Error(`AI request failed (HTTP ${res.status}): ${txt.slice(0, 200)}`);
71
150
  }
72
151
 
73
- // ── Spinner ──────────────────────────────────────────────────────────────
152
+ // ── Render state ───────────────────────────────────────────────────────────
153
+ let reasoningOpen = false; // true while inside a live reasoning block
154
+ let answerBuf = '';
155
+ let rawAnswer = '';
156
+ let finalResult = null;
157
+
74
158
  const spinner = ora({
75
- text: chalk.dim('Thinking…'),
76
- color: 'cyan',
77
- spinner: 'dots',
78
- prefixText: ' ',
159
+ text: C.textSecondary('Thinking…'),
160
+ color: 'blue',
161
+ spinner: 'dots',
162
+ prefixText: ' ',
79
163
  });
80
164
 
81
- let reasonBuf = '';
82
- let answerBuf = '';
83
- let rawAnswer = '';
84
- let finalResult = null;
165
+ const stopSpinner = () => { if (spinner.isSpinning) spinner.stop(); };
85
166
 
86
- function stopSpinner() {
87
- if (spinner.isSpinning) spinner.stop();
167
+ function openReasoning() {
168
+ if (reasoningOpen) return;
169
+ stopSpinner();
170
+ reasoningOpen = true;
171
+ // Copilot CLI style: label then indented content
172
+ process.stdout.write('\n ' + C.textSecondary('Reasoning') + '\n');
173
+ process.stdout.write(C.border(' │ '));
88
174
  }
89
175
 
90
- function printStatus(line) {
91
- stopSpinner();
92
- process.stdout.write(chalk.magenta(' › ') + chalk.dim(line) + '\n');
93
- if (showThinking) spinner.start();
176
+ function writeReasonDelta(delta) {
177
+ if (!reasoningOpen) return;
178
+ const parts = delta.split('\n');
179
+ for (let i = 0; i < parts.length; i++) {
180
+ if (i > 0) process.stdout.write('\n' + C.border(' │ '));
181
+ if (parts[i]) process.stdout.write(C.textSecondary(parts[i]));
182
+ }
183
+ }
184
+
185
+ function closeReasoning() {
186
+ if (!reasoningOpen) return;
187
+ process.stdout.write('\n\n');
188
+ reasoningOpen = false;
94
189
  }
95
190
 
96
- function flushReasoning() {
97
- if (!reasonBuf.trim()) return;
191
+ function printStatus(line) {
192
+ if (reasoningOpen) closeReasoning();
98
193
  stopSpinner();
99
- const lines = reasonBuf.trim().split('\n').filter(Boolean);
100
- process.stdout.write('\n');
101
- process.stdout.write(chalk.dim(' ┌─ Reasoning ' + ''.repeat(44)) + '\n');
102
- for (const l of lines) {
103
- process.stdout.write(chalk.dim(' │ ') + chalk.dim.italic(l) + '\n');
104
- }
105
- process.stdout.write(chalk.dim(' └' + '─'.repeat(57)) + '\n');
106
- reasonBuf = '';
194
+ const icon = statusIcon(line);
195
+ const text = line.trimStart();
196
+ const indent = /^[\s·•⌁]/.test(line) ? ' ' : ' ';
197
+ process.stdout.write(indent + icon + ' ' + C.textSecondary(text) + '\n');
198
+ if (showThinking) spinner.start();
107
199
  }
108
200
 
109
201
  function parseStreamText(raw) {
110
202
  const m = raw.match(/"text"\s*:\s*"((?:[^"\\]|\\.)*)/);
111
203
  if (!m) return raw.trimStart().startsWith('{') ? null : raw;
112
- return m[1]
113
- .replace(/\\n/g, '\n').replace(/\\t/g, '\t')
114
- .replace(/\\r/g, '').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
204
+ return m[1].replace(/\\n/g,'\n').replace(/\\t/g,'\t')
205
+ .replace(/\\r/g,'').replace(/\\"/g,'"').replace(/\\\\/g,'\\');
115
206
  }
116
207
 
117
- // ── SSE reader ───────────────────────────────────────────────────────────
208
+ // ── SSE reader ─────────────────────────────────────────────────────────────
118
209
  if (showThinking) spinner.start();
119
-
120
210
  const decoder = new TextDecoder();
121
211
  let sseBuffer = '';
122
212
 
@@ -126,16 +216,18 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
126
216
  sseBuffer = lines.pop();
127
217
 
128
218
  for (const line of lines) {
129
- const trimmed = line.replace(/\r$/, '');
130
- if (!trimmed.startsWith('data: ')) continue;
131
- let ev;
132
- try { ev = JSON.parse(trimmed.slice(6)); } catch { continue; }
219
+ const t = line.replace(/\r$/, '');
220
+ if (!t.startsWith('data: ')) continue;
221
+ let ev; try { ev = JSON.parse(t.slice(6)); } catch { continue; }
133
222
 
134
223
  if (ev.status_delta !== undefined) {
135
224
  printStatus(ev.status_delta);
136
225
  } else if (ev.reason_delta !== undefined) {
137
- reasonBuf += ev.reason_delta;
226
+ if (!reasoningOpen) openReasoning(); // re-open after STATUS closes it
227
+ writeReasonDelta(ev.reason_delta);
138
228
  } else if (ev.delta !== undefined) {
229
+ if (reasoningOpen) closeReasoning();
230
+ stopSpinner();
139
231
  rawAnswer += ev.delta;
140
232
  const txt = parseStreamText(rawAnswer);
141
233
  if (txt !== null) answerBuf = txt;
@@ -145,73 +237,56 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
145
237
  }
146
238
  }
147
239
 
148
- // ── Final render ─────────────────────────────────────────────────────────
240
+ // ── Final render ───────────────────────────────────────────────────────────
149
241
  stopSpinner();
150
- flushReasoning();
242
+ if (reasoningOpen) closeReasoning();
151
243
 
152
244
  const reply = finalResult?.reply ?? {};
153
245
  const replyType = reply.type ?? 'message';
246
+ const meta = finalResult?._meta ?? {};
154
247
 
155
- process.stdout.write('\n' + chalk.dim(' ' + '─'.repeat(58)) + '\n\n');
248
+ // Save model to cfg for banner re-use
249
+ if (meta.model && cfg) cfg._model = shortModel(meta.model);
250
+
251
+ // Content separator (same as Copilot CLI visual spacing)
252
+ process.stdout.write('\n');
156
253
 
157
254
  if (replyType === 'message') {
158
255
  const text = reply.text || answerBuf || '(no response)';
159
- const rendered = renderMarkdown(text);
160
- const indented = rendered.split('\n').map(l => ' ' + l).join('\n');
161
- process.stdout.write(indented + '\n\n');
162
- return text;
256
+ process.stdout.write(renderMarkdown(text).split('\n').map(l => ' ' + l).join('\n') + '\n');
163
257
 
164
258
  } else if (replyType === 'plan') {
165
- process.stdout.write(' ' + chalk.bold.cyan('📋 ') + chalk.bold.white(reply.summary || 'Plan') + '\n\n');
166
- (reply.steps || []).forEach((step, i) => {
167
- const risk = step.risk === 'high' ? chalk.red(' ⚠ high risk') : '';
168
- process.stdout.write(` ${chalk.cyan(String(i + 1) + '.')} ${chalk.yellow(step.cmd || '')}${risk}\n`);
169
- if (step.purpose) process.stdout.write(chalk.dim(` ${step.purpose}\n`));
170
- });
259
+ process.stdout.write(' ' + chalk.bold.white('Plan') + '\n');
260
+ if (reply.summary) process.stdout.write(' ' + C.textSecondary(reply.summary) + '\n');
171
261
  process.stdout.write('\n');
172
- return reply.summary || '';
262
+ (reply.steps || []).forEach((s, i) => {
263
+ const risk = s.risk === 'high' ? C.statusErr(' ⚠ high risk') :
264
+ s.risk === 'medium' ? C.statusWarn(' ⚡') : '';
265
+ process.stdout.write(` ${C.textSecondary(String(i+1)+'.')} ${chalk.yellow(s.cmd || '')}${risk}\n`);
266
+ if (s.purpose) process.stdout.write(C.textTertiary(` ${s.purpose}\n`));
267
+ });
268
+ process.stdout.write('\n' + C.textSecondary(' Reply ') + chalk.white('apply fix') + C.textSecondary(' to confirm.\n'));
173
269
 
174
270
  } else if (replyType === 'connect') {
175
271
  const dev = reply.device ?? {};
176
272
  process.stdout.write(
177
- ' ' + chalk.bold.green('🔌 ') +
178
- chalk.bold.green(`${dev.kind ?? '?'}#${dev.id ?? '?'}`) +
179
- chalk.white(` ${dev.name ?? ''} `) +
180
- chalk.dim(`(${dev.host ?? ''})`) + '\n'
273
+ ' ' + C.statusOk('🔌 ') +
274
+ chalk.bold.white(`${dev.kind ?? '?'}#${dev.id ?? '?'}`) + ' ' +
275
+ chalk.white(dev.name ?? '') + ' ' +
276
+ C.textSecondary(`(${dev.host ?? ''})`) + '\n'
181
277
  );
182
- if (reply.note) process.stdout.write(chalk.dim(' ' + reply.note) + '\n');
183
- process.stdout.write('\n');
184
- return reply.note || '';
278
+ if (reply.note) process.stdout.write(C.textSecondary(' ' + reply.note) + '\n');
185
279
  }
186
280
 
187
- return answerBuf;
188
- }
189
-
190
- /** Print a styled welcome banner. */
191
- export function printBanner(cfg) {
192
- const line = '─'.repeat(58);
281
+ // ── Footer — matches Copilot CLI status bar format: model · user · ... ──────
193
282
  process.stdout.write('\n');
194
- process.stdout.write(chalk.cyan(' ╭' + line + '╮') + '\n');
195
- process.stdout.write(
196
- chalk.cyan(' │ ') +
197
- chalk.bold.white('iCopilot') +
198
- chalk.dim(' · IspBills AI Network Engineer') +
199
- ' '.repeat(13) +
200
- chalk.cyan(' │') + '\n'
201
- );
202
- process.stdout.write(chalk.cyan(' ╰' + line + '╯') + '\n\n');
203
-
204
- if (cfg?.url) {
205
- process.stdout.write(chalk.dim(' Connected ') + chalk.cyan(cfg.url) + '\n');
283
+ const footParts = [];
284
+ if (meta.model) footParts.push(shortModel(meta.model));
285
+ if (meta.user) footParts.push(meta.user);
286
+ if (meta.role) footParts.push(meta.role);
287
+ if (footParts.length) {
288
+ process.stdout.write(C.textTertiary(' ' + footParts.join(' · ')) + '\n');
206
289
  }
207
- if (cfg?.user?.name || cfg?.user?.email) {
208
- process.stdout.write(
209
- chalk.dim(' Logged in ') +
210
- chalk.white(cfg.user.name || cfg.user.email) +
211
- chalk.dim(' · ' + (cfg.user.role ?? 'operator')) + '\n'
212
- );
213
- }
214
- process.stdout.write('\n');
215
- process.stdout.write(chalk.dim(' Type your question and press Enter. Type "exit" to quit.\n'));
216
- process.stdout.write(chalk.dim(' ' + line) + '\n\n');
290
+
291
+ return reply.text || answerBuf || '';
217
292
  }