ispbills-icli 8.4.12 → 8.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/icli.js +4 -24
- package/package.json +1 -1
- package/src/banner.js +1 -3
- package/src/client.js +0 -6
- package/src/clipboard.js +1 -35
- package/src/commands.js +8 -4
- package/src/tui/app.js +198 -194
- package/src/tui/components.js +166 -594
- package/src/tui/composer.js +18 -45
- package/src/tui/markdown.js +3 -19
- package/src/tui/run.js +1 -3
- package/src/tui/theme.js +58 -29
- package/src/logger.js +0 -23
- package/src/resume.js +0 -30
- package/src/shell.js +0 -18
- package/src/version-check.js +0 -43
package/src/tui/app.js
CHANGED
|
@@ -7,9 +7,8 @@ import { html, useState, useEffect, useRef, useCallback } from './dom.js';
|
|
|
7
7
|
import { Box, Text, Static, useStdout, useApp, useInput } from 'ink';
|
|
8
8
|
import { C, glyphs, midDot, dash } from './theme.js';
|
|
9
9
|
import {
|
|
10
|
-
Banner, UserMessage, AssistantMessage, StatusLines, Reasoning,
|
|
11
|
-
Plan, Connect, Notice, Thinking, Choice,
|
|
12
|
-
TabBar, MonitoringView, IssuesView, GistsView,
|
|
10
|
+
Banner, TabBar, FollowupChips, UserMessage, AssistantMessage, StatusLines, Reasoning,
|
|
11
|
+
Plan, Connect, Notice, Thinking, Choice,
|
|
13
12
|
} from './components.js';
|
|
14
13
|
import { Composer } from './composer.js';
|
|
15
14
|
import { streamChat } from '../client.js';
|
|
@@ -17,19 +16,12 @@ import { slashToQuestion, SLASH_COMMANDS } from '../commands.js';
|
|
|
17
16
|
import { saveConfig } from '../config.js';
|
|
18
17
|
import { shortModel } from '../ui.js';
|
|
19
18
|
import { loadNotes, addNote, forgetNote, memoryPreamble } from '../memory.js';
|
|
20
|
-
import { copyToClipboard
|
|
19
|
+
import { copyToClipboard } from '../clipboard.js';
|
|
21
20
|
import { createGist } from '../gist.js';
|
|
22
21
|
import { git as gitCmd, commitBackup } from '../gitrepo.js';
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import { basename } from 'path';
|
|
27
|
-
|
|
28
|
-
// CWD basename — computed once at startup (before any cd).
|
|
29
|
-
const APP_CWD = basename(process.cwd());
|
|
30
|
-
|
|
31
|
-
// Tab identifiers (order = display order).
|
|
32
|
-
export const TABS = ['Session', 'Monitoring', 'Issues', 'Gists'];
|
|
22
|
+
import { execSync } from 'child_process';
|
|
23
|
+
import { homedir } from 'os';
|
|
24
|
+
import { relative } from 'path';
|
|
33
25
|
|
|
34
26
|
// Split a command argument string into argv, honouring "quoted" segments.
|
|
35
27
|
function splitArgs(s) {
|
|
@@ -40,6 +32,33 @@ function splitArgs(s) {
|
|
|
40
32
|
return out;
|
|
41
33
|
}
|
|
42
34
|
|
|
35
|
+
// Shorten a path with ~ for home directory.
|
|
36
|
+
function shortenPath(p) {
|
|
37
|
+
try {
|
|
38
|
+
const home = homedir();
|
|
39
|
+
const rel = relative(home, p);
|
|
40
|
+
if (!rel.startsWith('..')) return '~/' + rel;
|
|
41
|
+
} catch {}
|
|
42
|
+
return p;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Get the current git branch name (synchronous, non-throwing).
|
|
46
|
+
function gitBranch() {
|
|
47
|
+
try {
|
|
48
|
+
return execSync('git rev-parse --abbrev-ref HEAD', {
|
|
49
|
+
cwd: process.cwd(), stdio: ['ignore', 'pipe', 'ignore'], timeout: 1000,
|
|
50
|
+
}).toString().trim();
|
|
51
|
+
} catch {
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Approximate token count from text (~4 chars per token).
|
|
57
|
+
function approxTokens(messages) {
|
|
58
|
+
const total = messages.reduce((n, m) => n + (m.content?.length || 0), 0);
|
|
59
|
+
return Math.round(total / 4);
|
|
60
|
+
}
|
|
61
|
+
|
|
43
62
|
function useTerminalSize() {
|
|
44
63
|
const { stdout } = useStdout();
|
|
45
64
|
const [size, setSize] = useState({
|
|
@@ -58,33 +77,39 @@ function useTerminalSize() {
|
|
|
58
77
|
let itemId = 0;
|
|
59
78
|
const nextId = () => ++itemId;
|
|
60
79
|
|
|
61
|
-
|
|
80
|
+
// Mode cycle: ask → plan → autopilot → ask
|
|
81
|
+
const MODES = ['ask', 'plan', 'autopilot'];
|
|
82
|
+
|
|
83
|
+
export function App({ cfg, display = {}, onExternal, onExit, initialQuestion }) {
|
|
62
84
|
const { exit } = useApp();
|
|
63
85
|
const { cols, rows } = useTerminalSize();
|
|
86
|
+
const colsRef = useRef(cols);
|
|
87
|
+
useEffect(() => { colsRef.current = cols; }, [cols]);
|
|
64
88
|
|
|
65
89
|
const [items, setItems] = useState([]);
|
|
66
90
|
const [live, setLive] = useState(null); // { status:[], reasoning:'', text:'' }
|
|
67
91
|
const [busy, setBusy] = useState(false);
|
|
68
92
|
const [model, setModel] = useState(cfg._model);
|
|
69
|
-
const [showReasoning, setShowReasoning] = useState(
|
|
70
|
-
const [
|
|
93
|
+
const [showReasoning, setShowReasoning] = useState(false); // collapsed by default per spec
|
|
94
|
+
const [mode, setMode] = useState('ask'); // 'ask' | 'plan' | 'autopilot'
|
|
95
|
+
const [activeTab, setActiveTab] = useState('session');
|
|
96
|
+
const [chips, setChips] = useState([]); // followup suggestion strings
|
|
97
|
+
const [activeChip, setActiveChip] = useState(0);
|
|
71
98
|
const [hint, setHint] = useState('');
|
|
72
99
|
const [startedAt, setStartedAt] = useState(0);
|
|
73
100
|
const [choice, setChoice] = useState(null); // { title, note, options, allowText, sel, text, focusText, onPick }
|
|
74
101
|
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
|
|
78
102
|
|
|
79
103
|
const convo = useRef([]); // [{role, content}] for the backend
|
|
80
104
|
const plain = useRef([]); // plain-text transcript for exit reprint
|
|
81
105
|
const abortRef = useRef(null); // AbortController of the in-flight turn
|
|
82
106
|
const ctrlC = useRef(0); // timestamp of the last lone Ctrl+C
|
|
83
107
|
const apRef = useRef(false); // live autopilot flag (read inside runTurn)
|
|
108
|
+
const modeRef = useRef('ask'); // live mode (read inside runTurn)
|
|
84
109
|
const modelRef = useRef(cfg._model); // preferred model to request
|
|
85
110
|
const lastAnswerRef = useRef(''); // most recent assistant answer (for /copy, /gist)
|
|
86
111
|
const choiceActionRef = useRef(null); // action to run when a non-AI Choice is picked
|
|
87
|
-
const
|
|
112
|
+
const branchRef = useRef(gitBranch()); // current git branch (detected once at start)
|
|
88
113
|
|
|
89
114
|
// Enable terminal bracketed-paste so multi-line pastes (mouse right-click or
|
|
90
115
|
// Cmd/Ctrl+V) arrive wrapped in markers as one block — the Composer inserts
|
|
@@ -94,41 +119,13 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
|
|
|
94
119
|
return () => { try { process.stdout.write('\x1b[?2004l'); } catch {} };
|
|
95
120
|
}, []);
|
|
96
121
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
126
|
-
const setAutopilotMode = useCallback((next) => {
|
|
127
|
-
apRef.current = next;
|
|
128
|
-
setAutopilot(next);
|
|
122
|
+
const setModeState = useCallback((next) => {
|
|
123
|
+
modeRef.current = next;
|
|
124
|
+
apRef.current = next === 'autopilot';
|
|
125
|
+
setMode(next);
|
|
129
126
|
}, []);
|
|
130
127
|
|
|
131
|
-
const push = useCallback((item) => setItems((xs) => [...xs, { id: nextId(), ...item }]), []);
|
|
128
|
+
const push = useCallback((item) => setItems((xs) => [...xs, { id: nextId(), cols: colsRef.current, ...item }]), []);
|
|
132
129
|
const log = (line) => plain.current.push(line);
|
|
133
130
|
|
|
134
131
|
// Clear the visible transcript: wipe the screen + native scrollback and
|
|
@@ -148,6 +145,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
|
|
|
148
145
|
convo.current.push({ role: 'user', content: question });
|
|
149
146
|
setBusy(true);
|
|
150
147
|
setHint('');
|
|
148
|
+
setChips([]);
|
|
151
149
|
setStartedAt(Date.now());
|
|
152
150
|
const s = { status: [], reasoning: '', text: '', reply: null };
|
|
153
151
|
setLive({ ...s });
|
|
@@ -200,6 +198,12 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
|
|
|
200
198
|
if (text) convo.current.push({ role: 'assistant', content: text });
|
|
201
199
|
lastAnswerRef.current = text || reply.summary || reply.question || lastAnswerRef.current;
|
|
202
200
|
|
|
201
|
+
// Populate followup chips from reply.suggestions (up to 4).
|
|
202
|
+
if (Array.isArray(reply.suggestions) && reply.suggestions.length) {
|
|
203
|
+
setChips(reply.suggestions.slice(0, 4));
|
|
204
|
+
setActiveChip(0);
|
|
205
|
+
}
|
|
206
|
+
|
|
203
207
|
// Archive a /backup answer to the per-user git repo.
|
|
204
208
|
if (archive === 'backup' && text) {
|
|
205
209
|
try {
|
|
@@ -285,22 +289,6 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
|
|
|
285
289
|
if (!q) return;
|
|
286
290
|
setHint('');
|
|
287
291
|
|
|
288
|
-
// ── ! shell command execution (GitHub Copilot CLI parity) ──────────────
|
|
289
|
-
if (q.startsWith('!')) {
|
|
290
|
-
const cmd = q.slice(1).trim();
|
|
291
|
-
if (!cmd) return;
|
|
292
|
-
push({ type: 'shell_in', cmd });
|
|
293
|
-
log(`$ ${cmd}`);
|
|
294
|
-
runShell(cmd).then((result) => {
|
|
295
|
-
const out = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
|
296
|
-
push({ type: 'shell_out', ok: result.ok, output: out || `(exit code ${result.exitCode})` });
|
|
297
|
-
if (out) log(out);
|
|
298
|
-
}).catch((e) => {
|
|
299
|
-
push({ type: 'notice', text: `${glyphs.cross} Shell error: ${e.message}`, color: C.red });
|
|
300
|
-
});
|
|
301
|
-
return;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
292
|
if (q === '/exit' || q === 'exit' || q === 'quit') { doExit(); return; }
|
|
305
293
|
if (q === '/clear') { clearAll(); return; }
|
|
306
294
|
if (q === '/new') {
|
|
@@ -310,17 +298,71 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
|
|
|
310
298
|
return;
|
|
311
299
|
}
|
|
312
300
|
if (q === '/reasoning') {
|
|
313
|
-
setShowReasoning((v) => { push({ type: 'notice', text: `Reasoning display ${!v ? '
|
|
301
|
+
setShowReasoning((v) => { push({ type: 'notice', text: `Reasoning display ${!v ? 'expanded' : 'collapsed'}.` }); return !v; });
|
|
314
302
|
return;
|
|
315
303
|
}
|
|
316
304
|
if (q === '/autopilot' || q === '/auto') {
|
|
317
|
-
const next =
|
|
318
|
-
|
|
305
|
+
const next = modeRef.current !== 'autopilot' ? 'autopilot' : 'ask';
|
|
306
|
+
setModeState(next);
|
|
319
307
|
push({ type: 'notice',
|
|
320
|
-
text: next
|
|
321
|
-
? `${glyphs.bolt} Autopilot ON ${dash()} proposed fixes will be applied automatically.
|
|
322
|
-
: `
|
|
323
|
-
color: next ? C.
|
|
308
|
+
text: next === 'autopilot'
|
|
309
|
+
? `${glyphs.bolt} Autopilot ON ${dash()} proposed fixes will be applied automatically. Shift+Tab to cycle modes.`
|
|
310
|
+
: `Mode: ask ${dash()} fixes will wait for your confirmation.`,
|
|
311
|
+
color: next === 'autopilot' ? C.warning : C.muted });
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (q === '/plan') {
|
|
315
|
+
const next = modeRef.current !== 'plan' ? 'plan' : 'ask';
|
|
316
|
+
setModeState(next);
|
|
317
|
+
push({ type: 'notice', text: `Mode: ${next} ${dash()} Shift+Tab to cycle.`, color: C.accent });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (q === '/context' || q === '/usage') {
|
|
321
|
+
const tokens = approxTokens(convo.current);
|
|
322
|
+
push({ type: 'notice', text: `Context: ~${tokens.toLocaleString()} tokens (${convo.current.length} messages). /compact to summarise.` });
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (q === '/compact' || q.startsWith('/compact ')) {
|
|
326
|
+
const focus = q.slice('/compact'.length).trim();
|
|
327
|
+
const prompt = focus
|
|
328
|
+
? `Summarise our conversation so far, focusing on: ${focus}`
|
|
329
|
+
: 'Summarise our conversation so far in a concise way, preserving key facts and decisions.';
|
|
330
|
+
convo.current = [];
|
|
331
|
+
push({ type: 'notice', text: `${glyphs.arrow} Compacting history…`, color: C.muted });
|
|
332
|
+
runTurn(prompt);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (q === '/theme') {
|
|
336
|
+
push({ type: 'notice', text: 'Theme: dark (default). Palette: purple accent, blue brand, green success, amber warning, red error.\nSet NO_COLOR=1 to disable colour or FORCE_COLOR=1 to force it.' });
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (q === '/session') {
|
|
340
|
+
const tokens = approxTokens(convo.current);
|
|
341
|
+
const branch = branchRef.current;
|
|
342
|
+
const cwd = shortenPath(process.cwd());
|
|
343
|
+
push({ type: 'notice', text: [
|
|
344
|
+
`Session ${dash()} ${cwd}${branch ? ' [' + branch + ']' : ''}`,
|
|
345
|
+
`Messages ${dash()} ${convo.current.length}`,
|
|
346
|
+
`Tokens ${dash()} ~${tokens.toLocaleString()} (approx)`,
|
|
347
|
+
`Model ${dash()} ${model || modelRef.current || 'server default'}`,
|
|
348
|
+
`Mode ${dash()} ${modeRef.current}`,
|
|
349
|
+
].join('\n') });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (q === '/share') {
|
|
353
|
+
if (!lastAnswerRef.current && convo.current.length === 0) {
|
|
354
|
+
push({ type: 'notice', text: 'Nothing to share yet — ask something first.', color: C.muted }); return;
|
|
355
|
+
}
|
|
356
|
+
choiceActionRef.current = (v) => doGist(v);
|
|
357
|
+
setChoice({
|
|
358
|
+
title: 'Share conversation…',
|
|
359
|
+
note: 'Saves as a secret GitHub gist', sel: 0, text: '', focusText: false, allowText: false,
|
|
360
|
+
options: [
|
|
361
|
+
{ label: 'Last AI answer', value: 'answer' },
|
|
362
|
+
{ label: 'Full conversation transcript', value: 'transcript' },
|
|
363
|
+
{ label: 'Cancel', value: null },
|
|
364
|
+
],
|
|
365
|
+
});
|
|
324
366
|
return;
|
|
325
367
|
}
|
|
326
368
|
if (q === '/model' || q.startsWith('/model ')) {
|
|
@@ -342,7 +384,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
|
|
|
342
384
|
const head = c.group !== group ? ((group = c.group), `\n ${c.group}\n`) : '';
|
|
343
385
|
return head + ` ${(c.cmd + (c.hint ? ' ' + c.hint : '')).padEnd(22)}${c.desc}`;
|
|
344
386
|
}).join('\n');
|
|
345
|
-
push({ type: 'notice', text: 'Commands:' + lines + '\n\n Keys: Shift+Tab autopilot · Ctrl+T reasoning · Ctrl+
|
|
387
|
+
push({ type: 'notice', text: 'Commands:' + lines + '\n\n Keys: Shift+Tab cycle modes (ask→plan→autopilot) · Ctrl+T toggle reasoning · Ctrl+L clear · Ctrl+N/P followup chips · Ctrl+D exit · Ctrl+C×2 exit' });
|
|
346
388
|
return;
|
|
347
389
|
}
|
|
348
390
|
if (q === '/history') {
|
|
@@ -350,31 +392,8 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
|
|
|
350
392
|
push({ type: 'notice', text: us.length ? us.map((m, i) => ` ${i + 1}. ${m.content.slice(0, 80)}`).join('\n') : 'No history yet.' });
|
|
351
393
|
return;
|
|
352
394
|
}
|
|
353
|
-
if (q === '/usage') {
|
|
354
|
-
const total = convo.current.length;
|
|
355
|
-
const userTurns = convo.current.filter((m) => m.role === 'user').length;
|
|
356
|
-
const chars = convo.current.reduce((acc, m) => acc + m.content.length, 0);
|
|
357
|
-
const approxTokens = Math.round(chars / 4);
|
|
358
|
-
const lines = [
|
|
359
|
-
`Conversation stats:`,
|
|
360
|
-
` turns ${userTurns}`,
|
|
361
|
-
` messages ${total}`,
|
|
362
|
-
` ~tokens ${approxTokens.toLocaleString()} (estimated)`,
|
|
363
|
-
` ~chars ${chars.toLocaleString()}`,
|
|
364
|
-
` model ${model || cfg._model || 'server default'}`,
|
|
365
|
-
` cwd ${APP_CWD}`,
|
|
366
|
-
];
|
|
367
|
-
push({ type: 'notice', text: lines.join('\n'), color: C.cyan });
|
|
368
|
-
return;
|
|
369
|
-
}
|
|
370
395
|
if (q === '/logout') { onExternal?.('logout'); doExit(); return; }
|
|
371
|
-
if (q === '/update') {
|
|
372
|
-
// Save current conversation so the new binary can resume it.
|
|
373
|
-
const resumeFile = saveResume(convo.current, plain.current, items);
|
|
374
|
-
onExternal?.({ action: 'update', resumeFile });
|
|
375
|
-
doExit();
|
|
376
|
-
return;
|
|
377
|
-
}
|
|
396
|
+
if (q === '/update') { onExternal?.('update'); doExit(); return; }
|
|
378
397
|
|
|
379
398
|
// ── Memory ──
|
|
380
399
|
if (q === '/remember' || q.startsWith('/remember ')) {
|
|
@@ -402,39 +421,8 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
|
|
|
402
421
|
}
|
|
403
422
|
|
|
404
423
|
// ── Clipboard ──
|
|
405
|
-
|
|
406
|
-
|
|
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;
|
|
424
|
+
if (q === '/copy') {
|
|
425
|
+
const ans = lastAnswerRef.current;
|
|
438
426
|
if (!ans) { push({ type: 'notice', text: 'Nothing to copy yet — ask something first.', color: C.gray }); return; }
|
|
439
427
|
copyToClipboard(ans).then((tool) => push({ type: 'notice',
|
|
440
428
|
text: tool ? `${glyphs.check} Copied last answer (${ans.length} chars) to the clipboard.`
|
|
@@ -493,15 +481,15 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
|
|
|
493
481
|
} else { push({ type: 'notice', text: `Unknown command: ${q}. Type /help.` }); return; }
|
|
494
482
|
}
|
|
495
483
|
runTurn(question, opts);
|
|
496
|
-
}, [doExit, push, runTurn, onExternal,
|
|
484
|
+
}, [doExit, push, runTurn, onExternal, setModeState, model, cfg, doGist]);
|
|
497
485
|
|
|
498
486
|
// Interactive prompt/choice handling (owns the keyboard while open).
|
|
499
487
|
const pickChoice = useCallback((value) => {
|
|
500
488
|
const act = choiceActionRef.current;
|
|
501
489
|
choiceActionRef.current = null;
|
|
502
490
|
setChoice(null);
|
|
503
|
-
if (act) { if (value != null) act(value); else push({ type: 'notice', text: 'Cancelled.', color: C.
|
|
504
|
-
if (value == null) { push({ type: 'notice', text: 'Dismissed.', color: C.
|
|
491
|
+
if (act) { if (value != null) act(value); else push({ type: 'notice', text: 'Cancelled.', color: C.muted }); return; }
|
|
492
|
+
if (value == null) { push({ type: 'notice', text: 'Dismissed.', color: C.muted }); return; }
|
|
505
493
|
runTurn(String(value));
|
|
506
494
|
}, [push, runTurn]);
|
|
507
495
|
|
|
@@ -543,15 +531,34 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
|
|
|
543
531
|
setHint('Press Ctrl+C again to exit');
|
|
544
532
|
return;
|
|
545
533
|
}
|
|
534
|
+
if (key.ctrl && input === 'd') { doExit(); return; } // Ctrl+D shutdown
|
|
546
535
|
if (key.escape && busy) { abortRef.current?.abort(); return; }
|
|
536
|
+
if (key.escape && chips.length) { setChips([]); return; } // Esc dismisses chips
|
|
547
537
|
if (key.tab && key.shift && !busy) {
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
538
|
+
// Cycle: ask → plan → autopilot → ask
|
|
539
|
+
const cur = MODES.indexOf(modeRef.current);
|
|
540
|
+
const next = MODES[(cur + 1) % MODES.length];
|
|
541
|
+
setModeState(next);
|
|
542
|
+
setHint(`Mode: ${next}`);
|
|
551
543
|
return;
|
|
552
544
|
}
|
|
553
545
|
if (key.ctrl && input === 't') { setShowReasoning((v) => !v); return; }
|
|
554
546
|
if (key.ctrl && input === 'l' && !busy) { clearAll(); return; }
|
|
547
|
+
// Ctrl+N / Ctrl+P — cycle followup chips
|
|
548
|
+
if (key.ctrl && input === 'n' && chips.length) {
|
|
549
|
+
setActiveChip((i) => (i + 1) % chips.length); return;
|
|
550
|
+
}
|
|
551
|
+
if (key.ctrl && input === 'p' && chips.length) {
|
|
552
|
+
setActiveChip((i) => (i - 1 + chips.length) % chips.length); return;
|
|
553
|
+
}
|
|
554
|
+
// Tab cycles the tab bar (when not in slash palette)
|
|
555
|
+
if (key.tab && !key.shift && !busy) {
|
|
556
|
+
setActiveTab((t) => {
|
|
557
|
+
const tabs = ['session', 'devices', 'customers', 'alarms'];
|
|
558
|
+
return tabs[(tabs.indexOf(t) + 1) % tabs.length];
|
|
559
|
+
});
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
555
562
|
});
|
|
556
563
|
|
|
557
564
|
// Auto-run a first question if launched with one.
|
|
@@ -561,85 +568,82 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
|
|
|
561
568
|
}, [initialQuestion, onSubmit]);
|
|
562
569
|
|
|
563
570
|
const renderItem = (it) => {
|
|
571
|
+
const c = it.cols || 80;
|
|
564
572
|
switch (it.type) {
|
|
565
|
-
case 'user': return html`<${UserMessage} key=${it.id} text=${it.text} />`;
|
|
566
|
-
case 'assistant': return html`<${AssistantMessage} key=${it.id} text=${it.text} />`;
|
|
573
|
+
case 'user': return html`<${UserMessage} key=${it.id} text=${it.text} cols=${c} />`;
|
|
574
|
+
case 'assistant': return html`<${AssistantMessage} key=${it.id} text=${it.text} cols=${c} />`;
|
|
567
575
|
case 'plan': return html`<${Plan} key=${it.id} reply=${it.reply} />`;
|
|
568
576
|
case 'connect': return html`<${Connect} key=${it.id} reply=${it.reply} />`;
|
|
569
577
|
case 'activity': return html`<${StatusLines} key=${it.id} lines=${it.status} busy=${false} />`;
|
|
570
578
|
case 'notice': return html`<${Notice} key=${it.id} text=${it.text} color=${it.color} />`;
|
|
571
|
-
case 'shell_in': return html`<${ShellInput} key=${it.id} cmd=${it.cmd} />`;
|
|
572
|
-
case 'shell_out': return html`<${ShellOut} key=${it.id} ok=${it.ok} output=${it.output} />`;
|
|
573
579
|
default: return null;
|
|
574
580
|
}
|
|
575
581
|
};
|
|
576
582
|
|
|
577
|
-
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
const
|
|
583
|
+
// ── Footer ───────────────────────────────────────────────────────────────────
|
|
584
|
+
// Left: ~/cwd ⏎ branch mode-badge
|
|
585
|
+
// Right: model ~Nk ctx
|
|
586
|
+
const cwd = shortenPath(process.cwd());
|
|
587
|
+
const branch = branchRef.current;
|
|
588
|
+
const tokenCount = approxTokens(convo.current);
|
|
589
|
+
const tokenLabel = tokenCount > 0 ? `~${Math.round(tokenCount / 1000)}k ctx` : '';
|
|
590
|
+
const modelLabel = shortModel(model || modelRef.current || '');
|
|
591
|
+
const modeLabel = mode !== 'ask' ? ` ${midDot()} ${glyphs.bolt} ${mode}` : '';
|
|
592
|
+
|
|
593
|
+
const footerLeft = [
|
|
594
|
+
cwd,
|
|
595
|
+
branch ? `${glyphs.gitBranch}${branch}` : null,
|
|
596
|
+
].filter(Boolean).join(' ');
|
|
597
|
+
|
|
598
|
+
const footerRight = hint
|
|
581
599
|
? hint
|
|
582
|
-
:
|
|
583
|
-
|
|
584
|
-
: `/help${midDot()}Shift+Tab autopilot${midDot()}Ctrl+C exit`;
|
|
585
|
-
const footerColor = hint ? C.yellow : autopilot ? C.yellow : void 0;
|
|
600
|
+
: [modelLabel, tokenLabel].filter(Boolean).join(` ${midDot()} `);
|
|
601
|
+
|
|
586
602
|
const compact = cols < 60;
|
|
587
603
|
|
|
588
604
|
// The banner is the first <Static> item so it prints once at the very top and
|
|
589
605
|
// scrolls away with history; completed transcript items follow it. Ink flushes
|
|
590
606
|
// each <Static> item to the terminal's normal buffer exactly once, leaving the
|
|
591
607
|
// native scrollback intact so earlier conversation can be scrolled back to.
|
|
592
|
-
const staticItems = [{ type: 'banner', id: `banner-${clearEpoch}
|
|
608
|
+
const staticItems = [{ type: 'banner', id: `banner-${clearEpoch}`, cols }, ...items];
|
|
593
609
|
|
|
594
610
|
return html`
|
|
595
611
|
<${Box} flexDirection="column" width=${cols}>
|
|
596
|
-
|
|
597
|
-
|
|
612
|
+
<${Static} key=${clearEpoch} items=${staticItems}>
|
|
613
|
+
${(it) => it.type === 'banner'
|
|
614
|
+
? html`<${Box} key=${it.id} flexDirection="column" alignItems="center" marginBottom=${1}>
|
|
615
|
+
<${Banner} cfg=${cfg} model=${model} width=${cols} compact=${compact} />
|
|
616
|
+
<//>`
|
|
617
|
+
: renderItem(it)}
|
|
618
|
+
<//>
|
|
598
619
|
|
|
599
|
-
${
|
|
620
|
+
${live
|
|
600
621
|
? html`<${Box} flexDirection="column">
|
|
601
|
-
<${
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
<//>`
|
|
623
|
-
: null}
|
|
622
|
+
<${StatusLines} lines=${live.status} busy=${busy} />
|
|
623
|
+
${live.reasoning ? html`<${Reasoning} text=${live.reasoning} show=${showReasoning} />` : null}
|
|
624
|
+
${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
|
|
625
|
+
${live.text ? html`<${AssistantMessage} text=${live.text} cols=${cols} />` : null}
|
|
626
|
+
<${Thinking} label=${live.reply ? 'Planning' : live.text ? 'Responding' : 'Working'} startedAt=${startedAt} />
|
|
624
627
|
<//>`
|
|
625
628
|
: null}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
629
|
+
|
|
630
|
+
<${TabBar} active=${activeTab} cols=${cols} />
|
|
631
|
+
|
|
632
|
+
${!busy && chips.length ? html`<${FollowupChips} chips=${chips} active=${activeChip} />` : null}
|
|
633
|
+
|
|
634
|
+
<${Box} flexDirection="column">
|
|
635
|
+
${choice
|
|
636
|
+
? html`<${Choice} ...${choice} />`
|
|
637
|
+
: html`<${Composer} onSubmit=${onSubmit} busy=${busy} width=${cols} />`}
|
|
638
|
+
|
|
639
|
+
<${Text} color=${C.separator}>${'─'.repeat(Math.max(1, cols))}<//>
|
|
640
|
+
|
|
636
641
|
<${Box} paddingX=${1} width=${cols}>
|
|
637
642
|
<${Box} flexGrow=${1}>
|
|
638
|
-
<${Text}
|
|
643
|
+
<${Text} color=${C.muted} wrap="truncate-end">${footerLeft}<//>
|
|
644
|
+
${modeLabel ? html`<${Text} color=${mode === 'autopilot' ? C.warning : C.accent}>${modeLabel}<//>` : null}
|
|
639
645
|
<//>
|
|
640
|
-
|
|
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}<//>` }
|
|
646
|
+
<${Text} color=${hint ? C.warning : C.muted} wrap="truncate-end">${footerRight}<//>
|
|
643
647
|
<//>
|
|
644
648
|
<//>
|
|
645
649
|
<//>`;
|