ispbills-icli 2.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/icli.js CHANGED
@@ -1,174 +1,225 @@
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 { cols, inner, onResize, boxTop, boxBot, boxMid, boxRow, boxTopOpen, boxBotOpen } from '../src/layout.js';
8
+ import {
9
+ streamChat, printBanner, printSlashMenu, printUserBox,
10
+ SLASH_COMMANDS, slashCompleter,
11
+ } from '../src/chat.js';
12
+
13
+ const P = {
14
+ border: chalk.hex('#3D444D'),
15
+ dim: chalk.hex('#9198A1'),
16
+ muted: chalk.hex('#6e7781'),
17
+ accent: chalk.hex('#4493F8').bold,
18
+ accentD: chalk.hex('#4493F8'),
19
+ bold: chalk.bold.white,
20
+ ok: chalk.green,
21
+ warn: chalk.yellow,
22
+ err: chalk.red,
23
+ };
8
24
 
9
- // ─── Parse args ─────────────────────────────────────────────────────────────
10
25
  const args = argv.slice(2);
11
26
  const cmd = args[0] ?? '';
12
27
 
13
- // ─── Help ────────────────────────────────────────────────────────────────────
28
+ // ── Help ───────────────────────────────────────────────────────────────────────
14
29
  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');
30
+ process.stdout.write('\n');
31
+ process.stdout.write(boxTop('iCli · Help', P.border) + '\n');
32
+
33
+ process.stdout.write(boxRow('', P.border) + '\n');
34
+ process.stdout.write(boxRow(' ' + P.bold('Usage'), P.border) + '\n');
35
+ process.stdout.write(boxRow('', P.border) + '\n');
36
+ [
37
+ ['icli', 'Interactive REPL with slash commands'],
38
+ ['icli "question"', 'Single-shot query then exit'],
39
+ ['icli login', 'Authenticate to your IspBills instance'],
40
+ ['icli whoami', 'Show current session info'],
41
+ ['icli --help', 'Show this help'],
42
+ ].forEach(([c, d]) =>
43
+ process.stdout.write(boxRow(' ' + P.accent(c.padEnd(20)) + P.dim(d), P.border) + '\n')
44
+ );
45
+
46
+ process.stdout.write(boxMid(P.border) + '\n');
47
+ process.stdout.write(boxRow(' ' + P.bold('Slash commands'), P.border) + '\n');
48
+ process.stdout.write(boxRow('', P.border) + '\n');
49
+ for (const { cmd: c, hint, desc } of SLASH_COMMANDS) {
50
+ const key = (c + (hint ? ' '+hint : '')).padEnd(22);
51
+ process.stdout.write(boxRow(' ' + P.accent(key) + P.dim(desc), P.border) + '\n');
52
+ }
53
+
54
+ process.stdout.write(boxMid(P.border) + '\n');
55
+ process.stdout.write(boxRow(' ' + P.bold('Keyboard shortcuts'), P.border) + '\n');
56
+ process.stdout.write(boxRow('', P.border) + '\n');
57
+ [
58
+ ['Tab', 'Autocomplete slash commands'],
59
+ ['↑ / ↓', 'Navigate input history'],
60
+ ['Ctrl+T', 'Toggle reasoning display'],
61
+ ['Ctrl+L', 'Clear screen'],
62
+ ['Ctrl+C', 'Cancel / exit'],
63
+ ].forEach(([k, d]) =>
64
+ process.stdout.write(boxRow(' ' + P.warn(k.padEnd(14)) + P.dim(d), P.border) + '\n')
65
+ );
66
+ process.stdout.write(boxRow('', P.border) + '\n');
67
+ process.stdout.write(boxBot(P.border) + '\n\n');
35
68
  }
36
69
 
37
- // ─── Ensure authenticated ────────────────────────────────────────────────────
70
+ // ── Auth ───────────────────────────────────────────────────────────────────────
38
71
  async function ensureAuth() {
39
72
  let cfg = loadConfig();
40
73
  if (!cfg) {
41
- console.log(chalk.yellow('\n No saved login found. Let\'s connect iCli.\n'));
74
+ process.stdout.write(P.warn('\n No saved credentials. Setting up iCli.\n\n'));
42
75
  cfg = await loginFlow();
43
- console.log('\n ' + chalk.green('✓') + ' Logged in as ' +
76
+ process.stdout.write('\n ' + P.ok('✓') + ' Logged in as ' +
44
77
  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');
78
+ P.dim(' · ' + (cfg.user?.role ?? 'operator')) + '\n\n');
47
79
  }
48
80
  return cfg;
49
81
  }
50
82
 
51
- // ─── Single-shot query ───────────────────────────────────────────────────────
52
- 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');
83
+ // ── Slash question ───────────────────────────────────────────────────────────
84
+ function slashToQuestion(line) {
85
+ const [sc, ...rest] = line.trim().split(/\s+/);
86
+ const arg = rest.join(' ');
87
+ switch (sc) {
88
+ case '/check': return arg ? `run diagnostics on ${arg}` : 'check status of all devices';
89
+ case '/vlan': return arg ? `is vlan ${arg} in use or free?` : 'list all vlans in use';
90
+ case '/onu': return arg ? `show ONU ${arg} status and signal level` : 'list all ONUs';
91
+ case '/customer': return arg ? `look up customer ${arg}` : 'show recent customers';
92
+ case '/ping': return arg ? `ping ${arg} through the network` : 'which devices are reachable?';
93
+ case '/online': return 'list currently online PPPoE sessions';
94
+ default: return null;
95
+ }
96
+ }
57
97
 
58
- const messages = [{ role: 'user', content: question }];
98
+ // ── Single-shot ────────────────────────────────────────────────────────────────
99
+ async function singleShot(cfg, question) {
100
+ printUserBox(question);
59
101
  try {
60
- await streamChat(cfg, messages);
102
+ await streamChat(cfg, [{ role: 'user', content: question }]);
61
103
  } catch (e) {
62
- console.error('\n ' + chalk.red('✖ ' + e.message) + '\n');
104
+ process.stdout.write('\n ' + P.err('✖ ') + chalk.white(e.message) + '\n\n');
63
105
  exit(1);
64
106
  }
65
107
  }
66
108
 
67
- // ─── Interactive REPL ────────────────────────────────────────────────────────
109
+ // ── REPL ───────────────────────────────────────────────────────────────────────
68
110
  async function interactiveRepl(cfg) {
69
- const history = [];
111
+ const history = [];
112
+ let showReasoning = true;
113
+ const PROMPT = () => ' ' + P.accentD('❯') + ' ';
70
114
 
71
115
  printBanner(cfg);
72
116
 
73
- const rl = readline.createInterface({
117
+ const rl = createInterface({
74
118
  input, output,
75
- prompt: chalk.cyan(' You ') + chalk.white(''),
119
+ prompt: PROMPT(),
120
+ completer: slashCompleter,
121
+ terminal: true,
76
122
  });
77
123
 
124
+ onResize(() => rl.setPrompt(PROMPT()));
78
125
  rl.prompt();
79
126
 
80
127
  rl.on('line', async (line) => {
81
128
  const q = line.trim();
82
129
  if (!q) { rl.prompt(); return; }
83
130
 
84
- if (q === 'exit' || q === 'quit') {
85
- console.log(chalk.dim('\n Goodbye.\n'));
86
- rl.close();
87
- exit(0);
131
+ if (q === '/exit' || q === 'exit' || q === 'quit') {
132
+ process.stdout.write(P.muted('\n Goodbye.\n\n')); rl.close(); exit(0);
88
133
  }
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;
134
+ if (q === '/clear' || q === 'clear') {
135
+ process.stdout.write('\x1Bc'); printBanner(cfg); rl.prompt(); return;
136
+ }
137
+ if (q === '/help' || q === 'help') { printHelp(); rl.prompt(); return; }
138
+ if (q === '/history' || q === 'history') {
139
+ const turns = history.filter(m => m.role === 'user');
140
+ if (!turns.length) {
141
+ process.stdout.write(P.muted('\n No history yet.\n\n'));
142
+ } else {
143
+ process.stdout.write('\n' + boxTop('History', P.border) + '\n');
144
+ turns.forEach((m, i) =>
145
+ process.stdout.write(boxRow(' ' + P.dim(String(i+1).padStart(2)+'.') + ' ' +
146
+ chalk.white(m.content.slice(0, inner() - 8)), P.border) + '\n')
147
+ );
148
+ process.stdout.write(boxBot(P.border) + '\n\n');
149
+ }
150
+ rl.prompt(); return;
151
+ }
152
+ if (q === '/') { printSlashMenu(); rl.prompt(); return; }
153
+
154
+ let question = q;
155
+ if (q.startsWith('/')) {
156
+ const resolved = slashToQuestion(q);
157
+ if (resolved) {
158
+ question = resolved;
159
+ process.stdout.write(' ' + P.muted('→ ') + P.dim(question) + '\n');
160
+ }
98
161
  }
99
- if (q === 'help') { printHelp(); rl.prompt(); return; }
100
162
 
101
163
  rl.pause();
102
- history.push({ role: 'user', content: q });
164
+ history.push({ role: 'user', content: question });
103
165
 
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');
166
+ // User message box
167
+ printUserBox(question);
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 ' + P.err('✖ ') + 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(''));
118
176
  rl.resume();
119
177
  rl.prompt();
120
178
  });
121
179
 
122
- rl.on('close', () => {
123
- console.log(chalk.dim('\n Goodbye.\n'));
124
- exit(0);
125
- });
180
+ rl.on('close', () => { process.stdout.write(P.muted('\n Goodbye.\n\n')); exit(0); });
126
181
  }
127
182
 
128
- // ─── Main ────────────────────────────────────────────────────────────────────
183
+ // ── Main ───────────────────────────────────────────────────────────────────────
129
184
  async function main() {
130
- if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
131
- printHelp(); return;
132
- }
185
+ if (cmd === '--help' || cmd === '-h' || cmd === 'help') { printHelp(); return; }
133
186
 
134
187
  if (cmd === 'login') {
135
188
  const cfg = await loginFlow();
136
- console.log('\n ' + chalk.green('✓') + ' Logged in as ' +
189
+ process.stdout.write('\n ' + P.ok('✓') + ' Logged in as ' +
137
190
  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');
191
+ P.dim(' · ' + (cfg.user?.role ?? 'operator')) + '\n');
192
+ process.stdout.write(' ' + P.dim('Config: ' + configPath()) + '\n\n');
140
193
  return;
141
194
  }
142
195
 
143
196
  if (cmd === 'whoami') {
144
197
  const cfg = loadConfig();
145
198
  if (!cfg) {
146
- console.log('\n ' + chalk.yellow('Not logged in.') + ' Run ' + chalk.cyan('icli login') + '\n');
199
+ process.stdout.write('\n ' + P.warn('Not logged in.') + ' Run ' + P.accent('icli login') + '\n\n');
147
200
  return;
148
201
  }
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');
202
+ process.stdout.write('\n' + boxTop('Session', P.border) + '\n');
203
+ [['url', cfg.url], ['name', cfg.user?.name], ['email', cfg.user?.email], ['role', cfg.user?.role]]
204
+ .filter(([, v]) => v)
205
+ .forEach(([k, v]) =>
206
+ process.stdout.write(boxRow(' ' + P.dim(k.padEnd(7)) + chalk.white(v), P.border) + '\n')
207
+ );
208
+ process.stdout.write(boxBot(P.border) + '\n\n');
156
209
  return;
157
210
  }
158
211
 
159
212
  const cfg = await ensureAuth();
160
213
 
161
- // Single-shot: icli "question here"
162
214
  if (cmd && !cmd.startsWith('-')) {
163
215
  await singleShot(cfg, args.join(' '));
164
216
  return;
165
217
  }
166
218
 
167
- // Interactive REPL
168
219
  await interactiveRepl(cfg);
169
220
  }
170
221
 
171
222
  main().catch(e => {
172
- console.error('\n ' + chalk.red('✖ ' + e.message) + '\n');
223
+ process.stdout.write('\n ' + P.err('✖ ') + chalk.white(e.message) + '\n\n');
173
224
  exit(1);
174
225
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ispbills-icli",
3
- "version": "2.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "iCli — IspBills AI network engineer in your terminal",
5
5
  "keywords": [
6
6
  "ispbills",
package/src/chat.js CHANGED
@@ -3,119 +3,258 @@ import ora from 'ora';
3
3
  import { marked } from 'marked';
4
4
  import TerminalRenderer from 'marked-terminal';
5
5
  import { refreshToken } from './auth.js';
6
+ import { cols, inner, visLen, padRight, stripAnsi,
7
+ boxTop, boxMid, boxBot, boxTopOpen, boxBotOpen,
8
+ boxRow, boxLine } from './layout.js';
6
9
 
7
- // Configure markdown renderer
10
+ // ── Copilot CLI palette ────────────────────────────────────────────────────────
11
+ const P = {
12
+ border: chalk.hex('#3D444D'),
13
+ dim: chalk.hex('#9198A1'),
14
+ muted: chalk.hex('#6e7781'),
15
+ accent: chalk.hex('#4493F8'),
16
+ accentB: chalk.hex('#4493F8').bold,
17
+ white: chalk.white,
18
+ bold: chalk.bold.white,
19
+ ok: chalk.green,
20
+ err: chalk.red,
21
+ warn: chalk.yellow,
22
+ cyan: chalk.cyan,
23
+ reasoning: chalk.hex('#9198A1').italic,
24
+ };
25
+
26
+ // ── Markdown ───────────────────────────────────────────────────────────────────
8
27
  marked.setOptions({ renderer: new TerminalRenderer({
9
- firstHeading: chalk.bold.cyan,
10
- heading: chalk.bold.cyan,
28
+ firstHeading: chalk.bold.white,
29
+ heading: chalk.bold.white,
11
30
  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),
31
+ em: chalk.italic.hex('#9198A1'),
32
+ codespan: (t) => chalk.bgHex('#161b22').hex('#c9d1d9')(` ${t} `),
33
+ code: (t) => '\n' + t.split('\n').map(l => ' ' + chalk.hex('#a5d6ff')(l)).join('\n') + '\n',
34
+ blockquote: (t) => P.dim(' ▌ ') + P.dim(t.trim()),
35
+ listitem: (t) => ` ${P.accent('·')} ${t.trimEnd()}`,
36
+ hr: () => P.border('─'.repeat(inner())) + '\n',
37
+ link: (_h, _t, t) => chalk.hex('#58a6ff').underline(t || _h),
19
38
  }) });
20
39
 
21
40
  function renderMarkdown(text) {
22
- try {
23
- return marked(text).trimEnd();
24
- } catch {
25
- return text;
41
+ try { return marked(text).trimEnd(); } catch { return text; }
42
+ }
43
+
44
+ // ── Status icon ────────────────────────────────────────────────────────────────
45
+ function statusIcon(line) {
46
+ const l = line.toLowerCase();
47
+ if (/unreachable|skip|fail|error/.test(l)) return P.err('✗');
48
+ if (/done|success|found|complet|online/.test(l)) return P.ok('✓');
49
+ if (/batch|sub.?agent|fleet|parallel/.test(l)) return P.accentB('◈');
50
+ if (/\[\d+\/\d+\]/.test(l)) return P.accent('◉');
51
+ if (/probe|reachab|ping/.test(l)) return P.warn('◎');
52
+ if (/connect|ssh|telnet|api/.test(l)) return P.accent('⇢');
53
+ if (/read|fetch|get|query/.test(l)) return P.dim('↓');
54
+ if (/⌁/.test(line)) return P.border('⌁');
55
+ return P.border('›');
56
+ }
57
+
58
+ const shortModel = (m) =>
59
+ m.replace('openai/', '').replace(/^claude-/, '').replace(/^gpt-/, 'gpt-');
60
+
61
+ // ── Banner ────────────────────────────────────────────────────────────────────
62
+ export function printBanner(cfg, meta = {}) {
63
+ const model = meta.model ? shortModel(meta.model) : (cfg?._model ?? null);
64
+ const w = cols();
65
+
66
+ process.stdout.write('\n');
67
+
68
+ // Top border
69
+ process.stdout.write(boxTop('', P.border) + '\n');
70
+
71
+ // Title row
72
+ const title = P.bold(' iCopilot') + P.dim(' · IspBills AI Network Engineer');
73
+ process.stdout.write(boxRow(title, P.border) + '\n');
74
+
75
+ // Divider
76
+ process.stdout.write(boxMid(P.border) + '\n');
77
+
78
+ // Session info rows
79
+ const row1parts = [];
80
+ if (model) row1parts.push(P.dim('model ') + P.white(model));
81
+ if (cfg?.user?.name) row1parts.push(P.dim('user ') + P.white(cfg.user.name));
82
+ if (cfg?.user?.role) row1parts.push(P.dim('role ') + P.white(cfg.user.role));
83
+ if (row1parts.length) {
84
+ process.stdout.write(boxRow(' ' + row1parts.join(P.dim(' · ')), P.border) + '\n');
85
+ }
86
+ if (cfg?.url) {
87
+ const maxUrl = inner() - 8;
88
+ const url = cfg.url.length > maxUrl ? cfg.url.slice(0, maxUrl - 1) + '…' : cfg.url;
89
+ process.stdout.write(boxRow(' ' + P.dim('url ') + P.accent(url), P.border) + '\n');
90
+ }
91
+
92
+ // Divider
93
+ process.stdout.write(boxMid(P.border) + '\n');
94
+
95
+ // Hints row
96
+ const hints = ' ' + [
97
+ P.accentB('/') + P.muted(' commands'),
98
+ P.accentB('Tab') + P.muted(' autocomplete'),
99
+ P.accentB('↑↓') + P.muted(' history'),
100
+ P.accentB('Ctrl+C') + P.muted(' exit'),
101
+ ].join(P.muted(' '));
102
+ process.stdout.write(boxRow(hints, P.border) + '\n');
103
+
104
+ // Bottom border
105
+ process.stdout.write(boxBot(P.border) + '\n\n');
106
+ }
107
+
108
+ // ── Slash commands ─────────────────────────────────────────────────────────────
109
+ export const SLASH_COMMANDS = [
110
+ { cmd: '/check', hint: '[device]', desc: 'Run diagnostics on a device' },
111
+ { cmd: '/vlan', hint: '[id]', desc: 'Check VLAN usage' },
112
+ { cmd: '/onu', hint: '[id]', desc: 'Show ONU status and signal' },
113
+ { cmd: '/customer', hint: '[name/IP]', desc: 'Look up a customer' },
114
+ { cmd: '/ping', hint: '[host]', desc: 'Ping a host' },
115
+ { cmd: '/online', hint: '', desc: 'List online sessions' },
116
+ { cmd: '/history', hint: '', desc: 'Show conversation history' },
117
+ { cmd: '/clear', hint: '', desc: 'Clear the screen' },
118
+ { cmd: '/help', hint: '', desc: 'Show help' },
119
+ { cmd: '/exit', hint: '', desc: 'Exit iCli' },
120
+ ];
121
+
122
+ export function printSlashMenu() {
123
+ process.stdout.write('\n');
124
+ process.stdout.write(boxTop('Commands', P.border) + '\n');
125
+ for (const { cmd: c, hint, desc } of SLASH_COMMANDS) {
126
+ const key = P.accentB(c) + (hint ? P.dim(' ' + hint) : '');
127
+ const line = ' ' + padRight(key, 22) + ' ' + P.dim(desc);
128
+ process.stdout.write(boxRow(line, P.border) + '\n');
129
+ }
130
+ process.stdout.write(boxBot(P.border) + '\n\n');
131
+ }
132
+
133
+ export function slashCompleter(line) {
134
+ if (line.startsWith('/')) {
135
+ const hits = SLASH_COMMANDS.map(s => s.cmd + ' ').filter(s => s.startsWith(line));
136
+ return [hits.length ? hits : SLASH_COMMANDS.map(s => s.cmd + ' '), line];
137
+ }
138
+ return [[], line];
139
+ }
140
+
141
+ // ── User message box ───────────────────────────────────────────────────────────
142
+ export function printUserBox(question) {
143
+ process.stdout.write('\n');
144
+ process.stdout.write(boxTop('You', P.accentB) + '\n');
145
+ // Word-wrap the question to fit inside the box
146
+ const maxW = inner() - 2;
147
+ const words = question.split(' ');
148
+ let line = '';
149
+ for (const word of words) {
150
+ if (line.length + word.length + 1 > maxW && line) {
151
+ process.stdout.write(boxRow(' ' + P.white(line), P.accentB) + '\n');
152
+ line = word;
153
+ } else {
154
+ line = line ? line + ' ' + word : word;
155
+ }
26
156
  }
157
+ if (line) process.stdout.write(boxRow(' ' + P.white(line), P.accentB) + '\n');
158
+ process.stdout.write(boxBot(P.accentB) + '\n');
27
159
  }
28
160
 
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
- */
161
+ // ── streamChat ────────────────────────────────────────────────────────────────
38
162
  export async function streamChat(cfg, messages, context = {}, opts = {}) {
39
163
  const showThinking = opts.thinking !== false;
40
164
  const body = JSON.stringify({ messages, context });
41
165
 
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',
166
+ async function doFetch(token) {
167
+ return fetch(`${cfg.url}/api/v1/ai/chat/stream`, {
168
+ method: 'POST',
59
169
  headers: {
60
170
  'Content-Type': 'application/json',
61
171
  'Accept': 'text/event-stream',
62
- 'Authorization': `Bearer ${cfg.token}`,
172
+ 'Authorization': `Bearer ${token}`,
63
173
  },
64
174
  body,
65
175
  });
66
176
  }
67
177
 
178
+ let res = await doFetch(cfg.token);
179
+ if (res.status === 401 && cfg.refresh_token) {
180
+ const updated = await refreshToken(cfg);
181
+ if (!updated) throw new Error('Session expired. Run `icli login`.');
182
+ Object.assign(cfg, updated);
183
+ res = await doFetch(cfg.token);
184
+ }
68
185
  if (!res.ok) {
69
186
  const txt = await res.text().catch(() => '');
70
187
  throw new Error(`AI request failed (HTTP ${res.status}): ${txt.slice(0, 200)}`);
71
188
  }
72
189
 
73
- // ── Spinner ──────────────────────────────────────────────────────────────
190
+ // ── Streaming state ─────────────────────────────────────────────────────────
191
+ let boxOpen = false; // have we printed ╭─ iCopilot ?
192
+ let reasoningOpen = false;
193
+ let answerBuf = '';
194
+ let rawAnswer = '';
195
+ let finalResult = null;
196
+
197
+ // Open the iCopilot response box
198
+ function ensureBoxOpen() {
199
+ if (boxOpen) return;
200
+ boxOpen = true;
201
+ process.stdout.write('\n' + boxTopOpen('iCopilot', P.border) + '\n');
202
+ }
203
+
204
+ // Ora spinner — sits on a │ prefixed line
74
205
  const spinner = ora({
75
- text: chalk.dim('Thinking…'),
76
- color: 'cyan',
77
- spinner: 'dots',
78
- prefixText: ' ',
206
+ text: P.dim('Thinking…'),
207
+ color: 'blue',
208
+ spinner: 'dots',
209
+ prefixText: P.border('│') + ' ',
79
210
  });
80
211
 
81
- let reasonBuf = '';
82
- let answerBuf = '';
83
- let rawAnswer = '';
84
- let finalResult = null;
212
+ const stopSpinner = () => { if (spinner.isSpinning) spinner.stop(); };
85
213
 
86
- function stopSpinner() {
87
- if (spinner.isSpinning) spinner.stop();
214
+ function openReasoning() {
215
+ if (reasoningOpen) return;
216
+ stopSpinner();
217
+ reasoningOpen = true;
218
+ process.stdout.write(boxLine('', P.border) + '\n');
219
+ process.stdout.write(boxLine(P.dim(' ╌ Reasoning ') + P.border('╌'.repeat(Math.max(0, inner() - 16))), P.border) + '\n');
220
+ process.stdout.write(boxLine(P.border(' │ '), P.border));
88
221
  }
89
222
 
90
- function printStatus(line) {
91
- stopSpinner();
92
- process.stdout.write(chalk.magenta(' › ') + chalk.dim(line) + '\n');
93
- if (showThinking) spinner.start();
223
+ function writeReasonDelta(delta) {
224
+ if (!reasoningOpen) return;
225
+ const parts = delta.split('\n');
226
+ for (let i = 0; i < parts.length; i++) {
227
+ if (i > 0) process.stdout.write('\n' + boxLine(P.border(' │ '), P.border));
228
+ if (parts[i]) process.stdout.write(P.reasoning(parts[i]));
229
+ }
94
230
  }
95
231
 
96
- function flushReasoning() {
97
- if (!reasonBuf.trim()) return;
232
+ function closeReasoning() {
233
+ if (!reasoningOpen) return;
234
+ process.stdout.write('\n' + boxLine('', P.border) + '\n');
235
+ reasoningOpen = false;
236
+ }
237
+
238
+ function printStatus(line) {
239
+ ensureBoxOpen();
240
+ if (reasoningOpen) closeReasoning();
98
241
  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 = '';
242
+ const icon = statusIcon(line);
243
+ const text = line.trimStart();
244
+ const indent = /^[\s·•⌁]/.test(line) ? ' ' : ' ';
245
+ process.stdout.write(boxLine(indent + icon + ' ' + P.dim(text), P.border) + '\n');
246
+ if (showThinking) spinner.start();
107
247
  }
108
248
 
109
249
  function parseStreamText(raw) {
110
250
  const m = raw.match(/"text"\s*:\s*"((?:[^"\\]|\\.)*)/);
111
251
  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, '\\');
252
+ return m[1].replace(/\\n/g,'\n').replace(/\\t/g,'\t')
253
+ .replace(/\\r/g,'').replace(/\\"/g,'"').replace(/\\\\/g,'\\');
115
254
  }
116
255
 
117
- // ── SSE reader ───────────────────────────────────────────────────────────
118
- if (showThinking) spinner.start();
256
+ // ── SSE reader ─────────────────────────────────────────────────────────────
257
+ if (showThinking) { ensureBoxOpen(); spinner.start(); }
119
258
 
120
259
  const decoder = new TextDecoder();
121
260
  let sseBuffer = '';
@@ -126,16 +265,19 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
126
265
  sseBuffer = lines.pop();
127
266
 
128
267
  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; }
268
+ const t = line.replace(/\r$/, '');
269
+ if (!t.startsWith('data: ')) continue;
270
+ let ev; try { ev = JSON.parse(t.slice(6)); } catch { continue; }
133
271
 
134
272
  if (ev.status_delta !== undefined) {
135
273
  printStatus(ev.status_delta);
136
274
  } else if (ev.reason_delta !== undefined) {
137
- reasonBuf += ev.reason_delta;
275
+ ensureBoxOpen();
276
+ if (!reasoningOpen) openReasoning();
277
+ writeReasonDelta(ev.reason_delta);
138
278
  } else if (ev.delta !== undefined) {
279
+ if (reasoningOpen) closeReasoning();
280
+ stopSpinner();
139
281
  rawAnswer += ev.delta;
140
282
  const txt = parseStreamText(rawAnswer);
141
283
  if (txt !== null) answerBuf = txt;
@@ -145,73 +287,59 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
145
287
  }
146
288
  }
147
289
 
148
- // ── Final render ─────────────────────────────────────────────────────────
290
+ // ── Final render ─────────────────────────────────────────────────────────────
149
291
  stopSpinner();
150
- flushReasoning();
292
+ if (reasoningOpen) closeReasoning();
293
+ ensureBoxOpen();
151
294
 
152
295
  const reply = finalResult?.reply ?? {};
153
296
  const replyType = reply.type ?? 'message';
297
+ const meta = finalResult?._meta ?? {};
154
298
 
155
- process.stdout.write('\n' + chalk.dim(' ' + '─'.repeat(58)) + '\n\n');
299
+ if (meta.model && cfg) cfg._model = shortModel(meta.model);
300
+
301
+ // Blank separator before content
302
+ process.stdout.write(boxLine('', P.border) + '\n');
156
303
 
157
304
  if (replyType === 'message') {
158
- const text = reply.text || answerBuf || '(no response)';
305
+ const text = reply.text || answerBuf || '(no response)';
159
306
  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;
307
+ // Render each markdown line with prefix
308
+ for (const l of rendered.split('\n')) {
309
+ process.stdout.write(boxLine(' ' + l, P.border) + '\n');
310
+ }
163
311
 
164
312
  } 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`));
313
+ process.stdout.write(boxLine(' ' + P.bold('Plan'), P.border) + '\n');
314
+ if (reply.summary) process.stdout.write(boxLine(' ' + P.dim(reply.summary), P.border) + '\n');
315
+ process.stdout.write(boxLine('', P.border) + '\n');
316
+ (reply.steps || []).forEach((s, i) => {
317
+ const risk = s.risk === 'high' ? P.err(' ⚠ high risk') :
318
+ s.risk === 'medium' ? P.warn(' ⚡') : '';
319
+ process.stdout.write(boxLine(` ${P.dim(String(i+1)+'.')} ${P.warn(s.cmd || '')}${risk}`, P.border) + '\n');
320
+ if (s.purpose) process.stdout.write(boxLine(' ' + P.muted(s.purpose), P.border) + '\n');
170
321
  });
171
- process.stdout.write('\n');
172
- return reply.summary || '';
322
+ process.stdout.write(boxLine('', P.border) + '\n');
323
+ process.stdout.write(boxLine(' ' + P.dim('Type ') + P.white('apply fix') + P.dim(' to confirm.'), P.border) + '\n');
173
324
 
174
325
  } else if (replyType === 'connect') {
175
326
  const dev = reply.device ?? {};
176
327
  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'
328
+ boxLine(' ' + P.ok('🔌 ') + P.bold(`${dev.kind ?? '?'}#${dev.id ?? '?'}`) +
329
+ ' ' + P.white(dev.name ?? '') + ' ' + P.dim(`(${dev.host ?? ''})`), P.border) + '\n'
181
330
  );
182
- if (reply.note) process.stdout.write(chalk.dim(' ' + reply.note) + '\n');
183
- process.stdout.write('\n');
184
- return reply.note || '';
331
+ if (reply.note) process.stdout.write(boxLine(' ' + P.dim(reply.note), P.border) + '\n');
185
332
  }
186
333
 
187
- return answerBuf;
188
- }
334
+ // Blank + footer line inside box
335
+ process.stdout.write(boxLine('', P.border) + '\n');
336
+ const foot = [meta.model ? shortModel(meta.model) : null, meta.user, meta.role].filter(Boolean);
337
+ if (foot.length) {
338
+ process.stdout.write(boxLine(' ' + P.muted(foot.join(' · ')), P.border) + '\n');
339
+ }
189
340
 
190
- /** Print a styled welcome banner. */
191
- export function printBanner(cfg) {
192
- const line = '─'.repeat(58);
193
- 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');
341
+ // Close the box
342
+ process.stdout.write(boxBotOpen(P.border) + '\n\n');
203
343
 
204
- if (cfg?.url) {
205
- process.stdout.write(chalk.dim(' Connected ') + chalk.cyan(cfg.url) + '\n');
206
- }
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');
344
+ return reply.text || answerBuf || '';
217
345
  }
package/src/layout.js ADDED
@@ -0,0 +1,69 @@
1
+ // ── Terminal width ─────────────────────────────────────────────────────────────
2
+ export function termCols() {
3
+ if (process.stdout.isTTY && process.stdout.columns > 0) return process.stdout.columns;
4
+ if (process.stderr.isTTY && process.stderr.columns > 0) return process.stderr.columns;
5
+ const env = parseInt(process.env.COLUMNS || process.env.TERM_WIDTH || '0');
6
+ if (env > 0) return env;
7
+ return 80;
8
+ }
9
+ export const cols = () => Math.max(40, termCols());
10
+ export const inner = () => cols() - 4; // usable width inside │ … │
11
+ export const onResize = (cb) => {
12
+ process.stdout.on('resize', cb);
13
+ process.stderr.on('resize', cb);
14
+ };
15
+
16
+ // ── ANSI-aware string helpers ─────────────────────────────────────────────────
17
+ const RE_ANSI = /\x1b\[[^m]*m/g;
18
+ export const stripAnsi = (s) => s.replace(RE_ANSI, '');
19
+ export const visLen = (s) => [...stripAnsi(s)].length; // Unicode-safe
20
+ export const padRight = (s, n) => s + ' '.repeat(Math.max(0, n - visLen(s)));
21
+
22
+ // ── Box-drawing helpers (rounded corners) ─────────────────────────────────────
23
+ // draw(color, char, n) — repeat char n times with color
24
+ const rep = (ch, n) => ch.repeat(Math.max(0, n));
25
+
26
+ /** Full top border: ╭─ [label] ──────────────╮ */
27
+ export function boxTop(label, borderFn) {
28
+ const w = cols();
29
+ const lbl = label ? ` ${label} ` : '';
30
+ const dash = rep('─', Math.max(0, w - 2 - lbl.length));
31
+ return borderFn(`╭${lbl.length ? '─' + lbl : ''}${dash}╮`);
32
+ }
33
+
34
+ /** Divider inside box: ├────────────────────────┤ */
35
+ export function boxMid(borderFn) {
36
+ return borderFn('├' + rep('─', cols() - 2) + '┤');
37
+ }
38
+
39
+ /** Full bottom border: ╰──────────────────────────╯ */
40
+ export function boxBot(borderFn) {
41
+ return borderFn('╰' + rep('─', cols() - 2) + '╯');
42
+ }
43
+
44
+ /** Open top border (no right cap — for streaming sections):
45
+ * ╭─ [label] ────────── */
46
+ export function boxTopOpen(label, borderFn) {
47
+ const w = cols();
48
+ const lbl = label ? ` ${label} ` : '';
49
+ const dash = rep('─', Math.max(0, w - 1 - lbl.length));
50
+ return borderFn(`╭─${lbl}${dash}`);
51
+ }
52
+
53
+ /** Open bottom border: ╰───────────────────────── */
54
+ export function boxBotOpen(borderFn) {
55
+ return borderFn('╰' + rep('─', cols() - 1));
56
+ }
57
+
58
+ /** One content row with left border and padded right border:
59
+ * │ [content] │ */
60
+ export function boxRow(content, borderFn) {
61
+ const w = cols();
62
+ const pad = Math.max(0, w - 4 - visLen(content));
63
+ return borderFn('│') + ' ' + content + ' '.repeat(pad) + ' ' + borderFn('│');
64
+ }
65
+
66
+ /** Left-border only line (for streaming): │ [content] */
67
+ export function boxLine(content, borderFn) {
68
+ return borderFn('│') + ' ' + content;
69
+ }