ispbills-icli 8.1.1 → 8.2.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 CHANGED
@@ -99,7 +99,8 @@ icli --reasoning "audit the config on olt#1" # stream reasoning inline
99
99
 
100
100
  | Flag | Description |
101
101
  |---|---|
102
- | `--reasoning` | Stream the model's reasoning inline |
102
+ | `--reasoning` | Stream the model's reasoning inline (on by default in the REPL) |
103
+ | `--no-reasoning` | Hide the reasoning trace |
103
104
  | `--loader-style STYLE` | Thinking indicator: `spinner` (default), `gradient`, or `minimal` |
104
105
  | `--ascii` | Force ASCII-safe glyphs (use if symbols show as boxes) |
105
106
  | `--unicode` | Force full Unicode glyphs |
@@ -165,6 +166,7 @@ suggestion (accept with **Tab** or **→**), and runs the highlighted command on
165
166
  |---|---|
166
167
  | **Enter** | Send message (or run the highlighted slash command) |
167
168
  | **/** then **↑/↓** | Open the slash menu and move the selection; **Tab** / **→** completes, **Enter** runs |
169
+ | **@** | Mention a device/entity (e.g. `@olt:`, `@onu:`); **Tab** / **→** completes the tag |
168
170
  | **↑/↓** (empty input) | Navigate input history |
169
171
  | **Shift+Tab** | Toggle autopilot mode |
170
172
  | **Ctrl+T** | Toggle reasoning display |
@@ -194,8 +196,11 @@ iCli connects to the same AI engine as the in-browser terminal:
194
196
  - **Customer lookup** — find customers by name, IP, MAC, username, or invoice
195
197
  - **Config audits** — review running config and get concrete suggestions
196
198
  - **Web search** — looks up vendor CLI docs when it needs to
197
- - **Streaming output** — answers stream live with a thinking indicator, reasoning, and step-by-step server progress
198
- - **Plans & connect cards** — proposed fixes render as a reviewable plan (`apply fix` to confirm); device connections show as a connect card
199
+ - **Streaming output** — reasoning, thoughts, and multi-step plans all stream live as they form, with an animated working indicator and step-by-step server progress
200
+ - **Reasoning on by default** — the model's reasoning trace streams inline (toggle with **Ctrl+T** / `/reasoning`, or start with `--no-reasoning`)
201
+ - **Rich rendering** — Markdown tables, colored lists, headings, and code blocks; `@`-mentions are highlighted; long lists render as aligned tables
202
+ - **Interactive prompts** — when the AI needs a decision, it shows a selectable choice list (↑/↓, **Enter**) with an optional free-text answer (**Tab**)
203
+ - **Plans & connect cards** — proposed fixes stream in live as a reviewable plan (`apply fix` to confirm, or use the Apply/Explain/Cancel prompt); device connections show as a connect card
199
204
  - **Full-screen terminal UI** — a full-height alternate-screen TUI (built on [Ink](https://github.com/vadimdemedes/ink)) with the banner pinned to the top, a bottom-pinned bordered input, a selectable slash-command menu, input history, and a bottom-anchored transcript — the same layout as GitHub Copilot CLI / Gemini CLI. Piped/non-interactive use falls back to plain streaming output
200
205
  - **Terminal-aware rendering** — the layout reflows to your terminal width and resizes live; on classic Windows consoles (`cmd.exe` / legacy PowerShell) it auto-switches to ASCII-safe glyphs and borders so nothing shows as broken boxes
201
206
 
package/bin/icli.js CHANGED
@@ -54,9 +54,9 @@ if (has('--ascii')) process.env.ICLI_ASCII = '1';
54
54
  if (has('--unicode')) process.env.ICLI_UNICODE = '1';
55
55
 
56
56
  const display = {
57
- reasoning: has('--reasoning'),
57
+ reasoning: !has('--no-reasoning'),
58
58
  loader: {
59
- text: 'Thinking',
59
+ text: 'Working',
60
60
  style: flag('--loader-style') || 'spinner',
61
61
  },
62
62
  };
@@ -109,7 +109,8 @@ function printHelp() {
109
109
 
110
110
  console.log(`\n ${BOLD}Flags${RESET}`);
111
111
  [
112
- ['--reasoning', 'Stream the model\'s reasoning inline'],
112
+ ['--reasoning', 'Stream reasoning inline (on by default)'],
113
+ ['--no-reasoning', 'Hide the reasoning trace'],
113
114
  ['--loader-style STYLE', 'spinner | gradient | minimal'],
114
115
  ['--ascii', 'Force ASCII-safe glyphs (classic Windows cmd.exe)'],
115
116
  ['--unicode', 'Force full Unicode glyphs'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ispbills-icli",
3
- "version": "8.1.1",
3
+ "version": "8.2.0",
4
4
  "description": "iCli — IspBills AI network engineer in your terminal",
5
5
  "keywords": [
6
6
  "ispbills",
package/src/client.js CHANGED
@@ -7,7 +7,11 @@ import { refreshToken } from './auth.js';
7
7
  function parseStreamText(raw) {
8
8
  const m = raw.match(/"text"\s*:\s*"((?:[^"\\]|\\.)*)/);
9
9
  if (!m) return raw.trimStart().startsWith('{') ? null : raw;
10
- return m[1]
10
+ return unescapeJsonStr(m[1]);
11
+ }
12
+
13
+ function unescapeJsonStr(s) {
14
+ return s
11
15
  .replace(/\\n/g, '\n')
12
16
  .replace(/\\t/g, '\t')
13
17
  .replace(/\\r/g, '')
@@ -15,6 +19,53 @@ function parseStreamText(raw) {
15
19
  .replace(/\\\\/g, '\\');
16
20
  }
17
21
 
22
+ /** Pull the value of a top-level string key out of a (possibly partial) JSON blob. */
23
+ function grabString(raw, key) {
24
+ const m = raw.match(new RegExp('"' + key + '"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"'));
25
+ return m ? unescapeJsonStr(m[1]) : null;
26
+ }
27
+
28
+ /**
29
+ * Extract a partially-streamed `plan` reply from the raw JSON.
30
+ * Returns { summary, steps:[{cmd, why, risk}] } using only the step objects
31
+ * that have finished streaming (balanced braces), so the plan can grow live.
32
+ */
33
+ export function parsePartialPlan(raw) {
34
+ const summary = grabString(raw, 'summary') || '';
35
+ const steps = [];
36
+ const sm = raw.match(/"steps"\s*:\s*\[/);
37
+ if (sm) {
38
+ let i = sm.index + sm[0].length;
39
+ while (i < raw.length) {
40
+ // find next object start
41
+ while (i < raw.length && raw[i] !== '{' && raw[i] !== ']') i++;
42
+ if (i >= raw.length || raw[i] === ']') break;
43
+ // scan a balanced object, respecting strings
44
+ let depth = 0, inStr = false, esc = false, start = i;
45
+ for (; i < raw.length; i++) {
46
+ const ch = raw[i];
47
+ if (inStr) {
48
+ if (esc) esc = false;
49
+ else if (ch === '\\') esc = true;
50
+ else if (ch === '"') inStr = false;
51
+ } else if (ch === '"') inStr = true;
52
+ else if (ch === '{') depth++;
53
+ else if (ch === '}') { depth--; if (depth === 0) { i++; break; } }
54
+ }
55
+ if (depth !== 0) break; // object still streaming
56
+ const objRaw = raw.slice(start, i);
57
+ const cmd = grabString(objRaw, 'cmd');
58
+ if (cmd === null) continue;
59
+ steps.push({
60
+ cmd,
61
+ why: grabString(objRaw, 'why') || grabString(objRaw, 'reason') || '',
62
+ risk: grabString(objRaw, 'risk') || '',
63
+ });
64
+ }
65
+ }
66
+ return { type: 'plan', summary, steps };
67
+ }
68
+
18
69
  /**
19
70
  * Stream a chat turn from the IspBills Terminal AI backend.
20
71
  *
@@ -82,10 +133,17 @@ export async function streamChat(cfg, messages, options = {}) {
82
133
  onEvent({ type: 'reasoning', delta: ev.reason_delta });
83
134
  } else if (ev.delta !== undefined) {
84
135
  rawAnswer += ev.delta;
85
- const txt = parseStreamText(rawAnswer);
86
- if (txt !== null && txt.length > answerText.length) {
87
- onEvent({ type: 'text', delta: txt.slice(answerText.length) });
88
- answerText = txt;
136
+ const trimmed = rawAnswer.trimStart();
137
+ const isPlan = /"type"\s*:\s*"plan"/.test(rawAnswer) ||
138
+ (trimmed.startsWith('{') && /"steps"\s*:/.test(rawAnswer));
139
+ if (isPlan) {
140
+ onEvent({ type: 'plan', reply: parsePartialPlan(rawAnswer) });
141
+ } else {
142
+ const txt = parseStreamText(rawAnswer);
143
+ if (txt !== null && txt.length > answerText.length) {
144
+ onEvent({ type: 'text', delta: txt.slice(answerText.length) });
145
+ answerText = txt;
146
+ }
89
147
  }
90
148
  } else if (ev.done) {
91
149
  finalResult = ev.result;
package/src/tui/app.js CHANGED
@@ -7,7 +7,7 @@ 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';
@@ -59,6 +59,7 @@ function liveHeight(live, cols, showReasoning) {
59
59
  let h = 2; // thinking line + margin
60
60
  if (live.status?.length) h += 1 + live.status.length;
61
61
  if (showReasoning && live.reasoning) h += 1 + wrapLines(live.reasoning, cols);
62
+ if (live.reply?.steps) h += 3 + live.reply.steps.length * 2 + (live.reply.summary ? 1 : 0);
62
63
  if (live.text) h += 1 + wrapLines(live.text, cols);
63
64
  return h;
64
65
  }
@@ -74,6 +75,8 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
74
75
  const [showReasoning, setShowReasoning] = useState(!!display.reasoning);
75
76
  const [autopilot, setAutopilot] = useState(false);
76
77
  const [hint, setHint] = useState('');
78
+ const [startedAt, setStartedAt] = useState(0);
79
+ const [choice, setChoice] = useState(null); // { title, note, options, allowText, sel, text, focusText, onPick }
77
80
 
78
81
  const convo = useRef([]); // [{role, content}] for the backend
79
82
  const plain = useRef([]); // plain-text transcript for exit reprint
@@ -96,13 +99,18 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
96
99
  convo.current.push({ role: 'user', content: question });
97
100
  setBusy(true);
98
101
  setHint('');
99
- const s = { status: [], reasoning: '', text: '' };
102
+ setStartedAt(Date.now());
103
+ const s = { status: [], reasoning: '', text: '', reply: null };
100
104
  setLive({ ...s });
101
105
 
102
106
  const abort = new AbortController();
103
107
  abortRef.current = abort;
104
108
 
105
- const context = { autopilot: apRef.current };
109
+ const context = {
110
+ autopilot: apRef.current,
111
+ client: 'icli',
112
+ ui: { tables: true, prompts: true, markdown: true },
113
+ };
106
114
  if (modelRef.current) context.model = modelRef.current;
107
115
 
108
116
  try {
@@ -113,6 +121,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
113
121
  if (ev.type === 'status') s.status = [...s.status, ev.line];
114
122
  else if (ev.type === 'reasoning') s.reasoning += ev.delta;
115
123
  else if (ev.type === 'text') s.text += ev.delta;
124
+ else if (ev.type === 'plan') s.reply = ev.reply;
116
125
  setLive({ ...s });
117
126
  },
118
127
  });
@@ -120,20 +129,46 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
120
129
  const type = reply.type ?? 'message';
121
130
  if (type === 'plan') { push({ type: 'plan', reply }); log('[plan] ' + (reply.summary || '')); }
122
131
  else if (type === 'connect') { push({ type: 'connect', reply }); log('[connect] ' + (reply.device?.kind || '')); }
132
+ else if (type === 'prompt' || type === 'choice') { push({ type: 'assistant', text: text || reply.question || '' }); log(text || reply.question || ''); }
123
133
  else { push({ type: 'assistant', text: text || '(no response)' }); log(text || ''); }
124
134
  if (text) convo.current.push({ role: 'assistant', content: text });
125
135
 
126
136
  // Autopilot: auto-confirm a proposed plan without waiting for the user.
127
137
  if (type === 'plan' && apRef.current && depth < 3) {
128
138
  push({ type: 'notice', text: `${glyphs.bolt} autopilot ${dash()} applying fix…`, color: C.yellow });
129
- setLive(null);
130
- setBusy(false);
131
- abortRef.current = null;
139
+ setLive(null); setBusy(false); abortRef.current = null;
132
140
  return runTurn('apply fix', depth + 1);
133
141
  }
142
+
143
+ // Otherwise surface an interactive prompt for the user to choose/answer.
144
+ setLive(null); setBusy(false); abortRef.current = null;
145
+ if (type === 'plan') {
146
+ const destructive = (reply.steps || []).some((st) => /destruct|high/.test(st.risk || ''));
147
+ setChoice({
148
+ title: 'Apply this fix?',
149
+ note: reply.summary || '',
150
+ sel: 0, text: '', focusText: false, allowText: false,
151
+ options: [
152
+ { label: destructive ? 'Apply fix (destructive)' : 'Apply fix', value: 'apply fix', danger: destructive },
153
+ { label: 'Explain the plan first', value: 'explain this plan in more detail before applying' },
154
+ { label: 'Cancel', value: null },
155
+ ],
156
+ });
157
+ } else if (type === 'prompt' || type === 'choice') {
158
+ const opts = (reply.options || []).map((o) =>
159
+ typeof o === 'string' ? { label: o, value: o } : { label: o.label ?? o.value, value: o.value ?? o.label, hint: o.hint });
160
+ setChoice({
161
+ title: reply.question || 'Choose an option',
162
+ note: reply.note || '',
163
+ sel: 0, text: '', focusText: opts.length === 0,
164
+ allowText: reply.allowText !== false,
165
+ options: opts,
166
+ });
167
+ }
134
168
  } catch (e) {
135
169
  if (abort.signal.aborted) push({ type: 'notice', text: `${glyphs.cross} Cancelled.`, color: C.yellow });
136
170
  else push({ type: 'notice', text: `${glyphs.cross} ${e.message}`, color: C.red });
171
+ setLive(null); setBusy(false); abortRef.current = null;
137
172
  } finally {
138
173
  abortRef.current = null;
139
174
  setLive(null);
@@ -209,8 +244,43 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
209
244
  runTurn(question);
210
245
  }, [doExit, push, runTurn, onExternal, setAutopilotMode, model, cfg]);
211
246
 
247
+ // Interactive prompt/choice handling (owns the keyboard while open).
248
+ const pickChoice = useCallback((value) => {
249
+ setChoice(null);
250
+ if (value == null) { push({ type: 'notice', text: 'Dismissed.', color: C.gray }); return; }
251
+ runTurn(String(value));
252
+ }, [push, runTurn]);
253
+
254
+ useInput((input, key) => {
255
+ if (!choice) return;
256
+ if (key.escape || (key.ctrl && input === 'c')) { setChoice(null); return; }
257
+ if (key.tab && choice.allowText) { setChoice((c) => ({ ...c, focusText: !c.focusText })); return; }
258
+ if (key.upArrow) { setChoice((c) => ({ ...c, focusText: false, sel: Math.max(0, c.sel - 1) })); return; }
259
+ if (key.downArrow) {
260
+ setChoice((c) => {
261
+ const max = c.options.length - 1;
262
+ if (c.allowText && c.sel >= max) return { ...c, focusText: true };
263
+ return { ...c, sel: Math.min(max, c.sel + 1) };
264
+ });
265
+ return;
266
+ }
267
+ if (key.return) {
268
+ if (choice.focusText) { const t = choice.text.trim(); if (t) pickChoice(t); return; }
269
+ pickChoice(choice.options[choice.sel]?.value);
270
+ return;
271
+ }
272
+ if (choice.focusText) {
273
+ if (key.backspace || key.delete) { setChoice((c) => ({ ...c, text: c.text.slice(0, -1) })); return; }
274
+ if (input && !key.ctrl && !key.meta) {
275
+ const printable = input.replace(/[\x00-\x1f\x7f]/g, '');
276
+ if (printable) setChoice((c) => ({ ...c, text: c.text + printable }));
277
+ }
278
+ }
279
+ });
280
+
212
281
  // Global keys (Composer handles text editing; here we handle app-level keys).
213
282
  useInput((input, key) => {
283
+ if (choice) return; // the Choice prompt owns the keyboard
214
284
  if (key.ctrl && input === 'c') {
215
285
  if (busy) { abortRef.current?.abort(); setHint(''); return; }
216
286
  const now = Date.now();
@@ -280,16 +350,19 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
280
350
  ${chosen.map(renderItem)}
281
351
  ${live
282
352
  ? html`<${Box} flexDirection="column">
283
- <${StatusLines} lines=${live.status} />
353
+ <${StatusLines} lines=${live.status} busy=${busy} />
284
354
  ${showReasoning && live.reasoning ? html`<${Reasoning} text=${live.reasoning} />` : null}
355
+ ${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
285
356
  ${live.text ? html`<${Box} marginTop=${1}><${AssistantMessage} text=${live.text} /><//>` : null}
286
- <${Thinking} label=${live.text ? 'Responding' : 'Thinking'} />
357
+ <${Thinking} label=${live.reply ? 'Planning' : live.text ? 'Responding' : 'Working'} startedAt=${startedAt} />
287
358
  <//>`
288
359
  : null}
289
360
  <//>
290
361
 
291
362
  <${Box} flexShrink=${0} flexDirection="column">
292
- <${Composer} onSubmit=${onSubmit} busy=${busy} width=${cols} />
363
+ ${choice
364
+ ? html`<${Choice} ...${choice} />`
365
+ : html`<${Composer} onSubmit=${onSubmit} busy=${busy} width=${cols} />`}
293
366
  <${Box} paddingX=${1} width=${cols}>
294
367
  <${Box} flexGrow=${1}><${Text} color=${C.gray} wrap="truncate-end">${ctx}<//><//>
295
368
  <${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
  }
@@ -6,6 +6,20 @@ import { Box, Text, useInput } from 'ink';
6
6
  import { C, glyphs, borderStyle } from './theme.js';
7
7
  import { SLASH_COMMANDS } from '../commands.js';
8
8
 
9
+ // @-mention entity types. Selecting one inserts e.g. "@olt:" so the user can
10
+ // append an identifier (@olt:3); the backend treats it as a scoped reference.
11
+ const MENTIONS = [
12
+ { tag: '@device', desc: 'Any device by name or id' },
13
+ { tag: '@olt', desc: 'An OLT' },
14
+ { tag: '@router', desc: 'A router / NAS' },
15
+ { tag: '@onu', desc: 'An ONU' },
16
+ { tag: '@customer', desc: 'A customer' },
17
+ { tag: '@vlan', desc: 'A VLAN id' },
18
+ { tag: '@circuit', desc: 'A circuit / uplink' },
19
+ { tag: '@ip', desc: 'An IP address' },
20
+ { tag: '@mac', desc: 'A MAC address' },
21
+ ];
22
+
9
23
  export function Composer({ onSubmit, busy, width = 80 }) {
10
24
  const [value, setValue] = useState('');
11
25
  const [cursor, setCursor] = useState(0);
@@ -13,9 +27,16 @@ export function Composer({ onSubmit, busy, width = 80 }) {
13
27
  const [histIdx, setHistIdx] = useState(-1);
14
28
  const [sel, setSel] = useState(0);
15
29
 
16
- // The slash palette is open while typing the command token (no space yet).
17
- const matches = value.startsWith('/') && !value.includes(' ')
18
- ? SLASH_COMMANDS.filter((s) => s.cmd.startsWith(value))
30
+ // Determine the token being typed at the cursor to drive the palette.
31
+ const upto = value.slice(0, cursor);
32
+ const tokStart = Math.max(upto.lastIndexOf(' '), upto.lastIndexOf('\n')) + 1;
33
+ const token = upto.slice(tokStart);
34
+ const slashMode = value.startsWith('/') && tokStart === 0;
35
+ const mentionMode = token.startsWith('@');
36
+ const matches = slashMode
37
+ ? SLASH_COMMANDS.filter((s) => s.cmd.startsWith(token))
38
+ : mentionMode
39
+ ? MENTIONS.filter((m) => m.tag.startsWith(token.toLowerCase()))
19
40
  : [];
20
41
  const menuOpen = matches.length > 0;
21
42
 
@@ -32,12 +53,23 @@ export function Composer({ onSubmit, busy, width = 80 }) {
32
53
 
33
54
  const setText = (v, c) => { setValue(v); setCursor(c ?? v.length); };
34
55
 
56
+ // Complete the active token to the highlighted palette entry.
57
+ const complete = () => {
58
+ if (!menuOpen) return;
59
+ if (slashMode) { const c = matches[sel].cmd + ' '; setText(c, c.length); return; }
60
+ const ins = matches[sel].tag + ':';
61
+ const v = value.slice(0, tokStart) + ins + value.slice(cursor);
62
+ setText(v, tokStart + ins.length);
63
+ };
64
+
35
65
  useInput((input, key) => {
36
66
  if (busy) return; // ignore edits while a turn streams (App handles Ctrl+C/Esc)
37
67
 
38
68
  // ── Submit / complete ──
39
69
  if (key.return) {
40
- if (menuOpen) { submit(matches[sel].cmd); return; }
70
+ // In slash mode Enter runs the command; in mention mode it completes.
71
+ if (menuOpen && slashMode) { submit(matches[sel].cmd); return; }
72
+ if (menuOpen && mentionMode) { complete(); return; }
41
73
  submit(value);
42
74
  return;
43
75
  }
@@ -46,10 +78,7 @@ export function Composer({ onSubmit, busy, width = 80 }) {
46
78
  submit(value.slice(0, cursor) + first + value.slice(cursor));
47
79
  return;
48
80
  }
49
- if (key.tab && !key.shift) {
50
- if (menuOpen) { const c = matches[sel].cmd + ' '; setText(c, c.length); }
51
- return;
52
- }
81
+ if (key.tab && !key.shift) { complete(); return; }
53
82
 
54
83
  // ── Menu / history navigation ──
55
84
  if (key.upArrow) {
@@ -76,8 +105,8 @@ export function Composer({ onSubmit, busy, width = 80 }) {
76
105
  // ── Cursor movement ──
77
106
  if (key.leftArrow) { setCursor((c) => Math.max(0, c - 1)); return; }
78
107
  if (key.rightArrow) {
79
- // At end of a slash token, Right accepts the highlighted completion.
80
- if (menuOpen && cursor >= value.length) { const c = matches[sel].cmd + ' '; setText(c, c.length); return; }
108
+ // At end of the active token, Right accepts the highlighted completion.
109
+ if (menuOpen && cursor >= value.length) { complete(); return; }
81
110
  setCursor((c) => Math.min(value.length, c + 1));
82
111
  return;
83
112
  }
@@ -112,13 +141,14 @@ export function Composer({ onSubmit, busy, width = 80 }) {
112
141
  const at = value[cursor] ?? ' ';
113
142
  const after = value.slice(cursor + 1);
114
143
  const empty = value.length === 0;
115
- const sug = menuOpen && matches[sel] ? matches[sel].cmd : '';
116
- const ghost = sug && sug.startsWith(value) && cursor >= value.length ? sug.slice(value.length) : '';
144
+ const sugTok = menuOpen && matches[sel] ? (slashMode ? matches[sel].cmd : matches[sel].tag) : '';
145
+ const ghost = sugTok && sugTok.startsWith(token) && cursor >= value.length ? sugTok.slice(token.length) : '';
117
146
 
118
147
  return html`
119
148
  <${Box} flexDirection="column" width=${width}>
120
149
  ${menuOpen
121
150
  ? html`<${Box} flexDirection="column" paddingX=${1} marginBottom=${0}>
151
+ ${slashMode ? null : html`<${Box}><${Text} color=${C.cyan} bold>@ mention <//><${Text} color=${C.gray}>a device, customer or entity<//><//>`}
122
152
  ${(() => {
123
153
  const MAX = 8;
124
154
  let start = 0;
@@ -127,10 +157,11 @@ export function Composer({ onSubmit, busy, width = 80 }) {
127
157
  return window.map((s, wi) => {
128
158
  const i = start + wi;
129
159
  const on = i === sel;
130
- const label = (s.cmd + (s.hint ? ' ' + s.hint : '')).padEnd(21);
160
+ const key = slashMode ? (s.cmd + (s.hint ? ' ' + s.hint : '')) : s.tag;
161
+ const label = key.padEnd(21);
131
162
  return html`<${Box} key=${'m' + i}>
132
163
  <${Text} color=${on ? C.accent : C.gray}>${on ? glyphs.prompt + ' ' : ' '}<//>
133
- <${Text} color=${on ? C.accent : C.white} bold=${on}>${label}<//>
164
+ <${Text} color=${on ? C.accent : slashMode ? C.white : C.cyan} bold=${on}>${label}<//>
134
165
  <${Text} color=${C.gray}>${s.desc}<//>
135
166
  <//>`;
136
167
  });
@@ -1,55 +1,145 @@
1
- // Minimal, dependency-free markdown → Ink renderer. Supports headings,
2
- // bold, inline code, bullet/numbered lists and fenced code blocks. Ink
1
+ // Small, dependency-free markdown → Ink renderer. Supports headings, bold,
2
+ // italic, strikethrough, inline code, links, bullet/numbered lists,
3
+ // blockquotes, horizontal rules, fenced code blocks and GFM tables. Ink
3
4
  // handles wrapping to the available width automatically.
4
- import { html, React } from './dom.js';
5
+ import { html } from './dom.js';
5
6
  import { Box, Text } from 'ink';
6
- import { C, glyphs } from './theme.js';
7
+ import { C, glyphs, asciiSafe } from './theme.js';
7
8
 
8
9
  let keyId = 0;
9
10
  const k = () => `md${keyId++}`;
10
11
 
11
- /** Parse a single line into styled Ink <Text> spans (bold, `code`). */
12
+ const stripInline = (s) =>
13
+ String(s)
14
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
15
+ .replace(/(^|[^*])\*([^*]+)\*/g, '$1$2')
16
+ .replace(/_([^_]+)_/g, '$1')
17
+ .replace(/~~([^~]+)~~/g, '$1')
18
+ .replace(/`([^`]+)`/g, '$1')
19
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
20
+
21
+ const cellWidth = (s) => stripInline(s).length;
22
+
23
+ /** Parse a single line into styled Ink <Text> spans. */
12
24
  function renderInline(line) {
13
25
  const spans = [];
14
- // Tokenise on **bold** and `code`.
15
- const re = /(\*\*([^*]+)\*\*|`([^`]+)`)/g;
26
+ // Order matters: links, bold, strike, italic, code.
27
+ const re = /(\[([^\]]+)\]\(([^)]+)\)|\*\*([^*]+)\*\*|~~([^~]+)~~|`([^`]+)`|\*([^*]+)\*|_([^_]+)_)/g;
16
28
  let last = 0;
17
29
  let m;
18
30
  while ((m = re.exec(line)) !== null) {
19
31
  if (m.index > last) spans.push(html`<${Text} key=${k()}>${line.slice(last, m.index)}<//>`);
20
- if (m[2] !== undefined) spans.push(html`<${Text} key=${k()} bold>${m[2]}<//>`);
21
- else if (m[3] !== undefined) spans.push(html`<${Text} key=${k()} color=${C.cyan}>${m[3]}<//>`);
32
+ if (m[2] !== undefined) spans.push(html`<${Text} key=${k()} color=${C.blue} underline>${m[2]}<//>`);
33
+ else if (m[4] !== undefined) spans.push(html`<${Text} key=${k()} bold>${m[4]}<//>`);
34
+ else if (m[5] !== undefined) spans.push(html`<${Text} key=${k()} strikethrough color=${C.gray}>${m[5]}<//>`);
35
+ else if (m[6] !== undefined) spans.push(html`<${Text} key=${k()} color=${C.cyan}>${m[6]}<//>`);
36
+ else if (m[7] !== undefined) spans.push(html`<${Text} key=${k()} italic>${m[7]}<//>`);
37
+ else if (m[8] !== undefined) spans.push(html`<${Text} key=${k()} italic>${m[8]}<//>`);
22
38
  last = m.index + m[0].length;
23
39
  }
24
40
  if (last < line.length) spans.push(html`<${Text} key=${k()}>${line.slice(last)}<//>`);
25
41
  return spans.length ? spans : [html`<${Text} key=${k()}> <//>`];
26
42
  }
27
43
 
44
+ const isTableRow = (l) => /^\s*\|.*\|\s*$/.test(l);
45
+ const isTableSep = (l) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(l);
46
+
47
+ const splitCells = (l) =>
48
+ l.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
49
+
50
+ /** Render a parsed GFM table as an aligned, bordered box. */
51
+ function renderTable(header, rows) {
52
+ const cols = header.length;
53
+ const widths = header.map((h, i) =>
54
+ Math.max(cellWidth(h), ...rows.map((r) => cellWidth(r[i] || ''))),
55
+ );
56
+ const bar = glyphs.vbar;
57
+ const pad = (text, w) => {
58
+ const gap = Math.max(0, w - cellWidth(text));
59
+ return ' '.repeat(gap);
60
+ };
61
+ const rule = (l, mid, r) =>
62
+ l + widths.map((w) => (asciiSafe() ? '-' : '─').repeat(w + 2)).join(mid) + r;
63
+ const A = asciiSafe();
64
+ const [tl, tm, tr] = A ? ['+', '+', '+'] : ['┌', '┬', '┐'];
65
+ const [ml, mm, mr] = A ? ['+', '+', '+'] : ['├', '┼', '┤'];
66
+ const [bl, bm, br] = A ? ['+', '+', '+'] : ['└', '┴', '┘'];
67
+
68
+ const line = (cells, color, bold) => html`
69
+ <${Box} key=${k()}>
70
+ <${Text} color=${C.gray}>${bar}<//>
71
+ ${cells.map((cell, i) => html`
72
+ <${Box} key=${k()}>
73
+ <${Text}> <//>
74
+ <${Text} color=${color} bold=${bold}>${renderInline(cell || '')}<//><${Text}>${pad(cell || '', widths[i])} <//>
75
+ <${Text} color=${C.gray}>${bar}<//>
76
+ <//>`)}
77
+ <//>`;
78
+
79
+ return html`
80
+ <${Box} flexDirection="column" key=${k()} marginY=${0}>
81
+ <${Text} color=${C.gray}>${rule(tl, tm, tr)}<//>
82
+ ${line(header, C.accent, true)}
83
+ <${Text} color=${C.gray}>${rule(ml, mm, mr)}<//>
84
+ ${rows.map((r) => line(r, C.white, false))}
85
+ <${Text} color=${C.gray}>${rule(bl, bm, br)}<//>
86
+ <//>`;
87
+ }
88
+
28
89
  /** Render a markdown string as a column of Ink lines. */
29
90
  export function Markdown({ content = '' }) {
30
91
  const rows = [];
31
92
  let inCode = false;
32
93
  const lines = String(content).replace(/\s+$/, '').split('\n');
33
94
 
34
- for (const raw of lines) {
35
- const line = raw.replace(/\t/g, ' ');
95
+ for (let i = 0; i < lines.length; i++) {
96
+ const line = lines[i].replace(/\t/g, ' ');
97
+
98
+ // Fenced code block.
99
+ if (line.trim().startsWith('```')) { inCode = !inCode; continue; }
100
+ if (inCode) { rows.push(html`<${Text} key=${k()} color=${C.green}> ${line}<//>`); continue; }
36
101
 
37
- if (line.trim().startsWith('```')) {
38
- inCode = !inCode;
102
+ // GFM table: header row + separator + body rows.
103
+ if (isTableRow(line) && i + 1 < lines.length && isTableSep(lines[i + 1])) {
104
+ const header = splitCells(line);
105
+ const body = [];
106
+ i += 2;
107
+ while (i < lines.length && isTableRow(lines[i])) { body.push(splitCells(lines[i])); i++; }
108
+ i--;
109
+ rows.push(renderTable(header, body));
39
110
  continue;
40
111
  }
41
- if (inCode) {
42
- rows.push(html`<${Text} key=${k()} color=${C.gray}> ${line}<//>`);
112
+
113
+ // Horizontal rule.
114
+ if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
115
+ rows.push(html`<${Text} key=${k()} color=${C.gray}>${(asciiSafe() ? '-' : '─').repeat(40)}<//>`);
43
116
  continue;
44
117
  }
45
118
 
46
- const h = line.match(/^(#{1,3})\s+(.*)$/);
119
+ // Headings.
120
+ const h = line.match(/^(#{1,6})\s+(.*)$/);
47
121
  if (h) {
48
- rows.push(html`<${Text} key=${k()} bold color=${C.white}>${h[2]}<//>`);
122
+ const color = h[1].length === 1 ? C.accent : h[1].length === 2 ? C.cyan : C.white;
123
+ rows.push(html`
124
+ <${Box} key=${k()} marginTop=${rows.length ? 1 : 0}>
125
+ <${Text} bold color=${color}>${stripInline(h[2])}<//>
126
+ <//>`);
127
+ continue;
128
+ }
129
+
130
+ // Blockquote.
131
+ const quote = line.match(/^\s*>\s?(.*)$/);
132
+ if (quote) {
133
+ rows.push(html`
134
+ <${Box} key=${k()}>
135
+ <${Text} color=${C.accent}>${glyphs.vbar} <//>
136
+ <${Text} color=${C.gray} italic>${renderInline(quote[1])}<//>
137
+ <//>`);
49
138
  continue;
50
139
  }
51
140
 
52
- const bullet = line.match(/^(\s*)[-*]\s+(.*)$/);
141
+ // Bullet list.
142
+ const bullet = line.match(/^(\s*)[-*+]\s+(.*)$/);
53
143
  if (bullet) {
54
144
  rows.push(html`
55
145
  <${Box} key=${k()}>
@@ -59,11 +149,12 @@ export function Markdown({ content = '' }) {
59
149
  continue;
60
150
  }
61
151
 
152
+ // Numbered list.
62
153
  const num = line.match(/^(\s*)(\d+)\.\s+(.*)$/);
63
154
  if (num) {
64
155
  rows.push(html`
65
156
  <${Box} key=${k()}>
66
- <${Text} color=${C.accent}>${num[1]}${num[2]}. <//>
157
+ <${Text} color=${C.accent} bold>${num[1]}${num[2]}. <//>
67
158
  <${Text}>${renderInline(num[3])}<//>
68
159
  <//>`);
69
160
  continue;