ispbills-icli 8.4.7 → 8.4.9
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 +24 -4
- package/package.json +1 -1
- package/src/resume.js +30 -0
- package/src/tui/app.js +82 -25
- package/src/tui/components.js +278 -2
- package/src/tui/run.js +2 -1
- package/src/version-check.js +43 -0
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
|
|
319
|
-
|
|
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 (
|
|
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
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';
|
|
@@ -20,11 +21,16 @@ 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
|
|
|
@@ -67,6 +73,8 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
|
|
|
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
|
|
69
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
|
|
70
78
|
|
|
71
79
|
const convo = useRef([]); // [{role, content}] for the backend
|
|
72
80
|
const plain = useRef([]); // plain-text transcript for exit reprint
|
|
@@ -76,6 +84,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
|
|
|
76
84
|
const modelRef = useRef(cfg._model); // preferred model to request
|
|
77
85
|
const lastAnswerRef = useRef(''); // most recent assistant answer (for /copy, /gist)
|
|
78
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
|
|
79
88
|
|
|
80
89
|
// Enable terminal bracketed-paste so multi-line pastes (mouse right-click or
|
|
81
90
|
// Cmd/Ctrl+V) arrive wrapped in markers as one block — the Composer inserts
|
|
@@ -85,6 +94,35 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
|
|
|
85
94
|
return () => { try { process.stdout.write('\x1b[?2004l'); } catch {} };
|
|
86
95
|
}, []);
|
|
87
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
|
+
|
|
88
126
|
const setAutopilotMode = useCallback((next) => {
|
|
89
127
|
apRef.current = next;
|
|
90
128
|
setAutopilot(next);
|
|
@@ -330,7 +368,13 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
|
|
|
330
368
|
return;
|
|
331
369
|
}
|
|
332
370
|
if (q === '/logout') { onExternal?.('logout'); doExit(); return; }
|
|
333
|
-
if (q === '/update') {
|
|
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
|
+
}
|
|
334
378
|
|
|
335
379
|
// ── Memory ──
|
|
336
380
|
if (q === '/remember' || q.startsWith('/remember ')) {
|
|
@@ -549,40 +593,53 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
|
|
|
549
593
|
|
|
550
594
|
return html`
|
|
551
595
|
<${Box} flexDirection="column" width=${cols}>
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
? html`<${Box} key=${it.id} flexDirection="column" alignItems="center" marginBottom=${1}>
|
|
555
|
-
<${Banner} cfg=${cfg} model=${model} width=${cols} compact=${compact} version=${version} cwd=${APP_CWD} />
|
|
556
|
-
<//>`
|
|
557
|
-
: renderItem(it)}
|
|
558
|
-
<//>
|
|
596
|
+
${/* Tab bar — always pinned above content */null}
|
|
597
|
+
<${TabBar} tabs=${TABS} active=${activeTab} onSelect=${setActiveTab} width=${cols} />
|
|
559
598
|
|
|
560
|
-
${
|
|
599
|
+
${activeTab === 0
|
|
561
600
|
? html`<${Box} flexDirection="column">
|
|
562
|
-
<${
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
${
|
|
570
|
-
? html`<${
|
|
571
|
-
|
|
572
|
-
|
|
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
|
+
<//>`
|
|
573
623
|
: null}
|
|
574
624
|
<//>`
|
|
575
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}
|
|
576
629
|
|
|
577
630
|
<${Box} flexDirection="column" marginTop=${1}>
|
|
578
|
-
${
|
|
579
|
-
?
|
|
580
|
-
|
|
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<//> <//>` }
|
|
581
636
|
<${Box} paddingX=${1} width=${cols}>
|
|
582
637
|
<${Box} flexGrow=${1}>
|
|
583
638
|
<${Text} dimColor wrap="truncate-end">${ctx}<//>
|
|
584
639
|
<//>
|
|
585
|
-
|
|
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}<//>` }
|
|
586
643
|
<//>
|
|
587
644
|
<//>
|
|
588
645
|
<//>`;
|
package/src/tui/components.js
CHANGED
|
@@ -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';
|
|
@@ -68,7 +68,7 @@ export function Banner({ cfg = {}, model, width = 80, compact = false, version =
|
|
|
68
68
|
C.white}>${val}<//>
|
|
69
69
|
<//>`)}
|
|
70
70
|
<//>
|
|
71
|
-
${cfg.url ? html`<${Box} marginTop=${0}><${Text} dimColor>${cfg.url}
|
|
71
|
+
${cfg.url ? html`<${Box} marginTop=${0}><${Text} dimColor>${cfg.url}<//><//>` : null}
|
|
72
72
|
<${Box} marginTop=${1}>
|
|
73
73
|
<${Text} dimColor>Type a message${midDot()}<//>
|
|
74
74
|
<${Text} color=${C.accent}>/help<//>
|
|
@@ -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
|
+
}
|
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
|
+
}
|