ispbills-icli 3.0.0 → 4.0.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/bin/icli.js +71 -88
- package/package.json +1 -1
- package/src/chat.js +154 -102
- package/src/layout.js +75 -0
package/bin/icli.js
CHANGED
|
@@ -4,16 +4,23 @@ 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 { cols, inner, onResize, boxTop, boxBot, boxMid, boxRow, boxTopOpen, boxBotOpen } from '../src/layout.js';
|
|
7
8
|
import {
|
|
8
|
-
streamChat, printBanner, printSlashMenu,
|
|
9
|
-
SLASH_COMMANDS, slashCompleter,
|
|
9
|
+
streamChat, printBanner, printSlashMenu, printUserBox,
|
|
10
|
+
SLASH_COMMANDS, slashCompleter,
|
|
10
11
|
} from '../src/chat.js';
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
+
};
|
|
17
24
|
|
|
18
25
|
const args = argv.slice(2);
|
|
19
26
|
const cmd = args[0] ?? '';
|
|
@@ -21,51 +28,54 @@ const cmd = args[0] ?? '';
|
|
|
21
28
|
// ── Help ───────────────────────────────────────────────────────────────────────
|
|
22
29
|
function printHelp() {
|
|
23
30
|
process.stdout.write('\n');
|
|
24
|
-
process.stdout.write(' '
|
|
25
|
-
process.stdout.write(rule() + '\n\n');
|
|
31
|
+
process.stdout.write(boxTop('iCli · Help', P.border) + '\n');
|
|
26
32
|
|
|
27
|
-
process.stdout.write(
|
|
28
|
-
|
|
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
|
+
[
|
|
29
37
|
['icli', 'Interactive REPL with slash commands'],
|
|
30
38
|
['icli "question"', 'Single-shot query then exit'],
|
|
31
39
|
['icli login', 'Authenticate to your IspBills instance'],
|
|
32
40
|
['icli whoami', 'Show current session info'],
|
|
33
41
|
['icli --help', 'Show this help'],
|
|
34
|
-
]
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
42
|
+
].forEach(([c, d]) =>
|
|
43
|
+
process.stdout.write(boxRow(P.accent(c.padEnd(20)) + P.dim(d), P.border) + '\n')
|
|
44
|
+
);
|
|
38
45
|
|
|
39
|
-
process.stdout.write(
|
|
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');
|
|
40
49
|
for (const { cmd: c, hint, desc } of SLASH_COMMANDS) {
|
|
41
|
-
const key = (c + (hint ? ' '
|
|
42
|
-
process.stdout.write(
|
|
50
|
+
const key = (c + (hint ? ' '+hint : '')).padEnd(22);
|
|
51
|
+
process.stdout.write(boxRow(P.accent(key) + P.dim(desc), P.border) + '\n');
|
|
43
52
|
}
|
|
44
53
|
|
|
45
|
-
process.stdout.write(
|
|
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');
|
|
46
57
|
[
|
|
47
|
-
['Tab',
|
|
48
|
-
['↑ / ↓',
|
|
49
|
-
['Ctrl+T',
|
|
50
|
-
['Ctrl+L',
|
|
51
|
-
['Ctrl+C',
|
|
52
|
-
].forEach(([k, d]) =>
|
|
53
|
-
process.stdout.write(
|
|
54
|
-
|
|
55
|
-
process.stdout.write('\n');
|
|
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');
|
|
56
68
|
}
|
|
57
69
|
|
|
58
70
|
// ── Auth ───────────────────────────────────────────────────────────────────────
|
|
59
71
|
async function ensureAuth() {
|
|
60
72
|
let cfg = loadConfig();
|
|
61
73
|
if (!cfg) {
|
|
62
|
-
process.stdout.write(
|
|
74
|
+
process.stdout.write(P.warn('\n No saved credentials. Setting up iCli.\n\n'));
|
|
63
75
|
cfg = await loginFlow();
|
|
64
|
-
process.stdout.write(
|
|
65
|
-
'\n ' + chalk.green('✓') + ' Logged in as ' +
|
|
76
|
+
process.stdout.write('\n ' + P.ok('✓') + ' Logged in as ' +
|
|
66
77
|
chalk.white(cfg.user?.name ?? cfg.user?.email) +
|
|
67
|
-
dim(' · ' + (cfg.user?.role ?? 'operator')) + '\n\n'
|
|
68
|
-
);
|
|
78
|
+
P.dim(' · ' + (cfg.user?.role ?? 'operator')) + '\n\n');
|
|
69
79
|
}
|
|
70
80
|
return cfg;
|
|
71
81
|
}
|
|
@@ -87,16 +97,11 @@ function slashToQuestion(line) {
|
|
|
87
97
|
|
|
88
98
|
// ── Single-shot ────────────────────────────────────────────────────────────────
|
|
89
99
|
async function singleShot(cfg, question) {
|
|
90
|
-
|
|
91
|
-
process.stdout.write('\n ' + chalk.hex('#4493F8').bold('You') + '\n');
|
|
92
|
-
process.stdout.write(' ' + chalk.white(question) + '\n\n');
|
|
93
|
-
process.stdout.write(rule() + '\n\n');
|
|
94
|
-
process.stdout.write(' ' + chalk.bold.white('iCopilot') + '\n');
|
|
95
|
-
|
|
100
|
+
printUserBox(question);
|
|
96
101
|
try {
|
|
97
102
|
await streamChat(cfg, [{ role: 'user', content: question }]);
|
|
98
103
|
} catch (e) {
|
|
99
|
-
process.stdout.write('\n ' +
|
|
104
|
+
process.stdout.write('\n ' + P.err('✖ ') + chalk.white(e.message) + '\n\n');
|
|
100
105
|
exit(1);
|
|
101
106
|
}
|
|
102
107
|
}
|
|
@@ -105,30 +110,26 @@ async function singleShot(cfg, question) {
|
|
|
105
110
|
async function interactiveRepl(cfg) {
|
|
106
111
|
const history = [];
|
|
107
112
|
let showReasoning = true;
|
|
113
|
+
const PROMPT = () => ' ' + P.accentD('❯') + ' ';
|
|
108
114
|
|
|
109
115
|
printBanner(cfg);
|
|
110
116
|
|
|
111
|
-
// Copilot CLI uses `> ` style prompt
|
|
112
|
-
const PROMPT = ' ' + chalk.hex('#4493F8').bold('>') + ' ';
|
|
113
|
-
|
|
114
117
|
const rl = createInterface({
|
|
115
118
|
input, output,
|
|
116
|
-
prompt: PROMPT,
|
|
119
|
+
prompt: PROMPT(),
|
|
117
120
|
completer: slashCompleter,
|
|
118
121
|
terminal: true,
|
|
119
122
|
});
|
|
120
123
|
|
|
121
|
-
|
|
122
|
-
|
|
124
|
+
onResize(() => rl.setPrompt(PROMPT()));
|
|
123
125
|
rl.prompt();
|
|
124
126
|
|
|
125
127
|
rl.on('line', async (line) => {
|
|
126
128
|
const q = line.trim();
|
|
127
129
|
if (!q) { rl.prompt(); return; }
|
|
128
130
|
|
|
129
|
-
// ── Built-in commands ──────────────────────────────────────────────────
|
|
130
131
|
if (q === '/exit' || q === 'exit' || q === 'quit') {
|
|
131
|
-
process.stdout.write(
|
|
132
|
+
process.stdout.write(P.muted('\n Goodbye.\n\n')); rl.close(); exit(0);
|
|
132
133
|
}
|
|
133
134
|
if (q === '/clear' || q === 'clear') {
|
|
134
135
|
process.stdout.write('\x1Bc'); printBanner(cfg); rl.prompt(); return;
|
|
@@ -137,59 +138,46 @@ async function interactiveRepl(cfg) {
|
|
|
137
138
|
if (q === '/history' || q === 'history') {
|
|
138
139
|
const turns = history.filter(m => m.role === 'user');
|
|
139
140
|
if (!turns.length) {
|
|
140
|
-
process.stdout.write(
|
|
141
|
+
process.stdout.write(P.muted('\n No history yet.\n\n'));
|
|
141
142
|
} else {
|
|
142
|
-
process.stdout.write('\n');
|
|
143
|
+
process.stdout.write('\n' + boxTop('History', P.border) + '\n');
|
|
143
144
|
turns.forEach((m, i) =>
|
|
144
|
-
process.stdout.write(
|
|
145
|
+
process.stdout.write(boxRow(P.dim(String(i+1).padStart(2)+'.') + ' ' +
|
|
146
|
+
chalk.white(m.content.slice(0, inner() - 5)), P.border) + '\n')
|
|
145
147
|
);
|
|
146
|
-
process.stdout.write('\n');
|
|
148
|
+
process.stdout.write(boxBot(P.border) + '\n\n');
|
|
147
149
|
}
|
|
148
150
|
rl.prompt(); return;
|
|
149
151
|
}
|
|
150
152
|
if (q === '/') { printSlashMenu(); rl.prompt(); return; }
|
|
151
153
|
|
|
152
|
-
// ── Resolve slash command ──────────────────────────────────────────────
|
|
153
154
|
let question = q;
|
|
154
155
|
if (q.startsWith('/')) {
|
|
155
156
|
const resolved = slashToQuestion(q);
|
|
156
157
|
if (resolved) {
|
|
157
158
|
question = resolved;
|
|
158
|
-
process.stdout.write(' ' +
|
|
159
|
+
process.stdout.write(' ' + P.muted('→ ') + P.dim(question) + '\n');
|
|
159
160
|
}
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
rl.pause();
|
|
163
164
|
history.push({ role: 'user', content: question });
|
|
164
165
|
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
process.stdout.write(' ' + chalk.bold.white('iCopilot') + '\n');
|
|
166
|
+
// User message box
|
|
167
|
+
printUserBox(question);
|
|
168
168
|
|
|
169
169
|
try {
|
|
170
170
|
const answer = await streamChat(cfg, [...history], {}, { thinking: showReasoning });
|
|
171
171
|
if (answer) history.push({ role: 'assistant', content: answer });
|
|
172
172
|
} catch (e) {
|
|
173
|
-
process.stdout.write('\n ' +
|
|
173
|
+
process.stdout.write('\n ' + P.err('✖ ') + chalk.white(e.message) + '\n\n');
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
process.stdout.write(rule() + '\n\n');
|
|
177
176
|
rl.resume();
|
|
178
177
|
rl.prompt();
|
|
179
178
|
});
|
|
180
179
|
|
|
181
|
-
|
|
182
|
-
if (input.setRawMode) {
|
|
183
|
-
process.stdin.on('keypress', (str, key) => {
|
|
184
|
-
if (key?.ctrl && key?.name === 't') {
|
|
185
|
-
showReasoning = !showReasoning;
|
|
186
|
-
process.stdout.write(tertiary(`\n Reasoning ${showReasoning ? 'on' : 'off'}\n\n`));
|
|
187
|
-
rl.prompt();
|
|
188
|
-
}
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
rl.on('close', () => { process.stdout.write(tertiary('\n Goodbye.\n\n')); exit(0); });
|
|
180
|
+
rl.on('close', () => { process.stdout.write(P.muted('\n Goodbye.\n\n')); exit(0); });
|
|
193
181
|
}
|
|
194
182
|
|
|
195
183
|
// ── Main ───────────────────────────────────────────────────────────────────────
|
|
@@ -198,31 +186,26 @@ async function main() {
|
|
|
198
186
|
|
|
199
187
|
if (cmd === 'login') {
|
|
200
188
|
const cfg = await loginFlow();
|
|
201
|
-
process.stdout.write(
|
|
202
|
-
'\n ' + chalk.green('✓') + ' Logged in as ' +
|
|
189
|
+
process.stdout.write('\n ' + P.ok('✓') + ' Logged in as ' +
|
|
203
190
|
chalk.white(cfg.user?.name ?? cfg.user?.email) +
|
|
204
|
-
dim(' · ' + (cfg.user?.role ?? 'operator')) + '\n'
|
|
205
|
-
);
|
|
206
|
-
process.stdout.write(' ' + dim('Config: ' + configPath()) + '\n\n');
|
|
191
|
+
P.dim(' · ' + (cfg.user?.role ?? 'operator')) + '\n');
|
|
192
|
+
process.stdout.write(' ' + P.dim('Config: ' + configPath()) + '\n\n');
|
|
207
193
|
return;
|
|
208
194
|
}
|
|
209
195
|
|
|
210
196
|
if (cmd === 'whoami') {
|
|
211
197
|
const cfg = loadConfig();
|
|
212
198
|
if (!cfg) {
|
|
213
|
-
process.stdout.write('\n ' +
|
|
199
|
+
process.stdout.write('\n ' + P.warn('Not logged in.') + ' Run ' + P.accent('icli login') + '\n\n');
|
|
214
200
|
return;
|
|
215
201
|
}
|
|
216
|
-
process.stdout.write('\n' +
|
|
217
|
-
[
|
|
218
|
-
[
|
|
219
|
-
[
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
if (v) process.stdout.write(' ' + dim(k.padEnd(7)) + chalk.white(v) + '\n');
|
|
224
|
-
});
|
|
225
|
-
process.stdout.write(rule() + '\n\n');
|
|
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');
|
|
226
209
|
return;
|
|
227
210
|
}
|
|
228
211
|
|
|
@@ -237,6 +220,6 @@ async function main() {
|
|
|
237
220
|
}
|
|
238
221
|
|
|
239
222
|
main().catch(e => {
|
|
240
|
-
process.stdout.write('\n ' +
|
|
223
|
+
process.stdout.write('\n ' + P.err('✖ ') + chalk.white(e.message) + '\n\n');
|
|
241
224
|
exit(1);
|
|
242
225
|
});
|
package/package.json
CHANGED
package/src/chat.js
CHANGED
|
@@ -3,38 +3,37 @@ 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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
6
|
+
import { cols, inner, visLen, padRight, stripAnsi,
|
|
7
|
+
boxTop, boxMid, boxBot, boxTopOpen, boxBotOpen,
|
|
8
|
+
boxRow, boxLine } from './layout.js';
|
|
9
|
+
|
|
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,
|
|
21
24
|
};
|
|
22
25
|
|
|
23
|
-
// ──
|
|
24
|
-
export const cols = () => Math.min((process.stdout.columns || 80), 88);
|
|
25
|
-
const rule = (ch = '─') => C.border(' ' + ch.repeat(Math.max(0, cols() - 4)));
|
|
26
|
-
|
|
27
|
-
// ── Markdown (matched to Copilot CLI syntax colours) ──────────────────────────
|
|
26
|
+
// ── Markdown ───────────────────────────────────────────────────────────────────
|
|
28
27
|
marked.setOptions({ renderer: new TerminalRenderer({
|
|
29
28
|
firstHeading: chalk.bold.white,
|
|
30
29
|
heading: chalk.bold.white,
|
|
31
30
|
strong: chalk.bold.white,
|
|
32
31
|
em: chalk.italic.hex('#9198A1'),
|
|
33
32
|
codespan: (t) => chalk.bgHex('#161b22').hex('#c9d1d9')(` ${t} `),
|
|
34
|
-
code: (t) => '\n' + t.split('\n').map(l => ' ' + chalk.hex('#
|
|
35
|
-
blockquote: (t) =>
|
|
36
|
-
listitem: (t) => ` ${
|
|
37
|
-
hr: () =>
|
|
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',
|
|
38
37
|
link: (_h, _t, t) => chalk.hex('#58a6ff').underline(t || _h),
|
|
39
38
|
}) });
|
|
40
39
|
|
|
@@ -42,50 +41,68 @@ function renderMarkdown(text) {
|
|
|
42
41
|
try { return marked(text).trimEnd(); } catch { return text; }
|
|
43
42
|
}
|
|
44
43
|
|
|
45
|
-
// ── Status icon
|
|
44
|
+
// ── Status icon ────────────────────────────────────────────────────────────────
|
|
46
45
|
function statusIcon(line) {
|
|
47
46
|
const l = line.toLowerCase();
|
|
48
|
-
if (/unreachable|skip|fail|error/.test(l)) return
|
|
49
|
-
if (/done|success|found|complet|online/.test(l)) return
|
|
50
|
-
if (/batch|sub.?agent|fleet|parallel/.test(l)) return
|
|
51
|
-
if (/\[\d+\/\d+\]/.test(l)) return
|
|
52
|
-
if (/probe|reachab|ping/.test(l)) return
|
|
53
|
-
if (/
|
|
54
|
-
if (/
|
|
55
|
-
if (/⌁/.test(line)) return
|
|
56
|
-
return
|
|
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('›');
|
|
57
56
|
}
|
|
58
57
|
|
|
59
|
-
|
|
58
|
+
const shortModel = (m) =>
|
|
59
|
+
m.replace('openai/', '').replace(/^claude-/, '').replace(/^gpt-/, 'gpt-');
|
|
60
|
+
|
|
61
|
+
// ── Banner ────────────────────────────────────────────────────────────────────
|
|
60
62
|
export function printBanner(cfg, meta = {}) {
|
|
61
63
|
const model = meta.model ? shortModel(meta.model) : (cfg?._model ?? null);
|
|
64
|
+
const w = cols();
|
|
65
|
+
|
|
62
66
|
process.stdout.write('\n');
|
|
63
67
|
|
|
64
|
-
//
|
|
65
|
-
process.stdout.write(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (
|
|
77
|
-
|
|
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() - 6;
|
|
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');
|
|
78
90
|
}
|
|
79
91
|
|
|
80
|
-
//
|
|
81
|
-
process.stdout.write(
|
|
82
|
-
C.textTertiary(' / commands Tab autocomplete ↑↓ history Ctrl+C exit') + '\n'
|
|
83
|
-
);
|
|
84
|
-
process.stdout.write(rule() + '\n\n');
|
|
85
|
-
}
|
|
92
|
+
// Divider
|
|
93
|
+
process.stdout.write(boxMid(P.border) + '\n');
|
|
86
94
|
|
|
87
|
-
|
|
88
|
-
|
|
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');
|
|
89
106
|
}
|
|
90
107
|
|
|
91
108
|
// ── Slash commands ─────────────────────────────────────────────────────────────
|
|
@@ -104,12 +121,13 @@ export const SLASH_COMMANDS = [
|
|
|
104
121
|
|
|
105
122
|
export function printSlashMenu() {
|
|
106
123
|
process.stdout.write('\n');
|
|
107
|
-
process.stdout.write(
|
|
124
|
+
process.stdout.write(boxTop('Commands', P.border) + '\n');
|
|
108
125
|
for (const { cmd: c, hint, desc } of SLASH_COMMANDS) {
|
|
109
|
-
const key
|
|
110
|
-
|
|
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');
|
|
111
129
|
}
|
|
112
|
-
process.stdout.write('\n');
|
|
130
|
+
process.stdout.write(boxBot(P.border) + '\n\n');
|
|
113
131
|
}
|
|
114
132
|
|
|
115
133
|
export function slashCompleter(line) {
|
|
@@ -120,6 +138,26 @@ export function slashCompleter(line) {
|
|
|
120
138
|
return [[], line];
|
|
121
139
|
}
|
|
122
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();
|
|
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
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (line) process.stdout.write(boxRow(P.white(line), P.accentB) + '\n');
|
|
158
|
+
process.stdout.write(boxBot(P.accentB) + '\n');
|
|
159
|
+
}
|
|
160
|
+
|
|
123
161
|
// ── streamChat ────────────────────────────────────────────────────────────────
|
|
124
162
|
export async function streamChat(cfg, messages, context = {}, opts = {}) {
|
|
125
163
|
const showThinking = opts.thinking !== false;
|
|
@@ -149,17 +187,26 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
|
|
|
149
187
|
throw new Error(`AI request failed (HTTP ${res.status}): ${txt.slice(0, 200)}`);
|
|
150
188
|
}
|
|
151
189
|
|
|
152
|
-
// ──
|
|
153
|
-
let
|
|
190
|
+
// ── Streaming state ─────────────────────────────────────────────────────────
|
|
191
|
+
let boxOpen = false; // have we printed ╭─ iCopilot ?
|
|
192
|
+
let reasoningOpen = false;
|
|
154
193
|
let answerBuf = '';
|
|
155
194
|
let rawAnswer = '';
|
|
156
195
|
let finalResult = null;
|
|
157
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
|
|
158
205
|
const spinner = ora({
|
|
159
|
-
text:
|
|
206
|
+
text: P.dim('Thinking…'),
|
|
160
207
|
color: 'blue',
|
|
161
208
|
spinner: 'dots',
|
|
162
|
-
prefixText: ' ',
|
|
209
|
+
prefixText: P.border('│') + ' ',
|
|
163
210
|
});
|
|
164
211
|
|
|
165
212
|
const stopSpinner = () => { if (spinner.isSpinning) spinner.stop(); };
|
|
@@ -168,33 +215,34 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
|
|
|
168
215
|
if (reasoningOpen) return;
|
|
169
216
|
stopSpinner();
|
|
170
217
|
reasoningOpen = true;
|
|
171
|
-
|
|
172
|
-
process.stdout.write('
|
|
173
|
-
process.stdout.write(
|
|
218
|
+
process.stdout.write(boxLine('', P.border) + '\n');
|
|
219
|
+
process.stdout.write(boxLine(P.dim('╌ Reasoning ') + P.border('╌'.repeat(Math.max(0, inner() - 14))), P.border) + '\n');
|
|
220
|
+
process.stdout.write(boxLine(P.border('│ '), P.border));
|
|
174
221
|
}
|
|
175
222
|
|
|
176
223
|
function writeReasonDelta(delta) {
|
|
177
224
|
if (!reasoningOpen) return;
|
|
178
225
|
const parts = delta.split('\n');
|
|
179
226
|
for (let i = 0; i < parts.length; i++) {
|
|
180
|
-
if (i > 0) process.stdout.write('\n' +
|
|
181
|
-
if (parts[i]) process.stdout.write(
|
|
227
|
+
if (i > 0) process.stdout.write('\n' + boxLine(P.border('│ '), P.border));
|
|
228
|
+
if (parts[i]) process.stdout.write(P.reasoning(parts[i]));
|
|
182
229
|
}
|
|
183
230
|
}
|
|
184
231
|
|
|
185
232
|
function closeReasoning() {
|
|
186
233
|
if (!reasoningOpen) return;
|
|
187
|
-
process.stdout.write('\n\n');
|
|
234
|
+
process.stdout.write('\n' + boxLine('', P.border) + '\n');
|
|
188
235
|
reasoningOpen = false;
|
|
189
236
|
}
|
|
190
237
|
|
|
191
238
|
function printStatus(line) {
|
|
239
|
+
ensureBoxOpen();
|
|
192
240
|
if (reasoningOpen) closeReasoning();
|
|
193
241
|
stopSpinner();
|
|
194
242
|
const icon = statusIcon(line);
|
|
195
243
|
const text = line.trimStart();
|
|
196
|
-
const indent = /^[\s·•⌁]/.test(line) ? '
|
|
197
|
-
process.stdout.write(indent + icon + ' ' +
|
|
244
|
+
const indent = /^[\s·•⌁]/.test(line) ? ' ' : '';
|
|
245
|
+
process.stdout.write(boxLine(indent + icon + ' ' + P.dim(text), P.border) + '\n');
|
|
198
246
|
if (showThinking) spinner.start();
|
|
199
247
|
}
|
|
200
248
|
|
|
@@ -206,7 +254,8 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
|
|
|
206
254
|
}
|
|
207
255
|
|
|
208
256
|
// ── SSE reader ─────────────────────────────────────────────────────────────
|
|
209
|
-
if (showThinking) spinner.start();
|
|
257
|
+
if (showThinking) { ensureBoxOpen(); spinner.start(); }
|
|
258
|
+
|
|
210
259
|
const decoder = new TextDecoder();
|
|
211
260
|
let sseBuffer = '';
|
|
212
261
|
|
|
@@ -223,7 +272,8 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
|
|
|
223
272
|
if (ev.status_delta !== undefined) {
|
|
224
273
|
printStatus(ev.status_delta);
|
|
225
274
|
} else if (ev.reason_delta !== undefined) {
|
|
226
|
-
|
|
275
|
+
ensureBoxOpen();
|
|
276
|
+
if (!reasoningOpen) openReasoning();
|
|
227
277
|
writeReasonDelta(ev.reason_delta);
|
|
228
278
|
} else if (ev.delta !== undefined) {
|
|
229
279
|
if (reasoningOpen) closeReasoning();
|
|
@@ -237,56 +287,58 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
|
|
|
237
287
|
}
|
|
238
288
|
}
|
|
239
289
|
|
|
240
|
-
// ── Final render
|
|
290
|
+
// ── Final render ─────────────────────────────────────────────────────────────
|
|
241
291
|
stopSpinner();
|
|
242
292
|
if (reasoningOpen) closeReasoning();
|
|
293
|
+
ensureBoxOpen();
|
|
243
294
|
|
|
244
295
|
const reply = finalResult?.reply ?? {};
|
|
245
296
|
const replyType = reply.type ?? 'message';
|
|
246
297
|
const meta = finalResult?._meta ?? {};
|
|
247
298
|
|
|
248
|
-
// Save model to cfg for banner re-use
|
|
249
299
|
if (meta.model && cfg) cfg._model = shortModel(meta.model);
|
|
250
300
|
|
|
251
|
-
//
|
|
252
|
-
process.stdout.write('\n');
|
|
301
|
+
// Blank separator before content
|
|
302
|
+
process.stdout.write(boxLine('', P.border) + '\n');
|
|
253
303
|
|
|
254
304
|
if (replyType === 'message') {
|
|
255
|
-
const text
|
|
256
|
-
|
|
305
|
+
const text = reply.text || answerBuf || '(no response)';
|
|
306
|
+
const rendered = renderMarkdown(text);
|
|
307
|
+
for (const l of rendered.split('\n')) {
|
|
308
|
+
process.stdout.write(boxLine(l, P.border) + '\n');
|
|
309
|
+
}
|
|
257
310
|
|
|
258
311
|
} else if (replyType === 'plan') {
|
|
259
|
-
process.stdout.write(
|
|
260
|
-
if (reply.summary) process.stdout.write(
|
|
261
|
-
process.stdout.write('\n');
|
|
312
|
+
process.stdout.write(boxLine(P.bold('Plan'), P.border) + '\n');
|
|
313
|
+
if (reply.summary) process.stdout.write(boxLine(P.dim(reply.summary), P.border) + '\n');
|
|
314
|
+
process.stdout.write(boxLine('', P.border) + '\n');
|
|
262
315
|
(reply.steps || []).forEach((s, i) => {
|
|
263
|
-
const risk = s.risk === 'high' ?
|
|
264
|
-
s.risk === 'medium' ?
|
|
265
|
-
process.stdout.write(
|
|
266
|
-
if (s.purpose) process.stdout.write(
|
|
316
|
+
const risk = s.risk === 'high' ? P.err(' ⚠ high risk') :
|
|
317
|
+
s.risk === 'medium' ? P.warn(' ⚡') : '';
|
|
318
|
+
process.stdout.write(boxLine(`${P.dim(String(i+1)+'.')} ${P.warn(s.cmd || '')}${risk}`, P.border) + '\n');
|
|
319
|
+
if (s.purpose) process.stdout.write(boxLine(' ' + P.muted(s.purpose), P.border) + '\n');
|
|
267
320
|
});
|
|
268
|
-
process.stdout.write(
|
|
321
|
+
process.stdout.write(boxLine('', P.border) + '\n');
|
|
322
|
+
process.stdout.write(boxLine(P.dim('Type ') + P.white('apply fix') + P.dim(' to confirm.'), P.border) + '\n');
|
|
269
323
|
|
|
270
324
|
} else if (replyType === 'connect') {
|
|
271
325
|
const dev = reply.device ?? {};
|
|
272
326
|
process.stdout.write(
|
|
273
|
-
'
|
|
274
|
-
|
|
275
|
-
chalk.white(dev.name ?? '') + ' ' +
|
|
276
|
-
C.textSecondary(`(${dev.host ?? ''})`) + '\n'
|
|
327
|
+
boxLine(P.ok('🔌 ') + P.bold(`${dev.kind ?? '?'}#${dev.id ?? '?'}`) +
|
|
328
|
+
' ' + P.white(dev.name ?? '') + ' ' + P.dim(`(${dev.host ?? ''})`), P.border) + '\n'
|
|
277
329
|
);
|
|
278
|
-
if (reply.note) process.stdout.write(
|
|
330
|
+
if (reply.note) process.stdout.write(boxLine(P.dim(reply.note), P.border) + '\n');
|
|
279
331
|
}
|
|
280
332
|
|
|
281
|
-
//
|
|
282
|
-
process.stdout.write('\n');
|
|
283
|
-
const
|
|
284
|
-
if (
|
|
285
|
-
|
|
286
|
-
if (meta.role) footParts.push(meta.role);
|
|
287
|
-
if (footParts.length) {
|
|
288
|
-
process.stdout.write(C.textTertiary(' ' + footParts.join(' · ')) + '\n');
|
|
333
|
+
// Blank + footer line inside box
|
|
334
|
+
process.stdout.write(boxLine('', P.border) + '\n');
|
|
335
|
+
const foot = [meta.model ? shortModel(meta.model) : null, meta.user, meta.role].filter(Boolean);
|
|
336
|
+
if (foot.length) {
|
|
337
|
+
process.stdout.write(boxLine(P.muted(foot.join(' · ')), P.border) + '\n');
|
|
289
338
|
}
|
|
290
339
|
|
|
340
|
+
// Close the box
|
|
341
|
+
process.stdout.write(boxBotOpen(P.border) + '\n\n');
|
|
342
|
+
|
|
291
343
|
return reply.text || answerBuf || '';
|
|
292
344
|
}
|
package/src/layout.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
* ╭ (1) + ─ (1) + lbl (lbl.len) + dash + ╮ (1) = w
|
|
28
|
+
* → dash = w - 3 - lbl.length */
|
|
29
|
+
export function boxTop(label, borderFn) {
|
|
30
|
+
const w = cols();
|
|
31
|
+
const lbl = label ? ` ${label} ` : '';
|
|
32
|
+
if (lbl.length) {
|
|
33
|
+
const dash = rep('─', Math.max(0, w - 3 - lbl.length));
|
|
34
|
+
return borderFn(`╭─${lbl}${dash}╮`);
|
|
35
|
+
}
|
|
36
|
+
return borderFn('╭' + rep('─', w - 2) + '╮');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Divider inside box: ├────────────────────────┤ */
|
|
40
|
+
export function boxMid(borderFn) {
|
|
41
|
+
return borderFn('├' + rep('─', cols() - 2) + '┤');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Full bottom border: ╰──────────────────────────╯ */
|
|
45
|
+
export function boxBot(borderFn) {
|
|
46
|
+
return borderFn('╰' + rep('─', cols() - 2) + '╯');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Open top border (no right cap — for streaming sections):
|
|
50
|
+
* ╭─ [label] ────────────────────────────── */
|
|
51
|
+
export function boxTopOpen(label, borderFn) {
|
|
52
|
+
const w = cols();
|
|
53
|
+
const lbl = label ? ` ${label} ` : '';
|
|
54
|
+
const dash = rep('─', Math.max(0, w - 2 - lbl.length));
|
|
55
|
+
return borderFn(`╭─${lbl}${dash}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Open bottom border: ╰───────────────────────── */
|
|
59
|
+
export function boxBotOpen(borderFn) {
|
|
60
|
+
return borderFn('╰' + rep('─', cols() - 1));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One content row with left border and padded right border:
|
|
64
|
+
* │ [content] │
|
|
65
|
+
* 1 + 2 + visLen(content) + pad + 1 = cols() → pad = cols()-4-visLen(content) */
|
|
66
|
+
export function boxRow(content, borderFn) {
|
|
67
|
+
const w = cols();
|
|
68
|
+
const pad = Math.max(1, w - 4 - visLen(content));
|
|
69
|
+
return borderFn('│') + ' ' + content + ' '.repeat(pad) + borderFn('│');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Left-border only line (for streaming): │ [content] */
|
|
73
|
+
export function boxLine(content, borderFn) {
|
|
74
|
+
return borderFn('│') + ' ' + content;
|
|
75
|
+
}
|