ispbills-icli 4.0.5 → 5.2.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,15 +1,58 @@
1
1
  #!/usr/bin/env node
2
- import { createInterface } from 'readline';
3
- import { stdin as input, stdout as output, argv, exit } from 'process';
2
+ import { createInterface, emitKeypressEvents } from 'readline';
3
+ import { stdin as input, stdout as output, argv, exit, platform } from 'process';
4
+ import { spawnSync } from 'child_process';
5
+ import { readFileSync } from 'fs';
6
+ import { fileURLToPath } from 'url';
7
+ import { dirname, join } from 'path';
4
8
  import chalk from 'chalk';
5
- import { loadConfig, configPath } from '../src/config.js';
9
+ import { loadConfig, configPath, clearConfig } from '../src/config.js';
6
10
  import { loginFlow } from '../src/auth.js';
7
- import { inner, onResize, boxTop, boxBot, boxMid, boxRow } from '../src/layout.js';
11
+ import { inner, onResize, boxTop, boxBot, boxMid, boxRow, bottomPalette } from '../src/layout.js';
8
12
  import {
9
13
  streamChat, printBanner, printSlashMenu, printUserBox,
10
14
  SLASH_COMMANDS, slashCompleter,
11
15
  } from '../src/chat.js';
12
16
 
17
+ const PKG_NAME = 'ispbills-icli';
18
+
19
+ /** Read this CLI's installed version from its package.json. */
20
+ function pkgVersion() {
21
+ try {
22
+ const p = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
23
+ return JSON.parse(readFileSync(p, 'utf8')).version ?? 'unknown';
24
+ } catch {
25
+ return 'unknown';
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Self-update: install the latest published version globally via npm.
31
+ * Returns true on success. The running process keeps the old code until the
32
+ * next launch, so callers should tell the user to restart.
33
+ */
34
+ function selfUpdate(P) {
35
+ const cur = pkgVersion();
36
+ process.stdout.write('\n ' + P.dim('Current version: ') + chalk.white(cur) + '\n');
37
+ process.stdout.write(' ' + P.muted('Updating ' + PKG_NAME + ' to the latest version…') + '\n\n');
38
+
39
+ const npm = platform === 'win32' ? 'npm.cmd' : 'npm';
40
+ const res = spawnSync(npm, ['install', '-g', `${PKG_NAME}@latest`], {
41
+ stdio: 'inherit',
42
+ shell: platform === 'win32',
43
+ });
44
+
45
+ if (res.status === 0) {
46
+ process.stdout.write('\n ' + P.ok('✓') + ' iCli updated. ' +
47
+ P.dim('Restart iCli to use the new version.') + '\n\n');
48
+ return true;
49
+ }
50
+ process.stdout.write('\n ' + P.err('✖') + ' Update failed. Try manually:\n');
51
+ process.stdout.write(' ' + P.accent(`npm install -g ${PKG_NAME}@latest`) + '\n');
52
+ process.stdout.write(' ' + P.dim('On some systems this needs elevated privileges (sudo / Administrator).') + '\n\n');
53
+ return false;
54
+ }
55
+
13
56
  const P = {
14
57
  border: chalk.hex('#303134'),
15
58
  dim: chalk.hex('#9AA0A6'),
@@ -25,6 +68,13 @@ const P = {
25
68
  const args = argv.slice(2);
26
69
  const cmd = args[0] ?? '';
27
70
 
71
+ // ── Session context (shown in top bar) ─────────────────────────────────────────
72
+ const ctx = {
73
+ device: '',
74
+ aiMode: 'MANUAL', // 'AUTOPILOT ON' | 'MANUAL'
75
+ operator: '',
76
+ };
77
+
28
78
  // ── Help ───────────────────────────────────────────────────────────────────────
29
79
  function printHelp() {
30
80
  process.stdout.write('\n');
@@ -37,6 +87,8 @@ function printHelp() {
37
87
  ['icli', 'Interactive REPL with slash commands'],
38
88
  ['icli "question"', 'Single-shot query then exit'],
39
89
  ['icli login', 'Authenticate to your IspBills instance'],
90
+ ['icli logout', 'Log out and clear saved credentials'],
91
+ ['icli update', 'Update iCli to the latest version'],
40
92
  ['icli whoami', 'Show current session info'],
41
93
  ['icli --help', 'Show this help'],
42
94
  ].forEach(([c, d]) =>
@@ -52,19 +104,23 @@ function printHelp() {
52
104
  }
53
105
 
54
106
  process.stdout.write(boxMid(P.border) + '\n');
55
- process.stdout.write(boxRow(P.bold('Keyboard shortcuts'), P.border) + '\n');
107
+ process.stdout.write(boxRow(P.bold('Function key shortcuts'), P.border) + '\n');
56
108
  process.stdout.write(boxRow('', P.border) + '\n');
57
109
  [
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'],
110
+ ['F1', 'Open documentation'],
111
+ ['F2', 'Show conversation history'],
112
+ ['F3', 'Rollback last AI action'],
113
+ ['F4', 'Run bulk device sweep'],
114
+ ['Tab', 'Autocomplete slash commands'],
115
+ ['↑ / ↓', 'Navigate input history'],
116
+ ['Ctrl+L', 'Clear screen'],
117
+ ['Ctrl+C', 'Cancel / exit'],
63
118
  ].forEach(([k, d]) =>
64
119
  process.stdout.write(boxRow(P.warn(k.padEnd(14)) + P.dim(d), P.border) + '\n')
65
120
  );
66
121
  process.stdout.write(boxRow('', P.border) + '\n');
67
- process.stdout.write(boxBot(P.border) + '\n\n');
122
+ process.stdout.write(boxBot(P.border) + '\n');
123
+ process.stdout.write(bottomPalette() + '\n\n');
68
124
  }
69
125
 
70
126
  // ── Auth ───────────────────────────────────────────────────────────────────────
@@ -108,11 +164,16 @@ async function singleShot(cfg, question) {
108
164
 
109
165
  // ── REPL ───────────────────────────────────────────────────────────────────────
110
166
  async function interactiveRepl(cfg) {
111
- const history = [];
112
- let showReasoning = true;
113
- const PROMPT = () => ' ' + P.accentD('❯') + ' ';
167
+ const history = [];
168
+ const PROMPT = () => ' ' + P.accentD('❯') + ' ';
114
169
 
115
- printBanner(cfg);
170
+ let isProcessing = false;
171
+
172
+ // Populate operator from cfg
173
+ if (cfg.user?.name) ctx.operator = cfg.user.name;
174
+ if (cfg.user?.role) ctx.aiMode = cfg.user.role === 'noc' ? 'AUTOPILOT ON' : 'MANUAL';
175
+
176
+ printBanner(cfg, {}, ctx);
116
177
 
117
178
  const rl = createInterface({
118
179
  input, output,
@@ -122,6 +183,74 @@ async function interactiveRepl(cfg) {
122
183
  });
123
184
 
124
185
  onResize(() => rl.setPrompt(PROMPT()));
186
+
187
+ // ── F-key handling via keypress events ───────────────────────────────────────
188
+ emitKeypressEvents(process.stdin);
189
+
190
+ process.stdin.on('keypress', async (_ch, key) => {
191
+ if (!key || rl.closed || isProcessing) return;
192
+
193
+ if (key.name === 'f1') {
194
+ process.stdout.write('\n');
195
+ printHelp();
196
+ rl.prompt();
197
+ return;
198
+ }
199
+
200
+ if (key.name === 'f2') {
201
+ // Show history
202
+ const turns = history.filter(m => m.role === 'user');
203
+ process.stdout.write('\n');
204
+ if (!turns.length) {
205
+ process.stdout.write(P.muted(' No history yet.\n\n'));
206
+ } else {
207
+ process.stdout.write(boxTop('History', P.border) + '\n');
208
+ turns.forEach((m, i) =>
209
+ process.stdout.write(
210
+ boxRow(P.dim(String(i + 1).padStart(2) + '.') + ' ' +
211
+ chalk.white(m.content.replace(/\s+/g, ' ').slice(0, inner() - 5)), P.border) + '\n')
212
+ );
213
+ process.stdout.write(boxBot(P.border) + '\n\n');
214
+ }
215
+ rl.prompt();
216
+ return;
217
+ }
218
+
219
+ if (key.name === 'f3') {
220
+ rl.pause();
221
+ const q = 'rollback the last change you made';
222
+ process.stdout.write('\n ' + P.muted('→ ') + P.dim(q) + '\n');
223
+ printUserBox(q);
224
+ history.push({ role: 'user', content: q });
225
+ try {
226
+ const answer = await streamChat(cfg, [...history]);
227
+ if (answer) history.push({ role: 'assistant', content: answer });
228
+ } catch (e) {
229
+ process.stdout.write('\n ' + P.err('✖ ') + chalk.white(e.message) + '\n\n');
230
+ }
231
+ rl.resume();
232
+ rl.prompt();
233
+ return;
234
+ }
235
+
236
+ if (key.name === 'f4') {
237
+ rl.pause();
238
+ const q = 'run a bulk sweep of all devices and report status';
239
+ process.stdout.write('\n ' + P.muted('→ ') + P.dim(q) + '\n');
240
+ printUserBox(q);
241
+ history.push({ role: 'user', content: q });
242
+ try {
243
+ const answer = await streamChat(cfg, [...history]);
244
+ if (answer) history.push({ role: 'assistant', content: answer });
245
+ } catch (e) {
246
+ process.stdout.write('\n ' + P.err('✖ ') + chalk.white(e.message) + '\n\n');
247
+ }
248
+ rl.resume();
249
+ rl.prompt();
250
+ return;
251
+ }
252
+ });
253
+
125
254
  rl.prompt();
126
255
 
127
256
  rl.on('line', async (line) => {
@@ -132,7 +261,25 @@ async function interactiveRepl(cfg) {
132
261
  process.stdout.write(P.muted('\n Goodbye.\n\n')); rl.close(); exit(0);
133
262
  }
134
263
  if (q === '/clear' || q === 'clear') {
135
- process.stdout.write('\x1Bc'); printBanner(cfg); rl.prompt(); return;
264
+ process.stdout.write('\x1Bc');
265
+ printBanner(cfg, {}, ctx);
266
+ rl.prompt();
267
+ return;
268
+ }
269
+ if (q === '/update' || q === 'update') {
270
+ rl.pause();
271
+ selfUpdate(P);
272
+ rl.resume();
273
+ rl.prompt();
274
+ return;
275
+ }
276
+ if (q === '/logout' || q === 'logout') {
277
+ const cleared = clearConfig();
278
+ process.stdout.write('\n ' + (cleared ? P.ok('✓') + ' Logged out — credentials cleared.'
279
+ : P.warn('Already logged out.')) +
280
+ '\n ' + P.dim('Run ') + P.accent('icli login') + P.dim(' to sign in again.') + '\n\n');
281
+ rl.close();
282
+ exit(0);
136
283
  }
137
284
  if (q === '/help' || q === 'help') { printHelp(); rl.prompt(); return; }
138
285
  if (q === '/history' || q === 'history') {
@@ -162,12 +309,10 @@ async function interactiveRepl(cfg) {
162
309
 
163
310
  rl.pause();
164
311
  history.push({ role: 'user', content: question });
165
-
166
- // User message box
167
312
  printUserBox(question);
168
313
 
169
314
  try {
170
- const answer = await streamChat(cfg, [...history], {}, { thinking: showReasoning });
315
+ const answer = await streamChat(cfg, [...history]);
171
316
  if (answer) history.push({ role: 'assistant', content: answer });
172
317
  } catch (e) {
173
318
  process.stdout.write('\n ' + P.err('✖ ') + chalk.white(e.message) + '\n\n');
@@ -193,6 +338,22 @@ async function main() {
193
338
  return;
194
339
  }
195
340
 
341
+ if (cmd === 'update') {
342
+ const ok = selfUpdate(P);
343
+ exit(ok ? 0 : 1);
344
+ }
345
+
346
+ if (cmd === 'logout') {
347
+ const cleared = clearConfig();
348
+ if (cleared) {
349
+ process.stdout.write('\n ' + P.ok('✓') + ' Logged out — credentials cleared.\n');
350
+ process.stdout.write(' ' + P.dim('Run ') + P.accent('icli login') + P.dim(' to sign in again.') + '\n\n');
351
+ } else {
352
+ process.stdout.write('\n ' + P.warn('Not logged in.') + ' Nothing to clear.\n\n');
353
+ }
354
+ return;
355
+ }
356
+
196
357
  if (cmd === 'whoami') {
197
358
  const cfg = loadConfig();
198
359
  if (!cfg) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ispbills-icli",
3
- "version": "4.0.5",
3
+ "version": "5.2.0",
4
4
  "description": "iCli — IspBills AI network engineer in your terminal",
5
5
  "keywords": [
6
6
  "ispbills",
package/src/chat.js CHANGED
@@ -5,7 +5,7 @@ import TerminalRenderer from 'marked-terminal';
5
5
  import { refreshToken } from './auth.js';
6
6
  import { cols, inner, visLen, padRight, stripAnsi,
7
7
  boxTop, boxMid, boxBot, boxTopOpen, boxBotOpen,
8
- boxRow, boxLine } from './layout.js';
8
+ boxRow, boxLine, bottomPalette, topBar } from './layout.js';
9
9
 
10
10
  // ── Gemini-style palette ───────────────────────────────────────────────────────
11
11
  const P = {
@@ -21,6 +21,7 @@ const P = {
21
21
  warn: chalk.yellow,
22
22
  cyan: chalk.cyan,
23
23
  reasoning: chalk.hex('#9198A1').italic,
24
+ pill: chalk.bgHex('#1a2b47').hex('#8AB4F8'),
24
25
  };
25
26
 
26
27
  // ── Markdown ───────────────────────────────────────────────────────────────────
@@ -36,11 +37,9 @@ const termRenderer = new TerminalRenderer({
36
37
  link: (_h, _t, t) => chalk.hex('#58a6ff').underline(t || _h),
37
38
  });
38
39
 
39
- // Override listitem on the instance — the options.listitem is a chalk color, not a renderer fn
40
40
  const _origListitem = termRenderer.listitem.bind(termRenderer);
41
41
  termRenderer.listitem = function(item) {
42
42
  const raw = _origListitem(item);
43
- // Replace the default ' * ' or ' - ' bullet with our accent '·'
44
43
  return raw.replace(/^\s*[*\-]\s/, (m) => m.replace(/[*\-]/, P.accent('·')));
45
44
  };
46
45
 
@@ -68,13 +67,21 @@ const shortModel = (m) =>
68
67
  m.replace('openai/', '').replace(/^claude-/, '').replace(/^gpt-/, 'gpt-');
69
68
 
70
69
  // ── Banner ────────────────────────────────────────────────────────────────────
71
- export function printBanner(cfg, meta = {}) {
70
+ export function printBanner(cfg, meta = {}, ctx = {}) {
72
71
  const model = meta.model ? shortModel(meta.model) : (cfg?._model ?? null);
73
72
 
74
73
  process.stdout.write('\n');
75
74
  process.stdout.write(boxTop('iCopilot', P.border) + '\n');
76
- process.stdout.write(boxRow(P.dim('Gemini-style terminal mode'), P.border) + '\n');
77
- process.stdout.write(boxMid(P.border) + '\n');
75
+
76
+ // Context bar (device · aiMode · operator) — delegates to topBar() in layout.js
77
+ const { device = '', aiMode = 'MANUAL', operator = '' } = ctx;
78
+ if (device || aiMode !== 'MANUAL' || operator) {
79
+ process.stdout.write(topBar(ctx) + '\n');
80
+ process.stdout.write(boxMid(P.border) + '\n');
81
+ } else {
82
+ process.stdout.write(boxRow(P.dim('Gemini-style terminal · iCopilot AI'), P.border) + '\n');
83
+ process.stdout.write(boxMid(P.border) + '\n');
84
+ }
78
85
 
79
86
  const row1parts = [];
80
87
  if (model) row1parts.push(P.dim('model ') + P.white(model));
@@ -98,7 +105,8 @@ export function printBanner(cfg, meta = {}) {
98
105
  P.accentB('Ctrl+C') + P.muted(' exit'),
99
106
  ].join(P.muted(' '));
100
107
  process.stdout.write(boxRow(hints, P.border) + '\n');
101
- process.stdout.write(boxBot(P.border) + '\n\n');
108
+ process.stdout.write(boxBot(P.border) + '\n');
109
+ process.stdout.write(bottomPalette() + '\n\n');
102
110
  }
103
111
 
104
112
  // ── Slash commands ─────────────────────────────────────────────────────────────
@@ -111,6 +119,8 @@ export const SLASH_COMMANDS = [
111
119
  { cmd: '/online', hint: '', desc: 'List online sessions' },
112
120
  { cmd: '/history', hint: '', desc: 'Show conversation history' },
113
121
  { cmd: '/clear', hint: '', desc: 'Clear the screen' },
122
+ { cmd: '/update', hint: '', desc: 'Update iCli to the latest version'},
123
+ { cmd: '/logout', hint: '', desc: 'Log out and clear credentials'},
114
124
  { cmd: '/help', hint: '', desc: 'Show help' },
115
125
  { cmd: '/exit', hint: '', desc: 'Exit iCli' },
116
126
  ];
@@ -138,7 +148,6 @@ export function slashCompleter(line) {
138
148
  export function printUserBox(question) {
139
149
  process.stdout.write('\n');
140
150
  process.stdout.write(boxTop('You', P.border) + '\n');
141
- // Word-wrap the question to fit inside the box
142
151
  const maxW = inner();
143
152
  const words = question.split(' ');
144
153
  let line = '';
@@ -155,8 +164,14 @@ export function printUserBox(question) {
155
164
  }
156
165
 
157
166
  // ── streamChat ────────────────────────────────────────────────────────────────
158
- export async function streamChat(cfg, messages, context = {}, opts = {}) {
159
- const showThinking = opts.thinking !== false;
167
+ /**
168
+ * Stream a chat request. Reasoning and plan always flow inline — no keys to toggle.
169
+ *
170
+ * @param {object} cfg Loaded config (url, token, …)
171
+ * @param {Array} messages Conversation history
172
+ * @param {object} [context] Extra context passed to server
173
+ */
174
+ export async function streamChat(cfg, messages, context = {}) {
160
175
  const body = JSON.stringify({ messages, context });
161
176
 
162
177
  async function doFetch(token) {
@@ -165,7 +180,7 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
165
180
  headers: {
166
181
  'Content-Type': 'application/json',
167
182
  'Accept': 'text/event-stream',
168
- 'Authorization': `Bearer ${token}`,
183
+ 'Authorization': 'Bearer ' + token,
169
184
  },
170
185
  body,
171
186
  });
@@ -184,7 +199,7 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
184
199
  }
185
200
 
186
201
  // ── Streaming state ─────────────────────────────────────────────────────────
187
- let boxOpen = false; // have we printed ╭─ iCopilot ?
202
+ let boxOpen = false;
188
203
  let reasoningOpen = false;
189
204
  let answerBuf = '';
190
205
  let rawAnswer = '';
@@ -196,38 +211,42 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
196
211
  process.stdout.write('\n' + boxTopOpen('iCopilot', P.border) + '\n');
197
212
  }
198
213
 
214
+ // Gemini-style thinking pill spinner
199
215
  const spinner = ora({
200
- text: P.dim('Thinking…'),
216
+ text: P.accent('Thinking…'),
201
217
  color: 'blue',
202
- spinner: 'dots',
203
- prefixText: P.border('│') + ' ',
218
+ spinner: { interval: 80, frames: ['','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'] },
219
+ prefixText: P.border('│') + ' ' + P.pill(' ◆ '),
204
220
  });
205
221
 
206
222
  const stopSpinner = () => { if (spinner.isSpinning) spinner.stop(); };
207
223
 
224
+ // ── Reasoning section (always streams inline) ───────────────────────────────
208
225
  function openReasoning() {
209
226
  if (reasoningOpen) return;
210
227
  stopSpinner();
211
228
  reasoningOpen = true;
212
- process.stdout.write(boxLine(P.dim('reasoning'), P.border) + '\n');
213
- process.stdout.write(boxLine(P.border(' '), P.border));
229
+ process.stdout.write(boxLine('', P.border) + '\n');
230
+ process.stdout.write(boxLine(P.accentB(' ') + P.dim('Reasoning'), P.border) + '\n');
231
+ process.stdout.write(boxLine(P.border(' │ '), P.border));
214
232
  }
215
233
 
216
234
  function writeReasonDelta(delta) {
217
- if (!reasoningOpen) return;
235
+ if (!reasoningOpen) openReasoning();
218
236
  const parts = delta.split('\n');
219
237
  for (let i = 0; i < parts.length; i++) {
220
- if (i > 0) process.stdout.write('\n' + boxLine(P.border('│ '), P.border));
238
+ if (i > 0) process.stdout.write('\n' + boxLine(P.border(' │'), P.border));
221
239
  if (parts[i]) process.stdout.write(P.reasoning(parts[i]));
222
240
  }
223
241
  }
224
242
 
225
243
  function closeReasoning() {
226
244
  if (!reasoningOpen) return;
227
- process.stdout.write('\n');
245
+ process.stdout.write('\n' + boxLine('', P.border) + '\n');
228
246
  reasoningOpen = false;
229
247
  }
230
248
 
249
+ // ── Status delta ────────────────────────────────────────────────────────────
231
250
  function printStatus(line) {
232
251
  ensureBoxOpen();
233
252
  if (reasoningOpen) closeReasoning();
@@ -236,7 +255,8 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
236
255
  const text = line.trimStart();
237
256
  const indent = /^[\s·•⌁]/.test(line) ? ' ' : '';
238
257
  process.stdout.write(boxLine(indent + icon + ' ' + P.dim(text), P.border) + '\n');
239
- if (showThinking) spinner.start();
258
+ // Restart spinner after each status line
259
+ spinner.start();
240
260
  }
241
261
 
242
262
  function parseStreamText(raw) {
@@ -247,7 +267,8 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
247
267
  }
248
268
 
249
269
  // ── SSE reader ─────────────────────────────────────────────────────────────
250
- if (showThinking) { ensureBoxOpen(); spinner.start(); }
270
+ ensureBoxOpen();
271
+ spinner.start();
251
272
 
252
273
  const decoder = new TextDecoder();
253
274
  let sseBuffer = '';
@@ -266,7 +287,6 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
266
287
  printStatus(ev.status_delta);
267
288
  } else if (ev.reason_delta !== undefined) {
268
289
  ensureBoxOpen();
269
- if (!reasoningOpen) openReasoning();
270
290
  writeReasonDelta(ev.reason_delta);
271
291
  } else if (ev.delta !== undefined) {
272
292
  if (reasoningOpen) closeReasoning();
@@ -291,7 +311,6 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
291
311
 
292
312
  if (meta.model && cfg) cfg._model = shortModel(meta.model);
293
313
 
294
- // Blank separator before content
295
314
  process.stdout.write(boxLine('', P.border) + '\n');
296
315
 
297
316
  if (replyType === 'message') {
@@ -302,36 +321,59 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
302
321
  }
303
322
 
304
323
  } else if (replyType === 'plan') {
305
- process.stdout.write(boxLine(P.bold('Plan'), P.border) + '\n');
306
- if (reply.summary) process.stdout.write(boxLine(P.dim(reply.summary), P.border) + '\n');
324
+ // Plan always rendered inline — no toggle key
325
+ process.stdout.write(boxLine(P.accentB('◆ ') + P.bold('Plan'), P.border) + '\n');
326
+ if (reply.summary) {
327
+ process.stdout.write(boxLine(P.dim(reply.summary), P.border) + '\n');
328
+ }
307
329
  process.stdout.write(boxLine('', P.border) + '\n');
308
- (reply.steps || []).forEach((s, i) => {
309
- const risk = s.risk === 'high' ? P.err(' ⚠ high risk') :
310
- s.risk === 'medium' ? P.warn(' ⚡') : '';
311
- process.stdout.write(boxLine(`${P.dim(String(i+1)+'.')} ${P.warn(s.cmd || '')}${risk}`, P.border) + '\n');
312
- if (s.purpose) process.stdout.write(boxLine(' ' + P.muted(s.purpose), P.border) + '\n');
330
+
331
+ const steps = reply.steps || [];
332
+ steps.forEach((s, i) => {
333
+ const isLast = i === steps.length - 1;
334
+ const prefix = isLast ? '└─ ' : '├─ ';
335
+ const risk = s.risk === 'high' ? ' ' + P.err('⚠ high risk') :
336
+ s.risk === 'medium' ? ' ' + P.warn('⚡') : '';
337
+ process.stdout.write(
338
+ boxLine(
339
+ P.border(' ' + prefix) + P.dim(String(i + 1) + '.') + ' ' + P.warn(s.cmd || '') + risk,
340
+ P.border,
341
+ ) + '\n',
342
+ );
343
+ if (s.purpose) {
344
+ process.stdout.write(boxLine(' ' + P.muted(s.purpose), P.border) + '\n');
345
+ }
313
346
  });
347
+
314
348
  process.stdout.write(boxLine('', P.border) + '\n');
315
- process.stdout.write(boxLine(P.dim('Type ') + P.white('apply fix') + P.dim(' to confirm.'), P.border) + '\n');
349
+ process.stdout.write(
350
+ boxLine(P.dim('Type ') + P.white('apply fix') + P.dim(' to confirm.'), P.border) + '\n',
351
+ );
316
352
 
317
353
  } else if (replyType === 'connect') {
318
354
  const dev = reply.device ?? {};
319
355
  process.stdout.write(
320
- boxLine(P.ok('🔌 ') + P.bold(`${dev.kind ?? '?'}#${dev.id ?? '?'}`) +
321
- ' ' + P.white(dev.name ?? '') + ' ' + P.dim(`(${dev.host ?? ''})`), P.border) + '\n'
356
+ boxLine(
357
+ P.ok('🔌 ') + P.bold(`${dev.kind ?? '?'}#${dev.id ?? '?'}`) +
358
+ ' ' + P.white(dev.name ?? '') + ' ' + P.dim(`(${dev.host ?? ''})`),
359
+ P.border,
360
+ ) + '\n',
322
361
  );
323
362
  if (reply.note) process.stdout.write(boxLine(P.dim(reply.note), P.border) + '\n');
324
363
  }
325
364
 
326
- // Blank + footer line inside box
365
+ // Footer inside box
327
366
  process.stdout.write(boxLine('', P.border) + '\n');
328
367
  const foot = [meta.model ? shortModel(meta.model) : null, meta.user, meta.role].filter(Boolean);
329
368
  if (foot.length) {
330
369
  process.stdout.write(boxLine(P.muted(foot.join(' · ')), P.border) + '\n');
331
370
  }
332
371
 
333
- // Close the box
334
- process.stdout.write(boxBotOpen(P.border) + '\n\n');
372
+ // Close the open box
373
+ process.stdout.write(boxBotOpen(P.border) + '\n');
374
+
375
+ // Bottom palette after every AI reply
376
+ process.stdout.write(bottomPalette() + '\n\n');
335
377
 
336
378
  return reply.text || answerBuf || '';
337
379
  }
package/src/config.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync, writeFileSync, mkdirSync } from 'fs';
1
+ import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'fs';
2
2
  import { homedir } from 'os';
3
3
  import { join } from 'path';
4
4
 
@@ -21,3 +21,13 @@ export function saveConfig(cfg) {
21
21
  export function configPath() {
22
22
  return CONFIG_FILE;
23
23
  }
24
+
25
+ /**
26
+ * Remove the saved credentials (log out). Returns true if a config file was
27
+ * present and deleted, false if there was nothing to clear.
28
+ */
29
+ export function clearConfig() {
30
+ if (!existsSync(CONFIG_FILE)) return false;
31
+ rmSync(CONFIG_FILE, { force: true });
32
+ return true;
33
+ }
package/src/layout.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execSync } from 'node:child_process';
2
+ import chalk from 'chalk';
2
3
 
3
4
  // ── Terminal width ─────────────────────────────────────────────────────────────
4
5
  let cachedCols = 0;
@@ -152,3 +153,57 @@ export function boxRow(content, borderFn) {
152
153
  export function boxLine(content, borderFn) {
153
154
  return borderFn('│') + ' ' + content;
154
155
  }
156
+
157
+ // ── Top context bar ───────────────────────────────────────────────────────────
158
+ /**
159
+ * Renders a single full-width bar:
160
+ * │ ● device · host ◆ AUTOPILOT ON operator: admin │
161
+ *
162
+ * @param {{ device?: string, aiMode?: 'AUTOPILOT ON'|'MANUAL', operator?: string }} ctx
163
+ */
164
+ export function topBar({ device = '', aiMode = 'MANUAL', operator = '' } = {}) {
165
+ const w = cols();
166
+
167
+ const lRaw = device ? `● ${device}` : '';
168
+ const cRaw = aiMode === 'AUTOPILOT ON' ? '◆ AUTOPILOT ON' : '◇ AI MANUAL';
169
+ const rRaw = operator ? `operator: ${operator}` : '';
170
+
171
+ const lC = lRaw ? chalk.hex('#8AB4F8').bold(lRaw) : '';
172
+ const cC = aiMode === 'AUTOPILOT ON'
173
+ ? chalk.green.bold(cRaw)
174
+ : chalk.yellow(cRaw);
175
+ const rC = rRaw ? chalk.hex('#9AA0A6')(rRaw) : '';
176
+
177
+ // avail = w - 2 borders - 2 side padding spaces
178
+ const avail = w - 4;
179
+ const lv = [...lRaw].length;
180
+ const cv = [...cRaw].length;
181
+ const rv = [...rRaw].length;
182
+
183
+ const remaining = avail - lv - cv - rv;
184
+ const g1 = remaining > 0 ? Math.floor(remaining / 2) : 0;
185
+ const g2 = remaining > 0 ? remaining - g1 : 0;
186
+
187
+ return chalk.hex('#303134')('│') + ' ' +
188
+ lC + ' '.repeat(g1) + cC + ' '.repeat(g2) + rC + ' ' +
189
+ chalk.hex('#303134')('│');
190
+ }
191
+
192
+ // ── Bottom command palette ────────────────────────────────────────────────────
193
+ /**
194
+ * Returns a single-line bottom palette string (no trailing newline).
195
+ * Caller is responsible for writing '\n'.
196
+ */
197
+ export function bottomPalette() {
198
+ const accent = chalk.hex('#8AB4F8').bold;
199
+ const desc = chalk.hex('#80868B');
200
+ const sep = chalk.hex('#303134')(' · ');
201
+ const items = [
202
+ accent('F1') + desc(' Docs'),
203
+ accent('F2') + desc(' Logs'),
204
+ accent('F3') + desc(' Rollback'),
205
+ accent('F4') + desc(' Bulk Sweep'),
206
+ accent('Ctrl+C') + desc(' Exit'),
207
+ ];
208
+ return ' ' + items.join(sep);
209
+ }