ispbills-icli 8.5.3 → 8.5.4
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 +2 -1
- package/package.json +1 -1
- package/src/commands.js +38 -35
- package/src/tui/app.js +508 -200
- package/src/tui/components.js +99 -48
- package/src/tui/composer.js +236 -72
- package/src/tui/run.js +34 -7
package/src/tui/app.js
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
|
-
// Root Ink application. Renders an INLINE chat UI (GitHub Copilot CLI style)
|
|
2
|
-
// the banner prints once at the top and scrolls away, completed transcript
|
|
3
|
-
// items are flushed to the terminal's native scrollback via Ink <Static> (so
|
|
4
|
-
// you can scroll back through history), and only the live/streaming area plus
|
|
5
|
-
// the input composer are re-rendered in place at the bottom.
|
|
1
|
+
// Root Ink application. Renders an INLINE chat UI (GitHub Copilot CLI style).
|
|
6
2
|
import { html, useState, useEffect, useRef, useCallback } from './dom.js';
|
|
7
3
|
import { Box, Text, Static, useStdout, useApp, useInput } from 'ink';
|
|
8
4
|
import { C, glyphs, midDot, dash } from './theme.js';
|
|
9
5
|
import {
|
|
10
6
|
Banner, TabBar, FollowupChips, UserMessage, AssistantMessage, StatusLines, Reasoning,
|
|
11
|
-
Plan, Connect, Notice, Thinking, Choice,
|
|
7
|
+
Plan, Connect, Notice, Thinking, Choice, ShellOutput, SearchOverlay, HelpOverlay,
|
|
12
8
|
} from './components.js';
|
|
13
9
|
import { Composer } from './composer.js';
|
|
14
10
|
import { streamChat } from '../client.js';
|
|
@@ -19,11 +15,11 @@ import { loadNotes, addNote, forgetNote, memoryPreamble } from '../memory.js';
|
|
|
19
15
|
import { copyToClipboard } from '../clipboard.js';
|
|
20
16
|
import { createGist } from '../gist.js';
|
|
21
17
|
import { git as gitCmd, commitBackup } from '../gitrepo.js';
|
|
22
|
-
import { execSync } from 'child_process';
|
|
18
|
+
import { execSync, spawnSync } from 'child_process';
|
|
23
19
|
import { homedir } from 'os';
|
|
24
|
-
import { relative } from 'path';
|
|
20
|
+
import { dirname, isAbsolute, relative, resolve } from 'path';
|
|
21
|
+
import { existsSync, readFileSync } from 'fs';
|
|
25
22
|
|
|
26
|
-
// Split a command argument string into argv, honouring "quoted" segments.
|
|
27
23
|
function splitArgs(s) {
|
|
28
24
|
const out = [];
|
|
29
25
|
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
@@ -32,7 +28,6 @@ function splitArgs(s) {
|
|
|
32
28
|
return out;
|
|
33
29
|
}
|
|
34
30
|
|
|
35
|
-
// Shorten a path with ~ for home directory.
|
|
36
31
|
function shortenPath(p) {
|
|
37
32
|
try {
|
|
38
33
|
const home = homedir();
|
|
@@ -42,29 +37,68 @@ function shortenPath(p) {
|
|
|
42
37
|
return p;
|
|
43
38
|
}
|
|
44
39
|
|
|
45
|
-
|
|
46
|
-
function gitBranch() {
|
|
40
|
+
function gitBranch(cwd = process.cwd()) {
|
|
47
41
|
try {
|
|
48
42
|
return execSync('git rev-parse --abbrev-ref HEAD', {
|
|
49
|
-
cwd
|
|
43
|
+
cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: 1000,
|
|
50
44
|
}).toString().trim();
|
|
51
45
|
} catch {
|
|
52
46
|
return '';
|
|
53
47
|
}
|
|
54
48
|
}
|
|
55
49
|
|
|
56
|
-
// Approximate token count from text (~4 chars per token).
|
|
57
50
|
function approxTokens(messages) {
|
|
58
51
|
const total = messages.reduce((n, m) => n + (m.content?.length || 0), 0);
|
|
59
52
|
return Math.round(total / 4);
|
|
60
53
|
}
|
|
61
54
|
|
|
55
|
+
function pkgVersion() {
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version || 'unknown';
|
|
58
|
+
} catch {
|
|
59
|
+
return 'unknown';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function feedbackUrl() {
|
|
64
|
+
try {
|
|
65
|
+
const raw = execSync('git config --get remote.origin.url', {
|
|
66
|
+
cwd: process.cwd(), stdio: ['ignore', 'pipe', 'ignore'], timeout: 1000,
|
|
67
|
+
}).toString().trim();
|
|
68
|
+
if (!raw) return 'https://github.com/issues/new';
|
|
69
|
+
const gh = raw
|
|
70
|
+
.replace(/^git@github\.com:/, 'https://github.com/')
|
|
71
|
+
.replace(/\.git$/, '');
|
|
72
|
+
if (/^https:\/\/github\.com\//.test(gh)) return `${gh}/issues/new`;
|
|
73
|
+
} catch {}
|
|
74
|
+
return 'https://github.com/issues/new';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isTrustedDir(cfg, dir) {
|
|
78
|
+
const trusted = cfg?.tui?.trustedDirs || {};
|
|
79
|
+
let cur = resolve(dir);
|
|
80
|
+
while (true) {
|
|
81
|
+
if (trusted[cur]) return true;
|
|
82
|
+
const parent = dirname(cur);
|
|
83
|
+
if (parent === cur) break;
|
|
84
|
+
cur = parent;
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function itemPreview(it) {
|
|
90
|
+
if (!it) return '';
|
|
91
|
+
if (it.type === 'user' || it.type === 'assistant' || it.type === 'notice' || it.type === 'help') return String(it.text || it.type);
|
|
92
|
+
if (it.type === 'shell') return `$ ${it.command}\n${it.output || ''}`;
|
|
93
|
+
if (it.type === 'plan') return `Plan ${it.reply?.summary || ''}`;
|
|
94
|
+
if (it.type === 'connect') return `Connect ${it.reply?.device?.kind || ''}`;
|
|
95
|
+
if (it.type === 'activity') return (it.status || []).join('\n');
|
|
96
|
+
return JSON.stringify(it);
|
|
97
|
+
}
|
|
98
|
+
|
|
62
99
|
function useTerminalSize() {
|
|
63
100
|
const { stdout } = useStdout();
|
|
64
|
-
const [size, setSize] = useState({
|
|
65
|
-
cols: stdout?.columns || 80,
|
|
66
|
-
rows: stdout?.rows || 24,
|
|
67
|
-
});
|
|
101
|
+
const [size, setSize] = useState({ cols: stdout?.columns || 80, rows: stdout?.rows || 24 });
|
|
68
102
|
useEffect(() => {
|
|
69
103
|
if (!stdout) return;
|
|
70
104
|
const on = () => setSize({ cols: stdout.columns || 80, rows: stdout.rows || 24 });
|
|
@@ -76,68 +110,203 @@ function useTerminalSize() {
|
|
|
76
110
|
|
|
77
111
|
let itemId = 0;
|
|
78
112
|
const nextId = () => ++itemId;
|
|
79
|
-
|
|
80
|
-
// Mode cycle: ask → plan → autopilot → ask
|
|
81
113
|
const MODES = ['ask', 'plan', 'autopilot'];
|
|
82
114
|
|
|
83
|
-
export function App({ cfg, display = {}, onExternal, onExit, initialQuestion }) {
|
|
115
|
+
export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, showBanner = true }) {
|
|
84
116
|
const { exit } = useApp();
|
|
85
|
-
const { cols
|
|
117
|
+
const { cols } = useTerminalSize();
|
|
86
118
|
const colsRef = useRef(cols);
|
|
87
119
|
useEffect(() => { colsRef.current = cols; }, [cols]);
|
|
88
120
|
|
|
89
121
|
const [items, setItems] = useState([]);
|
|
90
|
-
const [live, setLive] = useState(null);
|
|
122
|
+
const [live, setLive] = useState(null);
|
|
91
123
|
const [busy, setBusy] = useState(false);
|
|
92
124
|
const [model, setModel] = useState(cfg._model);
|
|
93
|
-
const [showReasoning, setShowReasoning] = useState(false);
|
|
94
|
-
const [mode, setMode] = useState('ask');
|
|
125
|
+
const [showReasoning, setShowReasoning] = useState(false);
|
|
126
|
+
const [mode, setMode] = useState('ask');
|
|
95
127
|
const [activeTab, setActiveTab] = useState('session');
|
|
96
|
-
const [chips, setChips] = useState([]);
|
|
128
|
+
const [chips, setChips] = useState([]);
|
|
97
129
|
const [activeChip, setActiveChip] = useState(0);
|
|
98
130
|
const [hint, setHint] = useState('');
|
|
99
131
|
const [startedAt, setStartedAt] = useState(0);
|
|
100
|
-
const [choice, setChoice] = useState(null);
|
|
101
|
-
const [clearEpoch, setClearEpoch] = useState(0);
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
132
|
+
const [choice, setChoice] = useState(null);
|
|
133
|
+
const [clearEpoch, setClearEpoch] = useState(0);
|
|
134
|
+
const [composerClearToken, setComposerClearToken] = useState(0);
|
|
135
|
+
const [composerText, setComposerText] = useState('');
|
|
136
|
+
const [search, setSearch] = useState(null);
|
|
137
|
+
const [expandedItems, setExpandedItems] = useState(null);
|
|
138
|
+
const [allowAll, setAllowAll] = useState(Boolean(cfg?.tui?.allowAll));
|
|
139
|
+
const [streamerMode, setStreamerMode] = useState(Boolean(cfg?.tui?.streamerMode));
|
|
140
|
+
const [shellMode, setShellMode] = useState(false);
|
|
141
|
+
const [trusted, setTrusted] = useState(isTrustedDir(cfg, process.cwd()));
|
|
142
|
+
|
|
143
|
+
const convo = useRef([]);
|
|
144
|
+
const plain = useRef([]);
|
|
145
|
+
const abortRef = useRef(null);
|
|
146
|
+
const ctrlC = useRef(0);
|
|
147
|
+
const apRef = useRef(false);
|
|
148
|
+
const modeRef = useRef('ask');
|
|
149
|
+
const modelRef = useRef(cfg._model);
|
|
150
|
+
const lastAnswerRef = useRef('');
|
|
151
|
+
const choiceActionRef = useRef(null);
|
|
152
|
+
const branchRef = useRef(gitBranch());
|
|
153
|
+
const queuedRef = useRef(null);
|
|
154
|
+
const activeTurns = useRef(0);
|
|
155
|
+
const dismissTimers = useRef(new Map());
|
|
156
|
+
const dispatchSubmitRef = useRef(null);
|
|
157
|
+
|
|
117
158
|
useEffect(() => {
|
|
118
159
|
try { process.stdout.write('\x1b[?2004h'); } catch {}
|
|
119
160
|
return () => { try { process.stdout.write('\x1b[?2004l'); } catch {} };
|
|
120
161
|
}, []);
|
|
121
162
|
|
|
163
|
+
useEffect(() => () => {
|
|
164
|
+
for (const timer of dismissTimers.current.values()) clearTimeout(timer);
|
|
165
|
+
}, []);
|
|
166
|
+
|
|
167
|
+
const persistCfg = useCallback(() => {
|
|
168
|
+
try { saveConfig(cfg); } catch {}
|
|
169
|
+
}, [cfg]);
|
|
170
|
+
|
|
122
171
|
const setModeState = useCallback((next) => {
|
|
123
172
|
modeRef.current = next;
|
|
124
173
|
apRef.current = next === 'autopilot';
|
|
125
174
|
setMode(next);
|
|
126
175
|
}, []);
|
|
127
176
|
|
|
128
|
-
const push = useCallback((item) =>
|
|
177
|
+
const push = useCallback((item) => {
|
|
178
|
+
const next = { id: nextId(), cols: colsRef.current, ts: item.ts ?? Date.now(), ...item };
|
|
179
|
+
setItems((xs) => [...xs, next]);
|
|
180
|
+
return next;
|
|
181
|
+
}, []);
|
|
182
|
+
|
|
183
|
+
const removeItem = useCallback((id) => {
|
|
184
|
+
setItems((xs) => xs.filter((x) => x.id !== id));
|
|
185
|
+
const timer = dismissTimers.current.get(id);
|
|
186
|
+
if (timer) {
|
|
187
|
+
clearTimeout(timer);
|
|
188
|
+
dismissTimers.current.delete(id);
|
|
189
|
+
}
|
|
190
|
+
}, []);
|
|
191
|
+
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
for (const item of items) {
|
|
194
|
+
if (item.type !== 'notice' || !item.autoDismiss || dismissTimers.current.has(item.id)) continue;
|
|
195
|
+
const timer = setTimeout(() => removeItem(item.id), 4000);
|
|
196
|
+
dismissTimers.current.set(item.id, timer);
|
|
197
|
+
}
|
|
198
|
+
}, [items, removeItem]);
|
|
199
|
+
|
|
200
|
+
const pushNotice = useCallback((text, color = C.gray, autoDismiss = false) => {
|
|
201
|
+
push({ type: 'notice', text, color, autoDismiss });
|
|
202
|
+
}, [push]);
|
|
203
|
+
|
|
129
204
|
const log = (line) => plain.current.push(line);
|
|
130
205
|
|
|
131
|
-
// Clear the visible transcript: wipe the screen + native scrollback and
|
|
132
|
-
// remount <Static> (via clearEpoch) so nothing is re-flushed above the input.
|
|
133
206
|
const clearAll = useCallback(() => {
|
|
134
207
|
try { process.stdout.write('\x1b[2J\x1b[3J\x1b[H'); } catch {}
|
|
135
208
|
setItems([]);
|
|
209
|
+
setExpandedItems(null);
|
|
210
|
+
setSearch(null);
|
|
136
211
|
setClearEpoch((n) => n + 1);
|
|
137
212
|
}, []);
|
|
138
213
|
|
|
214
|
+
const queueSubmission = useCallback((text) => {
|
|
215
|
+
const q = String(text || '').trim();
|
|
216
|
+
if (!q) { pushNotice('Nothing to queue.', C.muted, true); return; }
|
|
217
|
+
queuedRef.current = q;
|
|
218
|
+
pushNotice(`[queued] ${q}`, C.warning, true);
|
|
219
|
+
}, [pushNotice]);
|
|
220
|
+
|
|
221
|
+
const openTrustDialog = useCallback((dir = process.cwd()) => {
|
|
222
|
+
choiceActionRef.current = (value) => {
|
|
223
|
+
if (value === 'session') {
|
|
224
|
+
setTrusted(true);
|
|
225
|
+
pushNotice(`Trusted for this session: ${shortenPath(dir)}`, C.success, true);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (value === 'remember') {
|
|
229
|
+
cfg.tui = cfg.tui || {};
|
|
230
|
+
cfg.tui.trustedDirs = { ...(cfg.tui.trustedDirs || {}), [resolve(dir)]: true };
|
|
231
|
+
persistCfg();
|
|
232
|
+
setTrusted(true);
|
|
233
|
+
pushNotice(`Trusted and remembered: ${shortenPath(dir)}`, C.success, true);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
doExit();
|
|
237
|
+
};
|
|
238
|
+
setChoice({
|
|
239
|
+
title: 'Do you trust files in this folder?',
|
|
240
|
+
note: shortenPath(dir),
|
|
241
|
+
sel: 0,
|
|
242
|
+
text: '',
|
|
243
|
+
focusText: false,
|
|
244
|
+
allowText: false,
|
|
245
|
+
options: [
|
|
246
|
+
{ label: '1. Yes', value: 'session' },
|
|
247
|
+
{ label: '2. Yes, remember', value: 'remember' },
|
|
248
|
+
{ label: '3. No, exit', value: 'exit', danger: true },
|
|
249
|
+
],
|
|
250
|
+
});
|
|
251
|
+
}, [cfg, persistCfg]);
|
|
252
|
+
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
if (!trusted && !choice) openTrustDialog(process.cwd());
|
|
255
|
+
}, [trusted, choice, openTrustDialog]);
|
|
256
|
+
|
|
257
|
+
const doExit = useCallback(() => { onExit?.(plain.current); exit(); }, [exit, onExit]);
|
|
258
|
+
|
|
259
|
+
const maybeProcessQueue = useCallback(() => {
|
|
260
|
+
if (activeTurns.current !== 0 || !queuedRef.current) return;
|
|
261
|
+
const next = queuedRef.current;
|
|
262
|
+
queuedRef.current = null;
|
|
263
|
+
setComposerClearToken((n) => n + 1);
|
|
264
|
+
setTimeout(() => dispatchSubmitRef.current?.(next, { queued: true }), 0);
|
|
265
|
+
}, []);
|
|
266
|
+
|
|
267
|
+
const doGist = useCallback(async (kind) => {
|
|
268
|
+
let content; let filename; let description;
|
|
269
|
+
if (kind === 'transcript') {
|
|
270
|
+
content = convo.current.map((m) => `### ${m.role}\n\n${m.content}`).join('\n\n---\n\n') || '(empty)';
|
|
271
|
+
filename = 'icli-transcript.md';
|
|
272
|
+
description = 'iCli conversation transcript';
|
|
273
|
+
} else {
|
|
274
|
+
content = lastAnswerRef.current || '(no answer)';
|
|
275
|
+
filename = 'icli-answer.md';
|
|
276
|
+
description = 'iCli answer';
|
|
277
|
+
}
|
|
278
|
+
pushNotice(`${glyphs.arrow} Creating secret gist…`, C.gray);
|
|
279
|
+
try {
|
|
280
|
+
const { url } = await createGist(cfg, { filename, content, description, isPublic: false });
|
|
281
|
+
const tool = await copyToClipboard(url);
|
|
282
|
+
pushNotice(`${glyphs.check} Gist created: ${url}${tool ? ' (URL copied)' : ''}`, C.green);
|
|
283
|
+
} catch (e) {
|
|
284
|
+
pushNotice(`${glyphs.cross} ${e.message}`, C.red);
|
|
285
|
+
}
|
|
286
|
+
}, [cfg, pushNotice]);
|
|
287
|
+
|
|
288
|
+
const runShellCommand = useCallback((rawCommand) => {
|
|
289
|
+
const command = String(rawCommand || '').trim();
|
|
290
|
+
if (!command) {
|
|
291
|
+
setShellMode(true);
|
|
292
|
+
pushNotice('Shell mode enabled. Press Esc to exit.', C.warning, true);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (shellMode && /^(exit|quit)$/i.test(command)) {
|
|
296
|
+
setShellMode(false);
|
|
297
|
+
pushNotice('Exited shell mode.', C.muted, true);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
push({ type: 'user', text: shellMode ? command : `!${command}` });
|
|
301
|
+
log(`\n$ ${command}\n`);
|
|
302
|
+
const res = spawnSync(command, { shell: true, encoding: 'utf8', cwd: process.cwd(), maxBuffer: 1024 * 1024 });
|
|
303
|
+
const output = [res.stdout || '', res.stderr || ''].join('').trim() || '(no output)';
|
|
304
|
+
push({ type: 'shell', command, output, code: res.status ?? 0 });
|
|
305
|
+
}, [log, push, pushNotice, shellMode]);
|
|
306
|
+
|
|
139
307
|
const runTurn = useCallback(async (question, opts = {}) => {
|
|
140
308
|
const { depth = 0, archive = null, label = '', synthetic = false } = opts;
|
|
309
|
+
activeTurns.current += 1;
|
|
141
310
|
if (!synthetic) {
|
|
142
311
|
push({ type: 'user', text: question });
|
|
143
312
|
log(`\n${glyphs.prompt} ${question}\n`);
|
|
@@ -146,15 +315,17 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
146
315
|
setBusy(true);
|
|
147
316
|
setHint('');
|
|
148
317
|
setChips([]);
|
|
318
|
+
setExpandedItems(null);
|
|
319
|
+
setSearch(null);
|
|
149
320
|
setStartedAt(Date.now());
|
|
150
321
|
const s = { status: [], reasoning: '', text: '', reply: null };
|
|
151
322
|
setLive({ ...s });
|
|
152
323
|
|
|
153
324
|
const abort = new AbortController();
|
|
154
325
|
abortRef.current = abort;
|
|
155
|
-
|
|
156
326
|
const context = {
|
|
157
327
|
autopilot: apRef.current,
|
|
328
|
+
allowAll,
|
|
158
329
|
client: 'icli',
|
|
159
330
|
ui: { tables: true, prompts: true, markdown: true },
|
|
160
331
|
};
|
|
@@ -176,55 +347,51 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
176
347
|
});
|
|
177
348
|
if (meta.model) setModel(meta.model);
|
|
178
349
|
const type = reply.type ?? 'message';
|
|
179
|
-
// Persist the tool / sub-agent activity trace inline (Copilot-CLI style)
|
|
180
|
-
// so it stays in history instead of vanishing with the live region.
|
|
181
350
|
if (s.status.length) push({ type: 'activity', status: s.status });
|
|
182
|
-
if (type === 'plan') {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
351
|
+
if (type === 'plan') {
|
|
352
|
+
push({ type: 'plan', reply });
|
|
353
|
+
log('[plan] ' + (reply.summary || ''));
|
|
354
|
+
} else if (type === 'connect') {
|
|
355
|
+
push({ type: 'connect', reply });
|
|
356
|
+
log('[connect] ' + (reply.device?.kind || ''));
|
|
188
357
|
if (depth < 2) {
|
|
189
358
|
const dev = reply.device || {};
|
|
190
359
|
const goal = convo.current.filter((m) => m.role === 'user').slice(-1)[0]?.content || '';
|
|
191
360
|
const sessionEvent = `[session-event] Terminal session opened on ${dev.kind || 'device'} id=${dev.id || '?'} proto=${dev.proto || 'ssh'}. vendor=auto. OPERATOR GOAL: ${goal}`;
|
|
192
|
-
setLive(null);
|
|
361
|
+
setLive(null);
|
|
193
362
|
return runTurn(sessionEvent, { depth: depth + 1, synthetic: true });
|
|
194
363
|
}
|
|
364
|
+
} else if (type === 'prompt' || type === 'choice') {
|
|
365
|
+
push({ type: 'assistant', text: text || reply.question || '' });
|
|
366
|
+
log(text || reply.question || '');
|
|
367
|
+
} else {
|
|
368
|
+
push({ type: 'assistant', text: text || '(no response)' });
|
|
369
|
+
log(text || '');
|
|
195
370
|
}
|
|
196
|
-
else if (type === 'prompt' || type === 'choice') { push({ type: 'assistant', text: text || reply.question || '' }); log(text || reply.question || ''); }
|
|
197
|
-
else { push({ type: 'assistant', text: text || '(no response)' }); log(text || ''); }
|
|
198
371
|
if (text) convo.current.push({ role: 'assistant', content: text });
|
|
199
372
|
lastAnswerRef.current = text || reply.summary || reply.question || lastAnswerRef.current;
|
|
200
373
|
|
|
201
|
-
// Populate followup chips from reply.suggestions (up to 4).
|
|
202
374
|
if (Array.isArray(reply.suggestions) && reply.suggestions.length) {
|
|
203
375
|
setChips(reply.suggestions.slice(0, 4));
|
|
204
376
|
setActiveChip(0);
|
|
205
377
|
}
|
|
206
378
|
|
|
207
|
-
// Archive a /backup answer to the per-user git repo.
|
|
208
379
|
if (archive === 'backup' && text) {
|
|
209
380
|
try {
|
|
210
381
|
const r = await commitBackup(cfg, label, text);
|
|
211
|
-
|
|
212
|
-
text: `${glyphs.check} Backup saved to git repo (${r.file})${r.committed ? '' : ' — nothing new to commit'}.`,
|
|
213
|
-
color: C.green });
|
|
382
|
+
pushNotice(`${glyphs.check} Backup saved to git repo (${r.file})${r.committed ? '' : ' — nothing new to commit'}.`, C.green);
|
|
214
383
|
} catch (e) {
|
|
215
|
-
|
|
384
|
+
pushNotice(`${glyphs.warn} Could not archive backup: ${e.message}`, C.warning);
|
|
216
385
|
}
|
|
217
386
|
}
|
|
218
387
|
|
|
219
|
-
// Autopilot: auto-confirm a proposed plan without waiting for the user.
|
|
220
388
|
if (type === 'plan' && apRef.current && depth < 3) {
|
|
221
|
-
|
|
222
|
-
setLive(null);
|
|
389
|
+
pushNotice(`${glyphs.bolt} autopilot ${dash()} applying fix…`, C.warning, true);
|
|
390
|
+
setLive(null);
|
|
223
391
|
return runTurn('apply fix', { depth: depth + 1 });
|
|
224
392
|
}
|
|
225
393
|
|
|
226
|
-
|
|
227
|
-
setLive(null); setBusy(false); abortRef.current = null;
|
|
394
|
+
setLive(null);
|
|
228
395
|
if (type === 'plan') {
|
|
229
396
|
const destructive = (reply.steps || []).some((st) => /destruct|high/.test(st.risk || ''));
|
|
230
397
|
setChoice({
|
|
@@ -249,77 +416,73 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
249
416
|
});
|
|
250
417
|
}
|
|
251
418
|
} catch (e) {
|
|
252
|
-
if (abort.signal.aborted)
|
|
253
|
-
else
|
|
254
|
-
setLive(null); setBusy(false); abortRef.current = null;
|
|
419
|
+
if (abort.signal.aborted) pushNotice(`${glyphs.cross} Cancelled.`, C.warning);
|
|
420
|
+
else pushNotice(`${glyphs.cross} ${e.message}`, C.red);
|
|
255
421
|
} finally {
|
|
256
422
|
abortRef.current = null;
|
|
257
423
|
setLive(null);
|
|
258
424
|
setBusy(false);
|
|
425
|
+
activeTurns.current = Math.max(0, activeTurns.current - 1);
|
|
426
|
+
maybeProcessQueue();
|
|
259
427
|
}
|
|
260
|
-
}, [cfg, push]);
|
|
428
|
+
}, [allowAll, cfg, log, maybeProcessQueue, push, pushNotice]);
|
|
261
429
|
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const doGist = useCallback(async (kind) => {
|
|
267
|
-
let content, filename, description;
|
|
268
|
-
if (kind === 'transcript') {
|
|
269
|
-
content = convo.current.map((m) => `### ${m.role}\n\n${m.content}`).join('\n\n---\n\n') || '(empty)';
|
|
270
|
-
filename = 'icli-transcript.md';
|
|
271
|
-
description = 'iCli conversation transcript';
|
|
272
|
-
} else {
|
|
273
|
-
content = lastAnswerRef.current || '(no answer)';
|
|
274
|
-
filename = 'icli-answer.md';
|
|
275
|
-
description = 'iCli answer';
|
|
276
|
-
}
|
|
277
|
-
push({ type: 'notice', text: `${glyphs.arrow} Creating secret gist…`, color: C.gray });
|
|
278
|
-
try {
|
|
279
|
-
const { url } = await createGist(cfg, { filename, content, description, isPublic: false });
|
|
280
|
-
const tool = await copyToClipboard(url);
|
|
281
|
-
push({ type: 'notice', text: `${glyphs.check} Gist created: ${url}${tool ? ' (URL copied)' : ''}`, color: C.green });
|
|
282
|
-
} catch (e) {
|
|
283
|
-
push({ type: 'notice', text: `${glyphs.cross} ${e.message}`, color: C.red });
|
|
430
|
+
const showExpanded = useCallback((kind, sourceItems) => {
|
|
431
|
+
if (!sourceItems.length) {
|
|
432
|
+
pushNotice('Nothing to expand yet.', C.muted, true);
|
|
433
|
+
return;
|
|
284
434
|
}
|
|
285
|
-
|
|
435
|
+
setExpandedItems({
|
|
436
|
+
kind,
|
|
437
|
+
items: sourceItems.map((it, idx) => ({ ...it, id: `expanded-${kind}-${idx}-${it.id}` })),
|
|
438
|
+
});
|
|
439
|
+
pushNotice(`Expanded ${kind}.`, C.muted, true);
|
|
440
|
+
}, [pushNotice]);
|
|
286
441
|
|
|
287
|
-
const
|
|
288
|
-
const q = raw.trim();
|
|
289
|
-
if (!q) return;
|
|
442
|
+
const dispatchSubmit = useCallback((raw, opts = {}) => {
|
|
443
|
+
const q = String(raw || '').trim();
|
|
444
|
+
if (!q || !trusted) return;
|
|
290
445
|
setHint('');
|
|
446
|
+
setExpandedItems(null);
|
|
447
|
+
setSearch(null);
|
|
448
|
+
|
|
449
|
+
if (shellMode) { runShellCommand(q); return; }
|
|
450
|
+
if (q === '!') { runShellCommand(''); return; }
|
|
451
|
+
if (q.startsWith('!')) { runShellCommand(q.slice(1)); return; }
|
|
452
|
+
if (q === '?') { push({ type: 'help', text: 'help' }); return; }
|
|
291
453
|
|
|
292
454
|
if (q === '/exit' || q === 'exit' || q === 'quit') { doExit(); return; }
|
|
293
455
|
if (q === '/clear') { clearAll(); return; }
|
|
294
456
|
if (q === '/new') {
|
|
295
457
|
convo.current = [];
|
|
296
458
|
clearAll();
|
|
297
|
-
|
|
459
|
+
pushNotice(`${glyphs.check} New conversation started.`, C.green);
|
|
298
460
|
return;
|
|
299
461
|
}
|
|
300
462
|
if (q === '/reasoning') {
|
|
301
|
-
setShowReasoning((v) => {
|
|
463
|
+
setShowReasoning((v) => { pushNotice(`Reasoning display ${!v ? 'expanded' : 'collapsed'}.`); return !v; });
|
|
302
464
|
return;
|
|
303
465
|
}
|
|
304
466
|
if (q === '/autopilot' || q === '/auto') {
|
|
305
467
|
const next = modeRef.current !== 'autopilot' ? 'autopilot' : 'ask';
|
|
306
468
|
setModeState(next);
|
|
307
|
-
|
|
308
|
-
|
|
469
|
+
pushNotice(
|
|
470
|
+
next === 'autopilot'
|
|
309
471
|
? `${glyphs.bolt} Autopilot ON ${dash()} proposed fixes will be applied automatically. Shift+Tab to cycle modes.`
|
|
310
472
|
: `Mode: ask ${dash()} fixes will wait for your confirmation.`,
|
|
311
|
-
|
|
473
|
+
next === 'autopilot' ? C.warning : C.muted,
|
|
474
|
+
);
|
|
312
475
|
return;
|
|
313
476
|
}
|
|
314
477
|
if (q === '/plan') {
|
|
315
478
|
const next = modeRef.current !== 'plan' ? 'plan' : 'ask';
|
|
316
479
|
setModeState(next);
|
|
317
|
-
|
|
480
|
+
pushNotice(`Mode: ${next} ${dash()} Shift+Tab to cycle.`, C.accent);
|
|
318
481
|
return;
|
|
319
482
|
}
|
|
320
483
|
if (q === '/context' || q === '/usage') {
|
|
321
484
|
const tokens = approxTokens(convo.current);
|
|
322
|
-
|
|
485
|
+
pushNotice(`Context: ~${tokens.toLocaleString()} tokens (${convo.current.length} messages). /compact to summarise.`);
|
|
323
486
|
return;
|
|
324
487
|
}
|
|
325
488
|
if (q === '/compact' || q.startsWith('/compact ')) {
|
|
@@ -328,30 +491,43 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
328
491
|
? `Summarise our conversation so far, focusing on: ${focus}`
|
|
329
492
|
: 'Summarise our conversation so far in a concise way, preserving key facts and decisions.';
|
|
330
493
|
convo.current = [];
|
|
331
|
-
|
|
494
|
+
pushNotice(`${glyphs.arrow} Compacting history…`, C.muted);
|
|
332
495
|
runTurn(prompt);
|
|
333
496
|
return;
|
|
334
497
|
}
|
|
335
498
|
if (q === '/theme') {
|
|
336
|
-
|
|
499
|
+
pushNotice('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
500
|
return;
|
|
338
501
|
}
|
|
339
502
|
if (q === '/session') {
|
|
340
503
|
const tokens = approxTokens(convo.current);
|
|
341
504
|
const branch = branchRef.current;
|
|
342
505
|
const cwd = shortenPath(process.cwd());
|
|
343
|
-
|
|
344
|
-
|
|
506
|
+
const name = cfg?.tui?.sessionNames?.[process.cwd()] || '(unnamed)';
|
|
507
|
+
pushNotice([
|
|
508
|
+
`Session ${dash()} ${name}`,
|
|
509
|
+
`Path ${dash()} ${cwd}${branch ? ' [' + branch + ']' : ''}`,
|
|
345
510
|
`Messages ${dash()} ${convo.current.length}`,
|
|
346
511
|
`Tokens ${dash()} ~${tokens.toLocaleString()} (approx)`,
|
|
347
512
|
`Model ${dash()} ${model || modelRef.current || 'server default'}`,
|
|
348
513
|
`Mode ${dash()} ${modeRef.current}`,
|
|
349
|
-
].join('\n')
|
|
514
|
+
].join('\n'));
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (q === '/resume' || q.startsWith('/resume ') || q === '/continue' || q.startsWith('/continue ')) {
|
|
518
|
+
const name = cfg?.tui?.sessionNames?.[process.cwd()] || '(unnamed)';
|
|
519
|
+
pushNotice([
|
|
520
|
+
`Resume (local stub)`,
|
|
521
|
+
`Name ${dash()} ${name}`,
|
|
522
|
+
`Path ${dash()} ${shortenPath(process.cwd())}`,
|
|
523
|
+
`Messages ${dash()} ${convo.current.length}`,
|
|
524
|
+
`Last AI ${dash()} ${(lastAnswerRef.current || '(none)').slice(0, 120)}`,
|
|
525
|
+
].join('\n'));
|
|
350
526
|
return;
|
|
351
527
|
}
|
|
352
528
|
if (q === '/share') {
|
|
353
529
|
if (!lastAnswerRef.current && convo.current.length === 0) {
|
|
354
|
-
|
|
530
|
+
pushNotice('Nothing to share yet — ask something first.', C.muted); return;
|
|
355
531
|
}
|
|
356
532
|
choiceActionRef.current = (v) => doGist(v);
|
|
357
533
|
setChoice({
|
|
@@ -368,80 +544,169 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
368
544
|
if (q === '/model' || q.startsWith('/model ')) {
|
|
369
545
|
const name = q.slice('/model'.length).trim();
|
|
370
546
|
if (!name) {
|
|
371
|
-
|
|
547
|
+
pushNotice(`Current model: ${model || modelRef.current || 'server default'}.\nUse /model <name> to request a different model.`);
|
|
372
548
|
} else {
|
|
373
549
|
modelRef.current = name;
|
|
374
550
|
setModel(name);
|
|
375
551
|
cfg._model = name;
|
|
376
|
-
|
|
377
|
-
|
|
552
|
+
persistCfg();
|
|
553
|
+
pushNotice(`${glyphs.check} Model set to ${name}. It applies to your next message.`, C.green);
|
|
378
554
|
}
|
|
379
555
|
return;
|
|
380
556
|
}
|
|
557
|
+
if (q === '/cwd') {
|
|
558
|
+
pushNotice(process.cwd());
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (q === '/cd' || q.startsWith('/cd ')) {
|
|
562
|
+
const arg = q.slice('/cd'.length).trim();
|
|
563
|
+
if (!arg) { pushNotice(`cwd ${dash()} ${process.cwd()}`); return; }
|
|
564
|
+
const target = isAbsolute(arg) ? arg : resolve(process.cwd(), arg);
|
|
565
|
+
if (!existsSync(target)) { pushNotice(`Directory not found: ${target}`, C.red); return; }
|
|
566
|
+
try {
|
|
567
|
+
process.chdir(target);
|
|
568
|
+
branchRef.current = gitBranch(target);
|
|
569
|
+
const ok = isTrustedDir(cfg, target);
|
|
570
|
+
setTrusted(ok);
|
|
571
|
+
if (!ok) openTrustDialog(target);
|
|
572
|
+
pushNotice(`Changed directory to ${shortenPath(target)}`, C.green, true);
|
|
573
|
+
} catch (e) {
|
|
574
|
+
pushNotice(`Could not change directory: ${e.message}`, C.red);
|
|
575
|
+
}
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (q === '/allow-all' || q.startsWith('/allow-all ')) {
|
|
579
|
+
const arg = q.slice('/allow-all'.length).trim().toLowerCase();
|
|
580
|
+
const next = arg === 'show' ? allowAll : arg === 'on' ? true : arg === 'off' ? false : !allowAll;
|
|
581
|
+
if (arg !== 'show') {
|
|
582
|
+
cfg.tui = { ...(cfg.tui || {}), allowAll: next };
|
|
583
|
+
persistCfg();
|
|
584
|
+
setAllowAll(next);
|
|
585
|
+
}
|
|
586
|
+
pushNotice(`allow-all ${dash()} ${next ? 'ON' : 'OFF'}`, next ? C.warning : C.muted, arg !== 'show');
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
if (q === '/rename' || q.startsWith('/rename ')) {
|
|
590
|
+
const name = q.slice('/rename'.length).trim() || `session-${new Date().toISOString().slice(0, 16).replace(/[T:]/g, '-')}`;
|
|
591
|
+
cfg.tui = cfg.tui || {};
|
|
592
|
+
cfg.tui.sessionNames = { ...(cfg.tui.sessionNames || {}), [process.cwd()]: name };
|
|
593
|
+
persistCfg();
|
|
594
|
+
pushNotice(`${glyphs.check} Session renamed to ${name}.`, C.green);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
if (q === '/changelog' || q.startsWith('/changelog')) {
|
|
598
|
+
try {
|
|
599
|
+
const raw = execSync('npm view ispbills-icli version time --json', {
|
|
600
|
+
cwd: process.cwd(), stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000,
|
|
601
|
+
}).toString();
|
|
602
|
+
const data = JSON.parse(raw);
|
|
603
|
+
const versions = Object.entries(data.time || {})
|
|
604
|
+
.filter(([k]) => /^\d/.test(k))
|
|
605
|
+
.slice(-5)
|
|
606
|
+
.map(([k, v]) => ` ${k} ${dash()} ${String(v).slice(0, 10)}`)
|
|
607
|
+
.join('\n');
|
|
608
|
+
pushNotice(`ispbills-icli ${dash()} current ${pkgVersion()}\nRecent npm releases:\n${versions || ' unavailable'}`);
|
|
609
|
+
} catch {
|
|
610
|
+
pushNotice(`ispbills-icli ${dash()} current ${pkgVersion()}\nRecent changelog unavailable offline.`);
|
|
611
|
+
}
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
if (q === '/feedback' || q === '/bug') {
|
|
615
|
+
pushNotice(`Feedback: ${feedbackUrl()}`);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
if (q === '/env') {
|
|
619
|
+
pushNotice([
|
|
620
|
+
`url ${dash()} ${cfg.url}`,
|
|
621
|
+
`user ${dash()} ${cfg.user?.name || cfg.user?.email || 'unknown'}`,
|
|
622
|
+
`model ${dash()} ${model || modelRef.current || 'server default'}`,
|
|
623
|
+
`mode ${dash()} ${shellMode ? 'shell' : modeRef.current}`,
|
|
624
|
+
`memory ${dash()} ${loadNotes(cfg).length} notes`,
|
|
625
|
+
`allowAll ${dash()} ${allowAll ? 'on' : 'off'}`,
|
|
626
|
+
`streamer ${dash()} ${streamerMode ? 'on' : 'off'}`,
|
|
627
|
+
].join('\n'));
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (q === '/streamer-mode') {
|
|
631
|
+
const next = !streamerMode;
|
|
632
|
+
cfg.tui = { ...(cfg.tui || {}), streamerMode: next };
|
|
633
|
+
persistCfg();
|
|
634
|
+
setStreamerMode(next);
|
|
635
|
+
pushNotice(`streamer-mode ${dash()} ${next ? 'ON' : 'OFF'}`, next ? C.warning : C.muted, true);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (q === '/diff') {
|
|
639
|
+
gitCmd(cfg, ['diff', '--stat']).then((r) => {
|
|
640
|
+
const body = (r.stdout || '').trim() || (r.stderr || '').trim() || 'No recent diff in the backup repo.';
|
|
641
|
+
pushNotice(`backup repo diff\n${body.slice(0, 3500)}`, r.ok ? C.muted : C.red);
|
|
642
|
+
});
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
if (q === '/review' || q.startsWith('/review ')) {
|
|
646
|
+
if (!lastAnswerRef.current) { pushNotice('Nothing to review yet — ask something first.', C.muted); return; }
|
|
647
|
+
const focus = q.slice('/review'.length).trim();
|
|
648
|
+
const reviewPrompt = `${focus ? `${focus}. ` : ''}Review the following last answer for correctness, risks, and missing details in the ISP context:\n\n${lastAnswerRef.current}`;
|
|
649
|
+
runTurn(reviewPrompt, { synthetic: false });
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
381
652
|
if (q === '/help' || q === 'help') {
|
|
382
653
|
let group = null;
|
|
383
654
|
const lines = SLASH_COMMANDS.map((c) => {
|
|
384
655
|
const head = c.group !== group ? ((group = c.group), `\n ${c.group}\n`) : '';
|
|
385
656
|
return head + ` ${(c.cmd + (c.hint ? ' ' + c.hint : '')).padEnd(22)}${c.desc}`;
|
|
386
657
|
}).join('\n');
|
|
387
|
-
|
|
658
|
+
pushNotice('Commands:' + lines + '\n\n Keys: Shift+Tab cycle modes · Ctrl+T toggle reasoning · Ctrl+L clear · Ctrl+N/P followup chips · Ctrl+D exit · Ctrl+S preserve input · Ctrl+G external editor');
|
|
388
659
|
return;
|
|
389
660
|
}
|
|
390
661
|
if (q === '/history') {
|
|
391
662
|
const us = convo.current.filter((m) => m.role === 'user');
|
|
392
|
-
|
|
663
|
+
pushNotice(us.length ? us.map((m, i) => ` ${i + 1}. ${m.content.slice(0, 80)}`).join('\n') : 'No history yet.');
|
|
393
664
|
return;
|
|
394
665
|
}
|
|
395
666
|
if (q === '/logout') { onExternal?.('logout'); doExit(); return; }
|
|
396
667
|
if (q === '/update') { onExternal?.('update'); doExit(); return; }
|
|
397
668
|
|
|
398
|
-
// ── Memory ──
|
|
399
669
|
if (q === '/remember' || q.startsWith('/remember ')) {
|
|
400
670
|
const note = q.slice('/remember'.length).trim();
|
|
401
|
-
if (!note) {
|
|
671
|
+
if (!note) { pushNotice('Usage: /remember <note>'); return; }
|
|
402
672
|
const notes = addNote(cfg, note);
|
|
403
|
-
|
|
673
|
+
pushNotice(`${glyphs.check} Remembered (${notes.length} note${notes.length > 1 ? 's' : ''}). I'll keep this in mind.`, C.green);
|
|
404
674
|
return;
|
|
405
675
|
}
|
|
406
676
|
if (q === '/memory') {
|
|
407
677
|
const notes = loadNotes(cfg);
|
|
408
|
-
|
|
678
|
+
pushNotice(notes.length
|
|
409
679
|
? 'Memory notes:\n' + notes.map((n, i) => ` ${i + 1}. ${n.text}`).join('\n') + '\n\n /forget <n|all> to remove.'
|
|
410
|
-
: 'No memory notes yet. Add one with /remember <note>.'
|
|
680
|
+
: 'No memory notes yet. Add one with /remember <note>.');
|
|
411
681
|
return;
|
|
412
682
|
}
|
|
413
683
|
if (q === '/forget' || q.startsWith('/forget ')) {
|
|
414
684
|
const which = q.slice('/forget'.length).trim();
|
|
415
|
-
if (!which) {
|
|
685
|
+
if (!which) { pushNotice('Usage: /forget <number | all | text>'); return; }
|
|
416
686
|
const { notes, removed } = forgetNote(cfg, which);
|
|
417
|
-
|
|
418
|
-
text: removed ? `${glyphs.check} Forgot ${removed} note(s). ${notes.length} remain.` : 'No matching note found.',
|
|
419
|
-
color: removed ? C.green : C.gray });
|
|
687
|
+
pushNotice(removed ? `${glyphs.check} Forgot ${removed} note(s). ${notes.length} remain.` : 'No matching note found.', removed ? C.green : C.gray);
|
|
420
688
|
return;
|
|
421
689
|
}
|
|
422
|
-
|
|
423
|
-
// ── Clipboard ──
|
|
424
690
|
if (q === '/copy') {
|
|
425
691
|
const ans = lastAnswerRef.current;
|
|
426
|
-
if (!ans) {
|
|
427
|
-
copyToClipboard(ans).then((tool) =>
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
692
|
+
if (!ans) { pushNotice('Nothing to copy yet — ask something first.', C.gray); return; }
|
|
693
|
+
copyToClipboard(ans).then((tool) => pushNotice(
|
|
694
|
+
tool ? `${glyphs.check} Copied last answer (${ans.length} chars) to the clipboard.`
|
|
695
|
+
: `${glyphs.cross} No clipboard tool found. Install xclip / wl-clipboard (Linux), or use macOS/Windows.`,
|
|
696
|
+
tool ? C.green : C.red,
|
|
697
|
+
));
|
|
431
698
|
return;
|
|
432
699
|
}
|
|
433
|
-
|
|
434
|
-
// ── GitHub gist ──
|
|
435
700
|
if (q === '/gist' || q.startsWith('/gist ')) {
|
|
436
701
|
const rest = q.slice('/gist'.length).trim();
|
|
437
702
|
if (rest.startsWith('token ')) {
|
|
438
703
|
cfg.github_token = rest.slice('token '.length).trim();
|
|
439
|
-
|
|
440
|
-
|
|
704
|
+
persistCfg();
|
|
705
|
+
pushNotice(`${glyphs.check} GitHub token saved. /gist will use it (needs the "gist" scope).`, C.green);
|
|
441
706
|
return;
|
|
442
707
|
}
|
|
443
708
|
if (!lastAnswerRef.current && convo.current.length === 0) {
|
|
444
|
-
|
|
709
|
+
pushNotice('Nothing to save yet — ask something first.', C.gray);
|
|
445
710
|
return;
|
|
446
711
|
}
|
|
447
712
|
choiceActionRef.current = (v) => doGist(v);
|
|
@@ -456,42 +721,50 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
456
721
|
});
|
|
457
722
|
return;
|
|
458
723
|
}
|
|
459
|
-
|
|
460
|
-
// ── Local git repo ──
|
|
461
724
|
if (q === '/git' || q.startsWith('/git ')) {
|
|
462
725
|
const args = q.slice('/git'.length).trim();
|
|
463
|
-
if (!args) {
|
|
726
|
+
if (!args) { pushNotice('Usage: /git <status | log | commit -m "msg" | push | diff | remote add origin <url>>'); return; }
|
|
464
727
|
gitCmd(cfg, splitArgs(args)).then((r) => {
|
|
465
728
|
const body = (r.stdout || '').trim() || (r.stderr || '').trim() || (r.ok ? '(done)' : '(no output)');
|
|
466
|
-
|
|
729
|
+
pushNotice(`$ git ${args}\n${body.slice(0, 3500)}`, r.ok ? C.muted : C.red);
|
|
467
730
|
});
|
|
468
731
|
return;
|
|
469
732
|
}
|
|
470
733
|
|
|
471
734
|
let question = q;
|
|
472
|
-
const
|
|
735
|
+
const turnOpts = {};
|
|
473
736
|
if (q.startsWith('/')) {
|
|
474
737
|
const mapped = slashToQuestion(q);
|
|
475
738
|
if (mapped) {
|
|
476
739
|
question = mapped;
|
|
477
740
|
if (q === '/backup' || q.startsWith('/backup ')) {
|
|
478
|
-
|
|
479
|
-
|
|
741
|
+
turnOpts.archive = 'backup';
|
|
742
|
+
turnOpts.label = q.slice('/backup'.length).trim() || 'fleet';
|
|
480
743
|
}
|
|
481
|
-
} else {
|
|
744
|
+
} else {
|
|
745
|
+
pushNotice(`Unknown command: ${q}. Type /help.`);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
482
748
|
}
|
|
483
|
-
runTurn(question,
|
|
484
|
-
}, [doExit,
|
|
749
|
+
runTurn(question, turnOpts);
|
|
750
|
+
}, [allowAll, cfg, clearAll, doExit, doGist, modeRef, model, onExternal, openTrustDialog, persistCfg, push, pushNotice, runShellCommand, runTurn, setModeState, shellMode, streamerMode, trusted]);
|
|
751
|
+
|
|
752
|
+
useEffect(() => {
|
|
753
|
+
dispatchSubmitRef.current = dispatchSubmit;
|
|
754
|
+
}, [dispatchSubmit]);
|
|
755
|
+
|
|
756
|
+
useEffect(() => {
|
|
757
|
+
if (initialQuestion && trusted && !choice && convo.current.length === 0) dispatchSubmit(initialQuestion);
|
|
758
|
+
}, [initialQuestion, trusted, choice, dispatchSubmit]);
|
|
485
759
|
|
|
486
|
-
// Interactive prompt/choice handling (owns the keyboard while open).
|
|
487
760
|
const pickChoice = useCallback((value) => {
|
|
488
761
|
const act = choiceActionRef.current;
|
|
489
762
|
choiceActionRef.current = null;
|
|
490
763
|
setChoice(null);
|
|
491
|
-
if (act) {
|
|
492
|
-
if (value == null) {
|
|
764
|
+
if (act) { act(value); return; }
|
|
765
|
+
if (value == null) { pushNotice('Dismissed.', C.muted, true); return; }
|
|
493
766
|
runTurn(String(value));
|
|
494
|
-
}, [
|
|
767
|
+
}, [pushNotice, runTurn]);
|
|
495
768
|
|
|
496
769
|
useInput((input, key) => {
|
|
497
770
|
if (!choice) return;
|
|
@@ -520,9 +793,37 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
520
793
|
}
|
|
521
794
|
});
|
|
522
795
|
|
|
523
|
-
|
|
796
|
+
const openSearch = useCallback(() => {
|
|
797
|
+
setSearch({ query: '', active: 0 });
|
|
798
|
+
setHint('Timeline search');
|
|
799
|
+
}, []);
|
|
800
|
+
|
|
801
|
+
const searchMatches = search
|
|
802
|
+
? items
|
|
803
|
+
.map((it) => ({ ...it, preview: itemPreview(it) }))
|
|
804
|
+
.filter((it) => !search.query || it.preview.toLowerCase().includes(search.query.toLowerCase()))
|
|
805
|
+
: [];
|
|
806
|
+
|
|
524
807
|
useInput((input, key) => {
|
|
525
|
-
if (
|
|
808
|
+
if (!search) return;
|
|
809
|
+
if (key.escape || (key.ctrl && input === 'c')) { setSearch(null); setHint(''); return; }
|
|
810
|
+
if (key.backspace || key.delete) { setSearch((s) => ({ ...s, query: s.query.slice(0, -1), active: 0 })); return; }
|
|
811
|
+
if (key.upArrow) { setSearch((s) => ({ ...s, active: Math.max(0, s.active - 1) })); return; }
|
|
812
|
+
if (key.downArrow || key.tab) { setSearch((s) => ({ ...s, active: Math.min(Math.max(0, searchMatches.length - 1), s.active + 1) })); return; }
|
|
813
|
+
if (key.return) {
|
|
814
|
+
if (searchMatches.length) showExpanded('search results', [searchMatches[search.active] || searchMatches[0]]);
|
|
815
|
+
setSearch(null);
|
|
816
|
+
setHint('');
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
if (input && !key.ctrl && !key.meta) {
|
|
820
|
+
const printable = input.replace(/[\x00-\x1f\x7f]/g, '');
|
|
821
|
+
if (printable) setSearch((s) => ({ ...s, query: s.query + printable, active: 0 }));
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
useInput((input, key) => {
|
|
826
|
+
if (choice || search) return;
|
|
526
827
|
if (key.ctrl && input === 'c') {
|
|
527
828
|
if (busy) { abortRef.current?.abort(); setHint(''); return; }
|
|
528
829
|
const now = Date.now();
|
|
@@ -531,11 +832,12 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
531
832
|
setHint('Press Ctrl+C again to exit');
|
|
532
833
|
return;
|
|
533
834
|
}
|
|
534
|
-
if (key.ctrl && input === 'd') { doExit(); return; }
|
|
835
|
+
if (key.ctrl && input === 'd') { doExit(); return; }
|
|
535
836
|
if (key.escape && busy) { abortRef.current?.abort(); return; }
|
|
536
|
-
if (key.escape &&
|
|
837
|
+
if (key.escape && shellMode && !composerText.trim()) { setShellMode(false); pushNotice('Exited shell mode.', C.muted, true); return; }
|
|
838
|
+
if (key.escape && expandedItems) { setExpandedItems(null); return; }
|
|
839
|
+
if (key.escape && chips.length) { setChips([]); return; }
|
|
537
840
|
if (key.tab && key.shift && !busy) {
|
|
538
|
-
// Cycle: ask → plan → autopilot → ask
|
|
539
841
|
const cur = MODES.indexOf(modeRef.current);
|
|
540
842
|
const next = MODES[(cur + 1) % MODES.length];
|
|
541
843
|
setModeState(next);
|
|
@@ -544,75 +846,57 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
544
846
|
}
|
|
545
847
|
if (key.ctrl && input === 't') { setShowReasoning((v) => !v); return; }
|
|
546
848
|
if (key.ctrl && input === 'l' && !busy) { clearAll(); return; }
|
|
547
|
-
|
|
548
|
-
if (key.ctrl && input === '
|
|
549
|
-
|
|
550
|
-
}
|
|
551
|
-
if (key.ctrl && input === 'p' && chips.length) {
|
|
552
|
-
|
|
553
|
-
}
|
|
554
|
-
// Tab cycles the tab bar (when not in slash palette)
|
|
555
|
-
if (key.tab && !key.shift && !busy) {
|
|
849
|
+
if (key.ctrl && input === 'f' && !busy) { openSearch(); return; }
|
|
850
|
+
if (key.ctrl && input === 'o' && !busy && !composerText.trim()) { showExpanded('recent items', items.slice(-5)); return; }
|
|
851
|
+
if (key.ctrl && input === 'e' && !busy && !composerText.trim()) { showExpanded('all items', items); return; }
|
|
852
|
+
if (key.ctrl && input === 'n' && chips.length) { setActiveChip((i) => (i + 1) % chips.length); return; }
|
|
853
|
+
if (key.ctrl && input === 'p' && chips.length) { setActiveChip((i) => (i - 1 + chips.length) % chips.length); return; }
|
|
854
|
+
if (key.tab && !key.shift && !busy && !composerText.trim()) {
|
|
556
855
|
setActiveTab((t) => {
|
|
557
856
|
const tabs = ['session', 'devices', 'customers', 'alarms'];
|
|
558
857
|
return tabs[(tabs.indexOf(t) + 1) % tabs.length];
|
|
559
858
|
});
|
|
560
|
-
return;
|
|
561
859
|
}
|
|
562
860
|
});
|
|
563
861
|
|
|
564
|
-
// Auto-run a first question if launched with one.
|
|
565
|
-
const started = useRef(false);
|
|
566
|
-
useEffect(() => {
|
|
567
|
-
if (initialQuestion && !started.current) { started.current = true; onSubmit(initialQuestion); }
|
|
568
|
-
}, [initialQuestion, onSubmit]);
|
|
569
|
-
|
|
570
862
|
const renderItem = (it) => {
|
|
571
863
|
const c = it.cols || 80;
|
|
572
864
|
switch (it.type) {
|
|
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} />`;
|
|
865
|
+
case 'user': return html`<${UserMessage} key=${it.id} text=${it.text} cols=${c} ts=${it.ts} />`;
|
|
866
|
+
case 'assistant': return html`<${AssistantMessage} key=${it.id} text=${it.text} cols=${c} ts=${it.ts} />`;
|
|
575
867
|
case 'plan': return html`<${Plan} key=${it.id} reply=${it.reply} />`;
|
|
576
868
|
case 'connect': return html`<${Connect} key=${it.id} reply=${it.reply} />`;
|
|
577
869
|
case 'activity': return html`<${StatusLines} key=${it.id} lines=${it.status} busy=${false} />`;
|
|
578
870
|
case 'notice': return html`<${Notice} key=${it.id} text=${it.text} color=${it.color} />`;
|
|
871
|
+
case 'shell': return html`<${ShellOutput} key=${it.id} command=${it.command} output=${it.output} code=${it.code} cols=${c} ts=${it.ts} />`;
|
|
872
|
+
case 'help': return html`<${HelpOverlay} key=${it.id} cols=${c} ts=${it.ts} />`;
|
|
579
873
|
default: return null;
|
|
580
874
|
}
|
|
581
875
|
};
|
|
582
876
|
|
|
583
|
-
// ── Footer ───────────────────────────────────────────────────────────────────
|
|
584
|
-
// Left: ~/cwd ⏎ branch mode-badge
|
|
585
|
-
// Right: model ~Nk ctx
|
|
586
877
|
const cwd = shortenPath(process.cwd());
|
|
587
878
|
const branch = branchRef.current;
|
|
588
879
|
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
|
|
592
|
-
|
|
593
|
-
const footerLeft = [
|
|
594
|
-
cwd,
|
|
595
|
-
branch ? `${glyphs.gitBranch}${branch}` : null,
|
|
596
|
-
].filter(Boolean).join(' ');
|
|
597
|
-
|
|
880
|
+
const tokenLabel = tokenCount > 0 ? `~${Math.max(1, Math.round(tokenCount / 1000))}k ctx` : '';
|
|
881
|
+
const modelLabel = streamerMode ? '' : shortModel(model || modelRef.current || 'server default');
|
|
882
|
+
const footerLeft = [cwd, branch ? `${glyphs.gitBranch}${branch}` : null].filter(Boolean).join(' ');
|
|
598
883
|
const footerRight = hint
|
|
599
884
|
? hint
|
|
600
|
-
:
|
|
601
|
-
|
|
885
|
+
: streamerMode
|
|
886
|
+
? ''
|
|
887
|
+
: [modelLabel, tokenLabel].filter(Boolean).join(midDot());
|
|
602
888
|
const compact = cols < 60;
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
// native scrollback intact so earlier conversation can be scrolled back to.
|
|
608
|
-
const staticItems = [{ type: 'banner', id: `banner-${clearEpoch}`, cols }, ...items];
|
|
889
|
+
const composerMode = shellMode ? 'shell' : mode;
|
|
890
|
+
const staticItems = showBanner
|
|
891
|
+
? [{ type: 'banner', id: `banner-${clearEpoch}`, cols }, ...items]
|
|
892
|
+
: items;
|
|
609
893
|
|
|
610
894
|
return html`
|
|
611
895
|
<${Box} flexDirection="column" width=${cols}>
|
|
612
896
|
<${Static} key=${clearEpoch} items=${staticItems}>
|
|
613
897
|
${(it) => it.type === 'banner'
|
|
614
898
|
? html`<${Box} key=${it.id} flexDirection="column" alignItems="center" marginBottom=${1}>
|
|
615
|
-
<${Banner} cfg=${cfg} model=${model} width=${cols} compact=${compact} />
|
|
899
|
+
<${Banner} cfg=${cfg} model=${model} width=${cols} compact=${compact} streamerMode=${streamerMode} />
|
|
616
900
|
<//>`
|
|
617
901
|
: renderItem(it)}
|
|
618
902
|
<//>
|
|
@@ -622,24 +906,48 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
622
906
|
<${StatusLines} lines=${live.status} busy=${busy} />
|
|
623
907
|
${live.reasoning ? html`<${Reasoning} text=${live.reasoning} show=${showReasoning} />` : null}
|
|
624
908
|
${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
|
|
625
|
-
${live.text ? html`<${AssistantMessage} text=${live.text} cols=${cols} />` : null}
|
|
909
|
+
${live.text ? html`<${AssistantMessage} text=${live.text} cols=${cols} ts=${startedAt || Date.now()} />` : null}
|
|
626
910
|
<${Thinking} label=${live.reply ? 'Planning' : live.text ? 'Responding' : 'Working'} startedAt=${startedAt} />
|
|
627
911
|
<//>`
|
|
628
912
|
: null}
|
|
629
913
|
|
|
630
|
-
|
|
914
|
+
${expandedItems
|
|
915
|
+
? html`<${Box} flexDirection="column">
|
|
916
|
+
<${Notice} text=${`${glyphs.expand} ${expandedItems.kind}`} color=${C.muted} />
|
|
917
|
+
${expandedItems.items.map((it) => renderItem(it))}
|
|
918
|
+
<//>`
|
|
919
|
+
: null}
|
|
920
|
+
|
|
921
|
+
${search ? html`<${SearchOverlay} query=${search.query} matches=${searchMatches} active=${search.active} cols=${cols} />` : null}
|
|
922
|
+
|
|
923
|
+
<${TabBar} active=${activeTab} cols=${cols} mode=${composerMode} />
|
|
631
924
|
|
|
632
925
|
${!busy && chips.length ? html`<${FollowupChips} chips=${chips} active=${activeChip} />` : null}
|
|
633
926
|
|
|
634
927
|
<${Box} flexDirection="column">
|
|
635
928
|
${choice
|
|
636
929
|
? html`<${Choice} ...${choice} />`
|
|
637
|
-
: html`<${Composer}
|
|
930
|
+
: html`<${Composer}
|
|
931
|
+
onSubmit=${dispatchSubmit}
|
|
932
|
+
onSubmitKeep=${dispatchSubmit}
|
|
933
|
+
onQueue=${queueSubmission}
|
|
934
|
+
onSearch=${openSearch}
|
|
935
|
+
onNotice=${(text, color = C.muted) => pushNotice(text, color, true)}
|
|
936
|
+
onEmptyEsc=${() => { if (shellMode) setShellMode(false); else if (expandedItems) setExpandedItems(null); }}
|
|
937
|
+
onValueChange=${setComposerText}
|
|
938
|
+
onEmptySubmit=${() => {
|
|
939
|
+
if (chips.length) dispatchSubmit(chips[activeChip]);
|
|
940
|
+
}}
|
|
941
|
+
busy=${busy}
|
|
942
|
+
blocked=${Boolean(choice || search || !trusted)}
|
|
943
|
+
width=${cols}
|
|
944
|
+
mode=${composerMode}
|
|
945
|
+
clearToken=${composerClearToken}
|
|
946
|
+
/>`}
|
|
638
947
|
|
|
639
948
|
<${Box} paddingX=${1} width=${cols}>
|
|
640
949
|
<${Box} flexGrow=${1}>
|
|
641
950
|
<${Text} color=${C.muted} wrap="truncate-end">${footerLeft}<//>
|
|
642
|
-
${modeLabel ? html`<${Text} color=${mode === 'autopilot' ? C.warning : C.accent}>${modeLabel}<//>` : null}
|
|
643
951
|
<//>
|
|
644
952
|
<${Text} color=${hint ? C.warning : C.muted} wrap="truncate-end">${footerRight}<//>
|
|
645
953
|
<//>
|