ispbills-icli 8.1.1 → 8.3.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/README.md +33 -3
- package/bin/icli.js +4 -3
- package/package.json +1 -1
- package/src/client.js +63 -5
- package/src/clipboard.js +42 -0
- package/src/commands.js +22 -0
- package/src/gist.js +90 -0
- package/src/gitrepo.js +78 -0
- package/src/memory.js +72 -0
- package/src/tui/app.js +233 -17
- package/src/tui/components.js +109 -27
- package/src/tui/composer.js +85 -25
- package/src/tui/markdown.js +110 -19
- package/src/userdir.js +18 -0
package/src/tui/app.js
CHANGED
|
@@ -7,13 +7,26 @@ import { Box, Text, useStdout, useApp, useInput } from 'ink';
|
|
|
7
7
|
import { C, glyphs, midDot, dash } from './theme.js';
|
|
8
8
|
import {
|
|
9
9
|
Banner, UserMessage, AssistantMessage, StatusLines, Reasoning,
|
|
10
|
-
Plan, Connect, Notice, Thinking,
|
|
10
|
+
Plan, Connect, Notice, Thinking, Choice,
|
|
11
11
|
} from './components.js';
|
|
12
12
|
import { Composer } from './composer.js';
|
|
13
13
|
import { streamChat } from '../client.js';
|
|
14
14
|
import { slashToQuestion, SLASH_COMMANDS } from '../commands.js';
|
|
15
15
|
import { saveConfig } from '../config.js';
|
|
16
16
|
import { shortModel } from '../ui.js';
|
|
17
|
+
import { loadNotes, addNote, forgetNote, memoryPreamble } from '../memory.js';
|
|
18
|
+
import { copyToClipboard } from '../clipboard.js';
|
|
19
|
+
import { createGist } from '../gist.js';
|
|
20
|
+
import { git as gitCmd, commitBackup } from '../gitrepo.js';
|
|
21
|
+
|
|
22
|
+
// Split a command argument string into argv, honouring "quoted" segments.
|
|
23
|
+
function splitArgs(s) {
|
|
24
|
+
const out = [];
|
|
25
|
+
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
26
|
+
let m;
|
|
27
|
+
while ((m = re.exec(s))) out.push(m[1] ?? m[2] ?? m[3]);
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
17
30
|
|
|
18
31
|
function useTerminalSize() {
|
|
19
32
|
const { stdout } = useStdout();
|
|
@@ -59,6 +72,7 @@ function liveHeight(live, cols, showReasoning) {
|
|
|
59
72
|
let h = 2; // thinking line + margin
|
|
60
73
|
if (live.status?.length) h += 1 + live.status.length;
|
|
61
74
|
if (showReasoning && live.reasoning) h += 1 + wrapLines(live.reasoning, cols);
|
|
75
|
+
if (live.reply?.steps) h += 3 + live.reply.steps.length * 2 + (live.reply.summary ? 1 : 0);
|
|
62
76
|
if (live.text) h += 1 + wrapLines(live.text, cols);
|
|
63
77
|
return h;
|
|
64
78
|
}
|
|
@@ -74,6 +88,8 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
74
88
|
const [showReasoning, setShowReasoning] = useState(!!display.reasoning);
|
|
75
89
|
const [autopilot, setAutopilot] = useState(false);
|
|
76
90
|
const [hint, setHint] = useState('');
|
|
91
|
+
const [startedAt, setStartedAt] = useState(0);
|
|
92
|
+
const [choice, setChoice] = useState(null); // { title, note, options, allowText, sel, text, focusText, onPick }
|
|
77
93
|
|
|
78
94
|
const convo = useRef([]); // [{role, content}] for the backend
|
|
79
95
|
const plain = useRef([]); // plain-text transcript for exit reprint
|
|
@@ -81,6 +97,15 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
81
97
|
const ctrlC = useRef(0); // timestamp of the last lone Ctrl+C
|
|
82
98
|
const apRef = useRef(false); // live autopilot flag (read inside runTurn)
|
|
83
99
|
const modelRef = useRef(cfg._model); // preferred model to request
|
|
100
|
+
const lastAnswerRef = useRef(''); // most recent assistant answer (for /copy, /gist)
|
|
101
|
+
const choiceActionRef = useRef(null); // action to run when a non-AI Choice is picked
|
|
102
|
+
|
|
103
|
+
// Enable terminal bracketed-paste so multi-line pastes arrive as one chunk
|
|
104
|
+
// (the Composer flattens them) instead of submitting on the first newline.
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
try { process.stdout.write('\x1b[?2004h'); } catch {}
|
|
107
|
+
return () => { try { process.stdout.write('\x1b[?2004l'); } catch {} };
|
|
108
|
+
}, []);
|
|
84
109
|
|
|
85
110
|
const setAutopilotMode = useCallback((next) => {
|
|
86
111
|
apRef.current = next;
|
|
@@ -90,29 +115,38 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
90
115
|
const push = useCallback((item) => setItems((xs) => [...xs, { id: nextId(), ...item }]), []);
|
|
91
116
|
const log = (line) => plain.current.push(line);
|
|
92
117
|
|
|
93
|
-
const runTurn = useCallback(async (question,
|
|
118
|
+
const runTurn = useCallback(async (question, opts = {}) => {
|
|
119
|
+
const { depth = 0, archive = null, label = '' } = opts;
|
|
94
120
|
push({ type: 'user', text: question });
|
|
95
121
|
log(`\n${glyphs.prompt} ${question}\n`);
|
|
96
122
|
convo.current.push({ role: 'user', content: question });
|
|
97
123
|
setBusy(true);
|
|
98
124
|
setHint('');
|
|
99
|
-
|
|
125
|
+
setStartedAt(Date.now());
|
|
126
|
+
const s = { status: [], reasoning: '', text: '', reply: null };
|
|
100
127
|
setLive({ ...s });
|
|
101
128
|
|
|
102
129
|
const abort = new AbortController();
|
|
103
130
|
abortRef.current = abort;
|
|
104
131
|
|
|
105
|
-
const context = {
|
|
132
|
+
const context = {
|
|
133
|
+
autopilot: apRef.current,
|
|
134
|
+
client: 'icli',
|
|
135
|
+
ui: { tables: true, prompts: true, markdown: true },
|
|
136
|
+
};
|
|
106
137
|
if (modelRef.current) context.model = modelRef.current;
|
|
107
138
|
|
|
108
139
|
try {
|
|
109
|
-
const
|
|
140
|
+
const preamble = memoryPreamble(cfg);
|
|
141
|
+
const outbound = preamble ? [preamble, ...convo.current] : [...convo.current];
|
|
142
|
+
const { reply, text, meta } = await streamChat(cfg, outbound, {
|
|
110
143
|
signal: abort.signal,
|
|
111
144
|
context,
|
|
112
145
|
onEvent: (ev) => {
|
|
113
146
|
if (ev.type === 'status') s.status = [...s.status, ev.line];
|
|
114
147
|
else if (ev.type === 'reasoning') s.reasoning += ev.delta;
|
|
115
148
|
else if (ev.type === 'text') s.text += ev.delta;
|
|
149
|
+
else if (ev.type === 'plan') s.reply = ev.reply;
|
|
116
150
|
setLive({ ...s });
|
|
117
151
|
},
|
|
118
152
|
});
|
|
@@ -120,20 +154,59 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
120
154
|
const type = reply.type ?? 'message';
|
|
121
155
|
if (type === 'plan') { push({ type: 'plan', reply }); log('[plan] ' + (reply.summary || '')); }
|
|
122
156
|
else if (type === 'connect') { push({ type: 'connect', reply }); log('[connect] ' + (reply.device?.kind || '')); }
|
|
157
|
+
else if (type === 'prompt' || type === 'choice') { push({ type: 'assistant', text: text || reply.question || '' }); log(text || reply.question || ''); }
|
|
123
158
|
else { push({ type: 'assistant', text: text || '(no response)' }); log(text || ''); }
|
|
124
159
|
if (text) convo.current.push({ role: 'assistant', content: text });
|
|
160
|
+
lastAnswerRef.current = text || reply.summary || reply.question || lastAnswerRef.current;
|
|
161
|
+
|
|
162
|
+
// Archive a /backup answer to the per-user git repo.
|
|
163
|
+
if (archive === 'backup' && text) {
|
|
164
|
+
try {
|
|
165
|
+
const r = await commitBackup(cfg, label, text);
|
|
166
|
+
push({ type: 'notice',
|
|
167
|
+
text: `${glyphs.check} Backup saved to git repo (${r.file})${r.committed ? '' : ' — nothing new to commit'}.`,
|
|
168
|
+
color: C.green });
|
|
169
|
+
} catch (e) {
|
|
170
|
+
push({ type: 'notice', text: `${glyphs.warn} Could not archive backup: ${e.message}`, color: C.yellow });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
125
173
|
|
|
126
174
|
// Autopilot: auto-confirm a proposed plan without waiting for the user.
|
|
127
175
|
if (type === 'plan' && apRef.current && depth < 3) {
|
|
128
176
|
push({ type: 'notice', text: `${glyphs.bolt} autopilot ${dash()} applying fix…`, color: C.yellow });
|
|
129
|
-
setLive(null);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
177
|
+
setLive(null); setBusy(false); abortRef.current = null;
|
|
178
|
+
return runTurn('apply fix', { depth: depth + 1 });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Otherwise surface an interactive prompt for the user to choose/answer.
|
|
182
|
+
setLive(null); setBusy(false); abortRef.current = null;
|
|
183
|
+
if (type === 'plan') {
|
|
184
|
+
const destructive = (reply.steps || []).some((st) => /destruct|high/.test(st.risk || ''));
|
|
185
|
+
setChoice({
|
|
186
|
+
title: 'Apply this fix?',
|
|
187
|
+
note: reply.summary || '',
|
|
188
|
+
sel: 0, text: '', focusText: false, allowText: false,
|
|
189
|
+
options: [
|
|
190
|
+
{ label: destructive ? 'Apply fix (destructive)' : 'Apply fix', value: 'apply fix', danger: destructive },
|
|
191
|
+
{ label: 'Explain the plan first', value: 'explain this plan in more detail before applying' },
|
|
192
|
+
{ label: 'Cancel', value: null },
|
|
193
|
+
],
|
|
194
|
+
});
|
|
195
|
+
} else if (type === 'prompt' || type === 'choice') {
|
|
196
|
+
const opts = (reply.options || []).map((o) =>
|
|
197
|
+
typeof o === 'string' ? { label: o, value: o } : { label: o.label ?? o.value, value: o.value ?? o.label, hint: o.hint });
|
|
198
|
+
setChoice({
|
|
199
|
+
title: reply.question || 'Choose an option',
|
|
200
|
+
note: reply.note || '',
|
|
201
|
+
sel: 0, text: '', focusText: opts.length === 0,
|
|
202
|
+
allowText: reply.allowText !== false,
|
|
203
|
+
options: opts,
|
|
204
|
+
});
|
|
133
205
|
}
|
|
134
206
|
} catch (e) {
|
|
135
207
|
if (abort.signal.aborted) push({ type: 'notice', text: `${glyphs.cross} Cancelled.`, color: C.yellow });
|
|
136
208
|
else push({ type: 'notice', text: `${glyphs.cross} ${e.message}`, color: C.red });
|
|
209
|
+
setLive(null); setBusy(false); abortRef.current = null;
|
|
137
210
|
} finally {
|
|
138
211
|
abortRef.current = null;
|
|
139
212
|
setLive(null);
|
|
@@ -143,6 +216,29 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
143
216
|
|
|
144
217
|
const doExit = useCallback(() => { onExit?.(plain.current); exit(); }, [exit, onExit]);
|
|
145
218
|
|
|
219
|
+
// Create a gist from the last answer or the whole transcript (invoked by the
|
|
220
|
+
// /gist Choice below).
|
|
221
|
+
const doGist = useCallback(async (kind) => {
|
|
222
|
+
let content, filename, description;
|
|
223
|
+
if (kind === 'transcript') {
|
|
224
|
+
content = convo.current.map((m) => `### ${m.role}\n\n${m.content}`).join('\n\n---\n\n') || '(empty)';
|
|
225
|
+
filename = 'icli-transcript.md';
|
|
226
|
+
description = 'iCli conversation transcript';
|
|
227
|
+
} else {
|
|
228
|
+
content = lastAnswerRef.current || '(no answer)';
|
|
229
|
+
filename = 'icli-answer.md';
|
|
230
|
+
description = 'iCli answer';
|
|
231
|
+
}
|
|
232
|
+
push({ type: 'notice', text: `${glyphs.arrow} Creating secret gist…`, color: C.gray });
|
|
233
|
+
try {
|
|
234
|
+
const { url } = await createGist(cfg, { filename, content, description, isPublic: false });
|
|
235
|
+
const tool = await copyToClipboard(url);
|
|
236
|
+
push({ type: 'notice', text: `${glyphs.check} Gist created: ${url}${tool ? ' (URL copied)' : ''}`, color: C.green });
|
|
237
|
+
} catch (e) {
|
|
238
|
+
push({ type: 'notice', text: `${glyphs.cross} ${e.message}`, color: C.red });
|
|
239
|
+
}
|
|
240
|
+
}, [cfg, push]);
|
|
241
|
+
|
|
146
242
|
const onSubmit = useCallback((raw) => {
|
|
147
243
|
const q = raw.trim();
|
|
148
244
|
if (!q) return;
|
|
@@ -189,7 +285,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
189
285
|
const head = c.group !== group ? ((group = c.group), `\n ${c.group}\n`) : '';
|
|
190
286
|
return head + ` ${(c.cmd + (c.hint ? ' ' + c.hint : '')).padEnd(22)}${c.desc}`;
|
|
191
287
|
}).join('\n');
|
|
192
|
-
push({ type: 'notice', text: 'Commands:' + lines + '\n\n Keys: Shift+Tab autopilot · Ctrl+T reasoning · Ctrl+L clear · Ctrl+C twice to exit' });
|
|
288
|
+
push({ type: 'notice', text: 'Commands:' + lines + '\n\n Keys: Shift+Tab autopilot · Ctrl+T reasoning · Ctrl+Z/Ctrl+Y undo/redo · Ctrl+L clear · Ctrl+C twice to exit' });
|
|
193
289
|
return;
|
|
194
290
|
}
|
|
195
291
|
if (q === '/history') {
|
|
@@ -200,17 +296,134 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
200
296
|
if (q === '/logout') { onExternal?.('logout'); doExit(); return; }
|
|
201
297
|
if (q === '/update') { onExternal?.('update'); doExit(); return; }
|
|
202
298
|
|
|
299
|
+
// ── Memory ──
|
|
300
|
+
if (q === '/remember' || q.startsWith('/remember ')) {
|
|
301
|
+
const note = q.slice('/remember'.length).trim();
|
|
302
|
+
if (!note) { push({ type: 'notice', text: 'Usage: /remember <note>' }); return; }
|
|
303
|
+
const notes = addNote(cfg, note);
|
|
304
|
+
push({ type: 'notice', text: `${glyphs.check} Remembered (${notes.length} note${notes.length > 1 ? 's' : ''}). I'll keep this in mind.`, color: C.green });
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (q === '/memory') {
|
|
308
|
+
const notes = loadNotes(cfg);
|
|
309
|
+
push({ type: 'notice', text: notes.length
|
|
310
|
+
? 'Memory notes:\n' + notes.map((n, i) => ` ${i + 1}. ${n.text}`).join('\n') + '\n\n /forget <n|all> to remove.'
|
|
311
|
+
: 'No memory notes yet. Add one with /remember <note>.' });
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (q === '/forget' || q.startsWith('/forget ')) {
|
|
315
|
+
const which = q.slice('/forget'.length).trim();
|
|
316
|
+
if (!which) { push({ type: 'notice', text: 'Usage: /forget <number | all | text>' }); return; }
|
|
317
|
+
const { notes, removed } = forgetNote(cfg, which);
|
|
318
|
+
push({ type: 'notice',
|
|
319
|
+
text: removed ? `${glyphs.check} Forgot ${removed} note(s). ${notes.length} remain.` : 'No matching note found.',
|
|
320
|
+
color: removed ? C.green : C.gray });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── Clipboard ──
|
|
325
|
+
if (q === '/copy') {
|
|
326
|
+
const ans = lastAnswerRef.current;
|
|
327
|
+
if (!ans) { push({ type: 'notice', text: 'Nothing to copy yet — ask something first.', color: C.gray }); return; }
|
|
328
|
+
copyToClipboard(ans).then((tool) => push({ type: 'notice',
|
|
329
|
+
text: tool ? `${glyphs.check} Copied last answer (${ans.length} chars) to the clipboard.`
|
|
330
|
+
: `${glyphs.cross} No clipboard tool found. Install xclip / wl-clipboard (Linux), or use macOS/Windows.`,
|
|
331
|
+
color: tool ? C.green : C.red }));
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ── GitHub gist ──
|
|
336
|
+
if (q === '/gist' || q.startsWith('/gist ')) {
|
|
337
|
+
const rest = q.slice('/gist'.length).trim();
|
|
338
|
+
if (rest.startsWith('token ')) {
|
|
339
|
+
cfg.github_token = rest.slice('token '.length).trim();
|
|
340
|
+
try { saveConfig(cfg); } catch {}
|
|
341
|
+
push({ type: 'notice', text: `${glyphs.check} GitHub token saved. /gist will use it (needs the "gist" scope).`, color: C.green });
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (!lastAnswerRef.current && convo.current.length === 0) {
|
|
345
|
+
push({ type: 'notice', text: 'Nothing to save yet — ask something first.', color: C.gray });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
choiceActionRef.current = (v) => doGist(v);
|
|
349
|
+
setChoice({
|
|
350
|
+
title: 'Create a secret gist from…',
|
|
351
|
+
note: '', sel: 0, text: '', focusText: false, allowText: false,
|
|
352
|
+
options: [
|
|
353
|
+
{ label: 'The last AI answer', value: 'answer' },
|
|
354
|
+
{ label: 'The entire conversation transcript', value: 'transcript' },
|
|
355
|
+
{ label: 'Cancel', value: null },
|
|
356
|
+
],
|
|
357
|
+
});
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ── Local git repo ──
|
|
362
|
+
if (q === '/git' || q.startsWith('/git ')) {
|
|
363
|
+
const args = q.slice('/git'.length).trim();
|
|
364
|
+
if (!args) { push({ type: 'notice', text: 'Usage: /git <status | log | commit -m "msg" | push | diff | remote add origin <url>>' }); return; }
|
|
365
|
+
gitCmd(cfg, splitArgs(args)).then((r) => {
|
|
366
|
+
const body = (r.stdout || '').trim() || (r.stderr || '').trim() || (r.ok ? '(done)' : '(no output)');
|
|
367
|
+
push({ type: 'notice', text: `$ git ${args}\n${body.slice(0, 3500)}`, color: r.ok ? undefined : C.red });
|
|
368
|
+
});
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
203
372
|
let question = q;
|
|
373
|
+
const opts = {};
|
|
204
374
|
if (q.startsWith('/')) {
|
|
205
375
|
const mapped = slashToQuestion(q);
|
|
206
|
-
if (mapped)
|
|
207
|
-
|
|
376
|
+
if (mapped) {
|
|
377
|
+
question = mapped;
|
|
378
|
+
if (q === '/backup' || q.startsWith('/backup ')) {
|
|
379
|
+
opts.archive = 'backup';
|
|
380
|
+
opts.label = q.slice('/backup'.length).trim() || 'fleet';
|
|
381
|
+
}
|
|
382
|
+
} else { push({ type: 'notice', text: `Unknown command: ${q}. Type /help.` }); return; }
|
|
383
|
+
}
|
|
384
|
+
runTurn(question, opts);
|
|
385
|
+
}, [doExit, push, runTurn, onExternal, setAutopilotMode, model, cfg, doGist]);
|
|
386
|
+
|
|
387
|
+
// Interactive prompt/choice handling (owns the keyboard while open).
|
|
388
|
+
const pickChoice = useCallback((value) => {
|
|
389
|
+
const act = choiceActionRef.current;
|
|
390
|
+
choiceActionRef.current = null;
|
|
391
|
+
setChoice(null);
|
|
392
|
+
if (act) { if (value != null) act(value); else push({ type: 'notice', text: 'Cancelled.', color: C.gray }); return; }
|
|
393
|
+
if (value == null) { push({ type: 'notice', text: 'Dismissed.', color: C.gray }); return; }
|
|
394
|
+
runTurn(String(value));
|
|
395
|
+
}, [push, runTurn]);
|
|
396
|
+
|
|
397
|
+
useInput((input, key) => {
|
|
398
|
+
if (!choice) return;
|
|
399
|
+
if (key.escape || (key.ctrl && input === 'c')) { setChoice(null); return; }
|
|
400
|
+
if (key.tab && choice.allowText) { setChoice((c) => ({ ...c, focusText: !c.focusText })); return; }
|
|
401
|
+
if (key.upArrow) { setChoice((c) => ({ ...c, focusText: false, sel: Math.max(0, c.sel - 1) })); return; }
|
|
402
|
+
if (key.downArrow) {
|
|
403
|
+
setChoice((c) => {
|
|
404
|
+
const max = c.options.length - 1;
|
|
405
|
+
if (c.allowText && c.sel >= max) return { ...c, focusText: true };
|
|
406
|
+
return { ...c, sel: Math.min(max, c.sel + 1) };
|
|
407
|
+
});
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (key.return) {
|
|
411
|
+
if (choice.focusText) { const t = choice.text.trim(); if (t) pickChoice(t); return; }
|
|
412
|
+
pickChoice(choice.options[choice.sel]?.value);
|
|
413
|
+
return;
|
|
208
414
|
}
|
|
209
|
-
|
|
210
|
-
|
|
415
|
+
if (choice.focusText) {
|
|
416
|
+
if (key.backspace || key.delete) { setChoice((c) => ({ ...c, text: c.text.slice(0, -1) })); return; }
|
|
417
|
+
if (input && !key.ctrl && !key.meta) {
|
|
418
|
+
const printable = input.replace(/[\x00-\x1f\x7f]/g, '');
|
|
419
|
+
if (printable) setChoice((c) => ({ ...c, text: c.text + printable }));
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
});
|
|
211
423
|
|
|
212
424
|
// Global keys (Composer handles text editing; here we handle app-level keys).
|
|
213
425
|
useInput((input, key) => {
|
|
426
|
+
if (choice) return; // the Choice prompt owns the keyboard
|
|
214
427
|
if (key.ctrl && input === 'c') {
|
|
215
428
|
if (busy) { abortRef.current?.abort(); setHint(''); return; }
|
|
216
429
|
const now = Date.now();
|
|
@@ -280,16 +493,19 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
280
493
|
${chosen.map(renderItem)}
|
|
281
494
|
${live
|
|
282
495
|
? html`<${Box} flexDirection="column">
|
|
283
|
-
<${StatusLines} lines=${live.status} />
|
|
496
|
+
<${StatusLines} lines=${live.status} busy=${busy} />
|
|
284
497
|
${showReasoning && live.reasoning ? html`<${Reasoning} text=${live.reasoning} />` : null}
|
|
498
|
+
${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
|
|
285
499
|
${live.text ? html`<${Box} marginTop=${1}><${AssistantMessage} text=${live.text} /><//>` : null}
|
|
286
|
-
<${Thinking} label=${live.text ? 'Responding' : '
|
|
500
|
+
<${Thinking} label=${live.reply ? 'Planning' : live.text ? 'Responding' : 'Working'} startedAt=${startedAt} />
|
|
287
501
|
<//>`
|
|
288
502
|
: null}
|
|
289
503
|
<//>
|
|
290
504
|
|
|
291
505
|
<${Box} flexShrink=${0} flexDirection="column">
|
|
292
|
-
|
|
506
|
+
${choice
|
|
507
|
+
? html`<${Choice} ...${choice} />`
|
|
508
|
+
: html`<${Composer} onSubmit=${onSubmit} busy=${busy} width=${cols} />`}
|
|
293
509
|
<${Box} paddingX=${1} width=${cols}>
|
|
294
510
|
<${Box} flexGrow=${1}><${Text} color=${C.gray} wrap="truncate-end">${ctx}<//><//>
|
|
295
511
|
<${Text} color=${hint || autopilot ? C.yellow : C.gray} wrap="truncate-end">${footerRight}<//>
|
package/src/tui/components.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Presentational components for the iCli Ink TUI.
|
|
2
|
-
import { html } from './dom.js';
|
|
2
|
+
import { html, useState, useEffect } from './dom.js';
|
|
3
3
|
import { Box, Text } from 'ink';
|
|
4
4
|
import Spinner from 'ink-spinner';
|
|
5
5
|
import { C, glyphs, spinnerType, borderStyle, midDot, dash } from './theme.js';
|
|
@@ -55,12 +55,22 @@ export function Banner({ cfg = {}, model, width = 80, compact = false }) {
|
|
|
55
55
|
<//>`;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/** Split text into spans, colouring @mentions. */
|
|
59
|
+
function withMentions(text, base = C.white) {
|
|
60
|
+
const parts = String(text).split(/(@[\w:.\-/]+)/g);
|
|
61
|
+
return parts.map((p, i) =>
|
|
62
|
+
/^@[\w:.\-/]+$/.test(p)
|
|
63
|
+
? html`<${Text} key=${'mn' + i} color=${C.cyan} bold>${p}<//>`
|
|
64
|
+
: html`<${Text} key=${'mn' + i} color=${base}>${p}<//>`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
58
68
|
/** The user's prompt line. */
|
|
59
69
|
export function UserMessage({ text }) {
|
|
60
70
|
return html`
|
|
61
71
|
<${Box} marginTop=${1}>
|
|
62
72
|
<${Text} color=${C.accent} bold>${glyphs.prompt} <//>
|
|
63
|
-
<${Text}
|
|
73
|
+
<${Text}>${withMentions(text)}<//>
|
|
64
74
|
<//>`;
|
|
65
75
|
}
|
|
66
76
|
|
|
@@ -72,44 +82,71 @@ export function AssistantMessage({ text }) {
|
|
|
72
82
|
<//>`;
|
|
73
83
|
}
|
|
74
84
|
|
|
75
|
-
/**
|
|
76
|
-
function
|
|
85
|
+
/** Classify a server progress/status line into an icon + tone. */
|
|
86
|
+
function classify(line) {
|
|
77
87
|
const l = String(line).toLowerCase();
|
|
78
|
-
if (/unreachable|skip|fail|error|denied|timeout/.test(l)) return
|
|
79
|
-
if (/done|success|found|complet|online|ready|ok\b/.test(l)) return
|
|
80
|
-
if (/
|
|
81
|
-
if (
|
|
82
|
-
if (/
|
|
83
|
-
|
|
84
|
-
return html`<${Text} color=${C.gray}>${glyphs.bullet}<//>`;
|
|
88
|
+
if (/unreachable|skip|fail|error|denied|timeout|down\b|offline/.test(l)) return { icon: glyphs.cross, color: C.red };
|
|
89
|
+
if (/done|success|found|complet|online|ready|free\b|ok\b|\bup\b/.test(l)) return { icon: glyphs.check, color: C.green };
|
|
90
|
+
if (/assign|sub.?agent|fleet|parallel|dispatch|spawn/.test(l)) return { icon: glyphs.ring, color: C.magenta };
|
|
91
|
+
if (/probe|reachab|ping/.test(l)) return { icon: glyphs.target, color: C.yellow };
|
|
92
|
+
if (/connect|ssh|telnet|api|session|running|exec|command/.test(l)) return { icon: glyphs.arrow, color: C.accent };
|
|
93
|
+
return { icon: glyphs.bullet, color: C.gray };
|
|
85
94
|
}
|
|
86
95
|
|
|
87
|
-
|
|
88
|
-
|
|
96
|
+
// A sub-agent progress line, e.g. "✓ [1/3] nas#1: FREE" or "[2/3] olt#1: down".
|
|
97
|
+
const SUBAGENT = /^\s*[✓✗x]?\s*\[(\d+)\/(\d+)\]\s*(.+?)\s*[::]\s*(.+?)\s*$/;
|
|
98
|
+
|
|
99
|
+
/** Live server activity — tool calls and sub-agents happening backend-side. */
|
|
100
|
+
export function StatusLines({ lines = [], busy = false }) {
|
|
89
101
|
if (!lines.length) return null;
|
|
102
|
+
const subLines = lines.filter((l) => SUBAGENT.test(l));
|
|
90
103
|
return html`
|
|
91
104
|
<${Box} flexDirection="column" marginTop=${1}>
|
|
92
|
-
${lines.map((line, i) =>
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
105
|
+
${lines.map((line, i) => {
|
|
106
|
+
const sm = String(line).match(SUBAGENT);
|
|
107
|
+
if (sm) {
|
|
108
|
+
const [, idx, total, host, result] = sm;
|
|
109
|
+
const good = !/fail|error|down|offline|unreachable|timeout|deny/i.test(result);
|
|
110
|
+
const isLast = subLines[subLines.length - 1] === line;
|
|
111
|
+
const running = busy && isLast && Number(idx) < Number(total);
|
|
112
|
+
const branch = Number(idx) === Number(total) ? glyphs.branchEnd : glyphs.branchMid;
|
|
113
|
+
return html`
|
|
114
|
+
<${Box} key=${'s' + i}>
|
|
115
|
+
<${Text} color=${C.magenta}>${branch} <//>
|
|
116
|
+
<${Text} color=${C.gray}>${idx}/${total} <//>
|
|
117
|
+
<${Text} color=${C.white} bold>${host}<//>
|
|
118
|
+
<${Text} color=${C.gray}> ${dash()} <//>
|
|
119
|
+
${running
|
|
120
|
+
? html`<${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /><//>`
|
|
121
|
+
: html`<${Text} color=${good ? C.green : C.red} bold>${good ? glyphs.check : glyphs.cross} ${result}<//>`}
|
|
122
|
+
<//>`;
|
|
123
|
+
}
|
|
124
|
+
const { icon, color } = classify(line);
|
|
125
|
+
const clean = String(line).replace(/^[\s◈◆●○◎⇢→>*✓✗×!⚠·-]+/u, '').trimStart() || String(line);
|
|
126
|
+
return html`
|
|
127
|
+
<${Box} key=${'s' + i}>
|
|
128
|
+
<${Text} color=${color}>${icon}<//>
|
|
129
|
+
<${Text} color=${C.gray}> ${clean}<//>
|
|
130
|
+
<//>`;
|
|
131
|
+
})}
|
|
97
132
|
<//>`;
|
|
98
133
|
}
|
|
99
134
|
|
|
100
|
-
/** Reasoning trace (
|
|
135
|
+
/** Reasoning trace (shown by default; toggle with Ctrl+T). */
|
|
101
136
|
export function Reasoning({ text }) {
|
|
102
137
|
if (!text) return null;
|
|
103
138
|
return html`
|
|
104
|
-
<${Box} flexDirection="column" marginTop=${1}
|
|
105
|
-
|
|
106
|
-
<${Text} color=${C.
|
|
139
|
+
<${Box} flexDirection="column" marginTop=${1} borderStyle=${borderStyle()} borderColor=${C.gray}
|
|
140
|
+
borderTop=${false} borderRight=${false} borderBottom=${false} paddingLeft=${1}>
|
|
141
|
+
<${Text} color=${C.magenta}>${glyphs.reasoning} ${html`<${Text} color=${C.gray} bold>Reasoning<//>`}<//>
|
|
142
|
+
<${Text} color=${C.gray} italic>${text}<//>
|
|
107
143
|
<//>`;
|
|
108
144
|
}
|
|
109
145
|
|
|
110
146
|
/** A proposed remediation plan. */
|
|
111
|
-
export function Plan({ reply = {} }) {
|
|
147
|
+
export function Plan({ reply = {}, live = false }) {
|
|
112
148
|
const steps = reply.steps || [];
|
|
149
|
+
const dot3 = '…';
|
|
113
150
|
return html`
|
|
114
151
|
<${Box} flexDirection="column" marginTop=${1}>
|
|
115
152
|
<${Text} color=${C.accent} bold>${glyphs.diamond} Plan<//>
|
|
@@ -133,7 +170,9 @@ export function Plan({ reply = {} }) {
|
|
|
133
170
|
<//>`;
|
|
134
171
|
})}
|
|
135
172
|
<${Box} marginTop=${1}>
|
|
136
|
-
|
|
173
|
+
${live
|
|
174
|
+
? html`<${Text} color=${C.gray} italic>${glyphs.bullet} forming plan${dot3}<//>`
|
|
175
|
+
: html`<${Text} color=${C.gray}>Type ${html`<${Text} color=${C.white}>apply fix<//>`} to confirm.<//>`}
|
|
137
176
|
<//>
|
|
138
177
|
<//>`;
|
|
139
178
|
}
|
|
@@ -157,11 +196,54 @@ export function Notice({ text, color = C.gray }) {
|
|
|
157
196
|
return html`<${Box} marginTop=${1}><${Text} color=${color}>${text}<//><//>`;
|
|
158
197
|
}
|
|
159
198
|
|
|
160
|
-
/** The active "
|
|
161
|
-
export function Thinking({ label = '
|
|
199
|
+
/** The active "working" line — animated spinner, elapsed time, cancel hint. */
|
|
200
|
+
export function Thinking({ label = 'Working', startedAt }) {
|
|
201
|
+
const [, tick] = useState(0);
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
const id = setInterval(() => tick((n) => n + 1), 1000);
|
|
204
|
+
return () => clearInterval(id);
|
|
205
|
+
}, []);
|
|
206
|
+
const secs = startedAt ? Math.max(0, Math.round((Date.now() - startedAt) / 1000)) : 0;
|
|
162
207
|
return html`
|
|
163
208
|
<${Box} marginTop=${1}>
|
|
164
209
|
<${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /><//>
|
|
165
|
-
<${Text} color=${C.
|
|
210
|
+
<${Text} color=${C.accent} bold> ${label}<//>
|
|
211
|
+
<${Text} color=${C.gray}>… (${secs}s ${midDot()}esc to interrupt)<//>
|
|
212
|
+
<//>`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* An interactive prompt — pick an option with ↑/↓ + Enter, or (when
|
|
217
|
+
* `allowText`) type a free-form answer. Mirrors GitHub Copilot CLI prompts.
|
|
218
|
+
* Presentational only: the parent owns `sel`/`textValue` and key handling.
|
|
219
|
+
*/
|
|
220
|
+
export function Choice({ title, note, options = [], sel = 0, allowText = false, text = '', focusText = false }) {
|
|
221
|
+
return html`
|
|
222
|
+
<${Box} flexDirection="column" marginTop=${1} borderStyle=${borderStyle()} borderColor=${C.accent} paddingX=${1}>
|
|
223
|
+
${title ? html`<${Text} color=${C.accent} bold>${glyphs.diamond} ${title}<//>` : null}
|
|
224
|
+
${note ? html`<${Text} color=${C.gray}>${note}<//>` : null}
|
|
225
|
+
<${Box} flexDirection="column" marginTop=${title || note ? 1 : 0}>
|
|
226
|
+
${options.map((o, i) => {
|
|
227
|
+
const on = !focusText && i === sel;
|
|
228
|
+
const label = o.label ?? o.value ?? String(o);
|
|
229
|
+
const tone = o.danger ? C.red : on ? C.accent : C.white;
|
|
230
|
+
return html`
|
|
231
|
+
<${Box} key=${'o' + i}>
|
|
232
|
+
<${Text} color=${on ? C.accent : C.gray}>${on ? glyphs.prompt + ' ' : ' '}<//>
|
|
233
|
+
<${Text} color=${tone} bold=${on}>${label}<//>
|
|
234
|
+
${o.hint ? html`<${Text} color=${C.gray}> ${dash()} ${o.hint}<//>` : null}
|
|
235
|
+
<//>`;
|
|
236
|
+
})}
|
|
237
|
+
${allowText
|
|
238
|
+
? html`<${Box} key="freetext">
|
|
239
|
+
<${Text} color=${focusText ? C.accent : C.gray}>${focusText ? glyphs.prompt + ' ' : ' '}<//>
|
|
240
|
+
<${Text} color=${C.gray}>${text ? '' : 'Type a custom answer…'}<//>
|
|
241
|
+
<${Text} color=${C.white}>${text}<//>${focusText ? html`<${Text} inverse> <//>` : null}
|
|
242
|
+
<//>`
|
|
243
|
+
: null}
|
|
244
|
+
<//>
|
|
245
|
+
<${Box} marginTop=${1}>
|
|
246
|
+
<${Text} color=${C.gray}>${glyphs.up}${glyphs.down} choose ${midDot()}${allowText ? 'Tab type ' + midDot() : ''}Enter confirm ${midDot()}Esc dismiss<//>
|
|
247
|
+
<//>
|
|
166
248
|
<//>`;
|
|
167
249
|
}
|