ispbills-icli 8.4.13 → 8.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ispbills-icli",
3
- "version": "8.4.13",
3
+ "version": "8.5.0",
4
4
  "description": "iCli — IspBills AI network engineer in your terminal",
5
5
  "keywords": [
6
6
  "ispbills",
package/src/commands.js CHANGED
@@ -52,9 +52,16 @@ export const SLASH_COMMANDS = [
52
52
  { cmd: '/copy', hint: '', desc: 'Copy the last AI answer to the clipboard', group: 'Memory', local: true },
53
53
 
54
54
  // ── Session & app ──
55
- { cmd: '/autopilot', hint: '', desc: 'Toggle autopilot (auto-apply fixes)', group: 'Session', local: true },
55
+ { cmd: '/plan', hint: '[prompt]', desc: 'Switch to plan mode (Shift+Tab to cycle)', group: 'Session', local: true },
56
+ { cmd: '/autopilot', hint: '', desc: 'Toggle autopilot mode (auto-apply fixes)', group: 'Session', local: true },
56
57
  { cmd: '/model', hint: '[name]', desc: 'Show or set the AI model', group: 'Session', local: true },
57
58
  { cmd: '/reasoning', hint: '', desc: 'Toggle reasoning display (Ctrl+T)', group: 'Session', local: true },
59
+ { cmd: '/context', hint: '', desc: 'Show context window token usage', group: 'Session', local: true },
60
+ { cmd: '/usage', hint: '', desc: 'Show session usage metrics', group: 'Session', local: true },
61
+ { cmd: '/compact', hint: '[focus]', desc: 'Compress conversation history', group: 'Session', local: true },
62
+ { cmd: '/session', hint: '', desc: 'Show session info (cwd, branch, tokens)', group: 'Session', local: true },
63
+ { cmd: '/share', hint: '', desc: 'Share session to a GitHub gist', group: 'Session', local: true },
64
+ { cmd: '/theme', hint: '', desc: 'View current colour theme info', group: 'Session', local: true },
58
65
  { cmd: '/new', hint: '', desc: 'Start a fresh conversation', group: 'Session', local: true },
59
66
  { cmd: '/history', hint: '', desc: 'Show conversation history', group: 'Session', local: true },
60
67
  { cmd: '/clear', hint: '', desc: 'Clear the screen', group: 'Session', local: true },
package/src/tui/app.js CHANGED
@@ -7,7 +7,7 @@ import { html, useState, useEffect, useRef, useCallback } from './dom.js';
7
7
  import { Box, Text, Static, useStdout, useApp, useInput } from 'ink';
8
8
  import { C, glyphs, midDot, dash } from './theme.js';
9
9
  import {
10
- Banner, UserMessage, AssistantMessage, StatusLines, Reasoning,
10
+ Banner, TabBar, FollowupChips, UserMessage, AssistantMessage, StatusLines, Reasoning,
11
11
  Plan, Connect, Notice, Thinking, Choice,
12
12
  } from './components.js';
13
13
  import { Composer } from './composer.js';
@@ -19,6 +19,9 @@ import { loadNotes, addNote, forgetNote, memoryPreamble } from '../memory.js';
19
19
  import { copyToClipboard } from '../clipboard.js';
20
20
  import { createGist } from '../gist.js';
21
21
  import { git as gitCmd, commitBackup } from '../gitrepo.js';
22
+ import { execSync } from 'child_process';
23
+ import { homedir } from 'os';
24
+ import { relative } from 'path';
22
25
 
23
26
  // Split a command argument string into argv, honouring "quoted" segments.
24
27
  function splitArgs(s) {
@@ -29,6 +32,33 @@ function splitArgs(s) {
29
32
  return out;
30
33
  }
31
34
 
35
+ // Shorten a path with ~ for home directory.
36
+ function shortenPath(p) {
37
+ try {
38
+ const home = homedir();
39
+ const rel = relative(home, p);
40
+ if (!rel.startsWith('..')) return '~/' + rel;
41
+ } catch {}
42
+ return p;
43
+ }
44
+
45
+ // Get the current git branch name (synchronous, non-throwing).
46
+ function gitBranch() {
47
+ try {
48
+ return execSync('git rev-parse --abbrev-ref HEAD', {
49
+ cwd: process.cwd(), stdio: ['ignore', 'pipe', 'ignore'], timeout: 1000,
50
+ }).toString().trim();
51
+ } catch {
52
+ return '';
53
+ }
54
+ }
55
+
56
+ // Approximate token count from text (~4 chars per token).
57
+ function approxTokens(messages) {
58
+ const total = messages.reduce((n, m) => n + (m.content?.length || 0), 0);
59
+ return Math.round(total / 4);
60
+ }
61
+
32
62
  function useTerminalSize() {
33
63
  const { stdout } = useStdout();
34
64
  const [size, setSize] = useState({
@@ -47,16 +77,24 @@ function useTerminalSize() {
47
77
  let itemId = 0;
48
78
  const nextId = () => ++itemId;
49
79
 
80
+ // Mode cycle: ask → plan → autopilot → ask
81
+ const MODES = ['ask', 'plan', 'autopilot'];
82
+
50
83
  export function App({ cfg, display = {}, onExternal, onExit, initialQuestion }) {
51
84
  const { exit } = useApp();
52
85
  const { cols, rows } = useTerminalSize();
86
+ const colsRef = useRef(cols);
87
+ useEffect(() => { colsRef.current = cols; }, [cols]);
53
88
 
54
89
  const [items, setItems] = useState([]);
55
90
  const [live, setLive] = useState(null); // { status:[], reasoning:'', text:'' }
56
91
  const [busy, setBusy] = useState(false);
57
92
  const [model, setModel] = useState(cfg._model);
58
- const [showReasoning, setShowReasoning] = useState(!!display.reasoning);
59
- const [autopilot, setAutopilot] = useState(false);
93
+ const [showReasoning, setShowReasoning] = useState(false); // collapsed by default per spec
94
+ const [mode, setMode] = useState('ask'); // 'ask' | 'plan' | 'autopilot'
95
+ const [activeTab, setActiveTab] = useState('session');
96
+ const [chips, setChips] = useState([]); // followup suggestion strings
97
+ const [activeChip, setActiveChip] = useState(0);
60
98
  const [hint, setHint] = useState('');
61
99
  const [startedAt, setStartedAt] = useState(0);
62
100
  const [choice, setChoice] = useState(null); // { title, note, options, allowText, sel, text, focusText, onPick }
@@ -67,9 +105,11 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
67
105
  const abortRef = useRef(null); // AbortController of the in-flight turn
68
106
  const ctrlC = useRef(0); // timestamp of the last lone Ctrl+C
69
107
  const apRef = useRef(false); // live autopilot flag (read inside runTurn)
108
+ const modeRef = useRef('ask'); // live mode (read inside runTurn)
70
109
  const modelRef = useRef(cfg._model); // preferred model to request
71
110
  const lastAnswerRef = useRef(''); // most recent assistant answer (for /copy, /gist)
72
111
  const choiceActionRef = useRef(null); // action to run when a non-AI Choice is picked
112
+ const branchRef = useRef(gitBranch()); // current git branch (detected once at start)
73
113
 
74
114
  // Enable terminal bracketed-paste so multi-line pastes (mouse right-click or
75
115
  // Cmd/Ctrl+V) arrive wrapped in markers as one block — the Composer inserts
@@ -79,12 +119,13 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
79
119
  return () => { try { process.stdout.write('\x1b[?2004l'); } catch {} };
80
120
  }, []);
81
121
 
82
- const setAutopilotMode = useCallback((next) => {
83
- apRef.current = next;
84
- setAutopilot(next);
122
+ const setModeState = useCallback((next) => {
123
+ modeRef.current = next;
124
+ apRef.current = next === 'autopilot';
125
+ setMode(next);
85
126
  }, []);
86
127
 
87
- const push = useCallback((item) => setItems((xs) => [...xs, { id: nextId(), ...item }]), []);
128
+ const push = useCallback((item) => setItems((xs) => [...xs, { id: nextId(), cols: colsRef.current, ...item }]), []);
88
129
  const log = (line) => plain.current.push(line);
89
130
 
90
131
  // Clear the visible transcript: wipe the screen + native scrollback and
@@ -104,6 +145,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
104
145
  convo.current.push({ role: 'user', content: question });
105
146
  setBusy(true);
106
147
  setHint('');
148
+ setChips([]);
107
149
  setStartedAt(Date.now());
108
150
  const s = { status: [], reasoning: '', text: '', reply: null };
109
151
  setLive({ ...s });
@@ -156,6 +198,12 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
156
198
  if (text) convo.current.push({ role: 'assistant', content: text });
157
199
  lastAnswerRef.current = text || reply.summary || reply.question || lastAnswerRef.current;
158
200
 
201
+ // Populate followup chips from reply.suggestions (up to 4).
202
+ if (Array.isArray(reply.suggestions) && reply.suggestions.length) {
203
+ setChips(reply.suggestions.slice(0, 4));
204
+ setActiveChip(0);
205
+ }
206
+
159
207
  // Archive a /backup answer to the per-user git repo.
160
208
  if (archive === 'backup' && text) {
161
209
  try {
@@ -250,17 +298,71 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
250
298
  return;
251
299
  }
252
300
  if (q === '/reasoning') {
253
- setShowReasoning((v) => { push({ type: 'notice', text: `Reasoning display ${!v ? 'on' : 'off'}.` }); return !v; });
301
+ setShowReasoning((v) => { push({ type: 'notice', text: `Reasoning display ${!v ? 'expanded' : 'collapsed'}.` }); return !v; });
254
302
  return;
255
303
  }
256
304
  if (q === '/autopilot' || q === '/auto') {
257
- const next = !apRef.current;
258
- setAutopilotMode(next);
305
+ const next = modeRef.current !== 'autopilot' ? 'autopilot' : 'ask';
306
+ setModeState(next);
259
307
  push({ type: 'notice',
260
- text: next
261
- ? `${glyphs.bolt} Autopilot ON ${dash()} proposed fixes will be applied automatically. Toggle with Shift+Tab.`
262
- : `Autopilot OFF ${dash()} fixes will wait for your confirmation.`,
263
- color: next ? C.yellow : C.gray });
308
+ text: next === 'autopilot'
309
+ ? `${glyphs.bolt} Autopilot ON ${dash()} proposed fixes will be applied automatically. Shift+Tab to cycle modes.`
310
+ : `Mode: ask ${dash()} fixes will wait for your confirmation.`,
311
+ color: next === 'autopilot' ? C.warning : C.muted });
312
+ return;
313
+ }
314
+ if (q === '/plan') {
315
+ const next = modeRef.current !== 'plan' ? 'plan' : 'ask';
316
+ setModeState(next);
317
+ push({ type: 'notice', text: `Mode: ${next} ${dash()} Shift+Tab to cycle.`, color: C.accent });
318
+ return;
319
+ }
320
+ if (q === '/context' || q === '/usage') {
321
+ const tokens = approxTokens(convo.current);
322
+ push({ type: 'notice', text: `Context: ~${tokens.toLocaleString()} tokens (${convo.current.length} messages). /compact to summarise.` });
323
+ return;
324
+ }
325
+ if (q === '/compact' || q.startsWith('/compact ')) {
326
+ const focus = q.slice('/compact'.length).trim();
327
+ const prompt = focus
328
+ ? `Summarise our conversation so far, focusing on: ${focus}`
329
+ : 'Summarise our conversation so far in a concise way, preserving key facts and decisions.';
330
+ convo.current = [];
331
+ push({ type: 'notice', text: `${glyphs.arrow} Compacting history…`, color: C.muted });
332
+ runTurn(prompt);
333
+ return;
334
+ }
335
+ if (q === '/theme') {
336
+ push({ type: 'notice', text: 'Theme: dark (default). Palette: purple accent, blue brand, green success, amber warning, red error.\nSet NO_COLOR=1 to disable colour or FORCE_COLOR=1 to force it.' });
337
+ return;
338
+ }
339
+ if (q === '/session') {
340
+ const tokens = approxTokens(convo.current);
341
+ const branch = branchRef.current;
342
+ const cwd = shortenPath(process.cwd());
343
+ push({ type: 'notice', text: [
344
+ `Session ${dash()} ${cwd}${branch ? ' [' + branch + ']' : ''}`,
345
+ `Messages ${dash()} ${convo.current.length}`,
346
+ `Tokens ${dash()} ~${tokens.toLocaleString()} (approx)`,
347
+ `Model ${dash()} ${model || modelRef.current || 'server default'}`,
348
+ `Mode ${dash()} ${modeRef.current}`,
349
+ ].join('\n') });
350
+ return;
351
+ }
352
+ if (q === '/share') {
353
+ if (!lastAnswerRef.current && convo.current.length === 0) {
354
+ push({ type: 'notice', text: 'Nothing to share yet — ask something first.', color: C.muted }); return;
355
+ }
356
+ choiceActionRef.current = (v) => doGist(v);
357
+ setChoice({
358
+ title: 'Share conversation…',
359
+ note: 'Saves as a secret GitHub gist', sel: 0, text: '', focusText: false, allowText: false,
360
+ options: [
361
+ { label: 'Last AI answer', value: 'answer' },
362
+ { label: 'Full conversation transcript', value: 'transcript' },
363
+ { label: 'Cancel', value: null },
364
+ ],
365
+ });
264
366
  return;
265
367
  }
266
368
  if (q === '/model' || q.startsWith('/model ')) {
@@ -282,7 +384,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
282
384
  const head = c.group !== group ? ((group = c.group), `\n ${c.group}\n`) : '';
283
385
  return head + ` ${(c.cmd + (c.hint ? ' ' + c.hint : '')).padEnd(22)}${c.desc}`;
284
386
  }).join('\n');
285
- push({ type: 'notice', text: 'Commands:' + lines + '\n\n Keys: Shift+Tab autopilot · Ctrl+T reasoning · Ctrl+Z/Ctrl+Y undo/redo · Ctrl+L clear · Ctrl+C twice to exit' });
387
+ push({ type: 'notice', text: 'Commands:' + lines + '\n\n Keys: Shift+Tab cycle modes (ask→plan→autopilot) · Ctrl+T toggle reasoning · Ctrl+L clear · Ctrl+N/P followup chips · Ctrl+D exit · Ctrl+C×2 exit' });
286
388
  return;
287
389
  }
288
390
  if (q === '/history') {
@@ -379,15 +481,15 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
379
481
  } else { push({ type: 'notice', text: `Unknown command: ${q}. Type /help.` }); return; }
380
482
  }
381
483
  runTurn(question, opts);
382
- }, [doExit, push, runTurn, onExternal, setAutopilotMode, model, cfg, doGist]);
484
+ }, [doExit, push, runTurn, onExternal, setModeState, model, cfg, doGist]);
383
485
 
384
486
  // Interactive prompt/choice handling (owns the keyboard while open).
385
487
  const pickChoice = useCallback((value) => {
386
488
  const act = choiceActionRef.current;
387
489
  choiceActionRef.current = null;
388
490
  setChoice(null);
389
- if (act) { if (value != null) act(value); else push({ type: 'notice', text: 'Cancelled.', color: C.gray }); return; }
390
- if (value == null) { push({ type: 'notice', text: 'Dismissed.', color: C.gray }); return; }
491
+ if (act) { if (value != null) act(value); else push({ type: 'notice', text: 'Cancelled.', color: C.muted }); return; }
492
+ if (value == null) { push({ type: 'notice', text: 'Dismissed.', color: C.muted }); return; }
391
493
  runTurn(String(value));
392
494
  }, [push, runTurn]);
393
495
 
@@ -429,15 +531,34 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
429
531
  setHint('Press Ctrl+C again to exit');
430
532
  return;
431
533
  }
534
+ if (key.ctrl && input === 'd') { doExit(); return; } // Ctrl+D shutdown
432
535
  if (key.escape && busy) { abortRef.current?.abort(); return; }
536
+ if (key.escape && chips.length) { setChips([]); return; } // Esc dismisses chips
433
537
  if (key.tab && key.shift && !busy) {
434
- const next = !apRef.current;
435
- setAutopilotMode(next);
436
- setHint(next ? 'Autopilot ON' : 'Autopilot OFF');
538
+ // Cycle: ask → plan → autopilot → ask
539
+ const cur = MODES.indexOf(modeRef.current);
540
+ const next = MODES[(cur + 1) % MODES.length];
541
+ setModeState(next);
542
+ setHint(`Mode: ${next}`);
437
543
  return;
438
544
  }
439
545
  if (key.ctrl && input === 't') { setShowReasoning((v) => !v); return; }
440
546
  if (key.ctrl && input === 'l' && !busy) { clearAll(); return; }
547
+ // Ctrl+N / Ctrl+P — cycle followup chips
548
+ if (key.ctrl && input === 'n' && chips.length) {
549
+ setActiveChip((i) => (i + 1) % chips.length); return;
550
+ }
551
+ if (key.ctrl && input === 'p' && chips.length) {
552
+ setActiveChip((i) => (i - 1 + chips.length) % chips.length); return;
553
+ }
554
+ // Tab cycles the tab bar (when not in slash palette)
555
+ if (key.tab && !key.shift && !busy) {
556
+ setActiveTab((t) => {
557
+ const tabs = ['session', 'devices', 'customers', 'alarms'];
558
+ return tabs[(tabs.indexOf(t) + 1) % tabs.length];
559
+ });
560
+ return;
561
+ }
441
562
  });
442
563
 
443
564
  // Auto-run a first question if launched with one.
@@ -447,9 +568,10 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
447
568
  }, [initialQuestion, onSubmit]);
448
569
 
449
570
  const renderItem = (it) => {
571
+ const c = it.cols || 80;
450
572
  switch (it.type) {
451
- case 'user': return html`<${UserMessage} key=${it.id} text=${it.text} />`;
452
- case 'assistant': return html`<${AssistantMessage} key=${it.id} text=${it.text} />`;
573
+ case 'user': return html`<${UserMessage} key=${it.id} text=${it.text} cols=${c} />`;
574
+ case 'assistant': return html`<${AssistantMessage} key=${it.id} text=${it.text} cols=${c} />`;
453
575
  case 'plan': return html`<${Plan} key=${it.id} reply=${it.reply} />`;
454
576
  case 'connect': return html`<${Connect} key=${it.id} reply=${it.reply} />`;
455
577
  case 'activity': return html`<${StatusLines} key=${it.id} lines=${it.status} busy=${false} />`;
@@ -458,16 +580,32 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
458
580
  }
459
581
  };
460
582
 
461
- const ctx = [model ? shortModel(model) : null, cfg.user?.name, cfg.user?.role].filter(Boolean).join(midDot());
462
- const modeTag = autopilot ? `${glyphs.bolt} autopilot ${midDot()} ` : '';
463
- const footerRight = hint || `${modeTag}/help ${midDot()} Shift+Tab autopilot ${midDot()} Ctrl+C exit`;
583
+ // ── Footer ───────────────────────────────────────────────────────────────────
584
+ // Left: ~/cwd ⏎ branch mode-badge
585
+ // Right: model ~Nk ctx
586
+ const cwd = shortenPath(process.cwd());
587
+ const branch = branchRef.current;
588
+ const tokenCount = approxTokens(convo.current);
589
+ const tokenLabel = tokenCount > 0 ? `~${Math.round(tokenCount / 1000)}k ctx` : '';
590
+ const modelLabel = shortModel(model || modelRef.current || '');
591
+ const modeLabel = mode !== 'ask' ? ` ${midDot()} ${glyphs.bolt} ${mode}` : '';
592
+
593
+ const footerLeft = [
594
+ cwd,
595
+ branch ? `${glyphs.gitBranch}${branch}` : null,
596
+ ].filter(Boolean).join(' ');
597
+
598
+ const footerRight = hint
599
+ ? hint
600
+ : [modelLabel, tokenLabel].filter(Boolean).join(` ${midDot()} `);
601
+
464
602
  const compact = cols < 60;
465
603
 
466
604
  // The banner is the first <Static> item so it prints once at the very top and
467
605
  // scrolls away with history; completed transcript items follow it. Ink flushes
468
606
  // each <Static> item to the terminal's normal buffer exactly once, leaving the
469
607
  // native scrollback intact so earlier conversation can be scrolled back to.
470
- const staticItems = [{ type: 'banner', id: `banner-${clearEpoch}` }, ...items];
608
+ const staticItems = [{ type: 'banner', id: `banner-${clearEpoch}`, cols }, ...items];
471
609
 
472
610
  return html`
473
611
  <${Box} flexDirection="column" width=${cols}>
@@ -482,20 +620,30 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
482
620
  ${live
483
621
  ? html`<${Box} flexDirection="column">
484
622
  <${StatusLines} lines=${live.status} busy=${busy} />
485
- ${showReasoning && live.reasoning ? html`<${Reasoning} text=${live.reasoning} />` : null}
623
+ ${live.reasoning ? html`<${Reasoning} text=${live.reasoning} show=${showReasoning} />` : null}
486
624
  ${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
487
- ${live.text ? html`<${Box} marginTop=${1}><${AssistantMessage} text=${live.text} /><//>` : null}
625
+ ${live.text ? html`<${AssistantMessage} text=${live.text} cols=${cols} />` : null}
488
626
  <${Thinking} label=${live.reply ? 'Planning' : live.text ? 'Responding' : 'Working'} startedAt=${startedAt} />
489
627
  <//>`
490
628
  : null}
491
629
 
492
- <${Box} flexDirection="column" marginTop=${1}>
630
+ <${TabBar} active=${activeTab} cols=${cols} />
631
+
632
+ ${!busy && chips.length ? html`<${FollowupChips} chips=${chips} active=${activeChip} />` : null}
633
+
634
+ <${Box} flexDirection="column">
493
635
  ${choice
494
636
  ? html`<${Choice} ...${choice} />`
495
637
  : html`<${Composer} onSubmit=${onSubmit} busy=${busy} width=${cols} />`}
638
+
639
+ <${Text} color=${C.separator}>${'─'.repeat(Math.max(1, cols))}<//>
640
+
496
641
  <${Box} paddingX=${1} width=${cols}>
497
- <${Box} flexGrow=${1}><${Text} color=${C.gray} wrap="truncate-end">${ctx}<//><//>
498
- <${Text} color=${hint || autopilot ? C.yellow : C.gray} wrap="truncate-end">${footerRight}<//>
642
+ <${Box} flexGrow=${1}>
643
+ <${Text} color=${C.muted} wrap="truncate-end">${footerLeft}<//>
644
+ ${modeLabel ? html`<${Text} color=${mode === 'autopilot' ? C.warning : C.accent}>${modeLabel}<//>` : null}
645
+ <//>
646
+ <${Text} color=${hint ? C.warning : C.muted} wrap="truncate-end">${footerRight}<//>
499
647
  <//>
500
648
  <//>
501
649
  <//>`;
@@ -15,6 +15,52 @@ const LOGO = [
15
15
  '╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝ ',
16
16
  ];
17
17
 
18
+ // ── Tab Bar ─────────────────────────────────────────────────────────────────
19
+ // Matches the June 2026 Copilot CLI GA redesign: deep-blue track, active tab
20
+ // wrapped in [brackets], inactive tabs dimmed. Adapted for ISP NOC context.
21
+ const TABS = [
22
+ { id: 'session', label: 'Session' },
23
+ { id: 'devices', label: 'Devices' },
24
+ { id: 'customers', label: 'Customers' },
25
+ { id: 'alarms', label: 'Alarms' },
26
+ ];
27
+
28
+ export function TabBar({ active = 'session', cols = 80, onSwitch }) {
29
+ return html`
30
+ <${Box} width=${cols} paddingX=${1} backgroundColor="#0D1117">
31
+ ${TABS.map((t, i) => {
32
+ const on = t.id === active;
33
+ return html`
34
+ <${Box} key=${t.id}>
35
+ ${i > 0 ? html`<${Text} color=${C.muted}> <//>` : null}
36
+ ${on
37
+ ? html`<${Text} color="white" bold>[${t.label}]<//>`
38
+ : html`<${Text} color=${C.muted}>${t.label}<//>`}
39
+ <//>`;
40
+ })}
41
+ <//>`;
42
+ }
43
+
44
+ // ── Followup Chips ────────────────────────────────────────────────────────────
45
+ // Up to 4 suggested followup actions shown above the input after a response.
46
+ // Ctrl+N/P cycles; ↵ on empty input submits active; Esc clears.
47
+ export function FollowupChips({ chips = [], active = 0 }) {
48
+ if (!chips.length) return null;
49
+ return html`
50
+ <${Box} paddingX=${1} flexWrap="wrap">
51
+ <${Text} color=${C.muted}>Next: <//>
52
+ ${chips.map((chip, i) => {
53
+ const on = i === active;
54
+ return html`
55
+ <${Box} key=${'ch' + i}>
56
+ ${i > 0 ? html`<${Text} color=${C.muted}> <//>` : null}
57
+ <${Text} color=${on ? C.success : C.muted} bold=${on}>[${chip}]<//>
58
+ <//>`;
59
+ })}
60
+ <${Text} color=${C.muted}> ↵ run Ctrl+N/P cycle Esc dismiss<//>
61
+ <//>`;
62
+ }
63
+
18
64
  /** Header banner — full logo on wide/tall terminals, compact title otherwise. */
19
65
  export function Banner({ cfg = {}, model, width = 80, compact = false }) {
20
66
  const m = shortModel(model || cfg._model || 'IspBills AI');
@@ -29,8 +75,8 @@ export function Banner({ cfg = {}, model, width = 80, compact = false }) {
29
75
  <${Text} color=${C.accent} bold>iCopilot <//>
30
76
  ${meta.map(([label, val], i) => html`
31
77
  <${Box} key=${'m' + i}>
32
- <${Text} color=${C.gray}>${midDot()}<//>
33
- <${Text} color=${label === 'model' ? C.cyan : C.white}>${val}<//>
78
+ <${Text} color=${C.muted}>${midDot()}<//>
79
+ <${Text} color=${label === 'model' ? C.accent : C.brand}>${val}<//>
34
80
  <//>`)}
35
81
  <//>`;
36
82
  }
@@ -40,18 +86,18 @@ export function Banner({ cfg = {}, model, width = 80, compact = false }) {
40
86
  ${wide
41
87
  ? html`<${Box} flexDirection="column" alignItems="center">
42
88
  ${LOGO.map((l, i) => html`<${Text} key=${'l' + i} color=${C.accent} bold>${l}<//>`)}
43
- <${Text} color=${C.gray}>iCopilot ${dash()} network engineer in your terminal<//>
89
+ <${Text} color=${C.muted}>iCopilot ${dash()} IspBills network engineer in your terminal<//>
44
90
  <//>`
45
- : html`<${Text} color=${C.accent} bold>iCopilot ${html`<${Text} color=${C.gray}>${dash()} IspBills AI terminal<//>`}<//>`}
91
+ : html`<${Text} color=${C.accent} bold>iCopilot ${html`<${Text} color=${C.muted}>${dash()} IspBills AI terminal<//>`}<//>`}
46
92
  <${Box} marginTop=${1}>
47
93
  ${meta.map(([label, val], i) => html`
48
94
  <${Box} key=${'m' + i}>
49
- ${i > 0 ? html`<${Text} color=${C.gray}>${midDot()}<//>` : null}
50
- <${Text} color=${C.gray}>${label} <//>
51
- <${Text} color=${label === 'model' ? C.cyan : C.white}>${val}<//>
95
+ ${i > 0 ? html`<${Text} color=${C.muted}>${midDot()}<//>` : null}
96
+ <${Text} color=${C.muted}>${label} <//>
97
+ <${Text} color=${label === 'model' ? C.accent : C.brand}>${val}<//>
52
98
  <//>`)}
53
99
  <//>
54
- ${cfg.url ? html`<${Text} color=${C.gray}>${cfg.url}<//>` : null}
100
+ ${cfg.url ? html`<${Text} color=${C.muted}>${cfg.url}<//>` : null}
55
101
  <//>`;
56
102
  }
57
103
 
@@ -60,25 +106,42 @@ function withMentions(text, base = C.white) {
60
106
  const parts = String(text).split(/(@[\w:.\-/]+)/g);
61
107
  return parts.map((p, i) =>
62
108
  /^@[\w:.\-/]+$/.test(p)
63
- ? html`<${Text} key=${'mn' + i} color=${C.cyan} bold>${p}<//>`
109
+ ? html`<${Text} key=${'mn' + i} color=${C.accent} bold>${p}<//>`
64
110
  : html`<${Text} key=${'mn' + i} color=${base}>${p}<//>`,
65
111
  );
66
112
  }
67
113
 
68
- /** The user's prompt line. */
69
- export function UserMessage({ text }) {
114
+ // Full-width separator line, drawn in muted separator colour.
115
+ function Sep({ cols = 80 }) {
116
+ return html`<${Text} color=${C.separator}>${'─'.repeat(Math.max(1, cols))}<//>`;
117
+ }
118
+
119
+ /** The user's prompt block — separator + "You" label + text. */
120
+ export function UserMessage({ text, cols = 80 }) {
70
121
  return html`
71
- <${Box} marginTop=${1}>
72
- <${Text} color=${C.accent} bold>${glyphs.prompt} <//>
73
- <${Text}>${withMentions(text)}<//>
122
+ <${Box} flexDirection="column" marginTop=${1}>
123
+ <${Sep} cols=${cols} />
124
+ <${Box} paddingX=${2} marginTop=${1}>
125
+ <${Text} color=${C.brand} bold>You<//>
126
+ <//>
127
+ <${Box} paddingX=${2}>
128
+ <${Text}>${withMentions(text)}<//>
129
+ <//>
74
130
  <//>`;
75
131
  }
76
132
 
77
- /** A finished assistant answer (markdown). */
78
- export function AssistantMessage({ text }) {
133
+ /** A finished assistant answer — separator + "⬡ iCopilot" label + markdown. */
134
+ export function AssistantMessage({ text, cols = 80 }) {
135
+ const dot = glyphs.copilotDot;
79
136
  return html`
80
137
  <${Box} flexDirection="column" marginTop=${1}>
81
- <${Markdown} content=${text} />
138
+ <${Sep} cols=${cols} />
139
+ <${Box} paddingX=${2} marginTop=${1}>
140
+ <${Text} color=${C.accent} bold>${dot} iCopilot<//>
141
+ <//>
142
+ <${Box} paddingX=${2}>
143
+ <${Markdown} content=${text} />
144
+ <//>
82
145
  <//>`;
83
146
  }
84
147
 
@@ -142,14 +205,21 @@ export function StatusLines({ lines = [], busy = false }) {
142
205
  <//>`;
143
206
  }
144
207
 
145
- /** Reasoning trace (shown by default; toggle with Ctrl+T). */
146
- export function Reasoning({ text }) {
208
+ /** Reasoning trace collapsed to a single dim line; Ctrl+T to expand. */
209
+ export function Reasoning({ text, show = true }) {
147
210
  if (!text) return null;
211
+ // Estimate token count (~4 chars per token as rough heuristic)
212
+ const tokenEst = Math.round(text.length / 4);
213
+ if (!show) {
214
+ return html`
215
+ <${Box} paddingX=${2} marginTop=${1}>
216
+ <${Text} color=${C.muted} italic>${glyphs.reasoning} Thinking (≈${tokenEst} tokens)… Ctrl+T to expand<//><//>`;
217
+ }
148
218
  return html`
149
- <${Box} flexDirection="column" marginTop=${1} borderStyle=${borderStyle()} borderColor=${C.gray}
150
- borderTop=${false} borderRight=${false} borderBottom=${false} paddingLeft=${1}>
151
- <${Text} color=${C.magenta}>${glyphs.reasoning} ${html`<${Text} color=${C.gray} bold>Reasoning<//>`}<//>
152
- <${Text} color=${C.gray} italic>${text}<//>
219
+ <${Box} flexDirection="column" marginTop=${1} borderStyle=${borderStyle()} borderColor=${C.separator}
220
+ borderTop=${false} borderRight=${false} borderBottom=${false} paddingLeft=${2}>
221
+ <${Text} color=${C.accent}>${glyphs.reasoning} ${html`<${Text} color=${C.muted} bold>Reasoning<//>`}<//>
222
+ <${Text} color=${C.muted} italic>${text}<//>
153
223
  <//>`;
154
224
  }
155
225
 
@@ -206,7 +276,7 @@ export function Notice({ text, color = C.gray }) {
206
276
  return html`<${Box} marginTop=${1}><${Text} color=${color}>${text}<//><//>`;
207
277
  }
208
278
 
209
- /** The active "working" line — animated spinner, elapsed time, cancel hint. */
279
+ /** The active "working" line — animated spinner (warning colour), elapsed time, cancel hint. */
210
280
  export function Thinking({ label = 'Working', startedAt }) {
211
281
  const [, tick] = useState(0);
212
282
  useEffect(() => {
@@ -215,10 +285,10 @@ export function Thinking({ label = 'Working', startedAt }) {
215
285
  }, []);
216
286
  const secs = startedAt ? Math.max(0, Math.round((Date.now() - startedAt) / 1000)) : 0;
217
287
  return html`
218
- <${Box} marginTop=${1}>
219
- <${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /><//>
220
- <${Text} color=${C.accent} bold> ${label}<//>
221
- <${Text} color=${C.gray}>… (${secs}s ${midDot()}esc to interrupt)<//>
288
+ <${Box} marginTop=${1} paddingX=${2}>
289
+ <${Text} color=${C.warning}><${Spinner} type=${spinnerType()} /><//>
290
+ <${Text} color=${C.warning} bold> ${label}<//>
291
+ <${Text} color=${C.muted}>… (${secs}s ${midDot()}esc to interrupt)<//>
222
292
  <//>`;
223
293
  }
224
294
 
@@ -3,7 +3,7 @@
3
3
  // the input and is navigable with the arrow keys.
4
4
  import { html, useState, useEffect, useCallback, useRef } from './dom.js';
5
5
  import { Box, Text, useInput } from 'ink';
6
- import { C, glyphs, borderStyle } from './theme.js';
6
+ import { C, glyphs } from './theme.js';
7
7
  import { SLASH_COMMANDS } from '../commands.js';
8
8
 
9
9
  // @-mention entity types. Selecting one inserts e.g. "@olt:" so the user can
@@ -215,11 +215,17 @@ export function Composer({ onSubmit, busy, width = 80 }) {
215
215
  const sugTok = menuOpen && matches[sel] ? (slashMode ? matches[sel].cmd : matches[sel].tag) : '';
216
216
  const ghost = sugTok && sugTok.startsWith(token) && cursor >= value.length ? sugTok.slice(token.length) : '';
217
217
 
218
+ // Prompt glyph: ❯ (success/green) idle; ⧖ (warning/amber) busy.
219
+ const promptGlyph = busy ? glyphs.busy : glyphs.prompt;
220
+ const promptColor = busy ? C.warning : C.success;
221
+ // Ghost hint text when empty
222
+ const ghostHint = busy ? 'Working…' : 'Enter @ to mention a device or / for commands…';
223
+
218
224
  return html`
219
225
  <${Box} flexDirection="column" width=${width}>
220
226
  ${menuOpen
221
227
  ? html`<${Box} flexDirection="column" paddingX=${1} marginBottom=${0}>
222
- ${slashMode ? null : html`<${Box}><${Text} color=${C.cyan} bold>@ mention <//><${Text} color=${C.gray}>a device, customer or entity<//><//>`}
228
+ ${slashMode ? null : html`<${Box}><${Text} color=${C.accent} bold>@ mention <//><${Text} color=${C.muted}>a device, customer or entity<//><//>`}
223
229
  ${(() => {
224
230
  const MAX = 8;
225
231
  let start = 0;
@@ -231,24 +237,26 @@ export function Composer({ onSubmit, busy, width = 80 }) {
231
237
  const key = slashMode ? (s.cmd + (s.hint ? ' ' + s.hint : '')) : s.tag;
232
238
  const label = key.padEnd(21);
233
239
  return html`<${Box} key=${'m' + i}>
234
- <${Text} color=${on ? C.accent : C.gray}>${on ? glyphs.prompt + ' ' : ' '}<//>
235
- <${Text} color=${on ? C.accent : slashMode ? C.white : C.cyan} bold=${on}>${label}<//>
236
- <${Text} color=${C.gray}>${s.desc}<//>
240
+ <${Text} color=${on ? C.success : C.muted}>${on ? glyphs.prompt + ' ' : ' '}<//>
241
+ <${Text} color=${on ? C.success : slashMode ? C.slash : C.accent} bold=${on}>${label}<//>
242
+ <${Text} color=${C.muted}>${s.desc}<//>
237
243
  <//>`;
238
244
  });
239
245
  })()}
240
246
  <//>`
241
247
  : null}
242
248
 
243
- <${Box} width=${width} borderStyle=${borderStyle()} borderColor=${busy ? C.gray : C.accent} paddingX=${1}>
244
- <${Text} color=${busy ? C.gray : C.accent}>${glyphs.prompt} <//>
249
+ <${Text} color=${C.accent}>${'_'.repeat(Math.max(1, width))}<//>
250
+
251
+ <${Box} width=${width} paddingX=${1}>
252
+ <${Text} color=${promptColor} bold>${promptGlyph} <//>
245
253
  ${empty
246
254
  ? html`<${Box} flexGrow=${1}>
247
- <${Text} color=${C.gray} inverse> <//>
248
- <${Text} color=${C.gray}>${busy ? 'Working…' : 'Ask about your network, or type / for commands'}<//>
255
+ <${Text} color="white" inverse> <//>
256
+ <${Text} color=${C.muted}> ${ghostHint}<//>
249
257
  <//>`
250
258
  : html`<${Box} flexGrow=${1}>
251
- <${Text}>${before}<//><${Text} inverse>${at}<//><${Text}>${after}<//><${Text} color=${C.gray}>${ghost}<//>
259
+ <${Text}>${before}<//><${Text} inverse>${at}<//><${Text}>${after}<//><${Text} color=${C.muted}>${ghost}<//>
252
260
  <//>`}
253
261
  <//>
254
262
  <//>`;
package/src/tui/theme.js CHANGED
@@ -1,24 +1,71 @@
1
1
  // Colours + glyphs for the Ink TUI. Colours are Ink-friendly names/hex;
2
2
  // glyphs and the spinner reuse the ASCII-safe logic from ui.js so classic
3
3
  // Windows consoles never render "tofu" boxes.
4
- import { glyphs, asciiSafe } from '../ui.js';
4
+ import { glyphs as baseGlyphs, asciiSafe } from '../ui.js';
5
5
 
6
- export { glyphs, asciiSafe };
6
+ export { asciiSafe };
7
7
 
8
- // Soft-blue brand accent (approx. xterm-256 colour 111).
9
- export const ACCENT = '#87afff';
8
+ // ── Colour palette (matches GitHub Copilot CLI spec) ─────────────────────────
9
+ // accent = purple — Copilot bullet, timeline separators, active tab
10
+ // brand = blue — "You" speaker label, links, title
11
+ // success = green — prompt glyph (idle), checkmarks, active chip
12
+ // warning = amber — busy glyph, warnings, autopilot mode
13
+ // error = red — error messages
14
+ // muted = grey — footer, metadata, ghost text, dim labels
15
+ // separator= dark — turn dividers, lower input separator
16
+ // slash = gold — slash command highlight in palette
17
+ export const ACCENT = '#A371F7';
10
18
  export const C = {
11
- accent: ACCENT,
12
- gray: 'gray',
13
- white: 'white',
14
- cyan: 'cyan',
15
- green: 'green',
16
- yellow: 'yellow',
17
- red: 'red',
18
- magenta: 'magenta',
19
- blue: 'blue',
19
+ accent: '#A371F7', // purple
20
+ brand: '#58A6FF', // blue
21
+ success: '#3FB950', // green
22
+ warning: '#D29922', // amber
23
+ error: '#F85149', // red
24
+ muted: '#8B949E', // grey
25
+ separator: '#30363D', // dark
26
+ slash: '#E3B341', // gold
27
+ // Legacy aliases (keep existing code working)
28
+ gray: '#8B949E',
29
+ white: 'white',
30
+ cyan: 'cyan',
31
+ green: '#3FB950',
32
+ yellow: '#D29922',
33
+ red: '#F85149',
34
+ magenta: '#A371F7',
35
+ blue: '#58A6FF',
20
36
  };
21
37
 
38
+ // ── Extended glyph set (extends ui.js glyphs with TUI-specific ones) ─────────
39
+ const ASCII_EXTRA = {
40
+ copilotDot: 'o', // hex bullet for "o iCopilot" speaker label
41
+ busy: '*', // hourglass-style busy indicator
42
+ gitBranch: 'b:', // git branch prefix
43
+ tabOpen: '[', // active tab open bracket
44
+ tabClose: ']', // active tab close bracket
45
+ chip: '|', // chip separator
46
+ expand: '+', // expand indicator
47
+ collapse: '-', // collapse indicator
48
+ };
49
+ const UNICODE_EXTRA = {
50
+ copilotDot: '⬡', // white hexagon for Copilot label
51
+ busy: '⧖', // white hourglass for busy state
52
+ gitBranch: '\uE0A0 ', // Nerd Font powerline branch glyph
53
+ tabOpen: '[',
54
+ tabClose: ']',
55
+ chip: '·',
56
+ expand: '▸',
57
+ collapse: '▾',
58
+ };
59
+
60
+ // Merged glyph proxy: own extras first, then ui.js base glyphs.
61
+ export const glyphs = new Proxy({}, {
62
+ get(_t, key) {
63
+ const extra = asciiSafe() ? ASCII_EXTRA : UNICODE_EXTRA;
64
+ if (key in extra) return extra[key];
65
+ return baseGlyphs[key];
66
+ },
67
+ });
68
+
22
69
  // ink-spinner uses cli-spinners; 'line' is pure ASCII (|/-\), 'dots' is braille.
23
70
  export function spinnerType() {
24
71
  return asciiSafe() ? 'line' : 'dots';