ispbills-icli 8.1.0 → 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 +8 -3
- package/bin/icli.js +4 -3
- package/package.json +1 -1
- package/src/client.js +63 -5
- package/src/commands.js +13 -2
- package/src/tui/app.js +82 -9
- package/src/tui/components.js +109 -27
- package/src/tui/composer.js +45 -14
- package/src/tui/markdown.js +110 -19
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** —
|
|
198
|
-
- **
|
|
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: '
|
|
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
|
|
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
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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/commands.js
CHANGED
|
@@ -26,7 +26,8 @@ export const SLASH_COMMANDS = [
|
|
|
26
26
|
{ cmd: '/vlan', hint: '[id]', desc: 'VLAN usage — free or in use', group: 'Access' },
|
|
27
27
|
{ cmd: '/pppoe', hint: '[user]', desc: 'PPPoE session detail', group: 'Access' },
|
|
28
28
|
{ cmd: '/online', hint: '', desc: 'List online PPPoE sessions', group: 'Access' },
|
|
29
|
-
{ cmd: '/offline', hint: '',
|
|
29
|
+
{ cmd: '/offline', hint: '[olt/router]', desc: 'List offline OLTs, routers or ONUs', group: 'Access' },
|
|
30
|
+
{ cmd: '/down', hint: '[circuit]', desc: 'List down circuits / uplinks', group: 'Access' },
|
|
30
31
|
{ cmd: '/bandwidth', hint: '[device]', desc: 'Traffic / bandwidth usage', group: 'Access' },
|
|
31
32
|
|
|
32
33
|
// ── Layer 2/3 lookups ──
|
|
@@ -88,7 +89,17 @@ export function slashToQuestion(line) {
|
|
|
88
89
|
case '/vlan': return on(`is VLAN ${arg} in use or free across the network?`, 'list all VLANs and show which are in use or free');
|
|
89
90
|
case '/pppoe': return on(`show PPPoE session detail for ${arg}`, 'summarise current PPPoE sessions');
|
|
90
91
|
case '/online': return 'list currently online PPPoE sessions';
|
|
91
|
-
case '/offline':
|
|
92
|
+
case '/offline': {
|
|
93
|
+
const t = arg.toLowerCase();
|
|
94
|
+
if (/olt/.test(t)) return 'list all OLTs that are currently offline or unreachable';
|
|
95
|
+
if (/rout|nas|mikrotik|router/.test(t)) return 'list all routers / NAS devices that are currently offline or unreachable';
|
|
96
|
+
if (/onu|ont/.test(t)) return 'list all ONUs that are currently offline';
|
|
97
|
+
if (/cust|client|user/.test(t)) return 'list all customers that are currently offline';
|
|
98
|
+
return arg
|
|
99
|
+
? `list all ${arg} that are currently offline`
|
|
100
|
+
: 'list everything that is currently offline — OLTs, routers and ONUs';
|
|
101
|
+
}
|
|
102
|
+
case '/down': return on(`is the ${arg} circuit / uplink down? show its status`, 'list all down circuits, uplinks or backbone links across the network');
|
|
92
103
|
case '/bandwidth': return on(`show traffic and bandwidth usage on ${arg}`, 'show current bandwidth usage across the network and flag the top talkers');
|
|
93
104
|
|
|
94
105
|
// Layer 2/3 lookups
|
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
|
-
|
|
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 = {
|
|
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' : '
|
|
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
|
-
|
|
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}<//>
|
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
|
}
|
package/src/tui/composer.js
CHANGED
|
@@ -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
|
-
//
|
|
17
|
-
const
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
80
|
-
if (menuOpen && cursor >= value.length) {
|
|
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
|
|
116
|
-
const ghost =
|
|
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
|
|
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
|
});
|
package/src/tui/markdown.js
CHANGED
|
@@ -1,55 +1,145 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
15
|
-
const re = /(
|
|
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()}
|
|
21
|
-
else if (m[
|
|
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 (
|
|
35
|
-
const line =
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
119
|
+
// Headings.
|
|
120
|
+
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
|
47
121
|
if (h) {
|
|
48
|
-
|
|
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
|
-
|
|
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;
|