ispbills-icli 5.2.0 → 6.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/README.md +50 -11
- package/bin/icli.js +170 -246
- package/package.json +2 -7
- package/src/auth.js +0 -0
- package/src/banner.js +42 -0
- package/src/client.js +100 -0
- package/src/commands.js +67 -0
- package/src/config.js +0 -0
- package/src/loader.js +77 -0
- package/src/renderer.js +162 -0
- package/src/session.js +41 -0
- package/src/ui.js +61 -0
- package/src/chat.js +0 -379
- package/src/layout.js +0 -209
package/src/chat.js
DELETED
|
@@ -1,379 +0,0 @@
|
|
|
1
|
-
import chalk from 'chalk';
|
|
2
|
-
import ora from 'ora';
|
|
3
|
-
import { marked } from 'marked';
|
|
4
|
-
import TerminalRenderer from 'marked-terminal';
|
|
5
|
-
import { refreshToken } from './auth.js';
|
|
6
|
-
import { cols, inner, visLen, padRight, stripAnsi,
|
|
7
|
-
boxTop, boxMid, boxBot, boxTopOpen, boxBotOpen,
|
|
8
|
-
boxRow, boxLine, bottomPalette, topBar } from './layout.js';
|
|
9
|
-
|
|
10
|
-
// ── Gemini-style palette ───────────────────────────────────────────────────────
|
|
11
|
-
const P = {
|
|
12
|
-
border: chalk.hex('#303134'),
|
|
13
|
-
dim: chalk.hex('#9AA0A6'),
|
|
14
|
-
muted: chalk.hex('#80868B'),
|
|
15
|
-
accent: chalk.hex('#8AB4F8'),
|
|
16
|
-
accentB: chalk.hex('#8AB4F8').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
|
-
pill: chalk.bgHex('#1a2b47').hex('#8AB4F8'),
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
// ── Markdown ───────────────────────────────────────────────────────────────────
|
|
28
|
-
const termRenderer = new TerminalRenderer({
|
|
29
|
-
firstHeading: chalk.bold.white,
|
|
30
|
-
heading: chalk.bold.white,
|
|
31
|
-
strong: chalk.bold.white,
|
|
32
|
-
em: chalk.italic.hex('#9198A1'),
|
|
33
|
-
codespan: (t) => chalk.bgHex('#161b22').hex('#c9d1d9')(` ${t} `),
|
|
34
|
-
code: (t) => '\n' + t.split('\n').map(l => ' ' + chalk.hex('#a5d6ff')(l)).join('\n') + '\n',
|
|
35
|
-
blockquote: (t) => P.dim('▌ ') + P.dim(t.trim()) + '\n',
|
|
36
|
-
hr: () => P.border('─'.repeat(inner())) + '\n',
|
|
37
|
-
link: (_h, _t, t) => chalk.hex('#58a6ff').underline(t || _h),
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
const _origListitem = termRenderer.listitem.bind(termRenderer);
|
|
41
|
-
termRenderer.listitem = function(item) {
|
|
42
|
-
const raw = _origListitem(item);
|
|
43
|
-
return raw.replace(/^\s*[*\-]\s/, (m) => m.replace(/[*\-]/, P.accent('·')));
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
marked.setOptions({ renderer: termRenderer });
|
|
47
|
-
|
|
48
|
-
function renderMarkdown(text) {
|
|
49
|
-
try { return marked(text).trimEnd(); } catch { return text; }
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// ── Status icon ────────────────────────────────────────────────────────────────
|
|
53
|
-
function statusIcon(line) {
|
|
54
|
-
const l = line.toLowerCase();
|
|
55
|
-
if (/unreachable|skip|fail|error/.test(l)) return P.err('✗');
|
|
56
|
-
if (/done|success|found|complet|online/.test(l)) return P.ok('✓');
|
|
57
|
-
if (/batch|sub.?agent|fleet|parallel/.test(l)) return P.accentB('◈');
|
|
58
|
-
if (/\[\d+\/\d+\]/.test(l)) return P.accent('◉');
|
|
59
|
-
if (/probe|reachab|ping/.test(l)) return P.warn('◎');
|
|
60
|
-
if (/connect|ssh|telnet|api/.test(l)) return P.accent('⇢');
|
|
61
|
-
if (/read|fetch|get|query/.test(l)) return P.dim('↓');
|
|
62
|
-
if (/⌁/.test(line)) return P.border('⌁');
|
|
63
|
-
return P.border('›');
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const shortModel = (m) =>
|
|
67
|
-
m.replace('openai/', '').replace(/^claude-/, '').replace(/^gpt-/, 'gpt-');
|
|
68
|
-
|
|
69
|
-
// ── Banner ────────────────────────────────────────────────────────────────────
|
|
70
|
-
export function printBanner(cfg, meta = {}, ctx = {}) {
|
|
71
|
-
const model = meta.model ? shortModel(meta.model) : (cfg?._model ?? null);
|
|
72
|
-
|
|
73
|
-
process.stdout.write('\n');
|
|
74
|
-
process.stdout.write(boxTop('iCopilot', P.border) + '\n');
|
|
75
|
-
|
|
76
|
-
// Context bar (device · aiMode · operator) — delegates to topBar() in layout.js
|
|
77
|
-
const { device = '', aiMode = 'MANUAL', operator = '' } = ctx;
|
|
78
|
-
if (device || aiMode !== 'MANUAL' || operator) {
|
|
79
|
-
process.stdout.write(topBar(ctx) + '\n');
|
|
80
|
-
process.stdout.write(boxMid(P.border) + '\n');
|
|
81
|
-
} else {
|
|
82
|
-
process.stdout.write(boxRow(P.dim('Gemini-style terminal · iCopilot AI'), P.border) + '\n');
|
|
83
|
-
process.stdout.write(boxMid(P.border) + '\n');
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const row1parts = [];
|
|
87
|
-
if (model) row1parts.push(P.dim('model ') + P.white(model));
|
|
88
|
-
if (cfg?.user?.name) row1parts.push(P.dim('user ') + P.white(cfg.user.name));
|
|
89
|
-
if (cfg?.user?.role) row1parts.push(P.dim('role ') + P.white(cfg.user.role));
|
|
90
|
-
if (row1parts.length) {
|
|
91
|
-
process.stdout.write(boxRow(row1parts.join(P.dim(' · ')), P.border) + '\n');
|
|
92
|
-
}
|
|
93
|
-
if (cfg?.url) {
|
|
94
|
-
const maxUrl = inner() - 6;
|
|
95
|
-
const url = cfg.url.length > maxUrl ? cfg.url.slice(0, maxUrl - 1) + '…' : cfg.url;
|
|
96
|
-
process.stdout.write(boxRow(P.dim('url ') + P.accent(url), P.border) + '\n');
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
process.stdout.write(boxMid(P.border) + '\n');
|
|
100
|
-
|
|
101
|
-
const hints = [
|
|
102
|
-
P.accentB('/') + P.muted(' commands'),
|
|
103
|
-
P.accentB('Tab') + P.muted(' autocomplete'),
|
|
104
|
-
P.accentB('↑↓') + P.muted(' history'),
|
|
105
|
-
P.accentB('Ctrl+C') + P.muted(' exit'),
|
|
106
|
-
].join(P.muted(' '));
|
|
107
|
-
process.stdout.write(boxRow(hints, P.border) + '\n');
|
|
108
|
-
process.stdout.write(boxBot(P.border) + '\n');
|
|
109
|
-
process.stdout.write(bottomPalette() + '\n\n');
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// ── Slash commands ─────────────────────────────────────────────────────────────
|
|
113
|
-
export const SLASH_COMMANDS = [
|
|
114
|
-
{ cmd: '/check', hint: '[device]', desc: 'Run diagnostics on a device' },
|
|
115
|
-
{ cmd: '/vlan', hint: '[id]', desc: 'Check VLAN usage' },
|
|
116
|
-
{ cmd: '/onu', hint: '[id]', desc: 'Show ONU status and signal' },
|
|
117
|
-
{ cmd: '/customer', hint: '[name/IP]', desc: 'Look up a customer' },
|
|
118
|
-
{ cmd: '/ping', hint: '[host]', desc: 'Ping a host' },
|
|
119
|
-
{ cmd: '/online', hint: '', desc: 'List online sessions' },
|
|
120
|
-
{ cmd: '/history', hint: '', desc: 'Show conversation history' },
|
|
121
|
-
{ cmd: '/clear', hint: '', desc: 'Clear the screen' },
|
|
122
|
-
{ cmd: '/update', hint: '', desc: 'Update iCli to the latest version'},
|
|
123
|
-
{ cmd: '/logout', hint: '', desc: 'Log out and clear credentials'},
|
|
124
|
-
{ cmd: '/help', hint: '', desc: 'Show help' },
|
|
125
|
-
{ cmd: '/exit', hint: '', desc: 'Exit iCli' },
|
|
126
|
-
];
|
|
127
|
-
|
|
128
|
-
export function printSlashMenu() {
|
|
129
|
-
process.stdout.write('\n');
|
|
130
|
-
process.stdout.write(boxTop('Slash commands', P.border) + '\n');
|
|
131
|
-
for (const { cmd: c, hint, desc } of SLASH_COMMANDS) {
|
|
132
|
-
const key = P.accentB(c) + (hint ? P.dim(' ' + hint) : '');
|
|
133
|
-
const line = padRight(key, 22) + ' ' + P.dim(desc);
|
|
134
|
-
process.stdout.write(boxRow(line, P.border) + '\n');
|
|
135
|
-
}
|
|
136
|
-
process.stdout.write(boxBot(P.border) + '\n\n');
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export function slashCompleter(line) {
|
|
140
|
-
if (line.startsWith('/')) {
|
|
141
|
-
const hits = SLASH_COMMANDS.map(s => s.cmd + ' ').filter(s => s.startsWith(line));
|
|
142
|
-
return [hits.length ? hits : SLASH_COMMANDS.map(s => s.cmd + ' '), line];
|
|
143
|
-
}
|
|
144
|
-
return [[], line];
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// ── User message box ───────────────────────────────────────────────────────────
|
|
148
|
-
export function printUserBox(question) {
|
|
149
|
-
process.stdout.write('\n');
|
|
150
|
-
process.stdout.write(boxTop('You', P.border) + '\n');
|
|
151
|
-
const maxW = inner();
|
|
152
|
-
const words = question.split(' ');
|
|
153
|
-
let line = '';
|
|
154
|
-
for (const word of words) {
|
|
155
|
-
if (line.length + word.length + 1 > maxW && line) {
|
|
156
|
-
process.stdout.write(boxRow(P.white(line), P.border) + '\n');
|
|
157
|
-
line = word;
|
|
158
|
-
} else {
|
|
159
|
-
line = line ? line + ' ' + word : word;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
if (line) process.stdout.write(boxRow(P.white(line), P.border) + '\n');
|
|
163
|
-
process.stdout.write(boxBot(P.border) + '\n');
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// ── streamChat ────────────────────────────────────────────────────────────────
|
|
167
|
-
/**
|
|
168
|
-
* Stream a chat request. Reasoning and plan always flow inline — no keys to toggle.
|
|
169
|
-
*
|
|
170
|
-
* @param {object} cfg Loaded config (url, token, …)
|
|
171
|
-
* @param {Array} messages Conversation history
|
|
172
|
-
* @param {object} [context] Extra context passed to server
|
|
173
|
-
*/
|
|
174
|
-
export async function streamChat(cfg, messages, context = {}) {
|
|
175
|
-
const body = JSON.stringify({ messages, context });
|
|
176
|
-
|
|
177
|
-
async function doFetch(token) {
|
|
178
|
-
return fetch(`${cfg.url}/api/v1/ai/chat/stream`, {
|
|
179
|
-
method: 'POST',
|
|
180
|
-
headers: {
|
|
181
|
-
'Content-Type': 'application/json',
|
|
182
|
-
'Accept': 'text/event-stream',
|
|
183
|
-
'Authorization': 'Bearer ' + token,
|
|
184
|
-
},
|
|
185
|
-
body,
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
let res = await doFetch(cfg.token);
|
|
190
|
-
if (res.status === 401 && cfg.refresh_token) {
|
|
191
|
-
const updated = await refreshToken(cfg);
|
|
192
|
-
if (!updated) throw new Error('Session expired. Run `icli login`.');
|
|
193
|
-
Object.assign(cfg, updated);
|
|
194
|
-
res = await doFetch(cfg.token);
|
|
195
|
-
}
|
|
196
|
-
if (!res.ok) {
|
|
197
|
-
const txt = await res.text().catch(() => '');
|
|
198
|
-
throw new Error(`AI request failed (HTTP ${res.status}): ${txt.slice(0, 200)}`);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// ── Streaming state ─────────────────────────────────────────────────────────
|
|
202
|
-
let boxOpen = false;
|
|
203
|
-
let reasoningOpen = false;
|
|
204
|
-
let answerBuf = '';
|
|
205
|
-
let rawAnswer = '';
|
|
206
|
-
let finalResult = null;
|
|
207
|
-
|
|
208
|
-
function ensureBoxOpen() {
|
|
209
|
-
if (boxOpen) return;
|
|
210
|
-
boxOpen = true;
|
|
211
|
-
process.stdout.write('\n' + boxTopOpen('iCopilot', P.border) + '\n');
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// Gemini-style thinking pill spinner
|
|
215
|
-
const spinner = ora({
|
|
216
|
-
text: P.accent('Thinking…'),
|
|
217
|
-
color: 'blue',
|
|
218
|
-
spinner: { interval: 80, frames: ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'] },
|
|
219
|
-
prefixText: P.border('│') + ' ' + P.pill(' ◆ '),
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
const stopSpinner = () => { if (spinner.isSpinning) spinner.stop(); };
|
|
223
|
-
|
|
224
|
-
// ── Reasoning section (always streams inline) ───────────────────────────────
|
|
225
|
-
function openReasoning() {
|
|
226
|
-
if (reasoningOpen) return;
|
|
227
|
-
stopSpinner();
|
|
228
|
-
reasoningOpen = true;
|
|
229
|
-
process.stdout.write(boxLine('', P.border) + '\n');
|
|
230
|
-
process.stdout.write(boxLine(P.accentB('◆ ') + P.dim('Reasoning'), P.border) + '\n');
|
|
231
|
-
process.stdout.write(boxLine(P.border(' │ '), P.border));
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function writeReasonDelta(delta) {
|
|
235
|
-
if (!reasoningOpen) openReasoning();
|
|
236
|
-
const parts = delta.split('\n');
|
|
237
|
-
for (let i = 0; i < parts.length; i++) {
|
|
238
|
-
if (i > 0) process.stdout.write('\n' + boxLine(P.border(' │'), P.border));
|
|
239
|
-
if (parts[i]) process.stdout.write(P.reasoning(parts[i]));
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
function closeReasoning() {
|
|
244
|
-
if (!reasoningOpen) return;
|
|
245
|
-
process.stdout.write('\n' + boxLine('', P.border) + '\n');
|
|
246
|
-
reasoningOpen = false;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// ── Status delta ────────────────────────────────────────────────────────────
|
|
250
|
-
function printStatus(line) {
|
|
251
|
-
ensureBoxOpen();
|
|
252
|
-
if (reasoningOpen) closeReasoning();
|
|
253
|
-
stopSpinner();
|
|
254
|
-
const icon = statusIcon(line);
|
|
255
|
-
const text = line.trimStart();
|
|
256
|
-
const indent = /^[\s·•⌁]/.test(line) ? ' ' : '';
|
|
257
|
-
process.stdout.write(boxLine(indent + icon + ' ' + P.dim(text), P.border) + '\n');
|
|
258
|
-
// Restart spinner after each status line
|
|
259
|
-
spinner.start();
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function parseStreamText(raw) {
|
|
263
|
-
const m = raw.match(/"text"\s*:\s*"((?:[^"\\]|\\.)*)/);
|
|
264
|
-
if (!m) return raw.trimStart().startsWith('{') ? null : raw;
|
|
265
|
-
return m[1].replace(/\\n/g,'\n').replace(/\\t/g,'\t')
|
|
266
|
-
.replace(/\\r/g,'').replace(/\\"/g,'"').replace(/\\\\/g,'\\');
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// ── SSE reader ─────────────────────────────────────────────────────────────
|
|
270
|
-
ensureBoxOpen();
|
|
271
|
-
spinner.start();
|
|
272
|
-
|
|
273
|
-
const decoder = new TextDecoder();
|
|
274
|
-
let sseBuffer = '';
|
|
275
|
-
|
|
276
|
-
for await (const chunk of res.body) {
|
|
277
|
-
sseBuffer += decoder.decode(chunk, { stream: true });
|
|
278
|
-
const lines = sseBuffer.split('\n');
|
|
279
|
-
sseBuffer = lines.pop();
|
|
280
|
-
|
|
281
|
-
for (const line of lines) {
|
|
282
|
-
const t = line.replace(/\r$/, '');
|
|
283
|
-
if (!t.startsWith('data: ')) continue;
|
|
284
|
-
let ev; try { ev = JSON.parse(t.slice(6)); } catch { continue; }
|
|
285
|
-
|
|
286
|
-
if (ev.status_delta !== undefined) {
|
|
287
|
-
printStatus(ev.status_delta);
|
|
288
|
-
} else if (ev.reason_delta !== undefined) {
|
|
289
|
-
ensureBoxOpen();
|
|
290
|
-
writeReasonDelta(ev.reason_delta);
|
|
291
|
-
} else if (ev.delta !== undefined) {
|
|
292
|
-
if (reasoningOpen) closeReasoning();
|
|
293
|
-
stopSpinner();
|
|
294
|
-
rawAnswer += ev.delta;
|
|
295
|
-
const txt = parseStreamText(rawAnswer);
|
|
296
|
-
if (txt !== null) answerBuf = txt;
|
|
297
|
-
} else if (ev.done) {
|
|
298
|
-
finalResult = ev.result;
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// ── Final render ─────────────────────────────────────────────────────────────
|
|
304
|
-
stopSpinner();
|
|
305
|
-
if (reasoningOpen) closeReasoning();
|
|
306
|
-
ensureBoxOpen();
|
|
307
|
-
|
|
308
|
-
const reply = finalResult?.reply ?? {};
|
|
309
|
-
const replyType = reply.type ?? 'message';
|
|
310
|
-
const meta = finalResult?._meta ?? {};
|
|
311
|
-
|
|
312
|
-
if (meta.model && cfg) cfg._model = shortModel(meta.model);
|
|
313
|
-
|
|
314
|
-
process.stdout.write(boxLine('', P.border) + '\n');
|
|
315
|
-
|
|
316
|
-
if (replyType === 'message') {
|
|
317
|
-
const text = reply.text || answerBuf || '(no response)';
|
|
318
|
-
const rendered = renderMarkdown(text);
|
|
319
|
-
for (const l of rendered.split('\n')) {
|
|
320
|
-
process.stdout.write(boxLine(l, P.border) + '\n');
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
} else if (replyType === 'plan') {
|
|
324
|
-
// Plan always rendered inline — no toggle key
|
|
325
|
-
process.stdout.write(boxLine(P.accentB('◆ ') + P.bold('Plan'), P.border) + '\n');
|
|
326
|
-
if (reply.summary) {
|
|
327
|
-
process.stdout.write(boxLine(P.dim(reply.summary), P.border) + '\n');
|
|
328
|
-
}
|
|
329
|
-
process.stdout.write(boxLine('', P.border) + '\n');
|
|
330
|
-
|
|
331
|
-
const steps = reply.steps || [];
|
|
332
|
-
steps.forEach((s, i) => {
|
|
333
|
-
const isLast = i === steps.length - 1;
|
|
334
|
-
const prefix = isLast ? '└─ ' : '├─ ';
|
|
335
|
-
const risk = s.risk === 'high' ? ' ' + P.err('⚠ high risk') :
|
|
336
|
-
s.risk === 'medium' ? ' ' + P.warn('⚡') : '';
|
|
337
|
-
process.stdout.write(
|
|
338
|
-
boxLine(
|
|
339
|
-
P.border(' ' + prefix) + P.dim(String(i + 1) + '.') + ' ' + P.warn(s.cmd || '') + risk,
|
|
340
|
-
P.border,
|
|
341
|
-
) + '\n',
|
|
342
|
-
);
|
|
343
|
-
if (s.purpose) {
|
|
344
|
-
process.stdout.write(boxLine(' ' + P.muted(s.purpose), P.border) + '\n');
|
|
345
|
-
}
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
process.stdout.write(boxLine('', P.border) + '\n');
|
|
349
|
-
process.stdout.write(
|
|
350
|
-
boxLine(P.dim('Type ') + P.white('apply fix') + P.dim(' to confirm.'), P.border) + '\n',
|
|
351
|
-
);
|
|
352
|
-
|
|
353
|
-
} else if (replyType === 'connect') {
|
|
354
|
-
const dev = reply.device ?? {};
|
|
355
|
-
process.stdout.write(
|
|
356
|
-
boxLine(
|
|
357
|
-
P.ok('🔌 ') + P.bold(`${dev.kind ?? '?'}#${dev.id ?? '?'}`) +
|
|
358
|
-
' ' + P.white(dev.name ?? '') + ' ' + P.dim(`(${dev.host ?? ''})`),
|
|
359
|
-
P.border,
|
|
360
|
-
) + '\n',
|
|
361
|
-
);
|
|
362
|
-
if (reply.note) process.stdout.write(boxLine(P.dim(reply.note), P.border) + '\n');
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
// Footer inside box
|
|
366
|
-
process.stdout.write(boxLine('', P.border) + '\n');
|
|
367
|
-
const foot = [meta.model ? shortModel(meta.model) : null, meta.user, meta.role].filter(Boolean);
|
|
368
|
-
if (foot.length) {
|
|
369
|
-
process.stdout.write(boxLine(P.muted(foot.join(' · ')), P.border) + '\n');
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// Close the open box
|
|
373
|
-
process.stdout.write(boxBotOpen(P.border) + '\n');
|
|
374
|
-
|
|
375
|
-
// Bottom palette after every AI reply
|
|
376
|
-
process.stdout.write(bottomPalette() + '\n\n');
|
|
377
|
-
|
|
378
|
-
return reply.text || answerBuf || '';
|
|
379
|
-
}
|
package/src/layout.js
DELETED
|
@@ -1,209 +0,0 @@
|
|
|
1
|
-
import { execSync } from 'node:child_process';
|
|
2
|
-
import chalk from 'chalk';
|
|
3
|
-
|
|
4
|
-
// ── Terminal width ─────────────────────────────────────────────────────────────
|
|
5
|
-
let cachedCols = 0;
|
|
6
|
-
let lastProbeAt = 0;
|
|
7
|
-
let fallbackCols = 0;
|
|
8
|
-
|
|
9
|
-
function toPosInt(v) {
|
|
10
|
-
const n = Number.parseInt(String(v ?? ''), 10);
|
|
11
|
-
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function streamCols(stream) {
|
|
15
|
-
const direct = toPosInt(stream?.columns);
|
|
16
|
-
if (direct) return direct;
|
|
17
|
-
|
|
18
|
-
if (typeof stream?.getWindowSize === 'function') {
|
|
19
|
-
try {
|
|
20
|
-
const win = stream.getWindowSize();
|
|
21
|
-
const fromWin = Array.isArray(win) ? toPosInt(win[0]) : toPosInt(win?.columns);
|
|
22
|
-
if (fromWin) return fromWin;
|
|
23
|
-
} catch {}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
return 0;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function probeFallbackCols() {
|
|
30
|
-
if (fallbackCols > 0) return fallbackCols;
|
|
31
|
-
|
|
32
|
-
const candidates = [];
|
|
33
|
-
|
|
34
|
-
try {
|
|
35
|
-
candidates.push(toPosInt(execSync('tput cols', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()));
|
|
36
|
-
} catch {}
|
|
37
|
-
|
|
38
|
-
try {
|
|
39
|
-
const stty = execSync('stty size 2>/dev/null', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
40
|
-
const parts = stty.split(/\s+/);
|
|
41
|
-
candidates.push(toPosInt(parts[1]));
|
|
42
|
-
} catch {}
|
|
43
|
-
|
|
44
|
-
try {
|
|
45
|
-
const mode = execSync('mode con', { stdio: ['ignore', 'pipe', 'ignore'] }).toString();
|
|
46
|
-
const m = mode.match(/Columns:\s*(\d+)/i);
|
|
47
|
-
candidates.push(toPosInt(m?.[1]));
|
|
48
|
-
} catch {}
|
|
49
|
-
|
|
50
|
-
fallbackCols = Math.max(0, ...candidates.filter(Boolean));
|
|
51
|
-
return fallbackCols;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function termCols() {
|
|
55
|
-
const now = Date.now();
|
|
56
|
-
if (cachedCols > 0 && now - lastProbeAt < 250) return cachedCols;
|
|
57
|
-
|
|
58
|
-
const envCols = toPosInt(process.env.COLUMNS || process.env.TERM_WIDTH);
|
|
59
|
-
if (envCols) {
|
|
60
|
-
cachedCols = envCols;
|
|
61
|
-
lastProbeAt = now;
|
|
62
|
-
return envCols;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const stdoutCols = streamCols(process.stdout);
|
|
66
|
-
if (stdoutCols) {
|
|
67
|
-
cachedCols = stdoutCols;
|
|
68
|
-
lastProbeAt = now;
|
|
69
|
-
return stdoutCols;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const stderrCols = streamCols(process.stderr);
|
|
73
|
-
if (stderrCols) {
|
|
74
|
-
cachedCols = stderrCols;
|
|
75
|
-
lastProbeAt = now;
|
|
76
|
-
return stderrCols;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const probed = probeFallbackCols();
|
|
80
|
-
cachedCols = probed || 120;
|
|
81
|
-
lastProbeAt = now;
|
|
82
|
-
return cachedCols;
|
|
83
|
-
}
|
|
84
|
-
export const cols = () => Math.max(40, termCols());
|
|
85
|
-
export const inner = () => cols() - 4; // usable width inside │ … │
|
|
86
|
-
export const onResize = (cb) => {
|
|
87
|
-
const wrapped = () => {
|
|
88
|
-
cachedCols = 0;
|
|
89
|
-
fallbackCols = 0;
|
|
90
|
-
cb();
|
|
91
|
-
};
|
|
92
|
-
process.stdout.on('resize', wrapped);
|
|
93
|
-
process.stderr.on('resize', wrapped);
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
// ── ANSI-aware string helpers ─────────────────────────────────────────────────
|
|
97
|
-
const RE_ANSI = /\x1b\[[^m]*m/g;
|
|
98
|
-
export const stripAnsi = (s) => s.replace(RE_ANSI, '');
|
|
99
|
-
export const visLen = (s) => [...stripAnsi(s)].length; // Unicode-safe
|
|
100
|
-
export const padRight = (s, n) => s + ' '.repeat(Math.max(0, n - visLen(s)));
|
|
101
|
-
|
|
102
|
-
// ── Box-drawing helpers (rounded corners) ─────────────────────────────────────
|
|
103
|
-
// draw(color, char, n) — repeat char n times with color
|
|
104
|
-
const rep = (ch, n) => ch.repeat(Math.max(0, n));
|
|
105
|
-
|
|
106
|
-
/** Full top border: ╭─ [label] ──────────────╮
|
|
107
|
-
* ╭ (1) + ─ (1) + lbl (lbl.len) + dash + ╮ (1) = w
|
|
108
|
-
* → dash = w - 3 - lbl.length */
|
|
109
|
-
export function boxTop(label, borderFn) {
|
|
110
|
-
const w = cols();
|
|
111
|
-
const lbl = label ? ` ${label} ` : '';
|
|
112
|
-
if (lbl.length) {
|
|
113
|
-
const dash = rep('─', Math.max(0, w - 3 - lbl.length));
|
|
114
|
-
return borderFn(`╭─${lbl}${dash}╮`);
|
|
115
|
-
}
|
|
116
|
-
return borderFn('╭' + rep('─', w - 2) + '╮');
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** Divider inside box: ├────────────────────────┤ */
|
|
120
|
-
export function boxMid(borderFn) {
|
|
121
|
-
return borderFn('├' + rep('─', cols() - 2) + '┤');
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Full bottom border: ╰──────────────────────────╯ */
|
|
125
|
-
export function boxBot(borderFn) {
|
|
126
|
-
return borderFn('╰' + rep('─', cols() - 2) + '╯');
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** Open top border (no right cap — for streaming sections):
|
|
130
|
-
* ╭─ [label] ────────────────────────────── */
|
|
131
|
-
export function boxTopOpen(label, borderFn) {
|
|
132
|
-
const w = cols();
|
|
133
|
-
const lbl = label ? ` ${label} ` : '';
|
|
134
|
-
const dash = rep('─', Math.max(0, w - 2 - lbl.length));
|
|
135
|
-
return borderFn(`╭─${lbl}${dash}`);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** Open bottom border: ╰───────────────────────── */
|
|
139
|
-
export function boxBotOpen(borderFn) {
|
|
140
|
-
return borderFn('╰' + rep('─', cols() - 1));
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** One content row with left border and padded right border:
|
|
144
|
-
* │ [content] │
|
|
145
|
-
* 1 + 2 + visLen(content) + pad + 1 = cols() → pad = cols()-4-visLen(content) */
|
|
146
|
-
export function boxRow(content, borderFn) {
|
|
147
|
-
const w = cols();
|
|
148
|
-
const pad = Math.max(1, w - 4 - visLen(content));
|
|
149
|
-
return borderFn('│') + ' ' + content + ' '.repeat(pad) + borderFn('│');
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Left-border only line (for streaming): │ [content] */
|
|
153
|
-
export function boxLine(content, borderFn) {
|
|
154
|
-
return borderFn('│') + ' ' + content;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// ── Top context bar ───────────────────────────────────────────────────────────
|
|
158
|
-
/**
|
|
159
|
-
* Renders a single full-width bar:
|
|
160
|
-
* │ ● device · host ◆ AUTOPILOT ON operator: admin │
|
|
161
|
-
*
|
|
162
|
-
* @param {{ device?: string, aiMode?: 'AUTOPILOT ON'|'MANUAL', operator?: string }} ctx
|
|
163
|
-
*/
|
|
164
|
-
export function topBar({ device = '', aiMode = 'MANUAL', operator = '' } = {}) {
|
|
165
|
-
const w = cols();
|
|
166
|
-
|
|
167
|
-
const lRaw = device ? `● ${device}` : '';
|
|
168
|
-
const cRaw = aiMode === 'AUTOPILOT ON' ? '◆ AUTOPILOT ON' : '◇ AI MANUAL';
|
|
169
|
-
const rRaw = operator ? `operator: ${operator}` : '';
|
|
170
|
-
|
|
171
|
-
const lC = lRaw ? chalk.hex('#8AB4F8').bold(lRaw) : '';
|
|
172
|
-
const cC = aiMode === 'AUTOPILOT ON'
|
|
173
|
-
? chalk.green.bold(cRaw)
|
|
174
|
-
: chalk.yellow(cRaw);
|
|
175
|
-
const rC = rRaw ? chalk.hex('#9AA0A6')(rRaw) : '';
|
|
176
|
-
|
|
177
|
-
// avail = w - 2 borders - 2 side padding spaces
|
|
178
|
-
const avail = w - 4;
|
|
179
|
-
const lv = [...lRaw].length;
|
|
180
|
-
const cv = [...cRaw].length;
|
|
181
|
-
const rv = [...rRaw].length;
|
|
182
|
-
|
|
183
|
-
const remaining = avail - lv - cv - rv;
|
|
184
|
-
const g1 = remaining > 0 ? Math.floor(remaining / 2) : 0;
|
|
185
|
-
const g2 = remaining > 0 ? remaining - g1 : 0;
|
|
186
|
-
|
|
187
|
-
return chalk.hex('#303134')('│') + ' ' +
|
|
188
|
-
lC + ' '.repeat(g1) + cC + ' '.repeat(g2) + rC + ' ' +
|
|
189
|
-
chalk.hex('#303134')('│');
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// ── Bottom command palette ────────────────────────────────────────────────────
|
|
193
|
-
/**
|
|
194
|
-
* Returns a single-line bottom palette string (no trailing newline).
|
|
195
|
-
* Caller is responsible for writing '\n'.
|
|
196
|
-
*/
|
|
197
|
-
export function bottomPalette() {
|
|
198
|
-
const accent = chalk.hex('#8AB4F8').bold;
|
|
199
|
-
const desc = chalk.hex('#80868B');
|
|
200
|
-
const sep = chalk.hex('#303134')(' · ');
|
|
201
|
-
const items = [
|
|
202
|
-
accent('F1') + desc(' Docs'),
|
|
203
|
-
accent('F2') + desc(' Logs'),
|
|
204
|
-
accent('F3') + desc(' Rollback'),
|
|
205
|
-
accent('F4') + desc(' Bulk Sweep'),
|
|
206
|
-
accent('Ctrl+C') + desc(' Exit'),
|
|
207
|
-
];
|
|
208
|
-
return ' ' + items.join(sep);
|
|
209
|
-
}
|