ispbills-icli 4.0.4 → 5.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,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { createInterface } from 'readline';
2
+ import { createInterface, emitKeypressEvents } 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 { inner, onResize, boxTop, boxBot, boxMid, boxRow } from '../src/layout.js';
7
+ import { inner, onResize, boxTop, boxBot, boxMid, boxRow, bottomPalette } from '../src/layout.js';
8
8
  import {
9
9
  streamChat, printBanner, printSlashMenu, printUserBox,
10
10
  SLASH_COMMANDS, slashCompleter,
@@ -25,6 +25,13 @@ const P = {
25
25
  const args = argv.slice(2);
26
26
  const cmd = args[0] ?? '';
27
27
 
28
+ // ── Session context (shown in top bar) ─────────────────────────────────────────
29
+ const ctx = {
30
+ device: '',
31
+ aiMode: 'MANUAL', // 'AUTOPILOT ON' | 'MANUAL'
32
+ operator: '',
33
+ };
34
+
28
35
  // ── Help ───────────────────────────────────────────────────────────────────────
29
36
  function printHelp() {
30
37
  process.stdout.write('\n');
@@ -52,19 +59,23 @@ function printHelp() {
52
59
  }
53
60
 
54
61
  process.stdout.write(boxMid(P.border) + '\n');
55
- process.stdout.write(boxRow(P.bold('Keyboard shortcuts'), P.border) + '\n');
62
+ process.stdout.write(boxRow(P.bold('Function key shortcuts'), P.border) + '\n');
56
63
  process.stdout.write(boxRow('', P.border) + '\n');
57
64
  [
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'],
65
+ ['F1', 'Open documentation'],
66
+ ['F2', 'Show conversation history'],
67
+ ['F3', 'Rollback last AI action'],
68
+ ['F4', 'Run bulk device sweep'],
69
+ ['Tab', 'Autocomplete slash commands'],
70
+ ['↑ / ↓', 'Navigate input history'],
71
+ ['Ctrl+L', 'Clear screen'],
72
+ ['Ctrl+C', 'Cancel / exit'],
63
73
  ].forEach(([k, d]) =>
64
74
  process.stdout.write(boxRow(P.warn(k.padEnd(14)) + P.dim(d), P.border) + '\n')
65
75
  );
66
76
  process.stdout.write(boxRow('', P.border) + '\n');
67
- process.stdout.write(boxBot(P.border) + '\n\n');
77
+ process.stdout.write(boxBot(P.border) + '\n');
78
+ process.stdout.write(bottomPalette() + '\n\n');
68
79
  }
69
80
 
70
81
  // ── Auth ───────────────────────────────────────────────────────────────────────
@@ -108,11 +119,16 @@ async function singleShot(cfg, question) {
108
119
 
109
120
  // ── REPL ───────────────────────────────────────────────────────────────────────
110
121
  async function interactiveRepl(cfg) {
111
- const history = [];
112
- let showReasoning = true;
113
- const PROMPT = () => ' ' + P.accentD('❯') + ' ';
122
+ const history = [];
123
+ const PROMPT = () => ' ' + P.accentD('❯') + ' ';
124
+
125
+ let isProcessing = false;
126
+
127
+ // Populate operator from cfg
128
+ if (cfg.user?.name) ctx.operator = cfg.user.name;
129
+ if (cfg.user?.role) ctx.aiMode = cfg.user.role === 'noc' ? 'AUTOPILOT ON' : 'MANUAL';
114
130
 
115
- printBanner(cfg);
131
+ printBanner(cfg, {}, ctx);
116
132
 
117
133
  const rl = createInterface({
118
134
  input, output,
@@ -122,6 +138,74 @@ async function interactiveRepl(cfg) {
122
138
  });
123
139
 
124
140
  onResize(() => rl.setPrompt(PROMPT()));
141
+
142
+ // ── F-key handling via keypress events ───────────────────────────────────────
143
+ emitKeypressEvents(process.stdin);
144
+
145
+ process.stdin.on('keypress', async (_ch, key) => {
146
+ if (!key || rl.closed || isProcessing) return;
147
+
148
+ if (key.name === 'f1') {
149
+ process.stdout.write('\n');
150
+ printHelp();
151
+ rl.prompt();
152
+ return;
153
+ }
154
+
155
+ if (key.name === 'f2') {
156
+ // Show history
157
+ const turns = history.filter(m => m.role === 'user');
158
+ process.stdout.write('\n');
159
+ if (!turns.length) {
160
+ process.stdout.write(P.muted(' No history yet.\n\n'));
161
+ } else {
162
+ process.stdout.write(boxTop('History', P.border) + '\n');
163
+ turns.forEach((m, i) =>
164
+ process.stdout.write(
165
+ boxRow(P.dim(String(i + 1).padStart(2) + '.') + ' ' +
166
+ chalk.white(m.content.replace(/\s+/g, ' ').slice(0, inner() - 5)), P.border) + '\n',
167
+ );
168
+ process.stdout.write(boxBot(P.border) + '\n\n');
169
+ }
170
+ rl.prompt();
171
+ return;
172
+ }
173
+
174
+ if (key.name === 'f3') {
175
+ rl.pause();
176
+ const q = 'rollback the last change you made';
177
+ process.stdout.write('\n ' + P.muted('→ ') + P.dim(q) + '\n');
178
+ printUserBox(q);
179
+ history.push({ role: 'user', content: q });
180
+ try {
181
+ const answer = await streamChat(cfg, [...history]);
182
+ if (answer) history.push({ role: 'assistant', content: answer });
183
+ } catch (e) {
184
+ process.stdout.write('\n ' + P.err('✖ ') + chalk.white(e.message) + '\n\n');
185
+ }
186
+ rl.resume();
187
+ rl.prompt();
188
+ return;
189
+ }
190
+
191
+ if (key.name === 'f4') {
192
+ rl.pause();
193
+ const q = 'run a bulk sweep of all devices and report status';
194
+ process.stdout.write('\n ' + P.muted('→ ') + P.dim(q) + '\n');
195
+ printUserBox(q);
196
+ history.push({ role: 'user', content: q });
197
+ try {
198
+ const answer = await streamChat(cfg, [...history]);
199
+ if (answer) history.push({ role: 'assistant', content: answer });
200
+ } catch (e) {
201
+ process.stdout.write('\n ' + P.err('✖ ') + chalk.white(e.message) + '\n\n');
202
+ }
203
+ rl.resume();
204
+ rl.prompt();
205
+ return;
206
+ }
207
+ });
208
+
125
209
  rl.prompt();
126
210
 
127
211
  rl.on('line', async (line) => {
@@ -132,7 +216,10 @@ async function interactiveRepl(cfg) {
132
216
  process.stdout.write(P.muted('\n Goodbye.\n\n')); rl.close(); exit(0);
133
217
  }
134
218
  if (q === '/clear' || q === 'clear') {
135
- process.stdout.write('\x1Bc'); printBanner(cfg); rl.prompt(); return;
219
+ process.stdout.write('\x1Bc');
220
+ printBanner(cfg, {}, ctx);
221
+ rl.prompt();
222
+ return;
136
223
  }
137
224
  if (q === '/help' || q === 'help') { printHelp(); rl.prompt(); return; }
138
225
  if (q === '/history' || q === 'history') {
@@ -162,12 +249,10 @@ async function interactiveRepl(cfg) {
162
249
 
163
250
  rl.pause();
164
251
  history.push({ role: 'user', content: question });
165
-
166
- // User message box
167
252
  printUserBox(question);
168
253
 
169
254
  try {
170
- const answer = await streamChat(cfg, [...history], {}, { thinking: showReasoning });
255
+ const answer = await streamChat(cfg, [...history]);
171
256
  if (answer) history.push({ role: 'assistant', content: answer });
172
257
  } catch (e) {
173
258
  process.stdout.write('\n ' + P.err('✖ ') + chalk.white(e.message) + '\n\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ispbills-icli",
3
- "version": "4.0.4",
3
+ "version": "5.0.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 ─────────────────────────────────────────────────────────────
@@ -138,7 +146,6 @@ export function slashCompleter(line) {
138
146
  export function printUserBox(question) {
139
147
  process.stdout.write('\n');
140
148
  process.stdout.write(boxTop('You', P.border) + '\n');
141
- // Word-wrap the question to fit inside the box
142
149
  const maxW = inner();
143
150
  const words = question.split(' ');
144
151
  let line = '';
@@ -155,8 +162,14 @@ export function printUserBox(question) {
155
162
  }
156
163
 
157
164
  // ── streamChat ────────────────────────────────────────────────────────────────
158
- export async function streamChat(cfg, messages, context = {}, opts = {}) {
159
- const showThinking = opts.thinking !== false;
165
+ /**
166
+ * Stream a chat request. Reasoning and plan always flow inline — no keys to toggle.
167
+ *
168
+ * @param {object} cfg Loaded config (url, token, …)
169
+ * @param {Array} messages Conversation history
170
+ * @param {object} [context] Extra context passed to server
171
+ */
172
+ export async function streamChat(cfg, messages, context = {}) {
160
173
  const body = JSON.stringify({ messages, context });
161
174
 
162
175
  async function doFetch(token) {
@@ -165,7 +178,7 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
165
178
  headers: {
166
179
  'Content-Type': 'application/json',
167
180
  'Accept': 'text/event-stream',
168
- 'Authorization': `Bearer ${token}`,
181
+ 'Authorization': 'Bearer ' + token,
169
182
  },
170
183
  body,
171
184
  });
@@ -184,7 +197,7 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
184
197
  }
185
198
 
186
199
  // ── Streaming state ─────────────────────────────────────────────────────────
187
- let boxOpen = false; // have we printed ╭─ iCopilot ?
200
+ let boxOpen = false;
188
201
  let reasoningOpen = false;
189
202
  let answerBuf = '';
190
203
  let rawAnswer = '';
@@ -196,38 +209,42 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
196
209
  process.stdout.write('\n' + boxTopOpen('iCopilot', P.border) + '\n');
197
210
  }
198
211
 
212
+ // Gemini-style thinking pill spinner
199
213
  const spinner = ora({
200
- text: P.dim('Thinking…'),
214
+ text: P.accent('Thinking…'),
201
215
  color: 'blue',
202
- spinner: 'dots',
203
- prefixText: P.border('│') + ' ',
216
+ spinner: { interval: 80, frames: ['','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'] },
217
+ prefixText: P.border('│') + ' ' + P.pill(' ◆ '),
204
218
  });
205
219
 
206
220
  const stopSpinner = () => { if (spinner.isSpinning) spinner.stop(); };
207
221
 
222
+ // ── Reasoning section (always streams inline) ───────────────────────────────
208
223
  function openReasoning() {
209
224
  if (reasoningOpen) return;
210
225
  stopSpinner();
211
226
  reasoningOpen = true;
212
- process.stdout.write(boxLine(P.dim('reasoning'), P.border) + '\n');
213
- process.stdout.write(boxLine(P.border(' '), P.border));
227
+ process.stdout.write(boxLine('', P.border) + '\n');
228
+ process.stdout.write(boxLine(P.accentB(' ') + P.dim('Reasoning'), P.border) + '\n');
229
+ process.stdout.write(boxLine(P.border(' │ '), P.border));
214
230
  }
215
231
 
216
232
  function writeReasonDelta(delta) {
217
- if (!reasoningOpen) return;
233
+ if (!reasoningOpen) openReasoning();
218
234
  const parts = delta.split('\n');
219
235
  for (let i = 0; i < parts.length; i++) {
220
- if (i > 0) process.stdout.write('\n' + boxLine(P.border('│ '), P.border));
236
+ if (i > 0) process.stdout.write('\n' + boxLine(P.border(' │'), P.border));
221
237
  if (parts[i]) process.stdout.write(P.reasoning(parts[i]));
222
238
  }
223
239
  }
224
240
 
225
241
  function closeReasoning() {
226
242
  if (!reasoningOpen) return;
227
- process.stdout.write('\n');
243
+ process.stdout.write('\n' + boxLine('', P.border) + '\n');
228
244
  reasoningOpen = false;
229
245
  }
230
246
 
247
+ // ── Status delta ────────────────────────────────────────────────────────────
231
248
  function printStatus(line) {
232
249
  ensureBoxOpen();
233
250
  if (reasoningOpen) closeReasoning();
@@ -236,7 +253,8 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
236
253
  const text = line.trimStart();
237
254
  const indent = /^[\s·•⌁]/.test(line) ? ' ' : '';
238
255
  process.stdout.write(boxLine(indent + icon + ' ' + P.dim(text), P.border) + '\n');
239
- if (showThinking) spinner.start();
256
+ // Restart spinner after each status line
257
+ spinner.start();
240
258
  }
241
259
 
242
260
  function parseStreamText(raw) {
@@ -247,7 +265,8 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
247
265
  }
248
266
 
249
267
  // ── SSE reader ─────────────────────────────────────────────────────────────
250
- if (showThinking) { ensureBoxOpen(); spinner.start(); }
268
+ ensureBoxOpen();
269
+ spinner.start();
251
270
 
252
271
  const decoder = new TextDecoder();
253
272
  let sseBuffer = '';
@@ -266,7 +285,6 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
266
285
  printStatus(ev.status_delta);
267
286
  } else if (ev.reason_delta !== undefined) {
268
287
  ensureBoxOpen();
269
- if (!reasoningOpen) openReasoning();
270
288
  writeReasonDelta(ev.reason_delta);
271
289
  } else if (ev.delta !== undefined) {
272
290
  if (reasoningOpen) closeReasoning();
@@ -291,7 +309,6 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
291
309
 
292
310
  if (meta.model && cfg) cfg._model = shortModel(meta.model);
293
311
 
294
- // Blank separator before content
295
312
  process.stdout.write(boxLine('', P.border) + '\n');
296
313
 
297
314
  if (replyType === 'message') {
@@ -302,36 +319,59 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
302
319
  }
303
320
 
304
321
  } 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');
322
+ // Plan always rendered inline — no toggle key
323
+ process.stdout.write(boxLine(P.accentB('◆ ') + P.bold('Plan'), P.border) + '\n');
324
+ if (reply.summary) {
325
+ process.stdout.write(boxLine(P.dim(reply.summary), P.border) + '\n');
326
+ }
307
327
  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');
328
+
329
+ const steps = reply.steps || [];
330
+ steps.forEach((s, i) => {
331
+ const isLast = i === steps.length - 1;
332
+ const prefix = isLast ? '└─ ' : '├─ ';
333
+ const risk = s.risk === 'high' ? ' ' + P.err('⚠ high risk') :
334
+ s.risk === 'medium' ? ' ' + P.warn('⚡') : '';
335
+ process.stdout.write(
336
+ boxLine(
337
+ P.border(' ' + prefix) + P.dim(String(i + 1) + '.') + ' ' + P.warn(s.cmd || '') + risk,
338
+ P.border,
339
+ ) + '\n',
340
+ );
341
+ if (s.purpose) {
342
+ process.stdout.write(boxLine(' ' + P.muted(s.purpose), P.border) + '\n');
343
+ }
313
344
  });
345
+
314
346
  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');
347
+ process.stdout.write(
348
+ boxLine(P.dim('Type ') + P.white('apply fix') + P.dim(' to confirm.'), P.border) + '\n',
349
+ );
316
350
 
317
351
  } else if (replyType === 'connect') {
318
352
  const dev = reply.device ?? {};
319
353
  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'
354
+ boxLine(
355
+ P.ok('🔌 ') + P.bold(`${dev.kind ?? '?'}#${dev.id ?? '?'}`) +
356
+ ' ' + P.white(dev.name ?? '') + ' ' + P.dim(`(${dev.host ?? ''})`),
357
+ P.border,
358
+ ) + '\n',
322
359
  );
323
360
  if (reply.note) process.stdout.write(boxLine(P.dim(reply.note), P.border) + '\n');
324
361
  }
325
362
 
326
- // Blank + footer line inside box
363
+ // Footer inside box
327
364
  process.stdout.write(boxLine('', P.border) + '\n');
328
365
  const foot = [meta.model ? shortModel(meta.model) : null, meta.user, meta.role].filter(Boolean);
329
366
  if (foot.length) {
330
367
  process.stdout.write(boxLine(P.muted(foot.join(' · ')), P.border) + '\n');
331
368
  }
332
369
 
333
- // Close the box
334
- process.stdout.write(boxBotOpen(P.border) + '\n\n');
370
+ // Close the open box
371
+ process.stdout.write(boxBotOpen(P.border) + '\n');
372
+
373
+ // Bottom palette after every AI reply
374
+ process.stdout.write(bottomPalette() + '\n\n');
335
375
 
336
376
  return reply.text || answerBuf || '';
337
377
  }
package/src/layout.js CHANGED
@@ -1,16 +1,96 @@
1
+ import { execSync } from 'node:child_process';
2
+ import chalk from 'chalk';
3
+
1
4
  // ── Terminal width ─────────────────────────────────────────────────────────────
5
+ let cachedCols = 0;
6
+ let lastProbeAt = 0;
7
+ let fallbackCols = 0;
8
+
9
+ function toPosInt(v) {
10
+ const n = Number.parseInt(String(v ?? ''), 10);
11
+ return Number.isFinite(n) && n > 0 ? n : 0;
12
+ }
13
+
14
+ function streamCols(stream) {
15
+ const direct = toPosInt(stream?.columns);
16
+ if (direct) return direct;
17
+
18
+ if (typeof stream?.getWindowSize === 'function') {
19
+ try {
20
+ const win = stream.getWindowSize();
21
+ const fromWin = Array.isArray(win) ? toPosInt(win[0]) : toPosInt(win?.columns);
22
+ if (fromWin) return fromWin;
23
+ } catch {}
24
+ }
25
+
26
+ return 0;
27
+ }
28
+
29
+ function probeFallbackCols() {
30
+ if (fallbackCols > 0) return fallbackCols;
31
+
32
+ const candidates = [];
33
+
34
+ try {
35
+ candidates.push(toPosInt(execSync('tput cols', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()));
36
+ } catch {}
37
+
38
+ try {
39
+ const stty = execSync('stty size 2>/dev/null', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
40
+ const parts = stty.split(/\s+/);
41
+ candidates.push(toPosInt(parts[1]));
42
+ } catch {}
43
+
44
+ try {
45
+ const mode = execSync('mode con', { stdio: ['ignore', 'pipe', 'ignore'] }).toString();
46
+ const m = mode.match(/Columns:\s*(\d+)/i);
47
+ candidates.push(toPosInt(m?.[1]));
48
+ } catch {}
49
+
50
+ fallbackCols = Math.max(0, ...candidates.filter(Boolean));
51
+ return fallbackCols;
52
+ }
53
+
2
54
  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;
55
+ const now = Date.now();
56
+ if (cachedCols > 0 && now - lastProbeAt < 250) return cachedCols;
57
+
58
+ const envCols = toPosInt(process.env.COLUMNS || process.env.TERM_WIDTH);
59
+ if (envCols) {
60
+ cachedCols = envCols;
61
+ lastProbeAt = now;
62
+ return envCols;
63
+ }
64
+
65
+ const stdoutCols = streamCols(process.stdout);
66
+ if (stdoutCols) {
67
+ cachedCols = stdoutCols;
68
+ lastProbeAt = now;
69
+ return stdoutCols;
70
+ }
71
+
72
+ const stderrCols = streamCols(process.stderr);
73
+ if (stderrCols) {
74
+ cachedCols = stderrCols;
75
+ lastProbeAt = now;
76
+ return stderrCols;
77
+ }
78
+
79
+ const probed = probeFallbackCols();
80
+ cachedCols = probed || 120;
81
+ lastProbeAt = now;
82
+ return cachedCols;
8
83
  }
9
84
  export const cols = () => Math.max(40, termCols());
10
85
  export const inner = () => cols() - 4; // usable width inside │ … │
11
86
  export const onResize = (cb) => {
12
- process.stdout.on('resize', cb);
13
- process.stderr.on('resize', cb);
87
+ const wrapped = () => {
88
+ cachedCols = 0;
89
+ fallbackCols = 0;
90
+ cb();
91
+ };
92
+ process.stdout.on('resize', wrapped);
93
+ process.stderr.on('resize', wrapped);
14
94
  };
15
95
 
16
96
  // ── ANSI-aware string helpers ─────────────────────────────────────────────────
@@ -73,3 +153,57 @@ export function boxRow(content, borderFn) {
73
153
  export function boxLine(content, borderFn) {
74
154
  return borderFn('│') + ' ' + content;
75
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
+ }