ispbills-icli 8.4.2 → 8.4.5
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 +1 -1
- package/package.json +1 -1
- package/src/banner.js +3 -1
- package/src/commands.js +1 -0
- package/src/shell.js +18 -0
- package/src/tui/app.js +65 -10
- package/src/tui/components.js +310 -88
- package/src/tui/composer.js +29 -3
- package/src/tui/markdown.js +19 -3
- package/src/tui/run.js +2 -1
- package/src/tui/theme.js +29 -11
package/bin/icli.js
CHANGED
|
@@ -315,7 +315,7 @@ async function main() {
|
|
|
315
315
|
// plain readline REPL when stdout/stdin is not a TTY (pipes, dumb terminals).
|
|
316
316
|
if (output.isTTY && input.isTTY) {
|
|
317
317
|
const { runTui } = await import('../src/tui/run.js');
|
|
318
|
-
const action = await runTui(cfg, { display });
|
|
318
|
+
const action = await runTui(cfg, { display, version: pkgVersion() });
|
|
319
319
|
if (action === 'logout') {
|
|
320
320
|
const cleared = clearConfig();
|
|
321
321
|
console.log(`\n ${cleared ? GREEN + glyphs.check + RESET + ' Logged out — credentials cleared.' : YELLOW + 'Already logged out.' + RESET}`);
|
package/package.json
CHANGED
package/src/banner.js
CHANGED
|
@@ -22,6 +22,7 @@ const LOGO_WIDTH = Math.max(...LOGO.map((l) => l.length));
|
|
|
22
22
|
export function printBanner(cfg = {}, opts = {}) {
|
|
23
23
|
const width = cols();
|
|
24
24
|
const model = opts.model || cfg._model || 'IspBills AI';
|
|
25
|
+
const version = opts.version || '';
|
|
25
26
|
|
|
26
27
|
console.log();
|
|
27
28
|
if (width >= LOGO_WIDTH) {
|
|
@@ -36,10 +37,11 @@ export function printBanner(cfg = {}, opts = {}) {
|
|
|
36
37
|
parts.push(`${DIM}model${RESET} ${CYAN}${shortModel(model)}${RESET}`);
|
|
37
38
|
if (cfg.user?.name) parts.push(`${DIM}user${RESET} ${WHITE}${cfg.user.name}${RESET}`);
|
|
38
39
|
if (cfg.user?.role) parts.push(`${DIM}role${RESET} ${WHITE}${cfg.user.role}${RESET}`);
|
|
40
|
+
if (version) parts.push(`${DIM}v${RESET} ${WHITE}${version}${RESET}`);
|
|
39
41
|
console.log(' ' + parts.join(` ${GRAY}·${RESET} `));
|
|
40
42
|
if (cfg.url) console.log(` ${DIM}url${RESET} ${ACCENT}${cfg.url}${RESET}`);
|
|
41
43
|
console.log();
|
|
42
|
-
console.log(` ${DIM}Type a message to start. ${RESET}${ACCENT}/help${RESET}${DIM} for commands · ${RESET}${ACCENT}Ctrl+C${RESET}${DIM} to exit${RESET}`);
|
|
44
|
+
console.log(` ${DIM}Type a message to start. ${RESET}${ACCENT}/help${RESET}${DIM} for commands · ${RESET}${ACCENT}!cmd${RESET}${DIM} to run shell · ${RESET}${ACCENT}Ctrl+C${RESET}${DIM} to exit${RESET}`);
|
|
43
45
|
console.log();
|
|
44
46
|
hr();
|
|
45
47
|
console.log();
|
package/src/commands.js
CHANGED
|
@@ -52,6 +52,7 @@ export const SLASH_COMMANDS = [
|
|
|
52
52
|
{ cmd: '/copy', hint: '', desc: 'Copy the last AI answer to the clipboard', group: 'Memory', local: true },
|
|
53
53
|
|
|
54
54
|
// ── Session & app ──
|
|
55
|
+
{ cmd: '/usage', hint: '', desc: 'Show conversation stats (turns, ~tokens, model)', group: 'Session', local: true },
|
|
55
56
|
{ cmd: '/autopilot', hint: '', desc: 'Toggle autopilot (auto-apply fixes)', group: 'Session', local: true },
|
|
56
57
|
{ cmd: '/model', hint: '[name]', desc: 'Show or set the AI model', group: 'Session', local: true },
|
|
57
58
|
{ cmd: '/reasoning', hint: '', desc: 'Toggle reasoning display (Ctrl+T)', group: 'Session', local: true },
|
package/src/shell.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { exec } from 'child_process';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Run a shell command and capture combined stdout/stderr.
|
|
5
|
+
* Returns { ok, stdout, stderr, exitCode }.
|
|
6
|
+
*/
|
|
7
|
+
export function runShell(cmd, timeout = 30_000) {
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
exec(cmd, { timeout, maxBuffer: 512 * 1024, shell: true }, (err, stdout, stderr) => {
|
|
10
|
+
resolve({
|
|
11
|
+
ok: !err || err.code === 0,
|
|
12
|
+
stdout: (stdout || '').trim(),
|
|
13
|
+
stderr: (stderr || '').trim(),
|
|
14
|
+
exitCode: err?.code ?? 0,
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
}
|
package/src/tui/app.js
CHANGED
|
@@ -8,7 +8,7 @@ import { Box, Text, Static, useStdout, useApp, useInput } from 'ink';
|
|
|
8
8
|
import { C, glyphs, midDot, dash } from './theme.js';
|
|
9
9
|
import {
|
|
10
10
|
Banner, UserMessage, AssistantMessage, StatusLines, Reasoning,
|
|
11
|
-
Plan, Connect, Notice, Thinking, Choice,
|
|
11
|
+
Plan, Connect, Notice, Thinking, Choice, ShellInput, ShellOut,
|
|
12
12
|
} from './components.js';
|
|
13
13
|
import { Composer } from './composer.js';
|
|
14
14
|
import { streamChat } from '../client.js';
|
|
@@ -19,6 +19,11 @@ import { loadNotes, addNote, forgetNote, memoryPreamble } from '../memory.js';
|
|
|
19
19
|
import { copyToClipboard } from '../clipboard.js';
|
|
20
20
|
import { createGist } from '../gist.js';
|
|
21
21
|
import { git as gitCmd, commitBackup } from '../gitrepo.js';
|
|
22
|
+
import { runShell } from '../shell.js';
|
|
23
|
+
import { basename } from 'path';
|
|
24
|
+
|
|
25
|
+
// CWD basename — computed once at startup (before any cd).
|
|
26
|
+
const APP_CWD = basename(process.cwd());
|
|
22
27
|
|
|
23
28
|
// Split a command argument string into argv, honouring "quoted" segments.
|
|
24
29
|
function splitArgs(s) {
|
|
@@ -47,7 +52,7 @@ function useTerminalSize() {
|
|
|
47
52
|
let itemId = 0;
|
|
48
53
|
const nextId = () => ++itemId;
|
|
49
54
|
|
|
50
|
-
export function App({ cfg, display = {}, onExternal, onExit, initialQuestion }) {
|
|
55
|
+
export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, version = '' }) {
|
|
51
56
|
const { exit } = useApp();
|
|
52
57
|
const { cols, rows } = useTerminalSize();
|
|
53
58
|
|
|
@@ -241,6 +246,22 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
241
246
|
if (!q) return;
|
|
242
247
|
setHint('');
|
|
243
248
|
|
|
249
|
+
// ── ! shell command execution (GitHub Copilot CLI parity) ──────────────
|
|
250
|
+
if (q.startsWith('!')) {
|
|
251
|
+
const cmd = q.slice(1).trim();
|
|
252
|
+
if (!cmd) return;
|
|
253
|
+
push({ type: 'shell_in', cmd });
|
|
254
|
+
log(`$ ${cmd}`);
|
|
255
|
+
runShell(cmd).then((result) => {
|
|
256
|
+
const out = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
|
257
|
+
push({ type: 'shell_out', ok: result.ok, output: out || `(exit code ${result.exitCode})` });
|
|
258
|
+
if (out) log(out);
|
|
259
|
+
}).catch((e) => {
|
|
260
|
+
push({ type: 'notice', text: `${glyphs.cross} Shell error: ${e.message}`, color: C.red });
|
|
261
|
+
});
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
244
265
|
if (q === '/exit' || q === 'exit' || q === 'quit') { doExit(); return; }
|
|
245
266
|
if (q === '/clear') { clearAll(); return; }
|
|
246
267
|
if (q === '/new') {
|
|
@@ -290,6 +311,23 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
290
311
|
push({ type: 'notice', text: us.length ? us.map((m, i) => ` ${i + 1}. ${m.content.slice(0, 80)}`).join('\n') : 'No history yet.' });
|
|
291
312
|
return;
|
|
292
313
|
}
|
|
314
|
+
if (q === '/usage') {
|
|
315
|
+
const total = convo.current.length;
|
|
316
|
+
const userTurns = convo.current.filter((m) => m.role === 'user').length;
|
|
317
|
+
const chars = convo.current.reduce((acc, m) => acc + m.content.length, 0);
|
|
318
|
+
const approxTokens = Math.round(chars / 4);
|
|
319
|
+
const lines = [
|
|
320
|
+
`Conversation stats:`,
|
|
321
|
+
` turns ${userTurns}`,
|
|
322
|
+
` messages ${total}`,
|
|
323
|
+
` ~tokens ${approxTokens.toLocaleString()} (estimated)`,
|
|
324
|
+
` ~chars ${chars.toLocaleString()}`,
|
|
325
|
+
` model ${model || cfg._model || 'server default'}`,
|
|
326
|
+
` cwd ${APP_CWD}`,
|
|
327
|
+
];
|
|
328
|
+
push({ type: 'notice', text: lines.join('\n'), color: C.cyan });
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
293
331
|
if (q === '/logout') { onExternal?.('logout'); doExit(); return; }
|
|
294
332
|
if (q === '/update') { onExternal?.('update'); doExit(); return; }
|
|
295
333
|
|
|
@@ -454,13 +492,21 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
454
492
|
case 'connect': return html`<${Connect} key=${it.id} reply=${it.reply} />`;
|
|
455
493
|
case 'activity': return html`<${StatusLines} key=${it.id} lines=${it.status} busy=${false} />`;
|
|
456
494
|
case 'notice': return html`<${Notice} key=${it.id} text=${it.text} color=${it.color} />`;
|
|
495
|
+
case 'shell_in': return html`<${ShellInput} key=${it.id} cmd=${it.cmd} />`;
|
|
496
|
+
case 'shell_out': return html`<${ShellOut} key=${it.id} ok=${it.ok} output=${it.output} />`;
|
|
457
497
|
default: return null;
|
|
458
498
|
}
|
|
459
499
|
};
|
|
460
500
|
|
|
461
|
-
const ctx = [model ? shortModel(model) : null, cfg.user?.name, cfg.user?.role].filter(Boolean).join(midDot());
|
|
462
|
-
|
|
463
|
-
|
|
501
|
+
const ctx = [model ? shortModel(model) : null, APP_CWD, cfg.user?.name, cfg.user?.role].filter(Boolean).join(midDot());
|
|
502
|
+
// Footer: left = session context (dimmed), right = hint or key-hints.
|
|
503
|
+
// Autopilot mode turns the right side yellow.
|
|
504
|
+
const footerHint = hint
|
|
505
|
+
? hint
|
|
506
|
+
: autopilot
|
|
507
|
+
? `${glyphs.bolt} autopilot ON${midDot()}/help${midDot()}Shift+Tab off${midDot()}Ctrl+C exit`
|
|
508
|
+
: `/help${midDot()}Shift+Tab autopilot${midDot()}Ctrl+C exit`;
|
|
509
|
+
const footerColor = hint ? C.yellow : autopilot ? C.yellow : void 0;
|
|
464
510
|
const compact = cols < 60;
|
|
465
511
|
|
|
466
512
|
// The banner is the first <Static> item so it prints once at the very top and
|
|
@@ -474,7 +520,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
474
520
|
<${Static} key=${clearEpoch} items=${staticItems}>
|
|
475
521
|
${(it) => it.type === 'banner'
|
|
476
522
|
? html`<${Box} key=${it.id} flexDirection="column" alignItems="center" marginBottom=${1}>
|
|
477
|
-
<${Banner} cfg=${cfg} model=${model} width=${cols} compact=${compact} />
|
|
523
|
+
<${Banner} cfg=${cfg} model=${model} width=${cols} compact=${compact} version=${version} cwd=${APP_CWD} />
|
|
478
524
|
<//>`
|
|
479
525
|
: renderItem(it)}
|
|
480
526
|
<//>
|
|
@@ -484,8 +530,15 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
484
530
|
<${StatusLines} lines=${live.status} busy=${busy} />
|
|
485
531
|
${showReasoning && live.reasoning ? html`<${Reasoning} text=${live.reasoning} />` : null}
|
|
486
532
|
${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
|
|
487
|
-
${live.text
|
|
488
|
-
|
|
533
|
+
${live.text
|
|
534
|
+
? html`<${Box} marginTop=${live.status.length ? 0 : 1}>
|
|
535
|
+
<${AssistantMessage} text=${live.text} streaming=${true} />
|
|
536
|
+
<//>` : null}
|
|
537
|
+
${!live.text && !live.reply?.steps
|
|
538
|
+
? html`<${Thinking}
|
|
539
|
+
label=${live.status.length ? 'Working' : 'Thinking'}
|
|
540
|
+
startedAt=${startedAt} />`
|
|
541
|
+
: null}
|
|
489
542
|
<//>`
|
|
490
543
|
: null}
|
|
491
544
|
|
|
@@ -494,8 +547,10 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
494
547
|
? html`<${Choice} ...${choice} />`
|
|
495
548
|
: html`<${Composer} onSubmit=${onSubmit} busy=${busy} width=${cols} />`}
|
|
496
549
|
<${Box} paddingX=${1} width=${cols}>
|
|
497
|
-
<${Box} flexGrow=${1}
|
|
498
|
-
|
|
550
|
+
<${Box} flexGrow=${1}>
|
|
551
|
+
<${Text} dimColor wrap="truncate-end">${ctx}<//>
|
|
552
|
+
<//>
|
|
553
|
+
<${Text} color=${footerColor} dimColor=${!footerColor} wrap="truncate-end">${footerHint}<//>
|
|
499
554
|
<//>
|
|
500
555
|
<//>
|
|
501
556
|
<//>`;
|
package/src/tui/components.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { html, useState, useEffect } from './dom.js';
|
|
3
3
|
import { Box, Text } from 'ink';
|
|
4
4
|
import Spinner from 'ink-spinner';
|
|
5
|
-
import { C, glyphs, spinnerType, borderStyle, midDot, dash } from './theme.js';
|
|
5
|
+
import { C, SG, glyphs, spinnerType, borderStyle, midDot, dash, asciiSafe } from './theme.js';
|
|
6
6
|
import { Markdown } from './markdown.js';
|
|
7
7
|
import { shortModel } from '../ui.js';
|
|
8
8
|
|
|
@@ -15,13 +15,26 @@ const LOGO = [
|
|
|
15
15
|
'╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝ ',
|
|
16
16
|
];
|
|
17
17
|
|
|
18
|
+
// Width of the widest logo line (used for the separator beneath).
|
|
19
|
+
const LOGO_WIDTH = Math.max(...LOGO.map((l) => l.length));
|
|
20
|
+
|
|
21
|
+
/** Thin separator that spans the full logo width (or less on narrow terminals). */
|
|
22
|
+
function LogoRule({ width }) {
|
|
23
|
+
const w = Math.min(width, LOGO_WIDTH + 4);
|
|
24
|
+
return html`<${Text} dimColor>${(asciiSafe() ? '-' : '─').repeat(Math.max(8, w))}<//>`;
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
/** Header banner — full logo on wide/tall terminals, compact title otherwise. */
|
|
19
|
-
export function Banner({ cfg = {}, model, width = 80, compact = false }) {
|
|
28
|
+
export function Banner({ cfg = {}, model, width = 80, compact = false, version = '', cwd = '' }) {
|
|
20
29
|
const m = shortModel(model || cfg._model || 'IspBills AI');
|
|
21
30
|
const wide = !compact && width >= 60;
|
|
22
31
|
const meta = [
|
|
23
|
-
['model', m],
|
|
24
|
-
|
|
32
|
+
['model', m],
|
|
33
|
+
['user', cfg.user?.name],
|
|
34
|
+
['role', cfg.user?.role],
|
|
35
|
+
version ? ['v', version] : null,
|
|
36
|
+
cwd ? ['cwd', cwd] : null,
|
|
37
|
+
].filter(Boolean).filter(([, v]) => v);
|
|
25
38
|
|
|
26
39
|
if (compact) {
|
|
27
40
|
return html`
|
|
@@ -30,28 +43,42 @@ export function Banner({ cfg = {}, model, width = 80, compact = false }) {
|
|
|
30
43
|
${meta.map(([label, val], i) => html`
|
|
31
44
|
<${Box} key=${'m' + i}>
|
|
32
45
|
<${Text} color=${C.gray}>${midDot()}<//>
|
|
33
|
-
<${Text} color=${label === 'model' ? C.cyan : C.white}>${val}<//>
|
|
46
|
+
<${Text} color=${label === 'model' ? C.cyan : label === 'v' ? C.gray : C.white}>${val}<//>
|
|
34
47
|
<//>`)}
|
|
35
48
|
<//>`;
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
return html`
|
|
39
|
-
<${Box} flexDirection="column" marginBottom=${
|
|
52
|
+
<${Box} flexDirection="column" marginBottom=${0} alignItems="center">
|
|
40
53
|
${wide
|
|
41
54
|
? html`<${Box} flexDirection="column" alignItems="center">
|
|
42
55
|
${LOGO.map((l, i) => html`<${Text} key=${'l' + i} color=${C.accent} bold>${l}<//>`)}
|
|
43
|
-
<${Text}
|
|
56
|
+
<${Text} dimColor>iCopilot ${dash()} network engineer in your terminal<//>
|
|
44
57
|
<//>`
|
|
45
58
|
: html`<${Text} color=${C.accent} bold>iCopilot ${html`<${Text} color=${C.gray}>${dash()} IspBills AI terminal<//>`}<//>`}
|
|
46
59
|
<${Box} marginTop=${1}>
|
|
47
60
|
${meta.map(([label, val], i) => html`
|
|
48
61
|
<${Box} key=${'m' + i}>
|
|
49
62
|
${i > 0 ? html`<${Text} color=${C.gray}>${midDot()}<//>` : null}
|
|
50
|
-
<${Text}
|
|
51
|
-
<${Text} color=${
|
|
63
|
+
<${Text} dimColor>${label} <//>
|
|
64
|
+
<${Text} color=${
|
|
65
|
+
label === 'model' ? C.cyan :
|
|
66
|
+
label === 'v' ? C.gray :
|
|
67
|
+
label === 'cwd' ? C.yellow :
|
|
68
|
+
C.white}>${val}<//>
|
|
52
69
|
<//>`)}
|
|
53
70
|
<//>
|
|
54
|
-
${cfg.url ? html`<${
|
|
71
|
+
${cfg.url ? html`<${Box} marginTop=${0}><${Text} dimColor>${cfg.url}<//>` : null}
|
|
72
|
+
<${Box} marginTop=${1}>
|
|
73
|
+
<${Text} dimColor>Type a message${midDot()}<//>
|
|
74
|
+
<${Text} color=${C.accent}>/help<//>
|
|
75
|
+
<${Text} dimColor> for commands${midDot()}<//>
|
|
76
|
+
<${Text} color=${C.orange}>!<//>
|
|
77
|
+
<${Text} dimColor> to run shell<//>
|
|
78
|
+
<//>
|
|
79
|
+
<${Box} marginTop=${1}>
|
|
80
|
+
<${LogoRule} width=${width} />
|
|
81
|
+
<//>
|
|
55
82
|
<//>`;
|
|
56
83
|
}
|
|
57
84
|
|
|
@@ -68,17 +95,20 @@ function withMentions(text, base = C.white) {
|
|
|
68
95
|
/** The user's prompt line. */
|
|
69
96
|
export function UserMessage({ text }) {
|
|
70
97
|
return html`
|
|
71
|
-
<${Box} marginTop=${1}>
|
|
98
|
+
<${Box} marginTop=${1} marginBottom=${0}>
|
|
72
99
|
<${Text} color=${C.accent} bold>${glyphs.prompt} <//>
|
|
73
|
-
<${
|
|
100
|
+
<${Box} flexShrink=${1}>
|
|
101
|
+
<${Text} bold>${withMentions(text)}<//>
|
|
102
|
+
<//>
|
|
74
103
|
<//>`;
|
|
75
104
|
}
|
|
76
105
|
|
|
77
|
-
/** A finished assistant answer (markdown). */
|
|
78
|
-
export function AssistantMessage({ text }) {
|
|
106
|
+
/** A finished assistant answer (markdown). Optionally shows a streaming cursor. */
|
|
107
|
+
export function AssistantMessage({ text, streaming = false }) {
|
|
79
108
|
return html`
|
|
80
109
|
<${Box} flexDirection="column" marginTop=${1}>
|
|
81
110
|
<${Markdown} content=${text} />
|
|
111
|
+
${streaming ? html`<${Text} color="cyan">▌<//>` : null}
|
|
82
112
|
<//>`;
|
|
83
113
|
}
|
|
84
114
|
|
|
@@ -88,101 +118,248 @@ const SUBAGENT = /^\s*[✓✗x]?\s*\[(\d+)\/(\d+)\]\s*(.+?)\s*[::]\s*(.+?)\s*$
|
|
|
88
118
|
// A leading emoji/pictograph on a tool status label (from statusLabel()).
|
|
89
119
|
const LEAD_EMOJI = /^\s*([\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{2190}-\u{21FF}\u{FE0F}\u{2049}\u{203C}]+)\s*/u;
|
|
90
120
|
|
|
121
|
+
// Max tool-groups shown before collapsing older ones (GitHub Copilot CLI style).
|
|
122
|
+
const MAX_VISIBLE_GROUPS = 5;
|
|
123
|
+
|
|
124
|
+
// Max sub-agent rows per group before collapsing earlier agents.
|
|
125
|
+
const MAX_SUBAGENT_ROWS = 8;
|
|
126
|
+
|
|
91
127
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* green check once done), the tool's purpose label is kept, and sub-agent
|
|
95
|
-
* progress is nested underneath its parent as a tree.
|
|
128
|
+
* Group consecutive SUBAGENT lines under their preceding tool line.
|
|
129
|
+
* Returns [{ tool: string|null, subagents: string[] }]
|
|
96
130
|
*/
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
131
|
+
function groupStatusLines(lines) {
|
|
132
|
+
const groups = [];
|
|
133
|
+
let cur = null;
|
|
134
|
+
for (const line of lines) {
|
|
135
|
+
if (SUBAGENT.test(String(line))) {
|
|
136
|
+
if (!cur) { cur = { tool: null, subagents: [] }; groups.push(cur); }
|
|
137
|
+
cur.subagents.push(line);
|
|
138
|
+
} else {
|
|
139
|
+
cur = { tool: String(line), subagents: [] };
|
|
140
|
+
groups.push(cur);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return groups;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Parse a tool status string into { em, action, detail }.
|
|
148
|
+
* Strips leading emoji/glyphs and splits on the first ':'.
|
|
149
|
+
*/
|
|
150
|
+
function parseTool(line) {
|
|
151
|
+
const em = String(line).match(LEAD_EMOJI);
|
|
152
|
+
const body = em
|
|
153
|
+
? String(line).slice(em[0].length)
|
|
154
|
+
: String(line).replace(/^[\s◈◆●○◎⇢→>*✓✗×!⚠·-]+/u, '');
|
|
155
|
+
const ci = body.indexOf(':');
|
|
156
|
+
return {
|
|
157
|
+
em: em ? em[1] : null,
|
|
158
|
+
action: ci > -1 ? body.slice(0, ci).trim() : body.trim(),
|
|
159
|
+
detail: ci > -1 ? body.slice(ci + 1).trim() : '',
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Render a single tool activity line.
|
|
165
|
+
* running=true → accent spinner + white bold text.
|
|
166
|
+
* done → dim ● + dim text (GitHub Copilot CLI completed-step style).
|
|
167
|
+
* extraLabel → appended dim text (used for sub-agent progress badge "(N/M)").
|
|
168
|
+
*/
|
|
169
|
+
function toolLine(line, running, gi, extraLabel) {
|
|
170
|
+
const { em, action, detail } = parseTool(line);
|
|
101
171
|
return html`
|
|
102
|
-
<${Box}
|
|
103
|
-
${
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
172
|
+
<${Box} key=${'tl' + gi}>
|
|
173
|
+
${running
|
|
174
|
+
? html`<${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /><//>`
|
|
175
|
+
: html`<${Text} dimColor>${SG.filled}<//>` }
|
|
176
|
+
<${Text}> <//>
|
|
177
|
+
${em ? html`<${Text}>${em} <//>` : null}
|
|
178
|
+
<${Text} color=${running ? C.white : void 0} dimColor=${!running} bold=${running}>${action}<//>
|
|
179
|
+
${detail
|
|
180
|
+
? html`<${Text} dimColor>: <//>
|
|
181
|
+
<${Text} dimColor>${detail}<//>` : null}
|
|
182
|
+
${extraLabel ? html`<${Text} dimColor> ${extraLabel}<//>` : null}
|
|
183
|
+
<//>`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Render a group that has sub-agent rows nested inside it.
|
|
188
|
+
*
|
|
189
|
+
* GitHub Copilot CLI fleet/sub-agent style:
|
|
190
|
+
* ● spawn_subagent: fleet check (8/8) ← parent tool, progress badge
|
|
191
|
+
* ├─ 1/8 olt#1 ✓ 5 ONUs online ← completed OK (dim)
|
|
192
|
+
* ├─ 2/8 nas#2 ✗ unreachable ← completed ERR (dim red)
|
|
193
|
+
* │ 3/8 router#1 ⠙ checking… ← in-flight (│ pipe)
|
|
194
|
+
* └─ 4/8 switch#1 ✓ all OK ← last done (└─)
|
|
195
|
+
*/
|
|
196
|
+
function subagentGroup(group, groupRunning, gi) {
|
|
197
|
+
const { tool, subagents } = group;
|
|
198
|
+
|
|
199
|
+
// Cap visible sub-agents — collapse older ones to "N earlier agents".
|
|
200
|
+
const visibleSubs = subagents.slice(-MAX_SUBAGENT_ROWS);
|
|
201
|
+
const hiddenSubs = subagents.length - visibleSubs.length;
|
|
202
|
+
|
|
203
|
+
// Determine total from last sub-agent line.
|
|
204
|
+
const lastSm = SUBAGENT.exec(visibleSubs[visibleSubs.length - 1] || '');
|
|
205
|
+
const total = lastSm ? Number(lastSm[2]) : subagents.length;
|
|
206
|
+
|
|
207
|
+
// Count completed agents for progress badge.
|
|
208
|
+
const doneCount = subagents.filter(l => {
|
|
209
|
+
const m = SUBAGENT.exec(l);
|
|
210
|
+
return m && !/checking|running|pending/i.test(m[4]);
|
|
211
|
+
}).length;
|
|
212
|
+
|
|
213
|
+
// Is the last visible sub-agent still running?
|
|
214
|
+
const lastVisIdx = visibleSubs.length - 1;
|
|
215
|
+
const lastSmCheck = SUBAGENT.exec(visibleSubs[lastVisIdx] || '');
|
|
216
|
+
const lastRunning = groupRunning && lastSmCheck
|
|
217
|
+
? Number(lastSmCheck[1]) < Number(lastSmCheck[2])
|
|
218
|
+
: groupRunning;
|
|
219
|
+
|
|
220
|
+
// Progress badge on parent: "(3/8)" while running, "(8/8 done)" when complete.
|
|
221
|
+
const badge = lastRunning
|
|
222
|
+
? `(${doneCount}/${total})`
|
|
223
|
+
: `(${total}/${total} ${glyphs.check})`;
|
|
224
|
+
|
|
225
|
+
// Column-pad host names within this group for alignment.
|
|
226
|
+
const hosts = visibleSubs.map(l => (SUBAGENT.exec(l)?.[3] || '').trim());
|
|
227
|
+
const padW = Math.min(Math.max(...hosts.map(h => h.length), 6), 20);
|
|
228
|
+
|
|
229
|
+
// Width for idx/total fraction display (right-align idx).
|
|
230
|
+
const totLen = String(total).length;
|
|
231
|
+
|
|
232
|
+
return html`
|
|
233
|
+
<${Box} key=${'sg' + gi} flexDirection="column">
|
|
234
|
+
${tool ? toolLine(tool, groupRunning && lastRunning, gi, badge) : null}
|
|
235
|
+
${hiddenSubs > 0
|
|
236
|
+
? html`<${Box} key="hid">
|
|
237
|
+
<${Text} dimColor> ${glyphs.vbar} <//>
|
|
238
|
+
<${Text} dimColor>${glyphs.bullet} ${hiddenSubs} earlier agent${hiddenSubs > 1 ? 's' : ''} ${dash()} collapsed<//>
|
|
239
|
+
<//>` : null}
|
|
240
|
+
${visibleSubs.map((line, si) => {
|
|
241
|
+
const sm = SUBAGENT.exec(line);
|
|
242
|
+
if (!sm) return null;
|
|
243
|
+
const [, idx, tot, host, result] = sm;
|
|
244
|
+
const good = !/fail|error|down|offline|unreachable|timeout|deny/i.test(result);
|
|
245
|
+
const isLast = si === lastVisIdx;
|
|
246
|
+
const isRunning = groupRunning && isLast && Number(idx) < Number(tot);
|
|
247
|
+
|
|
248
|
+
// Tree marker: │ (pipe) for in-flight, └─ for last done, ├─ for others.
|
|
249
|
+
const isFinalEntry = Number(idx) === Number(tot) || (isLast && !isRunning);
|
|
250
|
+
const branch = isRunning
|
|
251
|
+
? glyphs.vbar + ' ' // "│ " in-flight
|
|
252
|
+
: (isFinalEntry ? glyphs.branchEnd + ' ' : glyphs.branchMid + ' '); // "└─ " or "├─ "
|
|
253
|
+
|
|
254
|
+
// Right-align idx within the fraction, e.g. " 3/8" vs "12/12".
|
|
255
|
+
const frac = String(idx).padStart(totLen) + '/' + tot;
|
|
256
|
+
const hostPad = host.padEnd(padW);
|
|
257
|
+
|
|
131
258
|
return html`
|
|
132
|
-
<${Box} key=${'
|
|
133
|
-
${
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
259
|
+
<${Box} key=${'sa' + si}>
|
|
260
|
+
<${Text} color=${isRunning ? C.accent : void 0} dimColor=${!isRunning}> ${branch}<//>
|
|
261
|
+
<${Text} color=${isRunning ? C.cyan : void 0} dimColor=${!isRunning}>${frac} <//>
|
|
262
|
+
<${Text} color=${isRunning ? C.white : void 0} dimColor=${!isRunning}>${hostPad} <//>
|
|
263
|
+
${isRunning
|
|
264
|
+
? html`<${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /><//>
|
|
265
|
+
<${Text} color=${C.accent}> checking…<//>`
|
|
266
|
+
: html`<${Text} color=${good ? C.green : C.red} dimColor> ${good ? glyphs.check : glyphs.cross}<//>
|
|
267
|
+
<${Text} color=${good ? C.green : C.red} dimColor> ${result}<//>` }
|
|
140
268
|
<//>`;
|
|
141
269
|
})}
|
|
142
270
|
<//>`;
|
|
143
271
|
}
|
|
144
272
|
|
|
145
|
-
/**
|
|
273
|
+
/**
|
|
274
|
+
* Live server activity — tool calls and sub-agents, rendered Copilot-CLI style.
|
|
275
|
+
*
|
|
276
|
+
* Tool groups are the primary unit: each tool line is rendered with a spinner
|
|
277
|
+
* (in-flight) or dim ● (done). Sub-agent blocks are nested tree-style under
|
|
278
|
+
* their parent tool with ├─/└─/│ markers, padded columns, and a progress
|
|
279
|
+
* badge on the parent line — matching GitHub Copilot CLI fleet/sub-agent UI.
|
|
280
|
+
*
|
|
281
|
+
* Old groups collapse to a summary count so the live area stays compact.
|
|
282
|
+
*/
|
|
283
|
+
export function StatusLines({ lines = [], busy = false }) {
|
|
284
|
+
if (!lines.length) return null;
|
|
285
|
+
|
|
286
|
+
const groups = groupStatusLines(lines);
|
|
287
|
+
|
|
288
|
+
// Collapse old groups — keep last MAX_VISIBLE_GROUPS.
|
|
289
|
+
const hiddenGroupCount = groups.length > MAX_VISIBLE_GROUPS
|
|
290
|
+
? groups.length - MAX_VISIBLE_GROUPS : 0;
|
|
291
|
+
const visibleGroups = groups.slice(hiddenGroupCount);
|
|
292
|
+
|
|
293
|
+
// Total lines in hidden groups (for the summary count).
|
|
294
|
+
const hiddenLineCount = groups
|
|
295
|
+
.slice(0, hiddenGroupCount)
|
|
296
|
+
.reduce((acc, g) => acc + 1 + g.subagents.length, 0);
|
|
297
|
+
|
|
298
|
+
const lastGi = visibleGroups.length - 1;
|
|
299
|
+
|
|
300
|
+
return html`
|
|
301
|
+
<${Box} flexDirection="column" marginTop=${0}>
|
|
302
|
+
${hiddenLineCount > 0
|
|
303
|
+
? html`<${Box} key="coll">
|
|
304
|
+
<${Text} dimColor>${SG.filled} ${hiddenLineCount} earlier step${hiddenLineCount > 1 ? 's' : ''} ${dash()} collapsed<//>
|
|
305
|
+
<//>` : null}
|
|
306
|
+
${visibleGroups.map((g, gi) => {
|
|
307
|
+
const isLast = gi === lastGi;
|
|
308
|
+
const groupRunning = busy && isLast;
|
|
309
|
+
if (g.subagents.length > 0) return subagentGroup(g, groupRunning, gi);
|
|
310
|
+
return toolLine(g.tool || '', groupRunning, gi, null);
|
|
311
|
+
})}
|
|
312
|
+
<//>`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Reasoning / thinking trace (shown by default; toggle with Ctrl+T). */
|
|
146
316
|
export function Reasoning({ text }) {
|
|
147
317
|
if (!text) return null;
|
|
148
318
|
return html`
|
|
149
|
-
<${Box} flexDirection="column" marginTop=${1}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
<${Text} color=${C.
|
|
319
|
+
<${Box} flexDirection="column" marginTop=${1}
|
|
320
|
+
borderStyle=${borderStyle()} borderColor=${C.magenta}
|
|
321
|
+
borderTop=${false} borderRight=${false} borderBottom=${false} paddingLeft=${1}>
|
|
322
|
+
<${Text} color=${C.magenta} bold>◆ Thinking<//>
|
|
323
|
+
<${Text} dimColor italic>${text}<//>
|
|
153
324
|
<//>`;
|
|
154
325
|
}
|
|
155
326
|
|
|
156
327
|
/** A proposed remediation plan. */
|
|
157
328
|
export function Plan({ reply = {}, live = false }) {
|
|
158
329
|
const steps = reply.steps || [];
|
|
159
|
-
const
|
|
330
|
+
const riskCount = steps.filter((s) => /destruct|high/i.test(s.risk || '')).length;
|
|
160
331
|
return html`
|
|
161
332
|
<${Box} flexDirection="column" marginTop=${1}>
|
|
162
|
-
<${
|
|
163
|
-
|
|
333
|
+
<${Box} key="hdr">
|
|
334
|
+
<${Text} color=${C.accent} bold>${glyphs.diamond} Plan<//>
|
|
335
|
+
${reply.summary ? html`<${Text} key="sum" dimColor> ${dash()} ${reply.summary}<//>` : null}
|
|
336
|
+
<//>
|
|
337
|
+
${riskCount > 0
|
|
338
|
+
? html`<${Text} key="risk" color=${C.red}>${glyphs.warn} ${riskCount} destructive step${riskCount > 1 ? 's' : ''} — review carefully<//>` : null}
|
|
164
339
|
${steps.map((s, i) => {
|
|
165
340
|
const last = i === steps.length - 1;
|
|
166
341
|
const risk = (s.risk === 'destructive' || s.risk === 'high')
|
|
167
|
-
? html`<${Text} color=${C.red}> ${glyphs.warn} destructive<//>`
|
|
342
|
+
? html`<${Text} key="r" color=${C.red}> ${glyphs.warn} destructive<//>`
|
|
168
343
|
: (s.risk === 'caution' || s.risk === 'medium')
|
|
169
|
-
? html`<${Text} color=${C.yellow}> ${glyphs.bolt} caution<//>` : null;
|
|
344
|
+
? html`<${Text} key="r" color=${C.yellow}> ${glyphs.bolt} caution<//>` : null;
|
|
170
345
|
return html`
|
|
171
346
|
<${Box} flexDirection="column" key=${'p' + i}>
|
|
172
|
-
<${Box}>
|
|
173
|
-
<${Text}
|
|
174
|
-
<${Text}
|
|
175
|
-
<${Text} color=${C.yellow}>${s.cmd || ''}<//>
|
|
347
|
+
<${Box} key="cmd">
|
|
348
|
+
<${Text} dimColor>${last ? glyphs.branchEnd : glyphs.branchMid} <//>
|
|
349
|
+
<${Text} dimColor>${i + 1}. <//>
|
|
350
|
+
<${Text} color=${C.yellow} bold>${s.cmd || ''}<//>
|
|
176
351
|
${risk}
|
|
177
|
-
${s.blocked ? html`<${Text} color=${C.red}> ${glyphs.block} blocked<//>` : null}
|
|
352
|
+
${s.blocked ? html`<${Text} key="blk" color=${C.red}> ${glyphs.block} blocked<//>` : null}
|
|
178
353
|
<//>
|
|
179
|
-
${(s.why || s.purpose) ? html`<${Text}
|
|
354
|
+
${(s.why || s.purpose) ? html`<${Text} key="why" dimColor> ${s.why || s.purpose}<//>` : null}
|
|
180
355
|
<//>`;
|
|
181
356
|
})}
|
|
182
|
-
<${Box} marginTop=${1}>
|
|
357
|
+
<${Box} key="foot" marginTop=${1}>
|
|
183
358
|
${live
|
|
184
|
-
? html`<${Text}
|
|
185
|
-
: html`<${Text}
|
|
359
|
+
? html`<${Text} key="lv" dimColor italic>${glyphs.bullet} forming plan…<//>`
|
|
360
|
+
: html`<${Text} key="cf" dimColor>Type <//>
|
|
361
|
+
<${Text} key="cf2" color=${C.white}>apply fix<//>
|
|
362
|
+
<${Text} key="cf3" dimColor> to confirm, or choose below.<//>` }
|
|
186
363
|
<//>
|
|
187
364
|
<//>`;
|
|
188
365
|
}
|
|
@@ -201,24 +378,34 @@ export function Connect({ reply = {} }) {
|
|
|
201
378
|
<//>`;
|
|
202
379
|
}
|
|
203
380
|
|
|
204
|
-
/** A local notice (help hint, errors, etc.). */
|
|
381
|
+
/** A local notice (help hint, errors, etc.). Handles multi-line text. */
|
|
205
382
|
export function Notice({ text, color = C.gray }) {
|
|
206
|
-
|
|
383
|
+
const lines = String(text).split('\n');
|
|
384
|
+
return html`
|
|
385
|
+
<${Box} marginTop=${1} flexDirection="column">
|
|
386
|
+
${lines.map((ln, i) => html`<${Text} key=${'n' + i} color=${color}>${ln}<//>` )}
|
|
387
|
+
<//>`;
|
|
207
388
|
}
|
|
208
389
|
|
|
209
|
-
/**
|
|
210
|
-
|
|
390
|
+
/**
|
|
391
|
+
* The active "working" line — animated spinner, elapsed time, cancel hint.
|
|
392
|
+
* label: 'Thinking' when waiting for AI, 'Working' when tools are active.
|
|
393
|
+
*/
|
|
394
|
+
export function Thinking({ label = 'Thinking', startedAt }) {
|
|
211
395
|
const [, tick] = useState(0);
|
|
212
396
|
useEffect(() => {
|
|
213
397
|
const id = setInterval(() => tick((n) => n + 1), 1000);
|
|
214
398
|
return () => clearInterval(id);
|
|
215
399
|
}, []);
|
|
216
400
|
const secs = startedAt ? Math.max(0, Math.round((Date.now() - startedAt) / 1000)) : 0;
|
|
401
|
+
const timeStr = secs >= 60
|
|
402
|
+
? `${Math.floor(secs / 60)}m ${secs % 60}s`
|
|
403
|
+
: secs > 0 ? `${secs}s` : '';
|
|
217
404
|
return html`
|
|
218
405
|
<${Box} marginTop=${1}>
|
|
219
406
|
<${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /><//>
|
|
220
|
-
<${Text} color=${C.accent} bold> ${label}
|
|
221
|
-
<${Text}
|
|
407
|
+
<${Text} color=${C.accent} bold> ${label}…<//>
|
|
408
|
+
<${Text} dimColor>${timeStr ? ` (${timeStr})` : ''} · esc to cancel<//>
|
|
222
409
|
<//>`;
|
|
223
410
|
}
|
|
224
411
|
|
|
@@ -231,7 +418,7 @@ export function Choice({ title, note, options = [], sel = 0, allowText = false,
|
|
|
231
418
|
return html`
|
|
232
419
|
<${Box} flexDirection="column" marginTop=${1} borderStyle=${borderStyle()} borderColor=${C.accent} paddingX=${1}>
|
|
233
420
|
${title ? html`<${Text} color=${C.accent} bold>${glyphs.diamond} ${title}<//>` : null}
|
|
234
|
-
${note ? html`<${Text}
|
|
421
|
+
${note ? html`<${Text} dimColor>${note}<//>` : null}
|
|
235
422
|
<${Box} flexDirection="column" marginTop=${title || note ? 1 : 0}>
|
|
236
423
|
${options.map((o, i) => {
|
|
237
424
|
const on = !focusText && i === sel;
|
|
@@ -241,19 +428,54 @@ export function Choice({ title, note, options = [], sel = 0, allowText = false,
|
|
|
241
428
|
<${Box} key=${'o' + i}>
|
|
242
429
|
<${Text} color=${on ? C.accent : C.gray}>${on ? glyphs.prompt + ' ' : ' '}<//>
|
|
243
430
|
<${Text} color=${tone} bold=${on}>${label}<//>
|
|
244
|
-
${o.hint ? html`<${Text}
|
|
431
|
+
${o.hint ? html`<${Text} dimColor> ${dash()} ${o.hint}<//>` : null}
|
|
245
432
|
<//>`;
|
|
246
433
|
})}
|
|
247
434
|
${allowText
|
|
248
435
|
? html`<${Box} key="freetext">
|
|
249
436
|
<${Text} color=${focusText ? C.accent : C.gray}>${focusText ? glyphs.prompt + ' ' : ' '}<//>
|
|
250
|
-
<${Text}
|
|
437
|
+
<${Text} dimColor>${text ? '' : 'Type a custom answer…'}<//>
|
|
251
438
|
<${Text} color=${C.white}>${text}<//>${focusText ? html`<${Text} inverse> <//>` : null}
|
|
252
439
|
<//>`
|
|
253
440
|
: null}
|
|
254
441
|
<//>
|
|
255
442
|
<${Box} marginTop=${1}>
|
|
256
|
-
<${Text}
|
|
443
|
+
<${Text} dimColor>${glyphs.up}${glyphs.down} navigate${allowText ? ' · Tab type' : ''} · Enter confirm · Esc dismiss<//>
|
|
444
|
+
<//>
|
|
445
|
+
<//>`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Shell command prompt line — shown in history when the user runs a ! command.
|
|
450
|
+
* Renders as a yellow $ prompt with the command text, matching GitHub Copilot
|
|
451
|
+
* CLI's visual treatment of direct shell commands.
|
|
452
|
+
*/
|
|
453
|
+
export function ShellInput({ cmd }) {
|
|
454
|
+
return html`
|
|
455
|
+
<${Box} marginTop=${1} marginBottom=${0}>
|
|
456
|
+
<${Text} color=${C.orange} bold>$ <//>
|
|
457
|
+
<${Box} flexShrink=${1}>
|
|
458
|
+
<${Text} color=${C.white} bold>${cmd}<//>
|
|
257
459
|
<//>
|
|
258
460
|
<//>`;
|
|
259
461
|
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Shell command output — shown below a ShellInput. Uses a left border to
|
|
465
|
+
* visually group the output with the command. Long outputs are capped at
|
|
466
|
+
* 80 lines to keep the terminal readable.
|
|
467
|
+
*/
|
|
468
|
+
export function ShellOut({ ok, output = '' }) {
|
|
469
|
+
const lines = output.split('\n');
|
|
470
|
+
const capped = lines.slice(0, 80);
|
|
471
|
+
const more = lines.length - capped.length;
|
|
472
|
+
return html`
|
|
473
|
+
<${Box} flexDirection="column" marginTop=${0}
|
|
474
|
+
borderStyle="single" borderColor=${ok ? C.gray : C.red}
|
|
475
|
+
borderTop=${false} borderRight=${false} borderBottom=${false}
|
|
476
|
+
paddingLeft=${1}>
|
|
477
|
+
${capped.map((l, i) => html`<${Text} key=${'so' + i} color=${ok ? C.white : C.red}>${l || ' '}<//>` )}
|
|
478
|
+
${more > 0
|
|
479
|
+
? html`<${Text} dimColor>${glyphs.bullet} ${more} more line${more > 1 ? 's' : ''} hidden<//>` : null}
|
|
480
|
+
<//>`;
|
|
481
|
+
}
|
package/src/tui/composer.js
CHANGED
|
@@ -40,6 +40,9 @@ export function Composer({ onSubmit, busy, width = 80 }) {
|
|
|
40
40
|
lastMut.current = kind;
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
+
// Ctrl+S stash: save the current prompt to restore later (GitHub Copilot CLI parity).
|
|
44
|
+
const stashRef = useRef('');
|
|
45
|
+
|
|
43
46
|
// Bracketed-paste assembly. A right-click / Cmd+V paste is wrapped by the
|
|
44
47
|
// terminal in \x1b[200~ … \x1b[201~ and can arrive split across several
|
|
45
48
|
// reads, so we buffer until the end marker before inserting.
|
|
@@ -143,6 +146,18 @@ export function Composer({ onSubmit, busy, width = 80 }) {
|
|
|
143
146
|
return;
|
|
144
147
|
}
|
|
145
148
|
|
|
149
|
+
// ── Ctrl+S: stash / pop prompt (GitHub Copilot CLI parity) ──
|
|
150
|
+
if (key.ctrl && input === 's') {
|
|
151
|
+
if (value) {
|
|
152
|
+
stashRef.current = value;
|
|
153
|
+
setValue(''); setCursor(0);
|
|
154
|
+
} else if (stashRef.current) {
|
|
155
|
+
const v = stashRef.current; stashRef.current = '';
|
|
156
|
+
setText(v, v.length);
|
|
157
|
+
}
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
146
161
|
// ── Menu / history navigation ──
|
|
147
162
|
if (key.upArrow) {
|
|
148
163
|
if (menuOpen) { setSel((i) => Math.max(0, i - 1)); return; }
|
|
@@ -215,6 +230,15 @@ export function Composer({ onSubmit, busy, width = 80 }) {
|
|
|
215
230
|
const sugTok = menuOpen && matches[sel] ? (slashMode ? matches[sel].cmd : matches[sel].tag) : '';
|
|
216
231
|
const ghost = sugTok && sugTok.startsWith(token) && cursor >= value.length ? sugTok.slice(token.length) : '';
|
|
217
232
|
|
|
233
|
+
// ── Shell mode: prefix '!' triggers yellow $ prompt + orange border ──
|
|
234
|
+
const shellMode = value.startsWith('!') && !menuOpen;
|
|
235
|
+
const borderColor = busy ? C.gray : shellMode ? C.orange : C.accent;
|
|
236
|
+
const promptGlyph = shellMode ? '$ ' : glyphs.prompt + ' ';
|
|
237
|
+
const promptColor = busy ? C.gray : shellMode ? C.orange : C.accent;
|
|
238
|
+
|
|
239
|
+
// Number of lines in the buffer (for the line-count badge).
|
|
240
|
+
const lineCount = value.split('\n').length;
|
|
241
|
+
|
|
218
242
|
return html`
|
|
219
243
|
<${Box} flexDirection="column" width=${width}>
|
|
220
244
|
${menuOpen
|
|
@@ -240,16 +264,18 @@ export function Composer({ onSubmit, busy, width = 80 }) {
|
|
|
240
264
|
<//>`
|
|
241
265
|
: null}
|
|
242
266
|
|
|
243
|
-
<${Box} width=${width} borderStyle=${borderStyle()} borderColor=${
|
|
244
|
-
<${Text} color=${
|
|
267
|
+
<${Box} width=${width} borderStyle=${borderStyle()} borderColor=${borderColor} paddingX=${1}>
|
|
268
|
+
<${Text} color=${promptColor}>${promptGlyph}<//>
|
|
245
269
|
${empty
|
|
246
270
|
? html`<${Box} flexGrow=${1}>
|
|
247
271
|
<${Text} color=${C.gray} inverse> <//>
|
|
248
|
-
<${Text} color=${C.gray}>${busy ? 'Working…' : '
|
|
272
|
+
<${Text} color=${C.gray}>${busy ? 'Working…' : 'How can I help with your network? · /help · ! shell'}<//>
|
|
249
273
|
<//>`
|
|
250
274
|
: html`<${Box} flexGrow=${1}>
|
|
251
275
|
<${Text}>${before}<//><${Text} inverse>${at}<//><${Text}>${after}<//><${Text} color=${C.gray}>${ghost}<//>
|
|
252
276
|
<//>`}
|
|
277
|
+
${lineCount > 1
|
|
278
|
+
? html`<${Text} dimColor> ${lineCount}L<//>` : null}
|
|
253
279
|
<//>
|
|
254
280
|
<//>`;
|
|
255
281
|
}
|
package/src/tui/markdown.js
CHANGED
|
@@ -90,14 +90,30 @@ function renderTable(header, rows) {
|
|
|
90
90
|
export function Markdown({ content = '' }) {
|
|
91
91
|
const rows = [];
|
|
92
92
|
let inCode = false;
|
|
93
|
+
let codeLines = [];
|
|
93
94
|
const lines = String(content).replace(/\s+$/, '').split('\n');
|
|
94
95
|
|
|
95
96
|
for (let i = 0; i < lines.length; i++) {
|
|
96
97
|
const line = lines[i].replace(/\t/g, ' ');
|
|
97
98
|
|
|
98
|
-
// Fenced code block.
|
|
99
|
-
if (line.trim().startsWith('```')) {
|
|
100
|
-
|
|
99
|
+
// Fenced code block — collect all lines then render as a left-bordered block.
|
|
100
|
+
if (line.trim().startsWith('```')) {
|
|
101
|
+
if (!inCode) {
|
|
102
|
+
inCode = true;
|
|
103
|
+
codeLines = [];
|
|
104
|
+
} else {
|
|
105
|
+
inCode = false;
|
|
106
|
+
const captured = codeLines;
|
|
107
|
+
rows.push(html`
|
|
108
|
+
<${Box} key=${k()} flexDirection="column" marginY=${1}
|
|
109
|
+
borderStyle=${asciiSafe() ? 'single' : 'single'} borderColor=${C.gray}
|
|
110
|
+
borderTop=${false} borderRight=${false} borderBottom=${false} paddingLeft=${1}>
|
|
111
|
+
${captured.map((cl, ci) => html`<${Text} key=${k()} color=${C.green}>${cl}<//>` )}
|
|
112
|
+
<//>`);
|
|
113
|
+
}
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (inCode) { codeLines.push(line); continue; }
|
|
101
117
|
|
|
102
118
|
// GFM table: header row + separator + body rows.
|
|
103
119
|
if (isTableRow(line) && i + 1 < lines.length && isTableSep(lines[i + 1])) {
|
package/src/tui/run.js
CHANGED
|
@@ -8,7 +8,7 @@ import { render } from 'ink';
|
|
|
8
8
|
import { App } from './app.js';
|
|
9
9
|
|
|
10
10
|
export async function runTui(cfg, opts = {}) {
|
|
11
|
-
const { display = {}, onExternal, initialQuestion } = opts;
|
|
11
|
+
const { display = {}, onExternal, initialQuestion, version = '' } = opts;
|
|
12
12
|
let externalAction;
|
|
13
13
|
|
|
14
14
|
const instance = render(
|
|
@@ -16,6 +16,7 @@ export async function runTui(cfg, opts = {}) {
|
|
|
16
16
|
cfg=${cfg}
|
|
17
17
|
display=${display}
|
|
18
18
|
initialQuestion=${initialQuestion}
|
|
19
|
+
version=${version}
|
|
19
20
|
onExternal=${(a) => { externalAction = a; onExternal?.(a); }}
|
|
20
21
|
/>`,
|
|
21
22
|
{ exitOnCtrlC: false },
|
package/src/tui/theme.js
CHANGED
|
@@ -5,18 +5,36 @@ import { glyphs, asciiSafe } from '../ui.js';
|
|
|
5
5
|
|
|
6
6
|
export { glyphs, asciiSafe };
|
|
7
7
|
|
|
8
|
-
// Soft-blue brand accent (approx. xterm-256 colour 111).
|
|
8
|
+
// Soft-blue brand accent (approx. xterm-256 colour 111) — matches Copilot CLI.
|
|
9
9
|
export const ACCENT = '#87afff';
|
|
10
|
+
|
|
11
|
+
// Semantic color palette. Where possible mirror Copilot CLI conventions:
|
|
12
|
+
// - accent/cyan for interactive / in-flight elements
|
|
13
|
+
// - green for success / completed
|
|
14
|
+
// - red for errors / destructive
|
|
15
|
+
// - yellow for warnings / caution / autopilot
|
|
16
|
+
// - magenta for reasoning/thinking
|
|
17
|
+
// - gray / dimColor for secondary / completed-dim text
|
|
10
18
|
export const C = {
|
|
11
|
-
accent:
|
|
12
|
-
gray:
|
|
13
|
-
white:
|
|
14
|
-
cyan:
|
|
15
|
-
green:
|
|
16
|
-
yellow:
|
|
17
|
-
red:
|
|
19
|
+
accent: ACCENT,
|
|
20
|
+
gray: 'gray',
|
|
21
|
+
white: 'white',
|
|
22
|
+
cyan: 'cyan',
|
|
23
|
+
green: 'green',
|
|
24
|
+
yellow: 'yellow',
|
|
25
|
+
red: 'red',
|
|
18
26
|
magenta: 'magenta',
|
|
19
|
-
blue:
|
|
27
|
+
blue: 'blue',
|
|
28
|
+
orange: '#ffb86c', // shell mode / caution
|
|
29
|
+
purple: '#d097ff', // sub-agent / agent role
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Copilot-style task status glyphs (Unicode only; fall back handled per use-site).
|
|
33
|
+
// ● filled = step completed ◐ half = step in-progress ○ empty = pending
|
|
34
|
+
export const SG = {
|
|
35
|
+
filled: '●', // U+25CF completed
|
|
36
|
+
half: '◐', // U+25D0 in-progress (used alongside spinner)
|
|
37
|
+
empty: '○', // U+25CB pending
|
|
20
38
|
};
|
|
21
39
|
|
|
22
40
|
// ink-spinner uses cli-spinners; 'line' is pure ASCII (|/-\), 'dots' is braille.
|
|
@@ -30,9 +48,9 @@ export function borderStyle() {
|
|
|
30
48
|
return asciiSafe() ? 'single' : 'round';
|
|
31
49
|
}
|
|
32
50
|
|
|
33
|
-
// Middle-dot separator
|
|
51
|
+
// Middle-dot separator. Copilot CLI uses a single space each side.
|
|
34
52
|
export function midDot() {
|
|
35
|
-
return asciiSafe() ? ' - ' : '
|
|
53
|
+
return asciiSafe() ? ' - ' : ' · ';
|
|
36
54
|
}
|
|
37
55
|
|
|
38
56
|
// Em-dash, ASCII-safe on classic Windows consoles.
|