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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ispbills-icli",
3
- "version": "8.4.2",
3
+ "version": "8.4.5",
4
4
  "description": "iCli — IspBills AI network engineer in your terminal",
5
5
  "keywords": [
6
6
  "ispbills",
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
- const modeTag = autopilot ? `${glyphs.bolt} autopilot ${midDot()} ` : '';
463
- const footerRight = hint || `${modeTag}/help ${midDot()} Shift+Tab autopilot ${midDot()} Ctrl+C exit`;
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 ? html`<${Box} marginTop=${1}><${AssistantMessage} text=${live.text} /><//>` : null}
488
- <${Thinking} label=${live.reply ? 'Planning' : live.text ? 'Responding' : 'Working'} startedAt=${startedAt} />
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}><${Text} color=${C.gray} wrap="truncate-end">${ctx}<//><//>
498
- <${Text} color=${hint || autopilot ? C.yellow : C.gray} wrap="truncate-end">${footerRight}<//>
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
  <//>`;
@@ -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], ['user', cfg.user?.name], ['role', cfg.user?.role],
24
- ].filter(([, v]) => v);
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=${1} alignItems="center">
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} color=${C.gray}>iCopilot ${dash()} network engineer in your terminal<//>
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} color=${C.gray}>${label} <//>
51
- <${Text} color=${label === 'model' ? C.cyan : C.white}>${val}<//>
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`<${Text} color=${C.gray}>${cfg.url}<//>` : null}
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
- <${Text}>${withMentions(text)}<//>
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
- * Live server activity tool calls and sub-agents, rendered Copilot-CLI style:
93
- * each activity gets a status marker (spinner while it's the in-flight step,
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
- export function StatusLines({ lines = [], busy = false }) {
98
- if (!lines.length) return null;
99
- const subLines = lines.filter((l) => SUBAGENT.test(l));
100
- const lastIdx = lines.length - 1;
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} flexDirection="column" marginTop=${1}>
103
- ${lines.map((line, i) => {
104
- const sm = String(line).match(SUBAGENT);
105
- if (sm) {
106
- const [, idx, total, host, result] = sm;
107
- const good = !/fail|error|down|offline|unreachable|timeout|deny/i.test(result);
108
- const isLast = subLines[subLines.length - 1] === line;
109
- const running = busy && isLast && Number(idx) < Number(total);
110
- const branch = Number(idx) === Number(total) ? glyphs.branchEnd : glyphs.branchMid;
111
- return html`
112
- <${Box} key=${'s' + i}>
113
- <${Text} color=${C.gray}> <//>
114
- <${Text} color=${C.magenta}>${branch} <//>
115
- <${Text} color=${C.gray}>${idx}/${total} <//>
116
- <${Text} color=${C.white} bold>${host}<//>
117
- <${Text} color=${C.gray}> ${dash()} <//>
118
- ${running
119
- ? html`<${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /> working<//>`
120
- : html`<${Text} color=${good ? C.green : C.red} bold>${good ? glyphs.check : glyphs.cross} ${result}<//>`}
121
- <//>`;
122
- }
123
-
124
- // Tool / activity line. The last line while busy is the in-flight step.
125
- const running = busy && i === lastIdx;
126
- const em = String(line).match(LEAD_EMOJI);
127
- const body = em ? String(line).slice(em[0].length) : String(line).replace(/^[\s◈◆●○◎⇢→>*✓✗×!⚠·-]+/u, '');
128
- const ci = body.indexOf(':');
129
- const action = ci > -1 ? body.slice(0, ci) : body;
130
- const detail = ci > -1 ? body.slice(ci + 1).trim() : '';
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=${'s' + i}>
133
- ${running
134
- ? html`<${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /><//>`
135
- : html`<${Text} color=${C.green}>${glyphs.check}<//>`}
136
- <${Text}> <//>
137
- ${em ? html`<${Text}>${em[1]} <//>` : null}
138
- <${Text} color=${running ? C.white : C.gray} bold=${running}>${action}<//>
139
- ${detail ? html`<${Text} color=${C.gray}>: ${detail}<//>` : null}
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
- /** Reasoning trace (shown by default; toggle with Ctrl+T). */
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} borderStyle=${borderStyle()} borderColor=${C.gray}
150
- borderTop=${false} borderRight=${false} borderBottom=${false} paddingLeft=${1}>
151
- <${Text} color=${C.magenta}>${glyphs.reasoning} ${html`<${Text} color=${C.gray} bold>Reasoning<//>`}<//>
152
- <${Text} color=${C.gray} italic>${text}<//>
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 dot3 = '';
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
- <${Text} color=${C.accent} bold>${glyphs.diamond} Plan<//>
163
- ${reply.summary ? html`<${Text} color=${C.gray}>${reply.summary}<//>` : null}
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} color=${C.gray}>${last ? glyphs.branchEnd : glyphs.branchMid} <//>
174
- <${Text} color=${C.gray}>${i + 1}. <//>
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} color=${C.gray}> ${s.why || s.purpose}<//>` : null}
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} color=${C.gray} italic>${glyphs.bullet} forming plan${dot3}<//>`
185
- : html`<${Text} color=${C.gray}>Type ${html`<${Text} color=${C.white}>apply fix<//>`} to confirm.<//>`}
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
- return html`<${Box} marginTop=${1}><${Text} color=${color}>${text}<//><//>`;
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
- /** The active "working" line — animated spinner, elapsed time, cancel hint. */
210
- export function Thinking({ label = 'Working', startedAt }) {
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} color=${C.gray}>… (${secs}s ${midDot()}esc to interrupt)<//>
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} color=${C.gray}>${note}<//>` : null}
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} color=${C.gray}> ${dash()} ${o.hint}<//>` : null}
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} color=${C.gray}>${text ? '' : 'Type a custom answer…'}<//>
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} color=${C.gray}>${glyphs.up}${glyphs.down} choose ${midDot()}${allowText ? 'Tab type ' + midDot() : ''}Enter confirm ${midDot()}Esc dismiss<//>
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
+ }
@@ -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=${busy ? C.gray : C.accent} paddingX=${1}>
244
- <${Text} color=${busy ? C.gray : C.accent}>${glyphs.prompt} <//>
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…' : 'Ask about your network, or type / for commands'}<//>
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
  }
@@ -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('```')) { inCode = !inCode; continue; }
100
- if (inCode) { rows.push(html`<${Text} key=${k()} color=${C.green}> ${line}<//>`); continue; }
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: ACCENT,
12
- gray: 'gray',
13
- white: 'white',
14
- cyan: 'cyan',
15
- green: 'green',
16
- yellow: 'yellow',
17
- red: '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: '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, ASCII-safe on classic Windows consoles.
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.