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/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, depth = 0) => {
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
- const s = { status: [], reasoning: '', text: '' };
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 = { autopilot: apRef.current };
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 { reply, text, meta } = await streamChat(cfg, [...convo.current], {
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
- setBusy(false);
131
- abortRef.current = null;
132
- return runTurn('apply fix', depth + 1);
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) question = mapped;
207
- else { push({ type: 'notice', text: `Unknown command: ${q}. Type /help.` }); return; }
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
- runTurn(question);
210
- }, [doExit, push, runTurn, onExternal, setAutopilotMode, model, cfg]);
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' : 'Thinking'} />
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
- <${Composer} onSubmit=${onSubmit} busy=${busy} width=${cols} />
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}<//>
@@ -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} color=${C.white}>${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
- /** Icon for a server progress/status line. */
76
- function statusIcon(line) {
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 html`<${Text} color=${C.red}>${glyphs.cross}<//>`;
79
- if (/done|success|found|complet|online|ready|ok\b/.test(l)) return html`<${Text} color=${C.green}>${glyphs.check}<//>`;
80
- if (/batch|sub.?agent|fleet|parallel/.test(l)) return html`<${Text} color=${C.accent}>${glyphs.ring}<//>`;
81
- if (/\[\d+\/\d+\]/.test(l)) return html`<${Text} color=${C.accent}>${glyphs.diamond}<//>`;
82
- if (/probe|reachab|ping/.test(l)) return html`<${Text} color=${C.yellow}>${glyphs.target}<//>`;
83
- if (/connect|ssh|telnet|api|session/.test(l)) return html`<${Text} color=${C.accent}>${glyphs.arrow}<//>`;
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
- /** Live server activity (tool calls happening backend-side). */
88
- export function StatusLines({ lines = [] }) {
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) => html`
93
- <${Box} key=${'s' + i}>
94
- ${statusIcon(line)}
95
- <${Text} color=${C.gray}> ${line}<//>
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 (only when enabled). */
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
- <${Text} color=${C.accent}>${glyphs.reasoning} ${html`<${Text} color=${C.gray}>Reasoning<//>`}<//>
106
- <${Text} color=${C.gray}>${text}<//>
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
- <${Text} color=${C.gray}>Type ${html`<${Text} color=${C.white}>apply fix<//>`} to confirm.<//>
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 "thinking" line with a spinner. */
161
- export function Thinking({ label = 'Thinking' }) {
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.gray}> ${label}…<//>
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
  }