ispbills-icli 2.0.0 → 4.0.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/bin/icli.js +138 -87
- package/package.json +1 -1
- package/src/chat.js +253 -125
- package/src/layout.js +69 -0
package/bin/icli.js
CHANGED
|
@@ -1,174 +1,225 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import
|
|
2
|
+
import { createInterface } from 'readline';
|
|
3
3
|
import { stdin as input, stdout as output, argv, exit } from 'process';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import { loadConfig, configPath } from '../src/config.js';
|
|
6
6
|
import { loginFlow } from '../src/auth.js';
|
|
7
|
-
import {
|
|
7
|
+
import { cols, inner, onResize, boxTop, boxBot, boxMid, boxRow, boxTopOpen, boxBotOpen } from '../src/layout.js';
|
|
8
|
+
import {
|
|
9
|
+
streamChat, printBanner, printSlashMenu, printUserBox,
|
|
10
|
+
SLASH_COMMANDS, slashCompleter,
|
|
11
|
+
} from '../src/chat.js';
|
|
12
|
+
|
|
13
|
+
const P = {
|
|
14
|
+
border: chalk.hex('#3D444D'),
|
|
15
|
+
dim: chalk.hex('#9198A1'),
|
|
16
|
+
muted: chalk.hex('#6e7781'),
|
|
17
|
+
accent: chalk.hex('#4493F8').bold,
|
|
18
|
+
accentD: chalk.hex('#4493F8'),
|
|
19
|
+
bold: chalk.bold.white,
|
|
20
|
+
ok: chalk.green,
|
|
21
|
+
warn: chalk.yellow,
|
|
22
|
+
err: chalk.red,
|
|
23
|
+
};
|
|
8
24
|
|
|
9
|
-
// ─── Parse args ─────────────────────────────────────────────────────────────
|
|
10
25
|
const args = argv.slice(2);
|
|
11
26
|
const cmd = args[0] ?? '';
|
|
12
27
|
|
|
13
|
-
//
|
|
28
|
+
// ── Help ───────────────────────────────────────────────────────────────────────
|
|
14
29
|
function printHelp() {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
process.stdout.write('\n');
|
|
31
|
+
process.stdout.write(boxTop('iCli · Help', P.border) + '\n');
|
|
32
|
+
|
|
33
|
+
process.stdout.write(boxRow('', P.border) + '\n');
|
|
34
|
+
process.stdout.write(boxRow(' ' + P.bold('Usage'), P.border) + '\n');
|
|
35
|
+
process.stdout.write(boxRow('', P.border) + '\n');
|
|
36
|
+
[
|
|
37
|
+
['icli', 'Interactive REPL with slash commands'],
|
|
38
|
+
['icli "question"', 'Single-shot query then exit'],
|
|
39
|
+
['icli login', 'Authenticate to your IspBills instance'],
|
|
40
|
+
['icli whoami', 'Show current session info'],
|
|
41
|
+
['icli --help', 'Show this help'],
|
|
42
|
+
].forEach(([c, d]) =>
|
|
43
|
+
process.stdout.write(boxRow(' ' + P.accent(c.padEnd(20)) + P.dim(d), P.border) + '\n')
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
process.stdout.write(boxMid(P.border) + '\n');
|
|
47
|
+
process.stdout.write(boxRow(' ' + P.bold('Slash commands'), P.border) + '\n');
|
|
48
|
+
process.stdout.write(boxRow('', P.border) + '\n');
|
|
49
|
+
for (const { cmd: c, hint, desc } of SLASH_COMMANDS) {
|
|
50
|
+
const key = (c + (hint ? ' '+hint : '')).padEnd(22);
|
|
51
|
+
process.stdout.write(boxRow(' ' + P.accent(key) + P.dim(desc), P.border) + '\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
process.stdout.write(boxMid(P.border) + '\n');
|
|
55
|
+
process.stdout.write(boxRow(' ' + P.bold('Keyboard shortcuts'), P.border) + '\n');
|
|
56
|
+
process.stdout.write(boxRow('', P.border) + '\n');
|
|
57
|
+
[
|
|
58
|
+
['Tab', 'Autocomplete slash commands'],
|
|
59
|
+
['↑ / ↓', 'Navigate input history'],
|
|
60
|
+
['Ctrl+T', 'Toggle reasoning display'],
|
|
61
|
+
['Ctrl+L', 'Clear screen'],
|
|
62
|
+
['Ctrl+C', 'Cancel / exit'],
|
|
63
|
+
].forEach(([k, d]) =>
|
|
64
|
+
process.stdout.write(boxRow(' ' + P.warn(k.padEnd(14)) + P.dim(d), P.border) + '\n')
|
|
65
|
+
);
|
|
66
|
+
process.stdout.write(boxRow('', P.border) + '\n');
|
|
67
|
+
process.stdout.write(boxBot(P.border) + '\n\n');
|
|
35
68
|
}
|
|
36
69
|
|
|
37
|
-
//
|
|
70
|
+
// ── Auth ───────────────────────────────────────────────────────────────────────
|
|
38
71
|
async function ensureAuth() {
|
|
39
72
|
let cfg = loadConfig();
|
|
40
73
|
if (!cfg) {
|
|
41
|
-
|
|
74
|
+
process.stdout.write(P.warn('\n No saved credentials. Setting up iCli.\n\n'));
|
|
42
75
|
cfg = await loginFlow();
|
|
43
|
-
|
|
76
|
+
process.stdout.write('\n ' + P.ok('✓') + ' Logged in as ' +
|
|
44
77
|
chalk.white(cfg.user?.name ?? cfg.user?.email) +
|
|
45
|
-
|
|
46
|
-
console.log(' ' + chalk.dim('Connected to: ') + chalk.cyan(cfg.url) + '\n');
|
|
78
|
+
P.dim(' · ' + (cfg.user?.role ?? 'operator')) + '\n\n');
|
|
47
79
|
}
|
|
48
80
|
return cfg;
|
|
49
81
|
}
|
|
50
82
|
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
83
|
+
// ── Slash → question ───────────────────────────────────────────────────────────
|
|
84
|
+
function slashToQuestion(line) {
|
|
85
|
+
const [sc, ...rest] = line.trim().split(/\s+/);
|
|
86
|
+
const arg = rest.join(' ');
|
|
87
|
+
switch (sc) {
|
|
88
|
+
case '/check': return arg ? `run diagnostics on ${arg}` : 'check status of all devices';
|
|
89
|
+
case '/vlan': return arg ? `is vlan ${arg} in use or free?` : 'list all vlans in use';
|
|
90
|
+
case '/onu': return arg ? `show ONU ${arg} status and signal level` : 'list all ONUs';
|
|
91
|
+
case '/customer': return arg ? `look up customer ${arg}` : 'show recent customers';
|
|
92
|
+
case '/ping': return arg ? `ping ${arg} through the network` : 'which devices are reachable?';
|
|
93
|
+
case '/online': return 'list currently online PPPoE sessions';
|
|
94
|
+
default: return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
57
97
|
|
|
58
|
-
|
|
98
|
+
// ── Single-shot ────────────────────────────────────────────────────────────────
|
|
99
|
+
async function singleShot(cfg, question) {
|
|
100
|
+
printUserBox(question);
|
|
59
101
|
try {
|
|
60
|
-
await streamChat(cfg,
|
|
102
|
+
await streamChat(cfg, [{ role: 'user', content: question }]);
|
|
61
103
|
} catch (e) {
|
|
62
|
-
|
|
104
|
+
process.stdout.write('\n ' + P.err('✖ ') + chalk.white(e.message) + '\n\n');
|
|
63
105
|
exit(1);
|
|
64
106
|
}
|
|
65
107
|
}
|
|
66
108
|
|
|
67
|
-
//
|
|
109
|
+
// ── REPL ───────────────────────────────────────────────────────────────────────
|
|
68
110
|
async function interactiveRepl(cfg) {
|
|
69
|
-
const history
|
|
111
|
+
const history = [];
|
|
112
|
+
let showReasoning = true;
|
|
113
|
+
const PROMPT = () => ' ' + P.accentD('❯') + ' ';
|
|
70
114
|
|
|
71
115
|
printBanner(cfg);
|
|
72
116
|
|
|
73
|
-
const rl =
|
|
117
|
+
const rl = createInterface({
|
|
74
118
|
input, output,
|
|
75
|
-
prompt:
|
|
119
|
+
prompt: PROMPT(),
|
|
120
|
+
completer: slashCompleter,
|
|
121
|
+
terminal: true,
|
|
76
122
|
});
|
|
77
123
|
|
|
124
|
+
onResize(() => rl.setPrompt(PROMPT()));
|
|
78
125
|
rl.prompt();
|
|
79
126
|
|
|
80
127
|
rl.on('line', async (line) => {
|
|
81
128
|
const q = line.trim();
|
|
82
129
|
if (!q) { rl.prompt(); return; }
|
|
83
130
|
|
|
84
|
-
if (q === 'exit' || q === 'quit') {
|
|
85
|
-
|
|
86
|
-
rl.close();
|
|
87
|
-
exit(0);
|
|
131
|
+
if (q === '/exit' || q === 'exit' || q === 'quit') {
|
|
132
|
+
process.stdout.write(P.muted('\n Goodbye.\n\n')); rl.close(); exit(0);
|
|
88
133
|
}
|
|
89
|
-
if (q === 'clear'
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
134
|
+
if (q === '/clear' || q === 'clear') {
|
|
135
|
+
process.stdout.write('\x1Bc'); printBanner(cfg); rl.prompt(); return;
|
|
136
|
+
}
|
|
137
|
+
if (q === '/help' || q === 'help') { printHelp(); rl.prompt(); return; }
|
|
138
|
+
if (q === '/history' || q === 'history') {
|
|
139
|
+
const turns = history.filter(m => m.role === 'user');
|
|
140
|
+
if (!turns.length) {
|
|
141
|
+
process.stdout.write(P.muted('\n No history yet.\n\n'));
|
|
142
|
+
} else {
|
|
143
|
+
process.stdout.write('\n' + boxTop('History', P.border) + '\n');
|
|
144
|
+
turns.forEach((m, i) =>
|
|
145
|
+
process.stdout.write(boxRow(' ' + P.dim(String(i+1).padStart(2)+'.') + ' ' +
|
|
146
|
+
chalk.white(m.content.slice(0, inner() - 8)), P.border) + '\n')
|
|
147
|
+
);
|
|
148
|
+
process.stdout.write(boxBot(P.border) + '\n\n');
|
|
149
|
+
}
|
|
150
|
+
rl.prompt(); return;
|
|
151
|
+
}
|
|
152
|
+
if (q === '/') { printSlashMenu(); rl.prompt(); return; }
|
|
153
|
+
|
|
154
|
+
let question = q;
|
|
155
|
+
if (q.startsWith('/')) {
|
|
156
|
+
const resolved = slashToQuestion(q);
|
|
157
|
+
if (resolved) {
|
|
158
|
+
question = resolved;
|
|
159
|
+
process.stdout.write(' ' + P.muted('→ ') + P.dim(question) + '\n');
|
|
160
|
+
}
|
|
98
161
|
}
|
|
99
|
-
if (q === 'help') { printHelp(); rl.prompt(); return; }
|
|
100
162
|
|
|
101
163
|
rl.pause();
|
|
102
|
-
history.push({ role: 'user', content:
|
|
164
|
+
history.push({ role: 'user', content: question });
|
|
103
165
|
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
process.stdout.write(' ' + chalk.bold.cyan('iCopilot') + '\n');
|
|
166
|
+
// User message box
|
|
167
|
+
printUserBox(question);
|
|
107
168
|
|
|
108
169
|
try {
|
|
109
|
-
const answer = await streamChat(cfg, [...history]);
|
|
170
|
+
const answer = await streamChat(cfg, [...history], {}, { thinking: showReasoning });
|
|
110
171
|
if (answer) history.push({ role: 'assistant', content: answer });
|
|
111
172
|
} catch (e) {
|
|
112
|
-
|
|
173
|
+
process.stdout.write('\n ' + P.err('✖ ') + chalk.white(e.message) + '\n\n');
|
|
113
174
|
}
|
|
114
175
|
|
|
115
|
-
// Prompt for next message with label
|
|
116
|
-
process.stdout.write(' ' + chalk.dim('─'.repeat(58)) + '\n');
|
|
117
|
-
rl.setPrompt(chalk.cyan(' You ') + chalk.white(''));
|
|
118
176
|
rl.resume();
|
|
119
177
|
rl.prompt();
|
|
120
178
|
});
|
|
121
179
|
|
|
122
|
-
rl.on('close', () => {
|
|
123
|
-
console.log(chalk.dim('\n Goodbye.\n'));
|
|
124
|
-
exit(0);
|
|
125
|
-
});
|
|
180
|
+
rl.on('close', () => { process.stdout.write(P.muted('\n Goodbye.\n\n')); exit(0); });
|
|
126
181
|
}
|
|
127
182
|
|
|
128
|
-
//
|
|
183
|
+
// ── Main ───────────────────────────────────────────────────────────────────────
|
|
129
184
|
async function main() {
|
|
130
|
-
if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
131
|
-
printHelp(); return;
|
|
132
|
-
}
|
|
185
|
+
if (cmd === '--help' || cmd === '-h' || cmd === 'help') { printHelp(); return; }
|
|
133
186
|
|
|
134
187
|
if (cmd === 'login') {
|
|
135
188
|
const cfg = await loginFlow();
|
|
136
|
-
|
|
189
|
+
process.stdout.write('\n ' + P.ok('✓') + ' Logged in as ' +
|
|
137
190
|
chalk.white(cfg.user?.name ?? cfg.user?.email) +
|
|
138
|
-
|
|
139
|
-
|
|
191
|
+
P.dim(' · ' + (cfg.user?.role ?? 'operator')) + '\n');
|
|
192
|
+
process.stdout.write(' ' + P.dim('Config: ' + configPath()) + '\n\n');
|
|
140
193
|
return;
|
|
141
194
|
}
|
|
142
195
|
|
|
143
196
|
if (cmd === 'whoami') {
|
|
144
197
|
const cfg = loadConfig();
|
|
145
198
|
if (!cfg) {
|
|
146
|
-
|
|
199
|
+
process.stdout.write('\n ' + P.warn('Not logged in.') + ' Run ' + P.accent('icli login') + '\n\n');
|
|
147
200
|
return;
|
|
148
201
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
202
|
+
process.stdout.write('\n' + boxTop('Session', P.border) + '\n');
|
|
203
|
+
[['url', cfg.url], ['name', cfg.user?.name], ['email', cfg.user?.email], ['role', cfg.user?.role]]
|
|
204
|
+
.filter(([, v]) => v)
|
|
205
|
+
.forEach(([k, v]) =>
|
|
206
|
+
process.stdout.write(boxRow(' ' + P.dim(k.padEnd(7)) + chalk.white(v), P.border) + '\n')
|
|
207
|
+
);
|
|
208
|
+
process.stdout.write(boxBot(P.border) + '\n\n');
|
|
156
209
|
return;
|
|
157
210
|
}
|
|
158
211
|
|
|
159
212
|
const cfg = await ensureAuth();
|
|
160
213
|
|
|
161
|
-
// Single-shot: icli "question here"
|
|
162
214
|
if (cmd && !cmd.startsWith('-')) {
|
|
163
215
|
await singleShot(cfg, args.join(' '));
|
|
164
216
|
return;
|
|
165
217
|
}
|
|
166
218
|
|
|
167
|
-
// Interactive REPL
|
|
168
219
|
await interactiveRepl(cfg);
|
|
169
220
|
}
|
|
170
221
|
|
|
171
222
|
main().catch(e => {
|
|
172
|
-
|
|
223
|
+
process.stdout.write('\n ' + P.err('✖ ') + chalk.white(e.message) + '\n\n');
|
|
173
224
|
exit(1);
|
|
174
225
|
});
|
package/package.json
CHANGED
package/src/chat.js
CHANGED
|
@@ -3,119 +3,258 @@ import ora from 'ora';
|
|
|
3
3
|
import { marked } from 'marked';
|
|
4
4
|
import TerminalRenderer from 'marked-terminal';
|
|
5
5
|
import { refreshToken } from './auth.js';
|
|
6
|
+
import { cols, inner, visLen, padRight, stripAnsi,
|
|
7
|
+
boxTop, boxMid, boxBot, boxTopOpen, boxBotOpen,
|
|
8
|
+
boxRow, boxLine } from './layout.js';
|
|
6
9
|
|
|
7
|
-
//
|
|
10
|
+
// ── Copilot CLI palette ────────────────────────────────────────────────────────
|
|
11
|
+
const P = {
|
|
12
|
+
border: chalk.hex('#3D444D'),
|
|
13
|
+
dim: chalk.hex('#9198A1'),
|
|
14
|
+
muted: chalk.hex('#6e7781'),
|
|
15
|
+
accent: chalk.hex('#4493F8'),
|
|
16
|
+
accentB: chalk.hex('#4493F8').bold,
|
|
17
|
+
white: chalk.white,
|
|
18
|
+
bold: chalk.bold.white,
|
|
19
|
+
ok: chalk.green,
|
|
20
|
+
err: chalk.red,
|
|
21
|
+
warn: chalk.yellow,
|
|
22
|
+
cyan: chalk.cyan,
|
|
23
|
+
reasoning: chalk.hex('#9198A1').italic,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// ── Markdown ───────────────────────────────────────────────────────────────────
|
|
8
27
|
marked.setOptions({ renderer: new TerminalRenderer({
|
|
9
|
-
firstHeading: chalk.bold.
|
|
10
|
-
heading: chalk.bold.
|
|
28
|
+
firstHeading: chalk.bold.white,
|
|
29
|
+
heading: chalk.bold.white,
|
|
11
30
|
strong: chalk.bold.white,
|
|
12
|
-
em: chalk.italic.
|
|
13
|
-
codespan: (
|
|
14
|
-
code: (
|
|
15
|
-
blockquote:
|
|
16
|
-
listitem: (
|
|
17
|
-
hr: () =>
|
|
18
|
-
link: (
|
|
31
|
+
em: chalk.italic.hex('#9198A1'),
|
|
32
|
+
codespan: (t) => chalk.bgHex('#161b22').hex('#c9d1d9')(` ${t} `),
|
|
33
|
+
code: (t) => '\n' + t.split('\n').map(l => ' ' + chalk.hex('#a5d6ff')(l)).join('\n') + '\n',
|
|
34
|
+
blockquote: (t) => P.dim(' ▌ ') + P.dim(t.trim()),
|
|
35
|
+
listitem: (t) => ` ${P.accent('·')} ${t.trimEnd()}`,
|
|
36
|
+
hr: () => P.border('─'.repeat(inner())) + '\n',
|
|
37
|
+
link: (_h, _t, t) => chalk.hex('#58a6ff').underline(t || _h),
|
|
19
38
|
}) });
|
|
20
39
|
|
|
21
40
|
function renderMarkdown(text) {
|
|
22
|
-
try {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
41
|
+
try { return marked(text).trimEnd(); } catch { return text; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Status icon ────────────────────────────────────────────────────────────────
|
|
45
|
+
function statusIcon(line) {
|
|
46
|
+
const l = line.toLowerCase();
|
|
47
|
+
if (/unreachable|skip|fail|error/.test(l)) return P.err('✗');
|
|
48
|
+
if (/done|success|found|complet|online/.test(l)) return P.ok('✓');
|
|
49
|
+
if (/batch|sub.?agent|fleet|parallel/.test(l)) return P.accentB('◈');
|
|
50
|
+
if (/\[\d+\/\d+\]/.test(l)) return P.accent('◉');
|
|
51
|
+
if (/probe|reachab|ping/.test(l)) return P.warn('◎');
|
|
52
|
+
if (/connect|ssh|telnet|api/.test(l)) return P.accent('⇢');
|
|
53
|
+
if (/read|fetch|get|query/.test(l)) return P.dim('↓');
|
|
54
|
+
if (/⌁/.test(line)) return P.border('⌁');
|
|
55
|
+
return P.border('›');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const shortModel = (m) =>
|
|
59
|
+
m.replace('openai/', '').replace(/^claude-/, '').replace(/^gpt-/, 'gpt-');
|
|
60
|
+
|
|
61
|
+
// ── Banner ────────────────────────────────────────────────────────────────────
|
|
62
|
+
export function printBanner(cfg, meta = {}) {
|
|
63
|
+
const model = meta.model ? shortModel(meta.model) : (cfg?._model ?? null);
|
|
64
|
+
const w = cols();
|
|
65
|
+
|
|
66
|
+
process.stdout.write('\n');
|
|
67
|
+
|
|
68
|
+
// Top border
|
|
69
|
+
process.stdout.write(boxTop('', P.border) + '\n');
|
|
70
|
+
|
|
71
|
+
// Title row
|
|
72
|
+
const title = P.bold(' iCopilot') + P.dim(' · IspBills AI Network Engineer');
|
|
73
|
+
process.stdout.write(boxRow(title, P.border) + '\n');
|
|
74
|
+
|
|
75
|
+
// Divider
|
|
76
|
+
process.stdout.write(boxMid(P.border) + '\n');
|
|
77
|
+
|
|
78
|
+
// Session info rows
|
|
79
|
+
const row1parts = [];
|
|
80
|
+
if (model) row1parts.push(P.dim('model ') + P.white(model));
|
|
81
|
+
if (cfg?.user?.name) row1parts.push(P.dim('user ') + P.white(cfg.user.name));
|
|
82
|
+
if (cfg?.user?.role) row1parts.push(P.dim('role ') + P.white(cfg.user.role));
|
|
83
|
+
if (row1parts.length) {
|
|
84
|
+
process.stdout.write(boxRow(' ' + row1parts.join(P.dim(' · ')), P.border) + '\n');
|
|
85
|
+
}
|
|
86
|
+
if (cfg?.url) {
|
|
87
|
+
const maxUrl = inner() - 8;
|
|
88
|
+
const url = cfg.url.length > maxUrl ? cfg.url.slice(0, maxUrl - 1) + '…' : cfg.url;
|
|
89
|
+
process.stdout.write(boxRow(' ' + P.dim('url ') + P.accent(url), P.border) + '\n');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Divider
|
|
93
|
+
process.stdout.write(boxMid(P.border) + '\n');
|
|
94
|
+
|
|
95
|
+
// Hints row
|
|
96
|
+
const hints = ' ' + [
|
|
97
|
+
P.accentB('/') + P.muted(' commands'),
|
|
98
|
+
P.accentB('Tab') + P.muted(' autocomplete'),
|
|
99
|
+
P.accentB('↑↓') + P.muted(' history'),
|
|
100
|
+
P.accentB('Ctrl+C') + P.muted(' exit'),
|
|
101
|
+
].join(P.muted(' '));
|
|
102
|
+
process.stdout.write(boxRow(hints, P.border) + '\n');
|
|
103
|
+
|
|
104
|
+
// Bottom border
|
|
105
|
+
process.stdout.write(boxBot(P.border) + '\n\n');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Slash commands ─────────────────────────────────────────────────────────────
|
|
109
|
+
export const SLASH_COMMANDS = [
|
|
110
|
+
{ cmd: '/check', hint: '[device]', desc: 'Run diagnostics on a device' },
|
|
111
|
+
{ cmd: '/vlan', hint: '[id]', desc: 'Check VLAN usage' },
|
|
112
|
+
{ cmd: '/onu', hint: '[id]', desc: 'Show ONU status and signal' },
|
|
113
|
+
{ cmd: '/customer', hint: '[name/IP]', desc: 'Look up a customer' },
|
|
114
|
+
{ cmd: '/ping', hint: '[host]', desc: 'Ping a host' },
|
|
115
|
+
{ cmd: '/online', hint: '', desc: 'List online sessions' },
|
|
116
|
+
{ cmd: '/history', hint: '', desc: 'Show conversation history' },
|
|
117
|
+
{ cmd: '/clear', hint: '', desc: 'Clear the screen' },
|
|
118
|
+
{ cmd: '/help', hint: '', desc: 'Show help' },
|
|
119
|
+
{ cmd: '/exit', hint: '', desc: 'Exit iCli' },
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
export function printSlashMenu() {
|
|
123
|
+
process.stdout.write('\n');
|
|
124
|
+
process.stdout.write(boxTop('Commands', P.border) + '\n');
|
|
125
|
+
for (const { cmd: c, hint, desc } of SLASH_COMMANDS) {
|
|
126
|
+
const key = P.accentB(c) + (hint ? P.dim(' ' + hint) : '');
|
|
127
|
+
const line = ' ' + padRight(key, 22) + ' ' + P.dim(desc);
|
|
128
|
+
process.stdout.write(boxRow(line, P.border) + '\n');
|
|
129
|
+
}
|
|
130
|
+
process.stdout.write(boxBot(P.border) + '\n\n');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function slashCompleter(line) {
|
|
134
|
+
if (line.startsWith('/')) {
|
|
135
|
+
const hits = SLASH_COMMANDS.map(s => s.cmd + ' ').filter(s => s.startsWith(line));
|
|
136
|
+
return [hits.length ? hits : SLASH_COMMANDS.map(s => s.cmd + ' '), line];
|
|
137
|
+
}
|
|
138
|
+
return [[], line];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── User message box ───────────────────────────────────────────────────────────
|
|
142
|
+
export function printUserBox(question) {
|
|
143
|
+
process.stdout.write('\n');
|
|
144
|
+
process.stdout.write(boxTop('You', P.accentB) + '\n');
|
|
145
|
+
// Word-wrap the question to fit inside the box
|
|
146
|
+
const maxW = inner() - 2;
|
|
147
|
+
const words = question.split(' ');
|
|
148
|
+
let line = '';
|
|
149
|
+
for (const word of words) {
|
|
150
|
+
if (line.length + word.length + 1 > maxW && line) {
|
|
151
|
+
process.stdout.write(boxRow(' ' + P.white(line), P.accentB) + '\n');
|
|
152
|
+
line = word;
|
|
153
|
+
} else {
|
|
154
|
+
line = line ? line + ' ' + word : word;
|
|
155
|
+
}
|
|
26
156
|
}
|
|
157
|
+
if (line) process.stdout.write(boxRow(' ' + P.white(line), P.accentB) + '\n');
|
|
158
|
+
process.stdout.write(boxBot(P.accentB) + '\n');
|
|
27
159
|
}
|
|
28
160
|
|
|
29
|
-
|
|
30
|
-
* Stream a single chat turn to stdout.
|
|
31
|
-
*
|
|
32
|
-
* @param {object} cfg - { url, token, refresh_token, user }
|
|
33
|
-
* @param {Array} messages - [{ role, content }]
|
|
34
|
-
* @param {object} [context] - optional device context
|
|
35
|
-
* @param {object} [opts] - { thinking: bool }
|
|
36
|
-
* @returns {string} The final answer text
|
|
37
|
-
*/
|
|
161
|
+
// ── streamChat ────────────────────────────────────────────────────────────────
|
|
38
162
|
export async function streamChat(cfg, messages, context = {}, opts = {}) {
|
|
39
163
|
const showThinking = opts.thinking !== false;
|
|
40
164
|
const body = JSON.stringify({ messages, context });
|
|
41
165
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
'Content-Type': 'application/json',
|
|
46
|
-
'Accept': 'text/event-stream',
|
|
47
|
-
'Authorization': `Bearer ${cfg.token}`,
|
|
48
|
-
},
|
|
49
|
-
body,
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
// Token expired — try refresh once
|
|
53
|
-
if (res.status === 401 && cfg.refresh_token) {
|
|
54
|
-
const updated = await refreshToken(cfg);
|
|
55
|
-
if (!updated) throw new Error('Session expired. Run `icli login` to re-authenticate.');
|
|
56
|
-
Object.assign(cfg, updated);
|
|
57
|
-
res = await fetch(`${cfg.url}/api/v1/ai/chat/stream`, {
|
|
58
|
-
method: 'POST',
|
|
166
|
+
async function doFetch(token) {
|
|
167
|
+
return fetch(`${cfg.url}/api/v1/ai/chat/stream`, {
|
|
168
|
+
method: 'POST',
|
|
59
169
|
headers: {
|
|
60
170
|
'Content-Type': 'application/json',
|
|
61
171
|
'Accept': 'text/event-stream',
|
|
62
|
-
'Authorization': `Bearer ${
|
|
172
|
+
'Authorization': `Bearer ${token}`,
|
|
63
173
|
},
|
|
64
174
|
body,
|
|
65
175
|
});
|
|
66
176
|
}
|
|
67
177
|
|
|
178
|
+
let res = await doFetch(cfg.token);
|
|
179
|
+
if (res.status === 401 && cfg.refresh_token) {
|
|
180
|
+
const updated = await refreshToken(cfg);
|
|
181
|
+
if (!updated) throw new Error('Session expired. Run `icli login`.');
|
|
182
|
+
Object.assign(cfg, updated);
|
|
183
|
+
res = await doFetch(cfg.token);
|
|
184
|
+
}
|
|
68
185
|
if (!res.ok) {
|
|
69
186
|
const txt = await res.text().catch(() => '');
|
|
70
187
|
throw new Error(`AI request failed (HTTP ${res.status}): ${txt.slice(0, 200)}`);
|
|
71
188
|
}
|
|
72
189
|
|
|
73
|
-
// ──
|
|
190
|
+
// ── Streaming state ─────────────────────────────────────────────────────────
|
|
191
|
+
let boxOpen = false; // have we printed ╭─ iCopilot ?
|
|
192
|
+
let reasoningOpen = false;
|
|
193
|
+
let answerBuf = '';
|
|
194
|
+
let rawAnswer = '';
|
|
195
|
+
let finalResult = null;
|
|
196
|
+
|
|
197
|
+
// Open the iCopilot response box
|
|
198
|
+
function ensureBoxOpen() {
|
|
199
|
+
if (boxOpen) return;
|
|
200
|
+
boxOpen = true;
|
|
201
|
+
process.stdout.write('\n' + boxTopOpen('iCopilot', P.border) + '\n');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Ora spinner — sits on a │ prefixed line
|
|
74
205
|
const spinner = ora({
|
|
75
|
-
text:
|
|
76
|
-
color:
|
|
77
|
-
spinner:
|
|
78
|
-
prefixText: ' ',
|
|
206
|
+
text: P.dim('Thinking…'),
|
|
207
|
+
color: 'blue',
|
|
208
|
+
spinner: 'dots',
|
|
209
|
+
prefixText: P.border('│') + ' ',
|
|
79
210
|
});
|
|
80
211
|
|
|
81
|
-
|
|
82
|
-
let answerBuf = '';
|
|
83
|
-
let rawAnswer = '';
|
|
84
|
-
let finalResult = null;
|
|
212
|
+
const stopSpinner = () => { if (spinner.isSpinning) spinner.stop(); };
|
|
85
213
|
|
|
86
|
-
function
|
|
87
|
-
if (
|
|
214
|
+
function openReasoning() {
|
|
215
|
+
if (reasoningOpen) return;
|
|
216
|
+
stopSpinner();
|
|
217
|
+
reasoningOpen = true;
|
|
218
|
+
process.stdout.write(boxLine('', P.border) + '\n');
|
|
219
|
+
process.stdout.write(boxLine(P.dim(' ╌ Reasoning ') + P.border('╌'.repeat(Math.max(0, inner() - 16))), P.border) + '\n');
|
|
220
|
+
process.stdout.write(boxLine(P.border(' │ '), P.border));
|
|
88
221
|
}
|
|
89
222
|
|
|
90
|
-
function
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
223
|
+
function writeReasonDelta(delta) {
|
|
224
|
+
if (!reasoningOpen) return;
|
|
225
|
+
const parts = delta.split('\n');
|
|
226
|
+
for (let i = 0; i < parts.length; i++) {
|
|
227
|
+
if (i > 0) process.stdout.write('\n' + boxLine(P.border(' │ '), P.border));
|
|
228
|
+
if (parts[i]) process.stdout.write(P.reasoning(parts[i]));
|
|
229
|
+
}
|
|
94
230
|
}
|
|
95
231
|
|
|
96
|
-
function
|
|
97
|
-
if (!
|
|
232
|
+
function closeReasoning() {
|
|
233
|
+
if (!reasoningOpen) return;
|
|
234
|
+
process.stdout.write('\n' + boxLine('', P.border) + '\n');
|
|
235
|
+
reasoningOpen = false;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function printStatus(line) {
|
|
239
|
+
ensureBoxOpen();
|
|
240
|
+
if (reasoningOpen) closeReasoning();
|
|
98
241
|
stopSpinner();
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
process.stdout.write(chalk.dim(' └' + '─'.repeat(57)) + '\n');
|
|
106
|
-
reasonBuf = '';
|
|
242
|
+
const icon = statusIcon(line);
|
|
243
|
+
const text = line.trimStart();
|
|
244
|
+
const indent = /^[\s·•⌁]/.test(line) ? ' ' : ' ';
|
|
245
|
+
process.stdout.write(boxLine(indent + icon + ' ' + P.dim(text), P.border) + '\n');
|
|
246
|
+
if (showThinking) spinner.start();
|
|
107
247
|
}
|
|
108
248
|
|
|
109
249
|
function parseStreamText(raw) {
|
|
110
250
|
const m = raw.match(/"text"\s*:\s*"((?:[^"\\]|\\.)*)/);
|
|
111
251
|
if (!m) return raw.trimStart().startsWith('{') ? null : raw;
|
|
112
|
-
return m[1]
|
|
113
|
-
|
|
114
|
-
.replace(/\\r/g, '').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
252
|
+
return m[1].replace(/\\n/g,'\n').replace(/\\t/g,'\t')
|
|
253
|
+
.replace(/\\r/g,'').replace(/\\"/g,'"').replace(/\\\\/g,'\\');
|
|
115
254
|
}
|
|
116
255
|
|
|
117
|
-
// ── SSE reader
|
|
118
|
-
if (showThinking) spinner.start();
|
|
256
|
+
// ── SSE reader ─────────────────────────────────────────────────────────────
|
|
257
|
+
if (showThinking) { ensureBoxOpen(); spinner.start(); }
|
|
119
258
|
|
|
120
259
|
const decoder = new TextDecoder();
|
|
121
260
|
let sseBuffer = '';
|
|
@@ -126,16 +265,19 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
|
|
|
126
265
|
sseBuffer = lines.pop();
|
|
127
266
|
|
|
128
267
|
for (const line of lines) {
|
|
129
|
-
const
|
|
130
|
-
if (!
|
|
131
|
-
let ev;
|
|
132
|
-
try { ev = JSON.parse(trimmed.slice(6)); } catch { continue; }
|
|
268
|
+
const t = line.replace(/\r$/, '');
|
|
269
|
+
if (!t.startsWith('data: ')) continue;
|
|
270
|
+
let ev; try { ev = JSON.parse(t.slice(6)); } catch { continue; }
|
|
133
271
|
|
|
134
272
|
if (ev.status_delta !== undefined) {
|
|
135
273
|
printStatus(ev.status_delta);
|
|
136
274
|
} else if (ev.reason_delta !== undefined) {
|
|
137
|
-
|
|
275
|
+
ensureBoxOpen();
|
|
276
|
+
if (!reasoningOpen) openReasoning();
|
|
277
|
+
writeReasonDelta(ev.reason_delta);
|
|
138
278
|
} else if (ev.delta !== undefined) {
|
|
279
|
+
if (reasoningOpen) closeReasoning();
|
|
280
|
+
stopSpinner();
|
|
139
281
|
rawAnswer += ev.delta;
|
|
140
282
|
const txt = parseStreamText(rawAnswer);
|
|
141
283
|
if (txt !== null) answerBuf = txt;
|
|
@@ -145,73 +287,59 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
|
|
|
145
287
|
}
|
|
146
288
|
}
|
|
147
289
|
|
|
148
|
-
// ── Final render
|
|
290
|
+
// ── Final render ─────────────────────────────────────────────────────────────
|
|
149
291
|
stopSpinner();
|
|
150
|
-
|
|
292
|
+
if (reasoningOpen) closeReasoning();
|
|
293
|
+
ensureBoxOpen();
|
|
151
294
|
|
|
152
295
|
const reply = finalResult?.reply ?? {};
|
|
153
296
|
const replyType = reply.type ?? 'message';
|
|
297
|
+
const meta = finalResult?._meta ?? {};
|
|
154
298
|
|
|
155
|
-
|
|
299
|
+
if (meta.model && cfg) cfg._model = shortModel(meta.model);
|
|
300
|
+
|
|
301
|
+
// Blank separator before content
|
|
302
|
+
process.stdout.write(boxLine('', P.border) + '\n');
|
|
156
303
|
|
|
157
304
|
if (replyType === 'message') {
|
|
158
|
-
const text
|
|
305
|
+
const text = reply.text || answerBuf || '(no response)';
|
|
159
306
|
const rendered = renderMarkdown(text);
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
307
|
+
// Render each markdown line with │ prefix
|
|
308
|
+
for (const l of rendered.split('\n')) {
|
|
309
|
+
process.stdout.write(boxLine(' ' + l, P.border) + '\n');
|
|
310
|
+
}
|
|
163
311
|
|
|
164
312
|
} else if (replyType === 'plan') {
|
|
165
|
-
process.stdout.write(' ' +
|
|
166
|
-
(reply.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
313
|
+
process.stdout.write(boxLine(' ' + P.bold('Plan'), P.border) + '\n');
|
|
314
|
+
if (reply.summary) process.stdout.write(boxLine(' ' + P.dim(reply.summary), P.border) + '\n');
|
|
315
|
+
process.stdout.write(boxLine('', P.border) + '\n');
|
|
316
|
+
(reply.steps || []).forEach((s, i) => {
|
|
317
|
+
const risk = s.risk === 'high' ? P.err(' ⚠ high risk') :
|
|
318
|
+
s.risk === 'medium' ? P.warn(' ⚡') : '';
|
|
319
|
+
process.stdout.write(boxLine(` ${P.dim(String(i+1)+'.')} ${P.warn(s.cmd || '')}${risk}`, P.border) + '\n');
|
|
320
|
+
if (s.purpose) process.stdout.write(boxLine(' ' + P.muted(s.purpose), P.border) + '\n');
|
|
170
321
|
});
|
|
171
|
-
process.stdout.write('\n');
|
|
172
|
-
|
|
322
|
+
process.stdout.write(boxLine('', P.border) + '\n');
|
|
323
|
+
process.stdout.write(boxLine(' ' + P.dim('Type ') + P.white('apply fix') + P.dim(' to confirm.'), P.border) + '\n');
|
|
173
324
|
|
|
174
325
|
} else if (replyType === 'connect') {
|
|
175
326
|
const dev = reply.device ?? {};
|
|
176
327
|
process.stdout.write(
|
|
177
|
-
' ' +
|
|
178
|
-
|
|
179
|
-
chalk.white(` ${dev.name ?? ''} `) +
|
|
180
|
-
chalk.dim(`(${dev.host ?? ''})`) + '\n'
|
|
328
|
+
boxLine(' ' + P.ok('🔌 ') + P.bold(`${dev.kind ?? '?'}#${dev.id ?? '?'}`) +
|
|
329
|
+
' ' + P.white(dev.name ?? '') + ' ' + P.dim(`(${dev.host ?? ''})`), P.border) + '\n'
|
|
181
330
|
);
|
|
182
|
-
if (reply.note) process.stdout.write(
|
|
183
|
-
process.stdout.write('\n');
|
|
184
|
-
return reply.note || '';
|
|
331
|
+
if (reply.note) process.stdout.write(boxLine(' ' + P.dim(reply.note), P.border) + '\n');
|
|
185
332
|
}
|
|
186
333
|
|
|
187
|
-
|
|
188
|
-
|
|
334
|
+
// Blank + footer line inside box
|
|
335
|
+
process.stdout.write(boxLine('', P.border) + '\n');
|
|
336
|
+
const foot = [meta.model ? shortModel(meta.model) : null, meta.user, meta.role].filter(Boolean);
|
|
337
|
+
if (foot.length) {
|
|
338
|
+
process.stdout.write(boxLine(' ' + P.muted(foot.join(' · ')), P.border) + '\n');
|
|
339
|
+
}
|
|
189
340
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const line = '─'.repeat(58);
|
|
193
|
-
process.stdout.write('\n');
|
|
194
|
-
process.stdout.write(chalk.cyan(' ╭' + line + '╮') + '\n');
|
|
195
|
-
process.stdout.write(
|
|
196
|
-
chalk.cyan(' │ ') +
|
|
197
|
-
chalk.bold.white('iCopilot') +
|
|
198
|
-
chalk.dim(' · IspBills AI Network Engineer') +
|
|
199
|
-
' '.repeat(13) +
|
|
200
|
-
chalk.cyan(' │') + '\n'
|
|
201
|
-
);
|
|
202
|
-
process.stdout.write(chalk.cyan(' ╰' + line + '╯') + '\n\n');
|
|
341
|
+
// Close the box
|
|
342
|
+
process.stdout.write(boxBotOpen(P.border) + '\n\n');
|
|
203
343
|
|
|
204
|
-
|
|
205
|
-
process.stdout.write(chalk.dim(' Connected ') + chalk.cyan(cfg.url) + '\n');
|
|
206
|
-
}
|
|
207
|
-
if (cfg?.user?.name || cfg?.user?.email) {
|
|
208
|
-
process.stdout.write(
|
|
209
|
-
chalk.dim(' Logged in ') +
|
|
210
|
-
chalk.white(cfg.user.name || cfg.user.email) +
|
|
211
|
-
chalk.dim(' · ' + (cfg.user.role ?? 'operator')) + '\n'
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
|
-
process.stdout.write('\n');
|
|
215
|
-
process.stdout.write(chalk.dim(' Type your question and press Enter. Type "exit" to quit.\n'));
|
|
216
|
-
process.stdout.write(chalk.dim(' ' + line) + '\n\n');
|
|
344
|
+
return reply.text || answerBuf || '';
|
|
217
345
|
}
|
package/src/layout.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// ── Terminal width ─────────────────────────────────────────────────────────────
|
|
2
|
+
export function termCols() {
|
|
3
|
+
if (process.stdout.isTTY && process.stdout.columns > 0) return process.stdout.columns;
|
|
4
|
+
if (process.stderr.isTTY && process.stderr.columns > 0) return process.stderr.columns;
|
|
5
|
+
const env = parseInt(process.env.COLUMNS || process.env.TERM_WIDTH || '0');
|
|
6
|
+
if (env > 0) return env;
|
|
7
|
+
return 80;
|
|
8
|
+
}
|
|
9
|
+
export const cols = () => Math.max(40, termCols());
|
|
10
|
+
export const inner = () => cols() - 4; // usable width inside │ … │
|
|
11
|
+
export const onResize = (cb) => {
|
|
12
|
+
process.stdout.on('resize', cb);
|
|
13
|
+
process.stderr.on('resize', cb);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// ── ANSI-aware string helpers ─────────────────────────────────────────────────
|
|
17
|
+
const RE_ANSI = /\x1b\[[^m]*m/g;
|
|
18
|
+
export const stripAnsi = (s) => s.replace(RE_ANSI, '');
|
|
19
|
+
export const visLen = (s) => [...stripAnsi(s)].length; // Unicode-safe
|
|
20
|
+
export const padRight = (s, n) => s + ' '.repeat(Math.max(0, n - visLen(s)));
|
|
21
|
+
|
|
22
|
+
// ── Box-drawing helpers (rounded corners) ─────────────────────────────────────
|
|
23
|
+
// draw(color, char, n) — repeat char n times with color
|
|
24
|
+
const rep = (ch, n) => ch.repeat(Math.max(0, n));
|
|
25
|
+
|
|
26
|
+
/** Full top border: ╭─ [label] ──────────────╮ */
|
|
27
|
+
export function boxTop(label, borderFn) {
|
|
28
|
+
const w = cols();
|
|
29
|
+
const lbl = label ? ` ${label} ` : '';
|
|
30
|
+
const dash = rep('─', Math.max(0, w - 2 - lbl.length));
|
|
31
|
+
return borderFn(`╭${lbl.length ? '─' + lbl : ''}${dash}╮`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Divider inside box: ├────────────────────────┤ */
|
|
35
|
+
export function boxMid(borderFn) {
|
|
36
|
+
return borderFn('├' + rep('─', cols() - 2) + '┤');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Full bottom border: ╰──────────────────────────╯ */
|
|
40
|
+
export function boxBot(borderFn) {
|
|
41
|
+
return borderFn('╰' + rep('─', cols() - 2) + '╯');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Open top border (no right cap — for streaming sections):
|
|
45
|
+
* ╭─ [label] ────────── */
|
|
46
|
+
export function boxTopOpen(label, borderFn) {
|
|
47
|
+
const w = cols();
|
|
48
|
+
const lbl = label ? ` ${label} ` : '';
|
|
49
|
+
const dash = rep('─', Math.max(0, w - 1 - lbl.length));
|
|
50
|
+
return borderFn(`╭─${lbl}${dash}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Open bottom border: ╰───────────────────────── */
|
|
54
|
+
export function boxBotOpen(borderFn) {
|
|
55
|
+
return borderFn('╰' + rep('─', cols() - 1));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One content row with left border and padded right border:
|
|
59
|
+
* │ [content] │ */
|
|
60
|
+
export function boxRow(content, borderFn) {
|
|
61
|
+
const w = cols();
|
|
62
|
+
const pad = Math.max(0, w - 4 - visLen(content));
|
|
63
|
+
return borderFn('│') + ' ' + content + ' '.repeat(pad) + ' ' + borderFn('│');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Left-border only line (for streaming): │ [content] */
|
|
67
|
+
export function boxLine(content, borderFn) {
|
|
68
|
+
return borderFn('│') + ' ' + content;
|
|
69
|
+
}
|