ispbills-icli 8.4.6 → 8.4.8

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
@@ -315,13 +315,33 @@ async function main() {
315
315
  // plain readline REPL when stdout/stdin is not a TTY (pipes, dumb terminals).
316
316
  if (output.isTTY && input.isTTY) {
317
317
  const { runTui } = await import('../src/tui/run.js');
318
- const action = await runTui(cfg, { display, version: pkgVersion() });
319
- if (action === 'logout') {
318
+ const { loadResume } = await import('../src/resume.js');
319
+
320
+ // Hot-reload resume: if started with --resume <file>, restore the saved session.
321
+ const resumeFile = flag('--resume');
322
+ const resumeSession = loadResume(resumeFile);
323
+
324
+ const action = await runTui(cfg, { display, version: pkgVersion(), resumeSession });
325
+
326
+ const actionName = typeof action === 'string' ? action : action?.action;
327
+ if (actionName === 'logout') {
320
328
  const cleared = clearConfig();
321
329
  console.log(`\n ${cleared ? GREEN + glyphs.check + RESET + ' Logged out — credentials cleared.' : YELLOW + 'Already logged out.' + RESET}`);
322
330
  console.log(` ${DIM}Run ${RESET}${ACCENT}icli login${RESET}${DIM} to sign in again.${RESET}\n`);
323
- } else if (action === 'update') {
324
- selfUpdate();
331
+ } else if (actionName === 'update') {
332
+ const ok = selfUpdate();
333
+ if (ok && action?.resumeFile) {
334
+ // Re-exec with the new binary, passing --resume so the session is restored.
335
+ const { execFileSync } = await import('child_process');
336
+ const newBin = process.argv[1]; // same script path — npm replaced the file in-place
337
+ const baseArgs = process.argv.slice(2).filter((a) => a !== '--resume' && a !== action.resumeFile);
338
+ process.stdout.write(`\n ${ACCENT}Reloading iCli with your session restored…${RESET}\n\n`);
339
+ try {
340
+ execFileSync(process.execPath, [newBin, ...baseArgs, '--resume', action.resumeFile], {
341
+ stdio: 'inherit',
342
+ });
343
+ } catch { /* user Ctrl+C'd the resumed session — that's fine */ }
344
+ }
325
345
  }
326
346
  return;
327
347
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ispbills-icli",
3
- "version": "8.4.6",
3
+ "version": "8.4.8",
4
4
  "description": "iCli — IspBills AI network engineer in your terminal",
5
5
  "keywords": [
6
6
  "ispbills",
package/src/clipboard.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Cross-platform "copy to system clipboard" helper. Best-effort: resolves to
2
2
  // false (rather than throwing) when no clipboard tool is available so the TUI
3
3
  // can show a friendly notice.
4
- import { spawn } from 'child_process';
4
+ import { spawn, execFile } from 'child_process';
5
5
 
6
6
  function candidates() {
7
7
  if (process.platform === 'darwin') return [['pbcopy', []]];
@@ -40,3 +40,37 @@ export async function copyToClipboard(text) {
40
40
  }
41
41
  return null;
42
42
  }
43
+
44
+ // ── Read from clipboard ─────────────────────────────────────────────────────
45
+
46
+ function readCandidates() {
47
+ if (process.platform === 'darwin') return [['pbpaste', []]];
48
+ if (process.platform === 'win32') return [['powershell', ['-noprofile', '-command', 'Get-Clipboard']]];
49
+ return [
50
+ ['wl-paste', ['--no-newline']],
51
+ ['xclip', ['-selection', 'clipboard', '-o']],
52
+ ['xsel', ['--clipboard', '--output']],
53
+ ];
54
+ }
55
+
56
+ /**
57
+ * Read text from the system clipboard.
58
+ * Returns the clipboard string on success, or null when no tool is available
59
+ * or the clipboard is empty.
60
+ */
61
+ export function readFromClipboard() {
62
+ return new Promise((resolve) => {
63
+ const cands = readCandidates();
64
+ let i = 0;
65
+ function tryNext() {
66
+ if (i >= cands.length) { resolve(null); return; }
67
+ const [cmd, args] = cands[i++];
68
+ execFile(cmd, args, { timeout: 3000, maxBuffer: 1024 * 512 }, (err, stdout) => {
69
+ if (err) { tryNext(); return; }
70
+ const text = stdout.trimEnd();
71
+ resolve(text || null);
72
+ });
73
+ }
74
+ tryNext();
75
+ });
76
+ }
package/src/commands.js CHANGED
@@ -56,6 +56,8 @@ export const SLASH_COMMANDS = [
56
56
  { cmd: '/autopilot', hint: '', desc: 'Toggle autopilot (auto-apply fixes)', group: 'Session', local: true },
57
57
  { cmd: '/model', hint: '[name]', desc: 'Show or set the AI model', group: 'Session', local: true },
58
58
  { cmd: '/reasoning', hint: '', desc: 'Toggle reasoning display (Ctrl+T)', group: 'Session', local: true },
59
+ { cmd: '/paste', hint: '', desc: 'Paste multiline text from clipboard into input', group: 'Session', local: true },
60
+ { cmd: '/about', hint: '', desc: 'About iCopilot — version, server, user', group: 'Session', local: true },
59
61
  { cmd: '/new', hint: '', desc: 'Start a fresh conversation', group: 'Session', local: true },
60
62
  { cmd: '/history', hint: '', desc: 'Show conversation history', group: 'Session', local: true },
61
63
  { cmd: '/clear', hint: '', desc: 'Clear the screen', group: 'Session', local: true },
package/src/resume.js ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Temporary session persistence for the /update hot-reload flow.
3
+ *
4
+ * Before updating, icli serialises the live conversation to a JSON file in
5
+ * /tmp so the new binary can resume right where the user left off.
6
+ */
7
+ import { writeFileSync, readFileSync, existsSync, unlinkSync } from 'fs';
8
+ import { tmpdir } from 'os';
9
+ import { join } from 'path';
10
+
11
+ const PREFIX = 'icli-resume-';
12
+
13
+ /** Write a resume file and return its path. */
14
+ export function saveResume(convo, plain, items = []) {
15
+ const path = join(tmpdir(), `${PREFIX}${Date.now()}.json`);
16
+ writeFileSync(path, JSON.stringify({ convo, plain, items, savedAt: new Date().toISOString() }), 'utf8');
17
+ return path;
18
+ }
19
+
20
+ /** Load and delete a resume file. Returns { convo, plain, items } or null. */
21
+ export function loadResume(path) {
22
+ if (!path || !existsSync(path)) return null;
23
+ try {
24
+ const data = JSON.parse(readFileSync(path, 'utf8'));
25
+ try { unlinkSync(path); } catch { /* best-effort delete */ }
26
+ return { convo: data.convo || [], plain: data.plain || [], items: data.items || [] };
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
package/src/tui/app.js CHANGED
@@ -9,6 +9,7 @@ import { C, glyphs, midDot, dash } from './theme.js';
9
9
  import {
10
10
  Banner, UserMessage, AssistantMessage, StatusLines, Reasoning,
11
11
  Plan, Connect, Notice, Thinking, Choice, ShellInput, ShellOut,
12
+ TabBar, MonitoringView, IssuesView, GistsView,
12
13
  } from './components.js';
13
14
  import { Composer } from './composer.js';
14
15
  import { streamChat } from '../client.js';
@@ -16,15 +17,20 @@ import { slashToQuestion, SLASH_COMMANDS } from '../commands.js';
16
17
  import { saveConfig } from '../config.js';
17
18
  import { shortModel } from '../ui.js';
18
19
  import { loadNotes, addNote, forgetNote, memoryPreamble } from '../memory.js';
19
- import { copyToClipboard } from '../clipboard.js';
20
+ import { copyToClipboard, readFromClipboard } from '../clipboard.js';
20
21
  import { createGist } from '../gist.js';
21
22
  import { git as gitCmd, commitBackup } from '../gitrepo.js';
22
23
  import { runShell } from '../shell.js';
24
+ import { checkLatestVersion } from '../version-check.js';
25
+ import { saveResume } from '../resume.js';
23
26
  import { basename } from 'path';
24
27
 
25
28
  // CWD basename — computed once at startup (before any cd).
26
29
  const APP_CWD = basename(process.cwd());
27
30
 
31
+ // Tab identifiers (order = display order).
32
+ export const TABS = ['Session', 'Monitoring', 'Issues', 'Gists'];
33
+
28
34
  // Split a command argument string into argv, honouring "quoted" segments.
29
35
  function splitArgs(s) {
30
36
  const out = [];
@@ -52,7 +58,7 @@ function useTerminalSize() {
52
58
  let itemId = 0;
53
59
  const nextId = () => ++itemId;
54
60
 
55
- export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, version = '' }) {
61
+ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, version = '', resumeSession = null }) {
56
62
  const { exit } = useApp();
57
63
  const { cols, rows } = useTerminalSize();
58
64
 
@@ -66,6 +72,9 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
66
72
  const [startedAt, setStartedAt] = useState(0);
67
73
  const [choice, setChoice] = useState(null); // { title, note, options, allowText, sel, text, focusText, onPick }
68
74
  const [clearEpoch, setClearEpoch] = useState(0); // bump to remount <Static> on /clear /new
75
+ const [prefillText, setPrefillText] = useState(null); // clipboard paste → pre-fills Composer
76
+ const [activeTab, setActiveTab] = useState(0); // 0=Session 1=Monitoring 2=Issues 3=Gists
77
+ const [latestVersion, setLatestVersion] = useState(null); // { latest, isNewer } from npm
69
78
 
70
79
  const convo = useRef([]); // [{role, content}] for the backend
71
80
  const plain = useRef([]); // plain-text transcript for exit reprint
@@ -75,6 +84,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
75
84
  const modelRef = useRef(cfg._model); // preferred model to request
76
85
  const lastAnswerRef = useRef(''); // most recent assistant answer (for /copy, /gist)
77
86
  const choiceActionRef = useRef(null); // action to run when a non-AI Choice is picked
87
+ const issuesRef = useRef([]); // AI-flagged issues accumulated across session
78
88
 
79
89
  // Enable terminal bracketed-paste so multi-line pastes (mouse right-click or
80
90
  // Cmd/Ctrl+V) arrive wrapped in markers as one block — the Composer inserts
@@ -84,6 +94,35 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
84
94
  return () => { try { process.stdout.write('\x1b[?2004l'); } catch {} };
85
95
  }, []);
86
96
 
97
+ // Background version check — runs once after mount, non-blocking.
98
+ useEffect(() => {
99
+ if (!version || version === 'unknown') return;
100
+ checkLatestVersion(version).then((result) => {
101
+ if (result?.isNewer) setLatestVersion(result);
102
+ });
103
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
104
+
105
+ // Restore session after a hot-reload /update.
106
+ useEffect(() => {
107
+ if (!resumeSession) return;
108
+ convo.current = resumeSession.convo || [];
109
+ plain.current = resumeSession.plain || [];
110
+ // Re-hydrate items list from plain text so the user sees their history.
111
+ if (resumeSession.items?.length) {
112
+ setItems(resumeSession.items.map((it) => ({ id: nextId(), ...it })));
113
+ }
114
+ push({ type: 'notice', text: `${glyphs.check} Session restored after update — conversation history preserved.`, color: C.green });
115
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
116
+
117
+ // Tab switching: Ctrl+1/2/3/4.
118
+ useInput((input, key) => {
119
+ if (!key.ctrl) return;
120
+ if (input === '1') { setActiveTab(0); return; }
121
+ if (input === '2') { setActiveTab(1); return; }
122
+ if (input === '3') { setActiveTab(2); return; }
123
+ if (input === '4') { setActiveTab(3); return; }
124
+ });
125
+
87
126
  const setAutopilotMode = useCallback((next) => {
88
127
  apRef.current = next;
89
128
  setAutopilot(next);
@@ -329,7 +368,13 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
329
368
  return;
330
369
  }
331
370
  if (q === '/logout') { onExternal?.('logout'); doExit(); return; }
332
- if (q === '/update') { onExternal?.('update'); doExit(); return; }
371
+ if (q === '/update') {
372
+ // Save current conversation so the new binary can resume it.
373
+ const resumeFile = saveResume(convo.current, plain.current, items);
374
+ onExternal?.({ action: 'update', resumeFile });
375
+ doExit();
376
+ return;
377
+ }
333
378
 
334
379
  // ── Memory ──
335
380
  if (q === '/remember' || q.startsWith('/remember ')) {
@@ -357,8 +402,39 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
357
402
  }
358
403
 
359
404
  // ── Clipboard ──
360
- if (q === '/copy') {
361
- const ans = lastAnswerRef.current;
405
+ // ── /about ─────────────────────────────────────────────────────────────
406
+ if (q === '/about') {
407
+ const lines = [
408
+ `iCopilot CLI v${version || '?'}`,
409
+ `Server ${cfg.url || '(unknown)'}`,
410
+ `User ${cfg.user?.name || '—'} (${cfg.user?.role || '—'})`,
411
+ `Model ${shortModel(model || cfg._model || '—')}`,
412
+ '',
413
+ 'Type /help for all commands · Ctrl+C to exit',
414
+ 'npm: ispbills-icli · github: lupael/IspBills',
415
+ ];
416
+ push({ type: 'notice', text: lines.join('\n'), color: C.accent });
417
+ return;
418
+ }
419
+
420
+ // ── /paste ─────────────────────────────────────────────────────────────
421
+ if (q === '/paste') {
422
+ push({ type: 'notice', text: `${glyphs.arrow} Reading clipboard…`, color: C.gray });
423
+ readFromClipboard().then((text) => {
424
+ if (!text) {
425
+ push({ type: 'notice', text: `${glyphs.cross} Clipboard is empty or no clipboard tool found.\nInstall xclip / wl-clipboard (Linux), or use macOS/Windows.`, color: C.red });
426
+ return;
427
+ }
428
+ const lines = text.split('\n').length;
429
+ push({ type: 'notice',
430
+ text: `${glyphs.check} Pasted ${lines} line${lines !== 1 ? 's' : ''} (${text.length} chars) into the input box — review and press Enter to send.`,
431
+ color: C.green });
432
+ setPrefillText(text);
433
+ });
434
+ return;
435
+ }
436
+
437
+ if (q === '/copy') { const ans = lastAnswerRef.current;
362
438
  if (!ans) { push({ type: 'notice', text: 'Nothing to copy yet — ask something first.', color: C.gray }); return; }
363
439
  copyToClipboard(ans).then((tool) => push({ type: 'notice',
364
440
  text: tool ? `${glyphs.check} Copied last answer (${ans.length} chars) to the clipboard.`
@@ -517,40 +593,53 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
517
593
 
518
594
  return html`
519
595
  <${Box} flexDirection="column" width=${cols}>
520
- <${Static} key=${clearEpoch} items=${staticItems}>
521
- ${(it) => it.type === 'banner'
522
- ? html`<${Box} key=${it.id} flexDirection="column" alignItems="center" marginBottom=${1}>
523
- <${Banner} cfg=${cfg} model=${model} width=${cols} compact=${compact} version=${version} cwd=${APP_CWD} />
524
- <//>`
525
- : renderItem(it)}
526
- <//>
596
+ ${/* Tab bar — always pinned above content */null}
597
+ <${TabBar} tabs=${TABS} active=${activeTab} onSelect=${setActiveTab} width=${cols} />
527
598
 
528
- ${live
599
+ ${activeTab === 0
529
600
  ? html`<${Box} flexDirection="column">
530
- <${StatusLines} lines=${live.status} busy=${busy} />
531
- ${showReasoning && live.reasoning ? html`<${Reasoning} text=${live.reasoning} />` : null}
532
- ${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
533
- ${live.text
534
- ? html`<${Box} marginTop=${live.status.length ? 0 : 1}>
535
- <${AssistantMessage} text=${live.text} streaming=${true} />
536
- <//>` : null}
537
- ${!live.text && !live.reply?.steps
538
- ? html`<${Thinking}
539
- label=${live.status.length ? 'Working' : 'Thinking'}
540
- startedAt=${startedAt} />`
601
+ <${Static} key=${clearEpoch} items=${staticItems}>
602
+ ${(it) => it.type === 'banner'
603
+ ? html`<${Box} key=${it.id} flexDirection="column" alignItems="center" marginBottom=${1}>
604
+ <${Banner} cfg=${cfg} model=${model} width=${cols} compact=${compact} version=${version} cwd=${APP_CWD} />
605
+ <//>`
606
+ : renderItem(it)}
607
+ <//>
608
+ ${live
609
+ ? html`<${Box} flexDirection="column">
610
+ <${StatusLines} lines=${live.status} busy=${busy} />
611
+ ${showReasoning && live.reasoning ? html`<${Reasoning} text=${live.reasoning} />` : null}
612
+ ${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
613
+ ${live.text
614
+ ? html`<${Box} marginTop=${live.status.length ? 0 : 1}>
615
+ <${AssistantMessage} text=${live.text} streaming=${true} />
616
+ <//>` : null}
617
+ ${!live.text && !live.reply?.steps
618
+ ? html`<${Thinking}
619
+ label=${live.status.length ? 'Working' : 'Thinking'}
620
+ startedAt=${startedAt} />`
621
+ : null}
622
+ <//>`
541
623
  : null}
542
624
  <//>`
543
625
  : null}
626
+ ${activeTab === 1 ? html`<${MonitoringView} cfg=${cfg} />` : null}
627
+ ${activeTab === 2 ? html`<${IssuesView} issues=${issuesRef.current} />` : null}
628
+ ${activeTab === 3 ? html`<${GistsView} cfg=${cfg} />` : null}
544
629
 
545
630
  <${Box} flexDirection="column" marginTop=${1}>
546
- ${choice
547
- ? html`<${Choice} ...${choice} />`
548
- : html`<${Composer} onSubmit=${onSubmit} busy=${busy} width=${cols} />`}
631
+ ${activeTab === 0
632
+ ? (choice
633
+ ? html`<${Choice} ...${choice} />`
634
+ : html`<${Composer} onSubmit=${onSubmit} busy=${busy} width=${cols} prefill=${prefillText} onPrefillConsumed=${() => setPrefillText(null)} />`)
635
+ : html`<${Box} paddingX=${1}><${Text} dimColor>Ctrl+1 for Session · Ctrl+2/3/4 for other tabs<//> <//>` }
549
636
  <${Box} paddingX=${1} width=${cols}>
550
637
  <${Box} flexGrow=${1}>
551
638
  <${Text} dimColor wrap="truncate-end">${ctx}<//>
552
639
  <//>
553
- <${Text} color=${footerColor} dimColor=${!footerColor} wrap="truncate-end">${footerHint}<//>
640
+ ${latestVersion?.isNewer
641
+ ? html`<${Text} color=${C.yellow} wrap="truncate-end">🔔 v${latestVersion.latest} available — /update<//>`
642
+ : html`<${Text} color=${footerColor} dimColor=${!footerColor} wrap="truncate-end">${footerHint}<//>` }
554
643
  <//>
555
644
  <//>
556
645
  <//>`;
@@ -1,5 +1,5 @@
1
1
  // Presentational components for the iCli Ink TUI.
2
- import { html, useState, useEffect } from './dom.js';
2
+ import { html, useState, useEffect, useRef, useCallback } from './dom.js';
3
3
  import { Box, Text } from 'ink';
4
4
  import Spinner from 'ink-spinner';
5
5
  import { C, SG, glyphs, spinnerType, borderStyle, midDot, dash, asciiSafe } from './theme.js';
@@ -479,3 +479,279 @@ export function ShellOut({ ok, output = '' }) {
479
479
  ? html`<${Text} dimColor>${glyphs.bullet} ${more} more line${more > 1 ? 's' : ''} hidden<//>` : null}
480
480
  <//>`;
481
481
  }
482
+
483
+ // ── Tab bar ──────────────────────────────────────────────────────────────────
484
+ // GitHub Copilot CLI-style tab bar pinned above the content area.
485
+ // Tabs are keyboard-accessible via Ctrl+1/2/3/4 (wired in app.js).
486
+
487
+ /**
488
+ * Top-of-screen tab bar: Session | Monitoring | Issues | Gists
489
+ * Active tab rendered with accent underline + bold; inactive tabs are dimmed.
490
+ * The separator rule beneath uses the brand accent colour.
491
+ */
492
+ export function TabBar({ tabs = [], active = 0, onSelect, width = 80 }) {
493
+ const A = asciiSafe();
494
+ const sep = A ? '-' : '─';
495
+ return html`
496
+ <${Box} flexDirection="column" width=${width}>
497
+ <${Box} flexDirection="row" paddingX=${1} paddingTop=${0}>
498
+ ${tabs.map((label, i) => {
499
+ const on = i === active;
500
+ return html`
501
+ <${Box} key=${'tab' + i} marginRight=${2}>
502
+ <${Text}
503
+ color=${on ? C.accent : C.gray}
504
+ bold=${on}
505
+ dimColor=${!on}
506
+ underline=${on}
507
+ >${on ? glyphs.prompt + ' ' : ' '}${label}<//><//>`;
508
+ })}
509
+ <${Box} flexGrow=${1}>
510
+ <${Text} dimColor> Ctrl+${tabs.map((_, i) => i + 1).join('/')}<//><//>
511
+ <//>
512
+ <${Text} color=${C.accent} dimColor>${sep.repeat(Math.max(8, width - 2))}<//>
513
+ <//>`;
514
+ }
515
+
516
+ // ── Monitoring tab ────────────────────────────────────────────────────────────
517
+
518
+ /**
519
+ * Auto-fetch /api/v2/monitoring/devices and /api/v2/monitoring/status-checks.
520
+ * Refreshes every 30 seconds.
521
+ */
522
+ export function MonitoringView({ cfg }) {
523
+ const [data, setData] = useState(null); // { devices, checks }
524
+ const [loading, setLoading] = useState(true);
525
+ const [err, setErr] = useState(null);
526
+ const [lastAt, setLastAt] = useState(null);
527
+ const timerRef = useRef(null);
528
+
529
+ const fetch_ = useCallback(async () => {
530
+ if (!cfg?.url || !cfg?.token) {
531
+ setErr('No server configured. Run icli login first.');
532
+ setLoading(false);
533
+ return;
534
+ }
535
+ try {
536
+ const headers = { Authorization: `Bearer ${cfg.token}`, Accept: 'application/json' };
537
+ const [devRes, chkRes] = await Promise.all([
538
+ fetch(`${cfg.url}/api/v2/monitoring/devices`, { headers }).catch(() => null),
539
+ fetch(`${cfg.url}/api/v2/monitoring/status-checks`, { headers }).catch(() => null),
540
+ ]);
541
+ const devices = devRes?.ok ? ((await devRes.json()).data || []) : [];
542
+ const checks = chkRes?.ok ? ((await chkRes.json()).data || []) : [];
543
+ setData({ devices, checks });
544
+ setErr(null);
545
+ } catch (e) {
546
+ setErr(e.message || 'Fetch failed');
547
+ } finally {
548
+ setLoading(false);
549
+ setLastAt(new Date().toLocaleTimeString());
550
+ }
551
+ }, [cfg]);
552
+
553
+ useEffect(() => {
554
+ fetch_();
555
+ timerRef.current = setInterval(fetch_, 30000);
556
+ return () => clearInterval(timerRef.current);
557
+ }, [fetch_]);
558
+
559
+ if (loading) return html`
560
+ <${Box} marginTop=${2} paddingX=${2}>
561
+ <${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /><//>
562
+ <${Text} color=${C.gray}> Fetching device status…<//>
563
+ <//>`;
564
+
565
+ if (err) return html`
566
+ <${Box} marginTop=${2} paddingX=${2} flexDirection="column">
567
+ <${Text} color=${C.red}>${glyphs.cross} Monitoring error: ${err}<//>
568
+ <${Text} dimColor>Make sure the server is reachable and you are logged in.<//>
569
+ <//>`;
570
+
571
+ const { devices = [], checks = [] } = data || {};
572
+ const devUp = devices.filter((d) => String(d.status).toLowerCase() === 'up' || d.status === 1).length;
573
+ const devDown = devices.length - devUp;
574
+ const chkOk = checks.filter((c) => c.latest_result?.status === 'up').length;
575
+ const chkFail = checks.filter((c) => c.latest_result?.status !== 'up' && c.latest_result).length;
576
+
577
+ return html`
578
+ <${Box} flexDirection="column" paddingX=${2} marginTop=${1}>
579
+ <${Box} marginBottom=${1}>
580
+ <${Text} color=${C.accent} bold>Network Monitoring<//>
581
+ ${lastAt ? html`<${Text} dimColor> · refreshes every 30s · last: ${lastAt}<//>` : null}
582
+ <//>
583
+
584
+ ${/* ── Device summary ── */ null}
585
+ <${Box} marginBottom=${1} flexDirection="row" gap=${3}>
586
+ <${Box} flexDirection="column">
587
+ <${Text} dimColor>Managed Devices<//>
588
+ <${Box}>
589
+ <${Text} color=${C.green} bold>${devUp}<//>
590
+ <${Text} color=${C.gray}> up <//>
591
+ <${Text} color=${devDown > 0 ? C.red : C.gray} bold=${devDown > 0}>${devDown}<//>
592
+ <${Text} color=${C.gray}> down<//>
593
+ <//>
594
+ <//>
595
+ <${Box} flexDirection="column">
596
+ <${Text} dimColor>Status Checks<//>
597
+ <${Box}>
598
+ <${Text} color=${C.green} bold>${chkOk}<//>
599
+ <${Text} color=${C.gray}> ok <//>
600
+ <${Text} color=${chkFail > 0 ? C.red : C.gray} bold=${chkFail > 0}>${chkFail}<//>
601
+ <${Text} color=${C.gray}> fail<//>
602
+ <//>
603
+ <//>
604
+ <//>
605
+
606
+ ${/* ── Down devices ── */ null}
607
+ ${devDown > 0
608
+ ? html`<${Box} flexDirection="column" marginBottom=${1}>
609
+ <${Text} color=${C.red} bold>${glyphs.warn} Down devices<//>
610
+ ${devices.filter((d) => String(d.status).toLowerCase() !== 'up' && d.status !== 1).slice(0, 10).map((d, i) =>
611
+ html`<${Box} key=${'dd' + i}>
612
+ <${Text} color=${C.red}> ${glyphs.cross} <//>
613
+ <${Text} color=${C.white}>${d.name || d.ip_address}<//>
614
+ <${Text} dimColor> ${d.device_type || ''} ${d.ip_address}<//>
615
+ <//>` )}
616
+ <//>` : null}
617
+
618
+ ${/* ── Failed checks ── */ null}
619
+ ${chkFail > 0
620
+ ? html`<${Box} flexDirection="column">
621
+ <${Text} color=${C.yellow} bold>${glyphs.warn} Failed checks<//>
622
+ ${checks.filter((c) => c.latest_result?.status !== 'up' && c.latest_result).slice(0, 10).map((c, i) =>
623
+ html`<${Box} key=${'cf' + i}>
624
+ <${Text} color=${C.yellow}> ${glyphs.warn} <//>
625
+ <${Text} color=${C.white}>${c.name}<//>
626
+ <${Text} dimColor> ${c.target || ''} ${c.latest_result?.status || 'unknown'}<//>
627
+ <//>` )}
628
+ <//>` : null}
629
+
630
+ ${devDown === 0 && chkFail === 0
631
+ ? html`<${Box}><${Text} color=${C.green}>${glyphs.check} All devices and checks are up<//> <//>`
632
+ : null}
633
+
634
+ <${Box} marginTop=${1}>
635
+ <${Text} dimColor>Switch to Session tab (Ctrl+1) · type /check in Session for details<//>
636
+ <//>
637
+ <//>`;
638
+ }
639
+
640
+ // ── Issues tab ────────────────────────────────────────────────────────────────
641
+
642
+ /**
643
+ * Shows AI-flagged issues collected during the current session.
644
+ * Issues are populated by app.js whenever the AI notices an error/alarm.
645
+ */
646
+ export function IssuesView({ issues = [] }) {
647
+ if (!issues.length) return html`
648
+ <${Box} flexDirection="column" paddingX=${2} marginTop=${2}>
649
+ <${Text} color=${C.accent} bold>Issues<//>
650
+ <${Box} marginTop=${1}>
651
+ <${Text} dimColor>${glyphs.bullet} No issues flagged in this session.<//>
652
+ <//>
653
+ <${Box} marginTop=${1}>
654
+ <${Text} dimColor>Run /alarms or /check in the Session tab to detect problems.<//>
655
+ <//>
656
+ <//>`;
657
+
658
+ return html`
659
+ <${Box} flexDirection="column" paddingX=${2} marginTop=${1}>
660
+ <${Text} color=${C.accent} bold>Issues <${Text} dimColor>(${issues.length} flagged this session)<//><//>
661
+ <${Box} flexDirection="column" marginTop=${1}>
662
+ ${issues.slice().reverse().slice(0, 20).map((iss, i) => html`
663
+ <${Box} key=${'iss' + i} flexDirection="column" marginBottom=${1}>
664
+ <${Box}>
665
+ <${Text} color=${iss.severity === 'high' ? C.red : iss.severity === 'medium' ? C.yellow : C.gray}
666
+ >${iss.severity === 'high' ? glyphs.cross : glyphs.warn} <//>
667
+ <${Text} color=${C.white} bold>${iss.title}<//>
668
+ <${Text} dimColor> ${iss.ts || ''}<//>
669
+ <//>
670
+ ${iss.body ? html`<${Text} dimColor> ${iss.body.slice(0, 120)}<//>` : null}
671
+ <//>` )}
672
+ <//>
673
+ <//>`;
674
+ }
675
+
676
+ // ── Gists tab ─────────────────────────────────────────────────────────────────
677
+
678
+ /**
679
+ * Lists GitHub gists created with /gist during this session or stored locally.
680
+ * If no GitHub token is set, shows instructions.
681
+ */
682
+ export function GistsView({ cfg }) {
683
+ const [gists, setGists] = useState([]);
684
+ const [loading, setLoading] = useState(true);
685
+ const [err, setErr] = useState(null);
686
+
687
+ useEffect(() => {
688
+ const token = cfg?.github_token;
689
+ if (!token) {
690
+ setLoading(false);
691
+ return;
692
+ }
693
+ fetch('https://api.github.com/gists?per_page=20', {
694
+ headers: { Authorization: `token ${token}`, Accept: 'application/vnd.github.v3+json' },
695
+ })
696
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
697
+ .then((data) => {
698
+ const icliGists = Array.isArray(data)
699
+ ? data.filter((g) => (g.description || '').startsWith('[icli]') || (g.description || '').includes('iCopilot'))
700
+ : [];
701
+ setGists(icliGists.length ? icliGists : data.slice(0, 10));
702
+ })
703
+ .catch((e) => setErr(e.message))
704
+ .finally(() => setLoading(false));
705
+ }, [cfg?.github_token]);
706
+
707
+ if (!cfg?.github_token) return html`
708
+ <${Box} flexDirection="column" paddingX=${2} marginTop=${2}>
709
+ <${Text} color=${C.accent} bold>Gists<//>
710
+ <${Box} marginTop=${1} flexDirection="column">
711
+ <${Text} dimColor>No GitHub token configured.<//>
712
+ <${Text} dimColor>Run: ${''}<//>
713
+ <${Text} color=${C.cyan}> /gist token YOUR_TOKEN<//>
714
+ <${Text} dimColor>Then use ${''}<//>
715
+ <${Text} color=${C.cyan}> /gist<//>
716
+ <${Text} dimColor> in Session to save AI answers as secret gists.<//>
717
+ <//>
718
+ <//>`;
719
+
720
+ if (loading) return html`
721
+ <${Box} paddingX=${2} marginTop=${2}>
722
+ <${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /><//>
723
+ <${Text} color=${C.gray}> Loading gists…<//>
724
+ <//>`;
725
+
726
+ if (err) return html`
727
+ <${Box} paddingX=${2} marginTop=${2} flexDirection="column">
728
+ <${Text} color=${C.red}>${glyphs.cross} GitHub error: ${err}<//>
729
+ <//>`;
730
+
731
+ if (!gists.length) return html`
732
+ <${Box} paddingX=${2} marginTop=${2} flexDirection="column">
733
+ <${Text} color=${C.accent} bold>Gists<//>
734
+ <${Box} marginTop=${1}><${Text} dimColor>No gists yet. Use /gist in Session to save an answer.<//> <//><//>
735
+ <//>`;
736
+
737
+ return html`
738
+ <${Box} flexDirection="column" paddingX=${2} marginTop=${1}>
739
+ <${Text} color=${C.accent} bold>Gists <${Text} dimColor>(${gists.length} recent)<//><//>
740
+ <${Box} flexDirection="column" marginTop=${1}>
741
+ ${gists.slice(0, 15).map((g, i) => {
742
+ const fname = Object.keys(g.files || {})[0] || 'unknown';
743
+ const desc = (g.description || fname).slice(0, 60);
744
+ const date = g.updated_at ? g.updated_at.slice(0, 10) : '';
745
+ return html`
746
+ <${Box} key=${'g' + i} marginBottom=${0}>
747
+ <${Text} color=${C.cyan}>${glyphs.bullet} <//>
748
+ <${Text} color=${C.white}>${desc}<//>
749
+ <${Text} dimColor> ${date}<//>
750
+ <//>
751
+ <${Box} key=${'gu' + i}>
752
+ <${Text} dimColor> ${g.html_url || ''}<//>
753
+ <//>`;
754
+ })}
755
+ <//>
756
+ <//>`;
757
+ }
@@ -20,7 +20,7 @@ const MENTIONS = [
20
20
  { tag: '@mac', desc: 'A MAC address' },
21
21
  ];
22
22
 
23
- export function Composer({ onSubmit, busy, width = 80 }) {
23
+ export function Composer({ onSubmit, busy, width = 80, prefill = null, onPrefillConsumed = null }) {
24
24
  const [value, setValue] = useState('');
25
25
  const [cursor, setCursor] = useState(0);
26
26
  const [history, setHistory] = useState([]);
@@ -40,6 +40,15 @@ export function Composer({ onSubmit, busy, width = 80 }) {
40
40
  lastMut.current = kind;
41
41
  };
42
42
 
43
+ // When app.js injects a /paste prefill, load it into the input box once.
44
+ useEffect(() => {
45
+ if (!prefill) return;
46
+ setValue(prefill);
47
+ setCursor(prefill.length);
48
+ undoRef.current = []; redoRef.current = []; lastMut.current = '';
49
+ onPrefillConsumed?.();
50
+ }, [prefill]); // eslint-disable-line react-hooks/exhaustive-deps
51
+
43
52
  // Ctrl+S stash: save the current prompt to restore later (GitHub Copilot CLI parity).
44
53
  const stashRef = useRef('');
45
54
 
package/src/tui/run.js CHANGED
@@ -8,7 +8,7 @@ import { render } from 'ink';
8
8
  import { App } from './app.js';
9
9
 
10
10
  export async function runTui(cfg, opts = {}) {
11
- const { display = {}, onExternal, initialQuestion, version = '' } = opts;
11
+ const { display = {}, onExternal, initialQuestion, version = '', resumeSession = null } = opts;
12
12
  let externalAction;
13
13
 
14
14
  const instance = render(
@@ -17,6 +17,7 @@ export async function runTui(cfg, opts = {}) {
17
17
  display=${display}
18
18
  initialQuestion=${initialQuestion}
19
19
  version=${version}
20
+ resumeSession=${resumeSession}
20
21
  onExternal=${(a) => { externalAction = a; onExternal?.(a); }}
21
22
  />`,
22
23
  { exitOnCtrlC: false },
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Background version check against npm registry.
3
+ * Caches the result in the session so we only hit npm once per run.
4
+ * Resolves to { latest, isNewer } or null on any error (network, etc.).
5
+ */
6
+
7
+ const PKG_NAME = 'ispbills-icli';
8
+ const REGISTRY = `https://registry.npmjs.org/${PKG_NAME}/latest`;
9
+
10
+ /** Parse a semver string into [major, minor, patch] ints. */
11
+ function parse(v) {
12
+ const parts = String(v || '').replace(/[^0-9.]/g, '').split('.').map(Number);
13
+ return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
14
+ }
15
+
16
+ /** Returns true when `remote` is strictly newer than `current`. */
17
+ function isNewer(current, remote) {
18
+ const [cMaj, cMin, cPat] = parse(current);
19
+ const [rMaj, rMin, rPat] = parse(remote);
20
+ if (rMaj !== cMaj) return rMaj > cMaj;
21
+ if (rMin !== cMin) return rMin > cMin;
22
+ return rPat > cPat;
23
+ }
24
+
25
+ /**
26
+ * Check npm for a newer version of ispbills-icli.
27
+ * Resolves to { latest: string, isNewer: boolean } or null.
28
+ */
29
+ export async function checkLatestVersion(currentVersion) {
30
+ try {
31
+ const ctrl = new AbortController();
32
+ const timer = setTimeout(() => ctrl.abort(), 5000);
33
+ const res = await fetch(REGISTRY, { signal: ctrl.signal });
34
+ clearTimeout(timer);
35
+ if (!res.ok) return null;
36
+ const data = await res.json();
37
+ const latest = data.version;
38
+ if (!latest) return null;
39
+ return { latest, isNewer: isNewer(currentVersion, latest) };
40
+ } catch {
41
+ return null;
42
+ }
43
+ }