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 +24 -4
- package/package.json +1 -1
- package/src/clipboard.js +35 -1
- package/src/commands.js +2 -0
- package/src/resume.js +30 -0
- package/src/tui/app.js +117 -28
- package/src/tui/components.js +277 -1
- package/src/tui/composer.js +10 -1
- 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/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') {
|
|
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
|
-
|
|
361
|
-
|
|
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
|
-
|
|
521
|
-
|
|
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
|
-
${
|
|
599
|
+
${activeTab === 0
|
|
529
600
|
? html`<${Box} flexDirection="column">
|
|
530
|
-
<${
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
${
|
|
538
|
-
? html`<${
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
${
|
|
547
|
-
?
|
|
548
|
-
|
|
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
|
-
|
|
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
|
<//>`;
|
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';
|
|
@@ -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/composer.js
CHANGED
|
@@ -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
|
+
}
|