ispbills-icli 8.4.13 → 8.5.1
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/package.json +1 -1
- package/src/commands.js +8 -1
- package/src/tui/app.js +180 -32
- package/src/tui/components.js +98 -28
- package/src/tui/composer.js +18 -10
- package/src/tui/theme.js +60 -13
package/package.json
CHANGED
package/src/commands.js
CHANGED
|
@@ -52,9 +52,16 @@ export const SLASH_COMMANDS = [
|
|
|
52
52
|
{ cmd: '/copy', hint: '', desc: 'Copy the last AI answer to the clipboard', group: 'Memory', local: true },
|
|
53
53
|
|
|
54
54
|
// ── Session & app ──
|
|
55
|
-
{ cmd: '/
|
|
55
|
+
{ cmd: '/plan', hint: '[prompt]', desc: 'Switch to plan mode (Shift+Tab to cycle)', group: 'Session', local: true },
|
|
56
|
+
{ cmd: '/autopilot', hint: '', desc: 'Toggle autopilot mode (auto-apply fixes)', group: 'Session', local: true },
|
|
56
57
|
{ cmd: '/model', hint: '[name]', desc: 'Show or set the AI model', group: 'Session', local: true },
|
|
57
58
|
{ cmd: '/reasoning', hint: '', desc: 'Toggle reasoning display (Ctrl+T)', group: 'Session', local: true },
|
|
59
|
+
{ cmd: '/context', hint: '', desc: 'Show context window token usage', group: 'Session', local: true },
|
|
60
|
+
{ cmd: '/usage', hint: '', desc: 'Show session usage metrics', group: 'Session', local: true },
|
|
61
|
+
{ cmd: '/compact', hint: '[focus]', desc: 'Compress conversation history', group: 'Session', local: true },
|
|
62
|
+
{ cmd: '/session', hint: '', desc: 'Show session info (cwd, branch, tokens)', group: 'Session', local: true },
|
|
63
|
+
{ cmd: '/share', hint: '', desc: 'Share session to a GitHub gist', group: 'Session', local: true },
|
|
64
|
+
{ cmd: '/theme', hint: '', desc: 'View current colour theme info', group: 'Session', local: true },
|
|
58
65
|
{ cmd: '/new', hint: '', desc: 'Start a fresh conversation', group: 'Session', local: true },
|
|
59
66
|
{ cmd: '/history', hint: '', desc: 'Show conversation history', group: 'Session', local: true },
|
|
60
67
|
{ cmd: '/clear', hint: '', desc: 'Clear the screen', group: 'Session', local: true },
|
package/src/tui/app.js
CHANGED
|
@@ -7,7 +7,7 @@ import { html, useState, useEffect, useRef, useCallback } from './dom.js';
|
|
|
7
7
|
import { Box, Text, Static, useStdout, useApp, useInput } from 'ink';
|
|
8
8
|
import { C, glyphs, midDot, dash } from './theme.js';
|
|
9
9
|
import {
|
|
10
|
-
Banner, UserMessage, AssistantMessage, StatusLines, Reasoning,
|
|
10
|
+
Banner, TabBar, FollowupChips, UserMessage, AssistantMessage, StatusLines, Reasoning,
|
|
11
11
|
Plan, Connect, Notice, Thinking, Choice,
|
|
12
12
|
} from './components.js';
|
|
13
13
|
import { Composer } from './composer.js';
|
|
@@ -19,6 +19,9 @@ import { loadNotes, addNote, forgetNote, memoryPreamble } from '../memory.js';
|
|
|
19
19
|
import { copyToClipboard } from '../clipboard.js';
|
|
20
20
|
import { createGist } from '../gist.js';
|
|
21
21
|
import { git as gitCmd, commitBackup } from '../gitrepo.js';
|
|
22
|
+
import { execSync } from 'child_process';
|
|
23
|
+
import { homedir } from 'os';
|
|
24
|
+
import { relative } from 'path';
|
|
22
25
|
|
|
23
26
|
// Split a command argument string into argv, honouring "quoted" segments.
|
|
24
27
|
function splitArgs(s) {
|
|
@@ -29,6 +32,33 @@ function splitArgs(s) {
|
|
|
29
32
|
return out;
|
|
30
33
|
}
|
|
31
34
|
|
|
35
|
+
// Shorten a path with ~ for home directory.
|
|
36
|
+
function shortenPath(p) {
|
|
37
|
+
try {
|
|
38
|
+
const home = homedir();
|
|
39
|
+
const rel = relative(home, p);
|
|
40
|
+
if (!rel.startsWith('..')) return '~/' + rel;
|
|
41
|
+
} catch {}
|
|
42
|
+
return p;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Get the current git branch name (synchronous, non-throwing).
|
|
46
|
+
function gitBranch() {
|
|
47
|
+
try {
|
|
48
|
+
return execSync('git rev-parse --abbrev-ref HEAD', {
|
|
49
|
+
cwd: process.cwd(), stdio: ['ignore', 'pipe', 'ignore'], timeout: 1000,
|
|
50
|
+
}).toString().trim();
|
|
51
|
+
} catch {
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Approximate token count from text (~4 chars per token).
|
|
57
|
+
function approxTokens(messages) {
|
|
58
|
+
const total = messages.reduce((n, m) => n + (m.content?.length || 0), 0);
|
|
59
|
+
return Math.round(total / 4);
|
|
60
|
+
}
|
|
61
|
+
|
|
32
62
|
function useTerminalSize() {
|
|
33
63
|
const { stdout } = useStdout();
|
|
34
64
|
const [size, setSize] = useState({
|
|
@@ -47,16 +77,24 @@ function useTerminalSize() {
|
|
|
47
77
|
let itemId = 0;
|
|
48
78
|
const nextId = () => ++itemId;
|
|
49
79
|
|
|
80
|
+
// Mode cycle: ask → plan → autopilot → ask
|
|
81
|
+
const MODES = ['ask', 'plan', 'autopilot'];
|
|
82
|
+
|
|
50
83
|
export function App({ cfg, display = {}, onExternal, onExit, initialQuestion }) {
|
|
51
84
|
const { exit } = useApp();
|
|
52
85
|
const { cols, rows } = useTerminalSize();
|
|
86
|
+
const colsRef = useRef(cols);
|
|
87
|
+
useEffect(() => { colsRef.current = cols; }, [cols]);
|
|
53
88
|
|
|
54
89
|
const [items, setItems] = useState([]);
|
|
55
90
|
const [live, setLive] = useState(null); // { status:[], reasoning:'', text:'' }
|
|
56
91
|
const [busy, setBusy] = useState(false);
|
|
57
92
|
const [model, setModel] = useState(cfg._model);
|
|
58
|
-
const [showReasoning, setShowReasoning] = useState(
|
|
59
|
-
const [
|
|
93
|
+
const [showReasoning, setShowReasoning] = useState(false); // collapsed by default per spec
|
|
94
|
+
const [mode, setMode] = useState('ask'); // 'ask' | 'plan' | 'autopilot'
|
|
95
|
+
const [activeTab, setActiveTab] = useState('session');
|
|
96
|
+
const [chips, setChips] = useState([]); // followup suggestion strings
|
|
97
|
+
const [activeChip, setActiveChip] = useState(0);
|
|
60
98
|
const [hint, setHint] = useState('');
|
|
61
99
|
const [startedAt, setStartedAt] = useState(0);
|
|
62
100
|
const [choice, setChoice] = useState(null); // { title, note, options, allowText, sel, text, focusText, onPick }
|
|
@@ -67,9 +105,11 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
67
105
|
const abortRef = useRef(null); // AbortController of the in-flight turn
|
|
68
106
|
const ctrlC = useRef(0); // timestamp of the last lone Ctrl+C
|
|
69
107
|
const apRef = useRef(false); // live autopilot flag (read inside runTurn)
|
|
108
|
+
const modeRef = useRef('ask'); // live mode (read inside runTurn)
|
|
70
109
|
const modelRef = useRef(cfg._model); // preferred model to request
|
|
71
110
|
const lastAnswerRef = useRef(''); // most recent assistant answer (for /copy, /gist)
|
|
72
111
|
const choiceActionRef = useRef(null); // action to run when a non-AI Choice is picked
|
|
112
|
+
const branchRef = useRef(gitBranch()); // current git branch (detected once at start)
|
|
73
113
|
|
|
74
114
|
// Enable terminal bracketed-paste so multi-line pastes (mouse right-click or
|
|
75
115
|
// Cmd/Ctrl+V) arrive wrapped in markers as one block — the Composer inserts
|
|
@@ -79,12 +119,13 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
79
119
|
return () => { try { process.stdout.write('\x1b[?2004l'); } catch {} };
|
|
80
120
|
}, []);
|
|
81
121
|
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
122
|
+
const setModeState = useCallback((next) => {
|
|
123
|
+
modeRef.current = next;
|
|
124
|
+
apRef.current = next === 'autopilot';
|
|
125
|
+
setMode(next);
|
|
85
126
|
}, []);
|
|
86
127
|
|
|
87
|
-
const push = useCallback((item) => setItems((xs) => [...xs, { id: nextId(), ...item }]), []);
|
|
128
|
+
const push = useCallback((item) => setItems((xs) => [...xs, { id: nextId(), cols: colsRef.current, ...item }]), []);
|
|
88
129
|
const log = (line) => plain.current.push(line);
|
|
89
130
|
|
|
90
131
|
// Clear the visible transcript: wipe the screen + native scrollback and
|
|
@@ -104,6 +145,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
104
145
|
convo.current.push({ role: 'user', content: question });
|
|
105
146
|
setBusy(true);
|
|
106
147
|
setHint('');
|
|
148
|
+
setChips([]);
|
|
107
149
|
setStartedAt(Date.now());
|
|
108
150
|
const s = { status: [], reasoning: '', text: '', reply: null };
|
|
109
151
|
setLive({ ...s });
|
|
@@ -156,6 +198,12 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
156
198
|
if (text) convo.current.push({ role: 'assistant', content: text });
|
|
157
199
|
lastAnswerRef.current = text || reply.summary || reply.question || lastAnswerRef.current;
|
|
158
200
|
|
|
201
|
+
// Populate followup chips from reply.suggestions (up to 4).
|
|
202
|
+
if (Array.isArray(reply.suggestions) && reply.suggestions.length) {
|
|
203
|
+
setChips(reply.suggestions.slice(0, 4));
|
|
204
|
+
setActiveChip(0);
|
|
205
|
+
}
|
|
206
|
+
|
|
159
207
|
// Archive a /backup answer to the per-user git repo.
|
|
160
208
|
if (archive === 'backup' && text) {
|
|
161
209
|
try {
|
|
@@ -250,17 +298,71 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
250
298
|
return;
|
|
251
299
|
}
|
|
252
300
|
if (q === '/reasoning') {
|
|
253
|
-
setShowReasoning((v) => { push({ type: 'notice', text: `Reasoning display ${!v ? '
|
|
301
|
+
setShowReasoning((v) => { push({ type: 'notice', text: `Reasoning display ${!v ? 'expanded' : 'collapsed'}.` }); return !v; });
|
|
254
302
|
return;
|
|
255
303
|
}
|
|
256
304
|
if (q === '/autopilot' || q === '/auto') {
|
|
257
|
-
const next =
|
|
258
|
-
|
|
305
|
+
const next = modeRef.current !== 'autopilot' ? 'autopilot' : 'ask';
|
|
306
|
+
setModeState(next);
|
|
259
307
|
push({ type: 'notice',
|
|
260
|
-
text: next
|
|
261
|
-
? `${glyphs.bolt} Autopilot ON ${dash()} proposed fixes will be applied automatically.
|
|
262
|
-
: `
|
|
263
|
-
color: next ? C.
|
|
308
|
+
text: next === 'autopilot'
|
|
309
|
+
? `${glyphs.bolt} Autopilot ON ${dash()} proposed fixes will be applied automatically. Shift+Tab to cycle modes.`
|
|
310
|
+
: `Mode: ask ${dash()} fixes will wait for your confirmation.`,
|
|
311
|
+
color: next === 'autopilot' ? C.warning : C.muted });
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (q === '/plan') {
|
|
315
|
+
const next = modeRef.current !== 'plan' ? 'plan' : 'ask';
|
|
316
|
+
setModeState(next);
|
|
317
|
+
push({ type: 'notice', text: `Mode: ${next} ${dash()} Shift+Tab to cycle.`, color: C.accent });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (q === '/context' || q === '/usage') {
|
|
321
|
+
const tokens = approxTokens(convo.current);
|
|
322
|
+
push({ type: 'notice', text: `Context: ~${tokens.toLocaleString()} tokens (${convo.current.length} messages). /compact to summarise.` });
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (q === '/compact' || q.startsWith('/compact ')) {
|
|
326
|
+
const focus = q.slice('/compact'.length).trim();
|
|
327
|
+
const prompt = focus
|
|
328
|
+
? `Summarise our conversation so far, focusing on: ${focus}`
|
|
329
|
+
: 'Summarise our conversation so far in a concise way, preserving key facts and decisions.';
|
|
330
|
+
convo.current = [];
|
|
331
|
+
push({ type: 'notice', text: `${glyphs.arrow} Compacting history…`, color: C.muted });
|
|
332
|
+
runTurn(prompt);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (q === '/theme') {
|
|
336
|
+
push({ type: 'notice', text: 'Theme: dark (default). Palette: purple accent, blue brand, green success, amber warning, red error.\nSet NO_COLOR=1 to disable colour or FORCE_COLOR=1 to force it.' });
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (q === '/session') {
|
|
340
|
+
const tokens = approxTokens(convo.current);
|
|
341
|
+
const branch = branchRef.current;
|
|
342
|
+
const cwd = shortenPath(process.cwd());
|
|
343
|
+
push({ type: 'notice', text: [
|
|
344
|
+
`Session ${dash()} ${cwd}${branch ? ' [' + branch + ']' : ''}`,
|
|
345
|
+
`Messages ${dash()} ${convo.current.length}`,
|
|
346
|
+
`Tokens ${dash()} ~${tokens.toLocaleString()} (approx)`,
|
|
347
|
+
`Model ${dash()} ${model || modelRef.current || 'server default'}`,
|
|
348
|
+
`Mode ${dash()} ${modeRef.current}`,
|
|
349
|
+
].join('\n') });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (q === '/share') {
|
|
353
|
+
if (!lastAnswerRef.current && convo.current.length === 0) {
|
|
354
|
+
push({ type: 'notice', text: 'Nothing to share yet — ask something first.', color: C.muted }); return;
|
|
355
|
+
}
|
|
356
|
+
choiceActionRef.current = (v) => doGist(v);
|
|
357
|
+
setChoice({
|
|
358
|
+
title: 'Share conversation…',
|
|
359
|
+
note: 'Saves as a secret GitHub gist', sel: 0, text: '', focusText: false, allowText: false,
|
|
360
|
+
options: [
|
|
361
|
+
{ label: 'Last AI answer', value: 'answer' },
|
|
362
|
+
{ label: 'Full conversation transcript', value: 'transcript' },
|
|
363
|
+
{ label: 'Cancel', value: null },
|
|
364
|
+
],
|
|
365
|
+
});
|
|
264
366
|
return;
|
|
265
367
|
}
|
|
266
368
|
if (q === '/model' || q.startsWith('/model ')) {
|
|
@@ -282,7 +384,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
282
384
|
const head = c.group !== group ? ((group = c.group), `\n ${c.group}\n`) : '';
|
|
283
385
|
return head + ` ${(c.cmd + (c.hint ? ' ' + c.hint : '')).padEnd(22)}${c.desc}`;
|
|
284
386
|
}).join('\n');
|
|
285
|
-
push({ type: 'notice', text: 'Commands:' + lines + '\n\n Keys: Shift+Tab autopilot · Ctrl+T reasoning · Ctrl+
|
|
387
|
+
push({ type: 'notice', text: 'Commands:' + lines + '\n\n Keys: Shift+Tab cycle modes (ask→plan→autopilot) · Ctrl+T toggle reasoning · Ctrl+L clear · Ctrl+N/P followup chips · Ctrl+D exit · Ctrl+C×2 exit' });
|
|
286
388
|
return;
|
|
287
389
|
}
|
|
288
390
|
if (q === '/history') {
|
|
@@ -379,15 +481,15 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
379
481
|
} else { push({ type: 'notice', text: `Unknown command: ${q}. Type /help.` }); return; }
|
|
380
482
|
}
|
|
381
483
|
runTurn(question, opts);
|
|
382
|
-
}, [doExit, push, runTurn, onExternal,
|
|
484
|
+
}, [doExit, push, runTurn, onExternal, setModeState, model, cfg, doGist]);
|
|
383
485
|
|
|
384
486
|
// Interactive prompt/choice handling (owns the keyboard while open).
|
|
385
487
|
const pickChoice = useCallback((value) => {
|
|
386
488
|
const act = choiceActionRef.current;
|
|
387
489
|
choiceActionRef.current = null;
|
|
388
490
|
setChoice(null);
|
|
389
|
-
if (act) { if (value != null) act(value); else push({ type: 'notice', text: 'Cancelled.', color: C.
|
|
390
|
-
if (value == null) { push({ type: 'notice', text: 'Dismissed.', color: C.
|
|
491
|
+
if (act) { if (value != null) act(value); else push({ type: 'notice', text: 'Cancelled.', color: C.muted }); return; }
|
|
492
|
+
if (value == null) { push({ type: 'notice', text: 'Dismissed.', color: C.muted }); return; }
|
|
391
493
|
runTurn(String(value));
|
|
392
494
|
}, [push, runTurn]);
|
|
393
495
|
|
|
@@ -429,15 +531,34 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
429
531
|
setHint('Press Ctrl+C again to exit');
|
|
430
532
|
return;
|
|
431
533
|
}
|
|
534
|
+
if (key.ctrl && input === 'd') { doExit(); return; } // Ctrl+D shutdown
|
|
432
535
|
if (key.escape && busy) { abortRef.current?.abort(); return; }
|
|
536
|
+
if (key.escape && chips.length) { setChips([]); return; } // Esc dismisses chips
|
|
433
537
|
if (key.tab && key.shift && !busy) {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
538
|
+
// Cycle: ask → plan → autopilot → ask
|
|
539
|
+
const cur = MODES.indexOf(modeRef.current);
|
|
540
|
+
const next = MODES[(cur + 1) % MODES.length];
|
|
541
|
+
setModeState(next);
|
|
542
|
+
setHint(`Mode: ${next}`);
|
|
437
543
|
return;
|
|
438
544
|
}
|
|
439
545
|
if (key.ctrl && input === 't') { setShowReasoning((v) => !v); return; }
|
|
440
546
|
if (key.ctrl && input === 'l' && !busy) { clearAll(); return; }
|
|
547
|
+
// Ctrl+N / Ctrl+P — cycle followup chips
|
|
548
|
+
if (key.ctrl && input === 'n' && chips.length) {
|
|
549
|
+
setActiveChip((i) => (i + 1) % chips.length); return;
|
|
550
|
+
}
|
|
551
|
+
if (key.ctrl && input === 'p' && chips.length) {
|
|
552
|
+
setActiveChip((i) => (i - 1 + chips.length) % chips.length); return;
|
|
553
|
+
}
|
|
554
|
+
// Tab cycles the tab bar (when not in slash palette)
|
|
555
|
+
if (key.tab && !key.shift && !busy) {
|
|
556
|
+
setActiveTab((t) => {
|
|
557
|
+
const tabs = ['session', 'devices', 'customers', 'alarms'];
|
|
558
|
+
return tabs[(tabs.indexOf(t) + 1) % tabs.length];
|
|
559
|
+
});
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
441
562
|
});
|
|
442
563
|
|
|
443
564
|
// Auto-run a first question if launched with one.
|
|
@@ -447,9 +568,10 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
447
568
|
}, [initialQuestion, onSubmit]);
|
|
448
569
|
|
|
449
570
|
const renderItem = (it) => {
|
|
571
|
+
const c = it.cols || 80;
|
|
450
572
|
switch (it.type) {
|
|
451
|
-
case 'user': return html`<${UserMessage} key=${it.id} text=${it.text} />`;
|
|
452
|
-
case 'assistant': return html`<${AssistantMessage} key=${it.id} text=${it.text} />`;
|
|
573
|
+
case 'user': return html`<${UserMessage} key=${it.id} text=${it.text} cols=${c} />`;
|
|
574
|
+
case 'assistant': return html`<${AssistantMessage} key=${it.id} text=${it.text} cols=${c} />`;
|
|
453
575
|
case 'plan': return html`<${Plan} key=${it.id} reply=${it.reply} />`;
|
|
454
576
|
case 'connect': return html`<${Connect} key=${it.id} reply=${it.reply} />`;
|
|
455
577
|
case 'activity': return html`<${StatusLines} key=${it.id} lines=${it.status} busy=${false} />`;
|
|
@@ -458,16 +580,32 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
458
580
|
}
|
|
459
581
|
};
|
|
460
582
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
583
|
+
// ── Footer ───────────────────────────────────────────────────────────────────
|
|
584
|
+
// Left: ~/cwd ⏎ branch mode-badge
|
|
585
|
+
// Right: model ~Nk ctx
|
|
586
|
+
const cwd = shortenPath(process.cwd());
|
|
587
|
+
const branch = branchRef.current;
|
|
588
|
+
const tokenCount = approxTokens(convo.current);
|
|
589
|
+
const tokenLabel = tokenCount > 0 ? `~${Math.round(tokenCount / 1000)}k ctx` : '';
|
|
590
|
+
const modelLabel = shortModel(model || modelRef.current || '');
|
|
591
|
+
const modeLabel = mode !== 'ask' ? ` ${midDot()} ${glyphs.bolt} ${mode}` : '';
|
|
592
|
+
|
|
593
|
+
const footerLeft = [
|
|
594
|
+
cwd,
|
|
595
|
+
branch ? `${glyphs.gitBranch}${branch}` : null,
|
|
596
|
+
].filter(Boolean).join(' ');
|
|
597
|
+
|
|
598
|
+
const footerRight = hint
|
|
599
|
+
? hint
|
|
600
|
+
: [modelLabel, tokenLabel].filter(Boolean).join(` ${midDot()} `);
|
|
601
|
+
|
|
464
602
|
const compact = cols < 60;
|
|
465
603
|
|
|
466
604
|
// The banner is the first <Static> item so it prints once at the very top and
|
|
467
605
|
// scrolls away with history; completed transcript items follow it. Ink flushes
|
|
468
606
|
// each <Static> item to the terminal's normal buffer exactly once, leaving the
|
|
469
607
|
// native scrollback intact so earlier conversation can be scrolled back to.
|
|
470
|
-
const staticItems = [{ type: 'banner', id: `banner-${clearEpoch}
|
|
608
|
+
const staticItems = [{ type: 'banner', id: `banner-${clearEpoch}`, cols }, ...items];
|
|
471
609
|
|
|
472
610
|
return html`
|
|
473
611
|
<${Box} flexDirection="column" width=${cols}>
|
|
@@ -482,20 +620,30 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
482
620
|
${live
|
|
483
621
|
? html`<${Box} flexDirection="column">
|
|
484
622
|
<${StatusLines} lines=${live.status} busy=${busy} />
|
|
485
|
-
${
|
|
623
|
+
${live.reasoning ? html`<${Reasoning} text=${live.reasoning} show=${showReasoning} />` : null}
|
|
486
624
|
${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
|
|
487
|
-
${live.text ? html`<${
|
|
625
|
+
${live.text ? html`<${AssistantMessage} text=${live.text} cols=${cols} />` : null}
|
|
488
626
|
<${Thinking} label=${live.reply ? 'Planning' : live.text ? 'Responding' : 'Working'} startedAt=${startedAt} />
|
|
489
627
|
<//>`
|
|
490
628
|
: null}
|
|
491
629
|
|
|
492
|
-
<${
|
|
630
|
+
<${TabBar} active=${activeTab} cols=${cols} />
|
|
631
|
+
|
|
632
|
+
${!busy && chips.length ? html`<${FollowupChips} chips=${chips} active=${activeChip} />` : null}
|
|
633
|
+
|
|
634
|
+
<${Box} flexDirection="column">
|
|
493
635
|
${choice
|
|
494
636
|
? html`<${Choice} ...${choice} />`
|
|
495
637
|
: html`<${Composer} onSubmit=${onSubmit} busy=${busy} width=${cols} />`}
|
|
638
|
+
|
|
639
|
+
<${Text} color=${C.separator}>${'─'.repeat(Math.max(1, cols))}<//>
|
|
640
|
+
|
|
496
641
|
<${Box} paddingX=${1} width=${cols}>
|
|
497
|
-
<${Box} flexGrow=${1}
|
|
498
|
-
|
|
642
|
+
<${Box} flexGrow=${1}>
|
|
643
|
+
<${Text} color=${C.muted} wrap="truncate-end">${footerLeft}<//>
|
|
644
|
+
${modeLabel ? html`<${Text} color=${mode === 'autopilot' ? C.warning : C.accent}>${modeLabel}<//>` : null}
|
|
645
|
+
<//>
|
|
646
|
+
<${Text} color=${hint ? C.warning : C.muted} wrap="truncate-end">${footerRight}<//>
|
|
499
647
|
<//>
|
|
500
648
|
<//>
|
|
501
649
|
<//>`;
|
package/src/tui/components.js
CHANGED
|
@@ -15,6 +15,52 @@ const LOGO = [
|
|
|
15
15
|
'╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝ ',
|
|
16
16
|
];
|
|
17
17
|
|
|
18
|
+
// ── Tab Bar ─────────────────────────────────────────────────────────────────
|
|
19
|
+
// Matches the June 2026 Copilot CLI GA redesign: deep-blue track, active tab
|
|
20
|
+
// wrapped in [brackets], inactive tabs dimmed. Adapted for ISP NOC context.
|
|
21
|
+
const TABS = [
|
|
22
|
+
{ id: 'session', label: 'Session' },
|
|
23
|
+
{ id: 'devices', label: 'Devices' },
|
|
24
|
+
{ id: 'customers', label: 'Customers' },
|
|
25
|
+
{ id: 'alarms', label: 'Alarms' },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export function TabBar({ active = 'session', cols = 80, onSwitch }) {
|
|
29
|
+
return html`
|
|
30
|
+
<${Box} width=${cols} paddingX=${1} backgroundColor="#0D1117">
|
|
31
|
+
${TABS.map((t, i) => {
|
|
32
|
+
const on = t.id === active;
|
|
33
|
+
return html`
|
|
34
|
+
<${Box} key=${t.id}>
|
|
35
|
+
${i > 0 ? html`<${Text} color=${C.muted}> <//>` : null}
|
|
36
|
+
${on
|
|
37
|
+
? html`<${Text} color="white" bold>[${t.label}]<//>`
|
|
38
|
+
: html`<${Text} color=${C.muted}>${t.label}<//>`}
|
|
39
|
+
<//>`;
|
|
40
|
+
})}
|
|
41
|
+
<//>`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Followup Chips ────────────────────────────────────────────────────────────
|
|
45
|
+
// Up to 4 suggested followup actions shown above the input after a response.
|
|
46
|
+
// Ctrl+N/P cycles; ↵ on empty input submits active; Esc clears.
|
|
47
|
+
export function FollowupChips({ chips = [], active = 0 }) {
|
|
48
|
+
if (!chips.length) return null;
|
|
49
|
+
return html`
|
|
50
|
+
<${Box} paddingX=${1} flexWrap="wrap">
|
|
51
|
+
<${Text} color=${C.muted}>Next: <//>
|
|
52
|
+
${chips.map((chip, i) => {
|
|
53
|
+
const on = i === active;
|
|
54
|
+
return html`
|
|
55
|
+
<${Box} key=${'ch' + i}>
|
|
56
|
+
${i > 0 ? html`<${Text} color=${C.muted}> <//>` : null}
|
|
57
|
+
<${Text} color=${on ? C.success : C.muted} bold=${on}>[${chip}]<//>
|
|
58
|
+
<//>`;
|
|
59
|
+
})}
|
|
60
|
+
<${Text} color=${C.muted}> ↵ run Ctrl+N/P cycle Esc dismiss<//>
|
|
61
|
+
<//>`;
|
|
62
|
+
}
|
|
63
|
+
|
|
18
64
|
/** Header banner — full logo on wide/tall terminals, compact title otherwise. */
|
|
19
65
|
export function Banner({ cfg = {}, model, width = 80, compact = false }) {
|
|
20
66
|
const m = shortModel(model || cfg._model || 'IspBills AI');
|
|
@@ -29,8 +75,8 @@ export function Banner({ cfg = {}, model, width = 80, compact = false }) {
|
|
|
29
75
|
<${Text} color=${C.accent} bold>iCopilot <//>
|
|
30
76
|
${meta.map(([label, val], i) => html`
|
|
31
77
|
<${Box} key=${'m' + i}>
|
|
32
|
-
<${Text} color=${C.
|
|
33
|
-
<${Text} color=${label === 'model' ? C.
|
|
78
|
+
<${Text} color=${C.muted}>${midDot()}<//>
|
|
79
|
+
<${Text} color=${label === 'model' ? C.accent : C.brand}>${val}<//>
|
|
34
80
|
<//>`)}
|
|
35
81
|
<//>`;
|
|
36
82
|
}
|
|
@@ -40,18 +86,18 @@ export function Banner({ cfg = {}, model, width = 80, compact = false }) {
|
|
|
40
86
|
${wide
|
|
41
87
|
? html`<${Box} flexDirection="column" alignItems="center">
|
|
42
88
|
${LOGO.map((l, i) => html`<${Text} key=${'l' + i} color=${C.accent} bold>${l}<//>`)}
|
|
43
|
-
<${Text} color=${C.
|
|
89
|
+
<${Text} color=${C.muted}>iCopilot ${dash()} IspBills network engineer in your terminal<//>
|
|
44
90
|
<//>`
|
|
45
|
-
: html`<${Text} color=${C.accent} bold>iCopilot ${html`<${Text} color=${C.
|
|
91
|
+
: html`<${Text} color=${C.accent} bold>iCopilot ${html`<${Text} color=${C.muted}>${dash()} IspBills AI terminal<//>`}<//>`}
|
|
46
92
|
<${Box} marginTop=${1}>
|
|
47
93
|
${meta.map(([label, val], i) => html`
|
|
48
94
|
<${Box} key=${'m' + i}>
|
|
49
|
-
${i > 0 ? html`<${Text} color=${C.
|
|
50
|
-
<${Text} color=${C.
|
|
51
|
-
<${Text} color=${label === 'model' ? C.
|
|
95
|
+
${i > 0 ? html`<${Text} color=${C.muted}>${midDot()}<//>` : null}
|
|
96
|
+
<${Text} color=${C.muted}>${label} <//>
|
|
97
|
+
<${Text} color=${label === 'model' ? C.accent : C.brand}>${val}<//>
|
|
52
98
|
<//>`)}
|
|
53
99
|
<//>
|
|
54
|
-
${cfg.url ? html`<${Text} color=${C.
|
|
100
|
+
${cfg.url ? html`<${Text} color=${C.muted}>${cfg.url}<//>` : null}
|
|
55
101
|
<//>`;
|
|
56
102
|
}
|
|
57
103
|
|
|
@@ -60,25 +106,42 @@ function withMentions(text, base = C.white) {
|
|
|
60
106
|
const parts = String(text).split(/(@[\w:.\-/]+)/g);
|
|
61
107
|
return parts.map((p, i) =>
|
|
62
108
|
/^@[\w:.\-/]+$/.test(p)
|
|
63
|
-
? html`<${Text} key=${'mn' + i} color=${C.
|
|
109
|
+
? html`<${Text} key=${'mn' + i} color=${C.accent} bold>${p}<//>`
|
|
64
110
|
: html`<${Text} key=${'mn' + i} color=${base}>${p}<//>`,
|
|
65
111
|
);
|
|
66
112
|
}
|
|
67
113
|
|
|
68
|
-
|
|
69
|
-
|
|
114
|
+
// Full-width separator line, drawn in muted separator colour.
|
|
115
|
+
function Sep({ cols = 80 }) {
|
|
116
|
+
return html`<${Text} color=${C.separator}>${'─'.repeat(Math.max(1, cols))}<//>`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The user's prompt block — separator + "You" label + text. */
|
|
120
|
+
export function UserMessage({ text, cols = 80 }) {
|
|
70
121
|
return html`
|
|
71
|
-
<${Box} marginTop=${1}>
|
|
72
|
-
<${
|
|
73
|
-
<${
|
|
122
|
+
<${Box} flexDirection="column" marginTop=${1}>
|
|
123
|
+
<${Sep} cols=${cols} />
|
|
124
|
+
<${Box} paddingX=${2} marginTop=${1}>
|
|
125
|
+
<${Text} color=${C.brand} bold>You<//>
|
|
126
|
+
<//>
|
|
127
|
+
<${Box} paddingX=${2}>
|
|
128
|
+
<${Text}>${withMentions(text)}<//>
|
|
129
|
+
<//>
|
|
74
130
|
<//>`;
|
|
75
131
|
}
|
|
76
132
|
|
|
77
|
-
/** A finished assistant answer
|
|
78
|
-
export function AssistantMessage({ text }) {
|
|
133
|
+
/** A finished assistant answer — separator + "⬡ iCopilot" label + markdown. */
|
|
134
|
+
export function AssistantMessage({ text, cols = 80 }) {
|
|
135
|
+
const dot = glyphs.copilotDot;
|
|
79
136
|
return html`
|
|
80
137
|
<${Box} flexDirection="column" marginTop=${1}>
|
|
81
|
-
<${
|
|
138
|
+
<${Sep} cols=${cols} />
|
|
139
|
+
<${Box} paddingX=${2} marginTop=${1}>
|
|
140
|
+
<${Text} color=${C.accent} bold>${dot} iCopilot<//>
|
|
141
|
+
<//>
|
|
142
|
+
<${Box} paddingX=${2}>
|
|
143
|
+
<${Markdown} content=${text} />
|
|
144
|
+
<//>
|
|
82
145
|
<//>`;
|
|
83
146
|
}
|
|
84
147
|
|
|
@@ -142,14 +205,21 @@ export function StatusLines({ lines = [], busy = false }) {
|
|
|
142
205
|
<//>`;
|
|
143
206
|
}
|
|
144
207
|
|
|
145
|
-
/** Reasoning trace
|
|
146
|
-
export function Reasoning({ text }) {
|
|
208
|
+
/** Reasoning trace — collapsed to a single dim line; Ctrl+T to expand. */
|
|
209
|
+
export function Reasoning({ text, show = true }) {
|
|
147
210
|
if (!text) return null;
|
|
211
|
+
// Estimate token count (~4 chars per token as rough heuristic)
|
|
212
|
+
const tokenEst = Math.round(text.length / 4);
|
|
213
|
+
if (!show) {
|
|
214
|
+
return html`
|
|
215
|
+
<${Box} paddingX=${2} marginTop=${1}>
|
|
216
|
+
<${Text} color=${C.muted} italic>${glyphs.reasoning} Thinking (≈${tokenEst} tokens)… Ctrl+T to expand<//><//>`;
|
|
217
|
+
}
|
|
148
218
|
return html`
|
|
149
|
-
<${Box} flexDirection="column" marginTop=${1} borderStyle=${borderStyle()} borderColor=${C.
|
|
150
|
-
borderTop=${false} borderRight=${false} borderBottom=${false} paddingLeft=${
|
|
151
|
-
<${Text} color=${C.
|
|
152
|
-
<${Text} color=${C.
|
|
219
|
+
<${Box} flexDirection="column" marginTop=${1} borderStyle=${borderStyle()} borderColor=${C.separator}
|
|
220
|
+
borderTop=${false} borderRight=${false} borderBottom=${false} paddingLeft=${2}>
|
|
221
|
+
<${Text} color=${C.accent}>${glyphs.reasoning} ${html`<${Text} color=${C.muted} bold>Reasoning<//>`}<//>
|
|
222
|
+
<${Text} color=${C.muted} italic>${text}<//>
|
|
153
223
|
<//>`;
|
|
154
224
|
}
|
|
155
225
|
|
|
@@ -206,7 +276,7 @@ export function Notice({ text, color = C.gray }) {
|
|
|
206
276
|
return html`<${Box} marginTop=${1}><${Text} color=${color}>${text}<//><//>`;
|
|
207
277
|
}
|
|
208
278
|
|
|
209
|
-
/** The active "working" line — animated spinner, elapsed time, cancel hint. */
|
|
279
|
+
/** The active "working" line — animated spinner (warning colour), elapsed time, cancel hint. */
|
|
210
280
|
export function Thinking({ label = 'Working', startedAt }) {
|
|
211
281
|
const [, tick] = useState(0);
|
|
212
282
|
useEffect(() => {
|
|
@@ -215,10 +285,10 @@ export function Thinking({ label = 'Working', startedAt }) {
|
|
|
215
285
|
}, []);
|
|
216
286
|
const secs = startedAt ? Math.max(0, Math.round((Date.now() - startedAt) / 1000)) : 0;
|
|
217
287
|
return html`
|
|
218
|
-
<${Box} marginTop=${1}>
|
|
219
|
-
<${Text} color=${C.
|
|
220
|
-
<${Text} color=${C.
|
|
221
|
-
<${Text} color=${C.
|
|
288
|
+
<${Box} marginTop=${1} paddingX=${2}>
|
|
289
|
+
<${Text} color=${C.warning}><${Spinner} type=${spinnerType()} /><//>
|
|
290
|
+
<${Text} color=${C.warning} bold> ${label}<//>
|
|
291
|
+
<${Text} color=${C.muted}>… (${secs}s ${midDot()}esc to interrupt)<//>
|
|
222
292
|
<//>`;
|
|
223
293
|
}
|
|
224
294
|
|
package/src/tui/composer.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// the input and is navigable with the arrow keys.
|
|
4
4
|
import { html, useState, useEffect, useCallback, useRef } from './dom.js';
|
|
5
5
|
import { Box, Text, useInput } from 'ink';
|
|
6
|
-
import { C, glyphs
|
|
6
|
+
import { C, glyphs } from './theme.js';
|
|
7
7
|
import { SLASH_COMMANDS } from '../commands.js';
|
|
8
8
|
|
|
9
9
|
// @-mention entity types. Selecting one inserts e.g. "@olt:" so the user can
|
|
@@ -215,11 +215,17 @@ export function Composer({ onSubmit, busy, width = 80 }) {
|
|
|
215
215
|
const sugTok = menuOpen && matches[sel] ? (slashMode ? matches[sel].cmd : matches[sel].tag) : '';
|
|
216
216
|
const ghost = sugTok && sugTok.startsWith(token) && cursor >= value.length ? sugTok.slice(token.length) : '';
|
|
217
217
|
|
|
218
|
+
// Prompt glyph: ❯ (success/green) idle; ⧖ (warning/amber) busy.
|
|
219
|
+
const promptGlyph = busy ? glyphs.busy : glyphs.prompt;
|
|
220
|
+
const promptColor = busy ? C.warning : C.success;
|
|
221
|
+
// Ghost hint text when empty
|
|
222
|
+
const ghostHint = busy ? 'Working…' : 'Enter @ to mention a device or / for commands…';
|
|
223
|
+
|
|
218
224
|
return html`
|
|
219
225
|
<${Box} flexDirection="column" width=${width}>
|
|
220
226
|
${menuOpen
|
|
221
227
|
? html`<${Box} flexDirection="column" paddingX=${1} marginBottom=${0}>
|
|
222
|
-
${slashMode ? null : html`<${Box}><${Text} color=${C.
|
|
228
|
+
${slashMode ? null : html`<${Box}><${Text} color=${C.accent} bold>@ mention <//><${Text} color=${C.muted}>a device, customer or entity<//><//>`}
|
|
223
229
|
${(() => {
|
|
224
230
|
const MAX = 8;
|
|
225
231
|
let start = 0;
|
|
@@ -231,24 +237,26 @@ export function Composer({ onSubmit, busy, width = 80 }) {
|
|
|
231
237
|
const key = slashMode ? (s.cmd + (s.hint ? ' ' + s.hint : '')) : s.tag;
|
|
232
238
|
const label = key.padEnd(21);
|
|
233
239
|
return html`<${Box} key=${'m' + i}>
|
|
234
|
-
<${Text} color=${on ? C.
|
|
235
|
-
<${Text} color=${on ? C.
|
|
236
|
-
<${Text} color=${C.
|
|
240
|
+
<${Text} color=${on ? C.success : C.muted}>${on ? glyphs.prompt + ' ' : ' '}<//>
|
|
241
|
+
<${Text} color=${on ? C.success : slashMode ? C.slash : C.accent} bold=${on}>${label}<//>
|
|
242
|
+
<${Text} color=${C.muted}>${s.desc}<//>
|
|
237
243
|
<//>`;
|
|
238
244
|
});
|
|
239
245
|
})()}
|
|
240
246
|
<//>`
|
|
241
247
|
: null}
|
|
242
248
|
|
|
243
|
-
<${
|
|
244
|
-
|
|
249
|
+
<${Text} color=${C.accent}>${'_'.repeat(Math.max(1, width))}<//>
|
|
250
|
+
|
|
251
|
+
<${Box} width=${width} paddingX=${1}>
|
|
252
|
+
<${Text} color=${promptColor} bold>${promptGlyph} <//>
|
|
245
253
|
${empty
|
|
246
254
|
? html`<${Box} flexGrow=${1}>
|
|
247
|
-
<${Text} color
|
|
248
|
-
<${Text} color=${C.
|
|
255
|
+
<${Text} color="white" inverse> <//>
|
|
256
|
+
<${Text} color=${C.muted}> ${ghostHint}<//>
|
|
249
257
|
<//>`
|
|
250
258
|
: html`<${Box} flexGrow=${1}>
|
|
251
|
-
<${Text}>${before}<//><${Text} inverse>${at}<//><${Text}>${after}<//><${Text} color=${C.
|
|
259
|
+
<${Text}>${before}<//><${Text} inverse>${at}<//><${Text}>${after}<//><${Text} color=${C.muted}>${ghost}<//>
|
|
252
260
|
<//>`}
|
|
253
261
|
<//>
|
|
254
262
|
<//>`;
|
package/src/tui/theme.js
CHANGED
|
@@ -1,24 +1,71 @@
|
|
|
1
1
|
// Colours + glyphs for the Ink TUI. Colours are Ink-friendly names/hex;
|
|
2
2
|
// glyphs and the spinner reuse the ASCII-safe logic from ui.js so classic
|
|
3
3
|
// Windows consoles never render "tofu" boxes.
|
|
4
|
-
import { glyphs, asciiSafe } from '../ui.js';
|
|
4
|
+
import { glyphs as baseGlyphs, asciiSafe } from '../ui.js';
|
|
5
5
|
|
|
6
|
-
export {
|
|
6
|
+
export { asciiSafe };
|
|
7
7
|
|
|
8
|
-
//
|
|
9
|
-
|
|
8
|
+
// ── Colour palette (matches GitHub Copilot CLI spec) ─────────────────────────
|
|
9
|
+
// accent = purple — Copilot bullet, timeline separators, active tab
|
|
10
|
+
// brand = blue — "You" speaker label, links, title
|
|
11
|
+
// success = green — prompt glyph (idle), checkmarks, active chip
|
|
12
|
+
// warning = amber — busy glyph, warnings, autopilot mode
|
|
13
|
+
// error = red — error messages
|
|
14
|
+
// muted = grey — footer, metadata, ghost text, dim labels
|
|
15
|
+
// separator= dark — turn dividers, lower input separator
|
|
16
|
+
// slash = gold — slash command highlight in palette
|
|
17
|
+
export const ACCENT = '#A371F7';
|
|
10
18
|
export const C = {
|
|
11
|
-
accent:
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
accent: '#A371F7', // purple
|
|
20
|
+
brand: '#58A6FF', // blue
|
|
21
|
+
success: '#3FB950', // green
|
|
22
|
+
warning: '#D29922', // amber
|
|
23
|
+
error: '#F85149', // red
|
|
24
|
+
muted: '#8B949E', // grey
|
|
25
|
+
separator: '#30363D', // dark
|
|
26
|
+
slash: '#E3B341', // gold
|
|
27
|
+
// Legacy aliases (keep existing code working)
|
|
28
|
+
gray: '#8B949E',
|
|
29
|
+
white: 'white',
|
|
30
|
+
cyan: 'cyan',
|
|
31
|
+
green: '#3FB950',
|
|
32
|
+
yellow: '#D29922',
|
|
33
|
+
red: '#F85149',
|
|
34
|
+
magenta: '#A371F7',
|
|
35
|
+
blue: '#58A6FF',
|
|
20
36
|
};
|
|
21
37
|
|
|
38
|
+
// ── Extended glyph set (extends ui.js glyphs with TUI-specific ones) ─────────
|
|
39
|
+
const ASCII_EXTRA = {
|
|
40
|
+
copilotDot: 'o', // hex bullet for "o iCopilot" speaker label
|
|
41
|
+
busy: '*', // hourglass-style busy indicator
|
|
42
|
+
gitBranch: 'b:', // git branch prefix
|
|
43
|
+
tabOpen: '[', // active tab open bracket
|
|
44
|
+
tabClose: ']', // active tab close bracket
|
|
45
|
+
chip: '|', // chip separator
|
|
46
|
+
expand: '+', // expand indicator
|
|
47
|
+
collapse: '-', // collapse indicator
|
|
48
|
+
};
|
|
49
|
+
const UNICODE_EXTRA = {
|
|
50
|
+
copilotDot: '⬡', // white hexagon for Copilot label
|
|
51
|
+
busy: '⧖', // white hourglass for busy state
|
|
52
|
+
gitBranch: '\uE0A0 ', // Nerd Font powerline branch glyph
|
|
53
|
+
tabOpen: '[',
|
|
54
|
+
tabClose: ']',
|
|
55
|
+
chip: '·',
|
|
56
|
+
expand: '▸',
|
|
57
|
+
collapse: '▾',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Merged glyph proxy: own extras first, then ui.js base glyphs.
|
|
61
|
+
export const glyphs = new Proxy({}, {
|
|
62
|
+
get(_t, key) {
|
|
63
|
+
const extra = asciiSafe() ? ASCII_EXTRA : UNICODE_EXTRA;
|
|
64
|
+
if (key in extra) return extra[key];
|
|
65
|
+
return baseGlyphs[key];
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
22
69
|
// ink-spinner uses cli-spinners; 'line' is pure ASCII (|/-\), 'dots' is braille.
|
|
23
70
|
export function spinnerType() {
|
|
24
71
|
return asciiSafe() ? 'line' : 'dots';
|