ispbills-icli 8.4.11 → 8.4.13
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 +4 -24
- package/package.json +1 -1
- package/src/banner.js +1 -3
- package/src/clipboard.js +1 -35
- package/src/commands.js +0 -3
- package/src/tui/app.js +27 -249
- package/src/tui/components.js +89 -587
- package/src/tui/composer.js +4 -39
- package/src/tui/markdown.js +3 -19
- package/src/tui/run.js +1 -17
- package/src/tui/theme.js +11 -29
- package/src/resume.js +0 -30
- package/src/shell.js +0 -18
- package/src/tui/mouse.js +0 -68
- package/src/version-check.js +0 -43
package/bin/icli.js
CHANGED
|
@@ -315,33 +315,13 @@ 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
|
-
|
|
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') {
|
|
318
|
+
const action = await runTui(cfg, { display });
|
|
319
|
+
if (action === 'logout') {
|
|
328
320
|
const cleared = clearConfig();
|
|
329
321
|
console.log(`\n ${cleared ? GREEN + glyphs.check + RESET + ' Logged out — credentials cleared.' : YELLOW + 'Already logged out.' + RESET}`);
|
|
330
322
|
console.log(` ${DIM}Run ${RESET}${ACCENT}icli login${RESET}${DIM} to sign in again.${RESET}\n`);
|
|
331
|
-
} else if (
|
|
332
|
-
|
|
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
|
-
}
|
|
323
|
+
} else if (action === 'update') {
|
|
324
|
+
selfUpdate();
|
|
345
325
|
}
|
|
346
326
|
return;
|
|
347
327
|
}
|
package/package.json
CHANGED
package/src/banner.js
CHANGED
|
@@ -22,7 +22,6 @@ const LOGO_WIDTH = Math.max(...LOGO.map((l) => l.length));
|
|
|
22
22
|
export function printBanner(cfg = {}, opts = {}) {
|
|
23
23
|
const width = cols();
|
|
24
24
|
const model = opts.model || cfg._model || 'IspBills AI';
|
|
25
|
-
const version = opts.version || '';
|
|
26
25
|
|
|
27
26
|
console.log();
|
|
28
27
|
if (width >= LOGO_WIDTH) {
|
|
@@ -37,11 +36,10 @@ export function printBanner(cfg = {}, opts = {}) {
|
|
|
37
36
|
parts.push(`${DIM}model${RESET} ${CYAN}${shortModel(model)}${RESET}`);
|
|
38
37
|
if (cfg.user?.name) parts.push(`${DIM}user${RESET} ${WHITE}${cfg.user.name}${RESET}`);
|
|
39
38
|
if (cfg.user?.role) parts.push(`${DIM}role${RESET} ${WHITE}${cfg.user.role}${RESET}`);
|
|
40
|
-
if (version) parts.push(`${DIM}v${RESET} ${WHITE}${version}${RESET}`);
|
|
41
39
|
console.log(' ' + parts.join(` ${GRAY}·${RESET} `));
|
|
42
40
|
if (cfg.url) console.log(` ${DIM}url${RESET} ${ACCENT}${cfg.url}${RESET}`);
|
|
43
41
|
console.log();
|
|
44
|
-
console.log(` ${DIM}Type a message to start. ${RESET}${ACCENT}/help${RESET}${DIM} for commands · ${RESET}${ACCENT}
|
|
42
|
+
console.log(` ${DIM}Type a message to start. ${RESET}${ACCENT}/help${RESET}${DIM} for commands · ${RESET}${ACCENT}Ctrl+C${RESET}${DIM} to exit${RESET}`);
|
|
45
43
|
console.log();
|
|
46
44
|
hr();
|
|
47
45
|
console.log();
|
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
|
|
4
|
+
import { spawn } from 'child_process';
|
|
5
5
|
|
|
6
6
|
function candidates() {
|
|
7
7
|
if (process.platform === 'darwin') return [['pbcopy', []]];
|
|
@@ -40,37 +40,3 @@ 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
|
@@ -52,12 +52,9 @@ export const SLASH_COMMANDS = [
|
|
|
52
52
|
{ cmd: '/copy', hint: '', desc: 'Copy the last AI answer to the clipboard', group: 'Memory', local: true },
|
|
53
53
|
|
|
54
54
|
// ── Session & app ──
|
|
55
|
-
{ cmd: '/usage', hint: '', desc: 'Show conversation stats (turns, ~tokens, model)', group: 'Session', local: true },
|
|
56
55
|
{ cmd: '/autopilot', hint: '', desc: 'Toggle autopilot (auto-apply fixes)', group: 'Session', local: true },
|
|
57
56
|
{ cmd: '/model', hint: '[name]', desc: 'Show or set the AI model', group: 'Session', local: true },
|
|
58
57
|
{ 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 },
|
|
61
58
|
{ cmd: '/new', hint: '', desc: 'Start a fresh conversation', group: 'Session', local: true },
|
|
62
59
|
{ cmd: '/history', hint: '', desc: 'Show conversation history', group: 'Session', local: true },
|
|
63
60
|
{ cmd: '/clear', hint: '', desc: 'Clear the screen', group: 'Session', local: true },
|
package/src/tui/app.js
CHANGED
|
@@ -3,20 +3,12 @@
|
|
|
3
3
|
// items are flushed to the terminal's native scrollback via Ink <Static> (so
|
|
4
4
|
// you can scroll back through history), and only the live/streaming area plus
|
|
5
5
|
// the input composer are re-rendered in place at the bottom.
|
|
6
|
-
|
|
7
|
-
// Mouse support (via src/tui/mouse.js + run.js):
|
|
8
|
-
// • Scroll UP → sends \x1b[3S to scroll the terminal viewport upward
|
|
9
|
-
// so the native scrollback buffer shows conversation history.
|
|
10
|
-
// • Scroll DOWN → swallowed; prevents drifting below the live/composer area.
|
|
11
|
-
// • Left click on tab bar row 1 → switches tabs by x-coordinate range.
|
|
12
|
-
import { html, useState, useEffect, useRef, useCallback, useMemo } from './dom.js';
|
|
6
|
+
import { html, useState, useEffect, useRef, useCallback } from './dom.js';
|
|
13
7
|
import { Box, Text, Static, useStdout, useApp, useInput } from 'ink';
|
|
14
8
|
import { C, glyphs, midDot, dash } from './theme.js';
|
|
15
|
-
import { mouseEvents } from './mouse.js';
|
|
16
9
|
import {
|
|
17
10
|
Banner, UserMessage, AssistantMessage, StatusLines, Reasoning,
|
|
18
|
-
Plan, Connect, Notice, Thinking, Choice,
|
|
19
|
-
TabBar, MonitoringView, IssuesView, GistsView,
|
|
11
|
+
Plan, Connect, Notice, Thinking, Choice,
|
|
20
12
|
} from './components.js';
|
|
21
13
|
import { Composer } from './composer.js';
|
|
22
14
|
import { streamChat } from '../client.js';
|
|
@@ -24,19 +16,9 @@ import { slashToQuestion, SLASH_COMMANDS } from '../commands.js';
|
|
|
24
16
|
import { saveConfig } from '../config.js';
|
|
25
17
|
import { shortModel } from '../ui.js';
|
|
26
18
|
import { loadNotes, addNote, forgetNote, memoryPreamble } from '../memory.js';
|
|
27
|
-
import { copyToClipboard
|
|
19
|
+
import { copyToClipboard } from '../clipboard.js';
|
|
28
20
|
import { createGist } from '../gist.js';
|
|
29
21
|
import { git as gitCmd, commitBackup } from '../gitrepo.js';
|
|
30
|
-
import { runShell } from '../shell.js';
|
|
31
|
-
import { checkLatestVersion } from '../version-check.js';
|
|
32
|
-
import { saveResume } from '../resume.js';
|
|
33
|
-
import { basename } from 'path';
|
|
34
|
-
|
|
35
|
-
// CWD basename — computed once at startup (before any cd).
|
|
36
|
-
const APP_CWD = basename(process.cwd());
|
|
37
|
-
|
|
38
|
-
// Tab identifiers (order = display order).
|
|
39
|
-
export const TABS = ['Session', 'Monitoring', 'Issues', 'Gists'];
|
|
40
22
|
|
|
41
23
|
// Split a command argument string into argv, honouring "quoted" segments.
|
|
42
24
|
function splitArgs(s) {
|
|
@@ -65,7 +47,7 @@ function useTerminalSize() {
|
|
|
65
47
|
let itemId = 0;
|
|
66
48
|
const nextId = () => ++itemId;
|
|
67
49
|
|
|
68
|
-
export function App({ cfg, display = {}, onExternal, onExit, initialQuestion
|
|
50
|
+
export function App({ cfg, display = {}, onExternal, onExit, initialQuestion }) {
|
|
69
51
|
const { exit } = useApp();
|
|
70
52
|
const { cols, rows } = useTerminalSize();
|
|
71
53
|
|
|
@@ -79,9 +61,6 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
|
|
|
79
61
|
const [startedAt, setStartedAt] = useState(0);
|
|
80
62
|
const [choice, setChoice] = useState(null); // { title, note, options, allowText, sel, text, focusText, onPick }
|
|
81
63
|
const [clearEpoch, setClearEpoch] = useState(0); // bump to remount <Static> on /clear /new
|
|
82
|
-
const [prefillText, setPrefillText] = useState(null); // clipboard paste → pre-fills Composer
|
|
83
|
-
const [activeTab, setActiveTab] = useState(0); // 0=Session 1=Monitoring 2=Issues 3=Gists
|
|
84
|
-
const [latestVersion, setLatestVersion] = useState(null); // { latest, isNewer } from npm
|
|
85
64
|
|
|
86
65
|
const convo = useRef([]); // [{role, content}] for the backend
|
|
87
66
|
const plain = useRef([]); // plain-text transcript for exit reprint
|
|
@@ -91,7 +70,6 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
|
|
|
91
70
|
const modelRef = useRef(cfg._model); // preferred model to request
|
|
92
71
|
const lastAnswerRef = useRef(''); // most recent assistant answer (for /copy, /gist)
|
|
93
72
|
const choiceActionRef = useRef(null); // action to run when a non-AI Choice is picked
|
|
94
|
-
const issuesRef = useRef([]); // AI-flagged issues accumulated across session
|
|
95
73
|
|
|
96
74
|
// Enable terminal bracketed-paste so multi-line pastes (mouse right-click or
|
|
97
75
|
// Cmd/Ctrl+V) arrive wrapped in markers as one block — the Composer inserts
|
|
@@ -101,91 +79,6 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
|
|
|
101
79
|
return () => { try { process.stdout.write('\x1b[?2004l'); } catch {} };
|
|
102
80
|
}, []);
|
|
103
81
|
|
|
104
|
-
// Background version check — runs once after mount, non-blocking.
|
|
105
|
-
useEffect(() => {
|
|
106
|
-
if (!version || version === 'unknown') return;
|
|
107
|
-
checkLatestVersion(version).then((result) => {
|
|
108
|
-
if (result?.isNewer) setLatestVersion(result);
|
|
109
|
-
});
|
|
110
|
-
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
111
|
-
|
|
112
|
-
// Restore session after a hot-reload /update.
|
|
113
|
-
useEffect(() => {
|
|
114
|
-
if (!resumeSession) return;
|
|
115
|
-
convo.current = resumeSession.convo || [];
|
|
116
|
-
plain.current = resumeSession.plain || [];
|
|
117
|
-
// Re-hydrate items list from plain text so the user sees their history.
|
|
118
|
-
if (resumeSession.items?.length) {
|
|
119
|
-
setItems(resumeSession.items.map((it) => ({ id: nextId(), ...it })));
|
|
120
|
-
}
|
|
121
|
-
push({ type: 'notice', text: `${glyphs.check} Session restored after update — conversation history preserved.`, color: C.green });
|
|
122
|
-
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
123
|
-
|
|
124
|
-
// Tab switching: Ctrl+1/2/3/4.
|
|
125
|
-
useInput((input, key) => {
|
|
126
|
-
if (!key.ctrl) return;
|
|
127
|
-
if (input === '1') { setActiveTab(0); return; }
|
|
128
|
-
if (input === '2') { setActiveTab(1); return; }
|
|
129
|
-
if (input === '3') { setActiveTab(2); return; }
|
|
130
|
-
if (input === '4') { setActiveTab(3); return; }
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
// ── Mouse event handling ────────────────────────────────────────────────────
|
|
134
|
-
// Tab bar x-ranges (1-indexed). Layout: paddingX=1 on the TabBar outer Box
|
|
135
|
-
// so text starts at column 2. Each tab = 2-char prefix (' ' or '❯ ') +
|
|
136
|
-
// label text, followed by marginRight=2 before the next tab.
|
|
137
|
-
const tabClickRanges = useMemo(() => {
|
|
138
|
-
let x = 2; // paddingX=1 → first tab starts at column 2
|
|
139
|
-
return TABS.map((label) => {
|
|
140
|
-
const start = x;
|
|
141
|
-
const w = 2 + label.length; // prefix (2 chars) + label
|
|
142
|
-
x += w + 2; // advance past tab + marginRight=2
|
|
143
|
-
return { start, end: start + w - 1 };
|
|
144
|
-
});
|
|
145
|
-
}, []);
|
|
146
|
-
|
|
147
|
-
useEffect(() => {
|
|
148
|
-
const onMouse = ({ btn, x, y, press }) => {
|
|
149
|
-
// ── Scroll wheel UP → scroll viewport to reveal history ──────────────
|
|
150
|
-
if (btn === 64) {
|
|
151
|
-
try { process.stdout.write('\x1b[3S'); } catch {}
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
// ── Scroll wheel DOWN → swallowed ─────────────────────────────────────
|
|
155
|
-
if (btn === 65) return;
|
|
156
|
-
|
|
157
|
-
// ── Left click → tab bar ─────────────────────────────────────────────
|
|
158
|
-
// Ink renders INLINE at the bottom of the terminal — NOT at y=1.
|
|
159
|
-
// SGR y coordinates are 1-indexed from the terminal top.
|
|
160
|
-
//
|
|
161
|
-
// Layout rows at idle (bottom-to-top):
|
|
162
|
-
// rows = footer hint
|
|
163
|
-
// rows-1 = composer bottom border
|
|
164
|
-
// rows-2 = composer input
|
|
165
|
-
// rows-3 = composer top border
|
|
166
|
-
// rows-4 = blank (marginTop=1 before composer)
|
|
167
|
-
// rows-5 = tab separator
|
|
168
|
-
// rows-6 = tab labels ← TAB_ROW at idle
|
|
169
|
-
// rows-7 = compact header
|
|
170
|
-
//
|
|
171
|
-
// When there is live content of height h between tab separator and
|
|
172
|
-
// composer, each row shifts up by h: TAB_ROW = rows - 6 - liveH.
|
|
173
|
-
if (btn === 0 && press) {
|
|
174
|
-
const liveH = live
|
|
175
|
-
? 1 + Math.min((live.status?.length || 0) + (live.text ? 2 : 0) + (!live.text && !live.reply?.steps ? 1 : 0), 12)
|
|
176
|
-
: 0;
|
|
177
|
-
const tabRowY = rows - 6 - liveH;
|
|
178
|
-
if (Math.abs(y - tabRowY) <= 1) { // ±1 row tolerance
|
|
179
|
-
const idx = tabClickRanges.findIndex((r) => x >= r.start && x <= r.end);
|
|
180
|
-
if (idx >= 0) setActiveTab(idx);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
};
|
|
184
|
-
|
|
185
|
-
mouseEvents.on('event', onMouse);
|
|
186
|
-
return () => mouseEvents.off('event', onMouse);
|
|
187
|
-
}, [tabClickRanges, rows, live]);
|
|
188
|
-
|
|
189
82
|
const setAutopilotMode = useCallback((next) => {
|
|
190
83
|
apRef.current = next;
|
|
191
84
|
setAutopilot(next);
|
|
@@ -348,22 +241,6 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
|
|
|
348
241
|
if (!q) return;
|
|
349
242
|
setHint('');
|
|
350
243
|
|
|
351
|
-
// ── ! shell command execution (GitHub Copilot CLI parity) ──────────────
|
|
352
|
-
if (q.startsWith('!')) {
|
|
353
|
-
const cmd = q.slice(1).trim();
|
|
354
|
-
if (!cmd) return;
|
|
355
|
-
push({ type: 'shell_in', cmd });
|
|
356
|
-
log(`$ ${cmd}`);
|
|
357
|
-
runShell(cmd).then((result) => {
|
|
358
|
-
const out = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
|
359
|
-
push({ type: 'shell_out', ok: result.ok, output: out || `(exit code ${result.exitCode})` });
|
|
360
|
-
if (out) log(out);
|
|
361
|
-
}).catch((e) => {
|
|
362
|
-
push({ type: 'notice', text: `${glyphs.cross} Shell error: ${e.message}`, color: C.red });
|
|
363
|
-
});
|
|
364
|
-
return;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
244
|
if (q === '/exit' || q === 'exit' || q === 'quit') { doExit(); return; }
|
|
368
245
|
if (q === '/clear') { clearAll(); return; }
|
|
369
246
|
if (q === '/new') {
|
|
@@ -413,31 +290,8 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
|
|
|
413
290
|
push({ type: 'notice', text: us.length ? us.map((m, i) => ` ${i + 1}. ${m.content.slice(0, 80)}`).join('\n') : 'No history yet.' });
|
|
414
291
|
return;
|
|
415
292
|
}
|
|
416
|
-
if (q === '/usage') {
|
|
417
|
-
const total = convo.current.length;
|
|
418
|
-
const userTurns = convo.current.filter((m) => m.role === 'user').length;
|
|
419
|
-
const chars = convo.current.reduce((acc, m) => acc + m.content.length, 0);
|
|
420
|
-
const approxTokens = Math.round(chars / 4);
|
|
421
|
-
const lines = [
|
|
422
|
-
`Conversation stats:`,
|
|
423
|
-
` turns ${userTurns}`,
|
|
424
|
-
` messages ${total}`,
|
|
425
|
-
` ~tokens ${approxTokens.toLocaleString()} (estimated)`,
|
|
426
|
-
` ~chars ${chars.toLocaleString()}`,
|
|
427
|
-
` model ${model || cfg._model || 'server default'}`,
|
|
428
|
-
` cwd ${APP_CWD}`,
|
|
429
|
-
];
|
|
430
|
-
push({ type: 'notice', text: lines.join('\n'), color: C.cyan });
|
|
431
|
-
return;
|
|
432
|
-
}
|
|
433
293
|
if (q === '/logout') { onExternal?.('logout'); doExit(); return; }
|
|
434
|
-
if (q === '/update') {
|
|
435
|
-
// Save current conversation so the new binary can resume it.
|
|
436
|
-
const resumeFile = saveResume(convo.current, plain.current, items);
|
|
437
|
-
onExternal?.({ action: 'update', resumeFile });
|
|
438
|
-
doExit();
|
|
439
|
-
return;
|
|
440
|
-
}
|
|
294
|
+
if (q === '/update') { onExternal?.('update'); doExit(); return; }
|
|
441
295
|
|
|
442
296
|
// ── Memory ──
|
|
443
297
|
if (q === '/remember' || q.startsWith('/remember ')) {
|
|
@@ -465,39 +319,8 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
|
|
|
465
319
|
}
|
|
466
320
|
|
|
467
321
|
// ── Clipboard ──
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
const lines = [
|
|
471
|
-
`iCopilot CLI v${version || '?'}`,
|
|
472
|
-
`Server ${cfg.url || '(unknown)'}`,
|
|
473
|
-
`User ${cfg.user?.name || '—'} (${cfg.user?.role || '—'})`,
|
|
474
|
-
`Model ${shortModel(model || cfg._model || '—')}`,
|
|
475
|
-
'',
|
|
476
|
-
'Type /help for all commands · Ctrl+C to exit',
|
|
477
|
-
'npm: ispbills-icli · github: lupael/IspBills',
|
|
478
|
-
];
|
|
479
|
-
push({ type: 'notice', text: lines.join('\n'), color: C.accent });
|
|
480
|
-
return;
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
// ── /paste ─────────────────────────────────────────────────────────────
|
|
484
|
-
if (q === '/paste') {
|
|
485
|
-
push({ type: 'notice', text: `${glyphs.arrow} Reading clipboard…`, color: C.gray });
|
|
486
|
-
readFromClipboard().then((text) => {
|
|
487
|
-
if (!text) {
|
|
488
|
-
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 });
|
|
489
|
-
return;
|
|
490
|
-
}
|
|
491
|
-
const lines = text.split('\n').length;
|
|
492
|
-
push({ type: 'notice',
|
|
493
|
-
text: `${glyphs.check} Pasted ${lines} line${lines !== 1 ? 's' : ''} (${text.length} chars) into the input box — review and press Enter to send.`,
|
|
494
|
-
color: C.green });
|
|
495
|
-
setPrefillText(text);
|
|
496
|
-
});
|
|
497
|
-
return;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
if (q === '/copy') { const ans = lastAnswerRef.current;
|
|
322
|
+
if (q === '/copy') {
|
|
323
|
+
const ans = lastAnswerRef.current;
|
|
501
324
|
if (!ans) { push({ type: 'notice', text: 'Nothing to copy yet — ask something first.', color: C.gray }); return; }
|
|
502
325
|
copyToClipboard(ans).then((tool) => push({ type: 'notice',
|
|
503
326
|
text: tool ? `${glyphs.check} Copied last answer (${ans.length} chars) to the clipboard.`
|
|
@@ -631,93 +454,48 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
|
|
|
631
454
|
case 'connect': return html`<${Connect} key=${it.id} reply=${it.reply} />`;
|
|
632
455
|
case 'activity': return html`<${StatusLines} key=${it.id} lines=${it.status} busy=${false} />`;
|
|
633
456
|
case 'notice': return html`<${Notice} key=${it.id} text=${it.text} color=${it.color} />`;
|
|
634
|
-
case 'shell_in': return html`<${ShellInput} key=${it.id} cmd=${it.cmd} />`;
|
|
635
|
-
case 'shell_out': return html`<${ShellOut} key=${it.id} ok=${it.ok} output=${it.output} />`;
|
|
636
457
|
default: return null;
|
|
637
458
|
}
|
|
638
459
|
};
|
|
639
460
|
|
|
640
|
-
const ctx = [model ? shortModel(model) : null,
|
|
641
|
-
|
|
642
|
-
const
|
|
461
|
+
const ctx = [model ? shortModel(model) : null, cfg.user?.name, cfg.user?.role].filter(Boolean).join(midDot());
|
|
462
|
+
const modeTag = autopilot ? `${glyphs.bolt} autopilot ${midDot()} ` : '';
|
|
463
|
+
const footerRight = hint || `${modeTag}/help ${midDot()} Shift+Tab autopilot ${midDot()} Ctrl+C exit`;
|
|
643
464
|
const compact = cols < 60;
|
|
644
465
|
|
|
645
466
|
// The banner is the first <Static> item so it prints once at the very top and
|
|
646
467
|
// scrolls away with history; completed transcript items follow it. Ink flushes
|
|
647
468
|
// each <Static> item to the terminal's normal buffer exactly once, leaving the
|
|
648
469
|
// native scrollback intact so earlier conversation can be scrolled back to.
|
|
649
|
-
// Scroll UP (mouse wheel) reveals this history; scroll DOWN is blocked.
|
|
650
470
|
const staticItems = [{ type: 'banner', id: `banner-${clearEpoch}` }, ...items];
|
|
651
471
|
|
|
652
|
-
// ── Main render ─────────────────────────────────────────────────────────────
|
|
653
|
-
// Layout (top to bottom):
|
|
654
|
-
// 1. Compact persistent header — "iCopilot" brand always visible
|
|
655
|
-
// 2. Tab bar — Session / Monitoring / Issues / Gists (mouse-clickable)
|
|
656
|
-
// 3. Session pane — Static history + live streaming area (scrollback via ↑)
|
|
657
|
-
// 4. Composer + footer — always pinned to the bottom
|
|
658
472
|
return html`
|
|
659
473
|
<${Box} flexDirection="column" width=${cols}>
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
${latestVersion?.isNewer
|
|
667
|
-
? html`<${Box} flexGrow=${1} justifyContent="flex-end"><${Text} color=${C.yellow}> 🔔 v${latestVersion.latest} /update<//> <//>`
|
|
668
|
-
: null}
|
|
474
|
+
<${Static} key=${clearEpoch} items=${staticItems}>
|
|
475
|
+
${(it) => it.type === 'banner'
|
|
476
|
+
? html`<${Box} key=${it.id} flexDirection="column" alignItems="center" marginBottom=${1}>
|
|
477
|
+
<${Banner} cfg=${cfg} model=${model} width=${cols} compact=${compact} />
|
|
478
|
+
<//>`
|
|
479
|
+
: renderItem(it)}
|
|
669
480
|
<//>
|
|
670
481
|
|
|
671
|
-
${
|
|
672
|
-
<${TabBar} tabs=${TABS} active=${activeTab} onSelect=${setActiveTab} width=${cols} />
|
|
673
|
-
|
|
674
|
-
${/* ── 3a. Session pane ────────────────────────────────────────────── */null}
|
|
675
|
-
${activeTab === 0
|
|
482
|
+
${live
|
|
676
483
|
? html`<${Box} flexDirection="column">
|
|
677
|
-
<${
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
: renderItem(it)}
|
|
683
|
-
<//>
|
|
684
|
-
${/* Gap between history and live area — gives breathing room */null}
|
|
685
|
-
${live
|
|
686
|
-
? html`<${Box} flexDirection="column" marginTop=${1}>
|
|
687
|
-
<${StatusLines} lines=${live.status} busy=${busy} />
|
|
688
|
-
${showReasoning && live.reasoning ? html`<${Reasoning} text=${live.reasoning} />` : null}
|
|
689
|
-
${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
|
|
690
|
-
${live.text
|
|
691
|
-
? html`<${Box} marginTop=${live.status.length ? 0 : 1}>
|
|
692
|
-
<${AssistantMessage} text=${live.text} streaming=${true} />
|
|
693
|
-
<//>` : null}
|
|
694
|
-
${!live.text && !live.reply?.steps
|
|
695
|
-
? html`<${Thinking}
|
|
696
|
-
label=${live.status.length ? 'Working' : 'Thinking'}
|
|
697
|
-
startedAt=${startedAt} />`
|
|
698
|
-
: null}
|
|
699
|
-
<//>`
|
|
700
|
-
: null}
|
|
484
|
+
<${StatusLines} lines=${live.status} busy=${busy} />
|
|
485
|
+
${showReasoning && live.reasoning ? html`<${Reasoning} text=${live.reasoning} />` : null}
|
|
486
|
+
${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
|
|
487
|
+
${live.text ? html`<${Box} marginTop=${1}><${AssistantMessage} text=${live.text} /><//>` : null}
|
|
488
|
+
<${Thinking} label=${live.reply ? 'Planning' : live.text ? 'Responding' : 'Working'} startedAt=${startedAt} />
|
|
701
489
|
<//>`
|
|
702
490
|
: null}
|
|
703
491
|
|
|
704
|
-
${/* ── 3b. Other tabs ──────────────────────────────────────────────── */null}
|
|
705
|
-
${activeTab === 1 ? html`<${MonitoringView} cfg=${cfg} />` : null}
|
|
706
|
-
${activeTab === 2 ? html`<${IssuesView} issues=${issuesRef.current} />` : null}
|
|
707
|
-
${activeTab === 3 ? html`<${GistsView} cfg=${cfg} />` : null}
|
|
708
|
-
|
|
709
|
-
${/* ── 4. Composer + footer — fixed at bottom ─────────────────────── */null}
|
|
710
492
|
<${Box} flexDirection="column" marginTop=${1}>
|
|
711
|
-
${
|
|
712
|
-
?
|
|
713
|
-
|
|
714
|
-
: html`<${Composer} onSubmit=${onSubmit} busy=${busy} width=${cols} prefill=${prefillText} onPrefillConsumed=${() => setPrefillText(null)} />`)
|
|
715
|
-
: html`<${Box} paddingX=${1}><${Text} dimColor>Click a tab or Ctrl+1–4 to switch · Ctrl+1 for Session<//> <//>` }
|
|
493
|
+
${choice
|
|
494
|
+
? html`<${Choice} ...${choice} />`
|
|
495
|
+
: html`<${Composer} onSubmit=${onSubmit} busy=${busy} width=${cols} />`}
|
|
716
496
|
<${Box} paddingX=${1} width=${cols}>
|
|
717
|
-
<${Box} flexGrow=${1}
|
|
718
|
-
|
|
719
|
-
${autopilot
|
|
720
|
-
? html`<${Text} color=${C.yellow} bold wrap="truncate-end"> ${glyphs.bolt} autopilot ON<//>` : null}
|
|
497
|
+
<${Box} flexGrow=${1}><${Text} color=${C.gray} wrap="truncate-end">${ctx}<//><//>
|
|
498
|
+
<${Text} color=${hint || autopilot ? C.yellow : C.gray} wrap="truncate-end">${footerRight}<//>
|
|
721
499
|
<//>
|
|
722
500
|
<//>
|
|
723
501
|
<//>`;
|