ispbills-icli 8.6.9 → 8.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ispbills-icli",
3
- "version": "8.6.9",
3
+ "version": "8.7.0",
4
4
  "description": "iCli — IspBills AI network engineer in your terminal",
5
5
  "keywords": [
6
6
  "ispbills",
package/src/auth.js CHANGED
@@ -2,6 +2,40 @@ import * as readline from 'readline/promises';
2
2
  import { stdin as input, stdout as output } from 'process';
3
3
  import { saveConfig } from './config.js';
4
4
 
5
+ /** Read a password from stdin without echoing characters. */
6
+ async function readPasswordHidden(prompt) {
7
+ return new Promise((resolve) => {
8
+ output.write(prompt);
9
+ let pass = '';
10
+ const onData = (ch) => {
11
+ if (ch === '\r' || ch === '\n') {
12
+ input.setRawMode(false);
13
+ input.removeListener('data', onData);
14
+ output.write('\n');
15
+ resolve(pass);
16
+ } else if (ch === '\u0003') {
17
+ input.setRawMode(false);
18
+ process.exit(1);
19
+ } else if (ch === '\u007f' || ch === '\b') {
20
+ if (pass.length > 0) pass = pass.slice(0, -1);
21
+ } else {
22
+ pass += ch;
23
+ }
24
+ };
25
+ try {
26
+ input.setRawMode(true);
27
+ input.resume();
28
+ input.setEncoding('utf8');
29
+ input.on('data', onData);
30
+ } catch {
31
+ // fallback: readline without masking (non-TTY env)
32
+ input.removeListener('data', onData);
33
+ const rl2 = readline.createInterface({ input, output });
34
+ rl2.question(prompt, (ans) => { rl2.close(); resolve(ans); });
35
+ }
36
+ });
37
+ }
38
+
5
39
  export async function loginFlow(existingUrl = '') {
6
40
  const rl = readline.createInterface({ input, output });
7
41
 
@@ -16,11 +50,11 @@ export async function loginFlow(existingUrl = '') {
16
50
  url = url.replace(/\/$/, '');
17
51
  if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
18
52
 
19
- const email = (await rl.question('Email: ')).trim();
20
- const password = await rl.question('Password: ');
21
-
53
+ const email = (await rl.question('Email: ')).trim();
22
54
  rl.close();
23
55
 
56
+ const password = await readPasswordHidden('Password: ');
57
+
24
58
  const res = await fetch(`${url}/api/v1/auth/login`, {
25
59
  method: 'POST',
26
60
  headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
@@ -44,15 +78,21 @@ export async function loginFlow(existingUrl = '') {
44
78
  }
45
79
 
46
80
  export async function refreshToken(cfg) {
47
- const res = await fetch(`${cfg.url}/api/v1/auth/refresh`, {
48
- method: 'POST',
49
- headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
50
- body: JSON.stringify({ refresh_token: cfg.refresh_token }),
51
- });
52
- if (!res.ok) return null;
53
- const data = await res.json();
54
- cfg.token = data.token;
55
- cfg.refresh_token = data.refresh_token;
56
- saveConfig(cfg);
57
- return cfg;
81
+ try {
82
+ const res = await fetch(`${cfg.url}/api/v1/auth/refresh`, {
83
+ method: 'POST',
84
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
85
+ body: JSON.stringify({ refresh_token: cfg.refresh_token }),
86
+ });
87
+ if (!res.ok) return null;
88
+ const data = await res.json().catch(() => null);
89
+ if (!data?.token) return null;
90
+ cfg.token = data.token;
91
+ cfg.refresh_token = data.refresh_token;
92
+ saveConfig(cfg);
93
+ return cfg;
94
+ } catch {
95
+ return null;
96
+ }
58
97
  }
98
+
package/src/gitrepo.js CHANGED
@@ -42,13 +42,16 @@ function run(cfg, args, { input } = {}) {
42
42
  export async function ensureRepo(cfg) {
43
43
  const dir = repoDir(cfg);
44
44
  if (!existsSync(join(dir, '.git'))) {
45
- await run(cfg, ['init', '-q']);
45
+ const init = await run(cfg, ['init', '-q']);
46
+ if (init.code !== 0) throw new Error(`git init failed: ${init.stderr.trim()}`);
46
47
  await run(cfg, ['config', 'user.name', cfg?.user?.name || 'IspBills operator']);
47
48
  await run(cfg, ['config', 'user.email', cfg?.user?.email || 'operator@ispbills.local']);
48
49
  await run(cfg, ['config', 'commit.gpgsign', 'false']);
49
50
  writeFileSync(join(dir, 'README.md'), '# IspBills device backups\n\nManaged by iCli (`/backup`, `/git`).\n');
50
- await run(cfg, ['add', 'README.md']);
51
- await run(cfg, ['commit', '-q', '-m', 'Initialise backup repository']);
51
+ const add = await run(cfg, ['add', 'README.md']);
52
+ if (add.code !== 0) throw new Error(`git add failed: ${add.stderr.trim()}`);
53
+ const commit = await run(cfg, ['commit', '-q', '-m', 'Initialise backup repository']);
54
+ if (commit.code !== 0) throw new Error(`git commit failed: ${commit.stderr.trim()}`);
52
55
  }
53
56
  return dir;
54
57
  }
package/src/session.js CHANGED
@@ -134,8 +134,12 @@ export function listSessions() {
134
134
  export function loadSession(sessionPathOrId) {
135
135
  if (!sessionPathOrId) return [];
136
136
  if (sessionPathOrId.endsWith?.('.json')) {
137
- const session = safeJson(readFileSync(sessionPathOrId, 'utf8'), null);
138
- return normalizeSession(session || {}).messages;
137
+ try {
138
+ const session = safeJson(readFileSync(sessionPathOrId, 'utf8'), null);
139
+ return normalizeSession(session || {}).messages;
140
+ } catch {
141
+ return [];
142
+ }
139
143
  }
140
144
  return loadSessionById(sessionPathOrId)?.messages || [];
141
145
  }
package/src/tui/app.js CHANGED
@@ -2,10 +2,11 @@
2
2
  import { html, useState, useEffect, useRef, useCallback } from './dom.js';
3
3
  import { Box, Text, useStdout, useApp, useInput } from 'ink';
4
4
  import { C, glyphs, midDot, dash } from './theme.js';
5
+ import { useMouse } from './mouse.js';
5
6
  import {
6
7
  Banner, TabBar, FollowupChips, UserMessage, AssistantMessage, StatusLines, Reasoning,
7
8
  Plan, Connect, Notice, Thinking, Choice, ShellOutput, SearchOverlay, HelpOverlay,
8
- SessionPicker, DiffViewer, LabeledNotice,
9
+ SessionPicker, DiffViewer, LabeledNotice, TABS, TAB_W,
9
10
  } from './components.js';
10
11
  import { Composer } from './composer.js';
11
12
  import { streamChat } from '../client.js';
@@ -243,15 +244,15 @@ function itemHeight(it, cols = 80) {
243
244
  const w = Math.max(1, cols - 4); // paddingX + body inset
244
245
  switch (it.type) {
245
246
  case 'user':
246
- return 3 + wrappedLines(it.text, w); // sep + speaker + margin + body
247
+ return 2 + wrappedLines(it.text, w); // marginTop + speaker + body
247
248
  case 'assistant':
248
- return 3 + wrappedLines(it.text, Math.min(w, 96));
249
+ return 2 + wrappedLines(it.text, Math.min(w, 96));
249
250
  case 'shell':
250
- return 4 + wrappedLines(it.output || '(no output)', w) + 1;
251
+ return 3 + wrappedLines(it.output || '(no output)', w) + 1;
251
252
  case 'plan':
252
- return 4 + (it.reply?.steps?.length || 0);
253
+ return 3 + (it.reply?.steps?.length || 0);
253
254
  case 'connect':
254
- return 5;
255
+ return 4;
255
256
  case 'activity':
256
257
  return 1 + (it.status?.length || 0);
257
258
  case 'notice':
@@ -295,7 +296,7 @@ function liveAreaHeight(live, cols = 80, showReasoning = false) {
295
296
  if (!live) return 0;
296
297
  const w = Math.max(1, cols - 4);
297
298
  let h = 2; // thinking line + margin
298
- if (live.text) h += 3 + wrappedLines(live.text, Math.min(w, 96));
299
+ if (live.text) h += 2 + wrappedLines(live.text, Math.min(w, 96));
299
300
  if (live.reasoning) h += showReasoning ? 2 + wrappedLines(live.reasoning, w) : 1;
300
301
  if (live.reply?.steps) h += 3 + live.reply.steps.length;
301
302
  if (live.status?.length) h += 1 + live.status.length;
@@ -362,6 +363,35 @@ const TAB_CONTENT = {
362
363
  },
363
364
  };
364
365
 
366
+ function TabView({ panel, cols, activeTab }) {
367
+ const descriptions = {
368
+ devices: 'ISP device management — status, diagnostics, reachability',
369
+ maps: 'Network topology — VLAN, routing, IP planning, topology',
370
+ alarms: 'Real-time monitoring — alarms, offline devices, optical levels',
371
+ };
372
+ const desc = descriptions[activeTab] || '';
373
+ const bw = cols - 4;
374
+ return html`
375
+ <${Box} flexDirection="column" paddingX=${2} paddingY=${1}>
376
+ <${Box} marginBottom=${1}>
377
+ <${Text} color=${C.brand} bold>${panel.title}<//><${Text} color=${C.muted}> ${desc}<//>
378
+ <//>
379
+ <${Text} color=${C.separator}>${'─'.repeat(Math.max(1, bw))}<//>
380
+ <${Box} flexDirection="column" marginTop=${1}>
381
+ ${panel.commands.map((c, i) => html`
382
+ <${Box} key=${i}>
383
+ <${Text} color=${C.accent} bold>${String(i + 1).padStart(2)}. <//>
384
+ <${Text} color=${C.slash}>${c.cmd.padEnd(26)}<//>
385
+ <${Text} color=${C.muted}>${c.desc.slice(0, Math.max(10, cols - 38))}<//>
386
+ <//>`)}
387
+ <//>
388
+ <${Text} color=${C.separator} dimColor>${'─'.repeat(Math.max(1, bw))}<//>
389
+ <${Box} marginTop=${1}>
390
+ <${Text} color=${C.muted} dimColor>${panel.hint}<//><${Text} color=${C.muted} dimColor> ${midDot()} o open in browser<//><${Text} color=${C.muted} dimColor> ${midDot()} c/Enter → Session<//>
391
+ <//>
392
+ <//>`;
393
+ }
394
+
365
395
  export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, showBanner = true, agentName, resumeMode }) {
366
396
  const { exit } = useApp();
367
397
  const initialResumeRef = useRef(undefined);
@@ -377,11 +407,6 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
377
407
  const { cols, rows } = useTerminalSize();
378
408
  const colsRef = useRef(cols);
379
409
  useEffect(() => { colsRef.current = cols; }, [cols]);
380
- // Fixed-height viewport: total rows minus chrome (tabbar + spacing + chips +
381
- // composer + footer). Everything else scrolls inside this region.
382
- const viewportRows = Math.max(3, rows - 6);
383
- const scrollPage = Math.max(4, viewportRows - 2);
384
-
385
410
  const [items, setItems] = useState(() => renderConversationItems(initialMessages, cols));
386
411
  const [live, setLive] = useState(null);
387
412
  const [busy, setBusy] = useState(false);
@@ -407,6 +432,11 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
407
432
  const [trusted, setTrusted] = useState(isTrustedDir(cfg, process.cwd()));
408
433
  const [sessionPicker, setSessionPicker] = useState(null);
409
434
  const [diffState, setDiffState] = useState(null);
435
+ const chipsRows = (!busy && chips.length && !choice && !search && !sessionPicker && !diffState) ? 1 : 0;
436
+ const composerRows = 2;
437
+ const chromeRows = 2 + composerRows + 1 + chipsRows + 1;
438
+ const viewportRows = Math.max(3, rows - chromeRows);
439
+ const scrollPage = Math.max(4, viewportRows - 2);
410
440
 
411
441
  const convo = useRef(initialMessages);
412
442
  const plain = useRef([]);
@@ -427,6 +457,23 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
427
457
  const sessionCreatedAtRef = useRef(initialSession?.createdAt || new Date().toISOString());
428
458
  const sessionNameRef = useRef(initialSession?.name || sessionAutoName(initialMessages));
429
459
 
460
+ useMouse(({ btn, x, y, press }) => {
461
+ if (!press) return;
462
+ if (y <= 1 && btn === 0) {
463
+ const idx = Math.floor(x / TAB_W);
464
+ if (idx >= 0 && idx < TABS.length) {
465
+ setActiveTab(TABS[idx].id);
466
+ return;
467
+ }
468
+ }
469
+ if (choice || search || sessionPicker || diffState) return;
470
+ if (btn === 64) {
471
+ setScrollOffset((n) => Math.min(maxScrollRef.current, n + 3));
472
+ } else if (btn === 65) {
473
+ setScrollOffset((n) => Math.max(0, n - 3));
474
+ }
475
+ });
476
+
430
477
  useEffect(() => {
431
478
  try { process.stdout.write('\x1b[?2004h'); } catch {}
432
479
  return () => { try { process.stdout.write('\x1b[?2004l'); } catch {} };
@@ -1197,7 +1244,10 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1197
1244
  ' Ctrl+Home / End jump to start/end\n' +
1198
1245
  ' Ctrl+D shutdown\n' +
1199
1246
  ' Ctrl+C × 2 exit\n' +
1200
- ' Esc cancel / exit mode\n'
1247
+ ' Esc cancel / exit mode\n' +
1248
+ '\n Web commands:\n' +
1249
+ ' /search QUERY Web search (DuckDuckGo instant answers)\n' +
1250
+ ' /fetch URL Fetch and display a web page\n'
1201
1251
  );
1202
1252
  return;
1203
1253
  }
@@ -1387,7 +1437,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1387
1437
  ' research — Deep research across codebase, repos, web with citations',
1388
1438
  ' rubber-duck — Constructive critic; consulted automatically on non-trivial tasks',
1389
1439
  '',
1390
- 'Use: /rubber-duck [prompt] · /research TOPIC · /fleet [prompt]',
1440
+ 'Use: /search QUERY · /fetch URL · /rubber-duck [prompt] · /research TOPIC · /fleet [prompt]',
1391
1441
  ].join('\n'));
1392
1442
  return;
1393
1443
  }
@@ -1429,6 +1479,68 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1429
1479
  runTurn(`Play the role of a rubber-duck reviewer. Give a second opinion on the following, focusing on what could go wrong, what's missing, and what risks exist. Be concise and high-signal.${context ? context : ' ' + (prompt || 'Review our conversation so far.')}`);
1430
1480
  return;
1431
1481
  }
1482
+ if (q === '/search' || q.startsWith('/search ')) {
1483
+ const query = q.slice('/search'.length).trim();
1484
+ if (!query) { pushNotice('Usage: /search QUERY', C.warning); return; }
1485
+ pushNotice(`Searching: ${query}…`, C.muted, true);
1486
+ (async () => {
1487
+ try {
1488
+ const url = `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json&no_redirect=1&no_html=1&skip_disambig=1`;
1489
+ const res = await fetch(url, { headers: { 'User-Agent': 'ispbills-icli/8.6.10' } });
1490
+ const data = await res.json();
1491
+ const lines = [];
1492
+ if (data.AbstractText) lines.push(`**${data.Heading}**\n\n${data.AbstractText}\n\nSource: ${data.AbstractURL}`);
1493
+ if (data.RelatedTopics?.length) {
1494
+ lines.push('\n**Related:**');
1495
+ data.RelatedTopics.slice(0, 5).forEach((t) => {
1496
+ if (t.Text) lines.push(`- ${t.Text}`);
1497
+ });
1498
+ }
1499
+ if (!lines.length) lines.push(`No instant results. Try: /research ${query}`);
1500
+ push({ type: 'assistant', text: lines.join('\n') });
1501
+ } catch (err) {
1502
+ push({ type: 'error', text: `Search failed: ${err.message}` });
1503
+ }
1504
+ })();
1505
+ return;
1506
+ }
1507
+ if (q === '/fetch' || q.startsWith('/fetch ')) {
1508
+ const rawUrl = q.slice('/fetch'.length).trim();
1509
+ if (!rawUrl) { pushNotice('Usage: /fetch URL', C.warning); return; }
1510
+ const fetchUrl = rawUrl.startsWith('http') ? rawUrl : `https://${rawUrl}`;
1511
+ pushNotice(`Fetching ${fetchUrl}…`, C.muted, true);
1512
+ (async () => {
1513
+ try {
1514
+ const res = await fetch(fetchUrl, {
1515
+ headers: { 'User-Agent': 'ispbills-icli/8.6.10', 'Accept': 'text/html,text/plain,*/*' },
1516
+ redirect: 'follow',
1517
+ signal: AbortSignal.timeout(15000),
1518
+ });
1519
+ if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
1520
+ const ct = res.headers.get('content-type') || '';
1521
+ const text = await res.text();
1522
+ let content;
1523
+ if (ct.includes('json')) {
1524
+ try { content = '```json\n' + JSON.stringify(JSON.parse(text), null, 2).slice(0, 4000) + '\n```'; }
1525
+ catch { content = text.slice(0, 4000); }
1526
+ } else {
1527
+ content = text
1528
+ .replace(/<script[\s\S]*?<\/script>/gi, '')
1529
+ .replace(/<style[\s\S]*?<\/style>/gi, '')
1530
+ .replace(/<[^>]+>/g, ' ')
1531
+ .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&nbsp;/g, ' ').replace(/&#?\w+;/g, '')
1532
+ .replace(/[ \t]{2,}/g, ' ')
1533
+ .replace(/\n{3,}/g, '\n\n')
1534
+ .trim()
1535
+ .slice(0, 5000);
1536
+ }
1537
+ push({ type: 'assistant', text: `**${fetchUrl}**\n\n${content}` });
1538
+ } catch (err) {
1539
+ push({ type: 'error', text: `Fetch failed: ${err.message}` });
1540
+ }
1541
+ })();
1542
+ return;
1543
+ }
1432
1544
  if (q === '/research' || q.startsWith('/research ')) {
1433
1545
  const topic = q.slice('/research'.length).trim();
1434
1546
  if (!topic) { pushNotice('Usage: /research TOPIC'); return; }
@@ -1541,7 +1653,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1541
1653
  return;
1542
1654
  }
1543
1655
  if (q === '/app') {
1544
- pushNotice('Launching GitHub Copilot desktop app… (stub install from github.com/github/copilot-for-desktop)', C.muted);
1656
+ pushNotice('iCopilot web app open your IspBills instance in the browser with /o or by typing the URL.', C.muted);
1545
1657
  return;
1546
1658
  }
1547
1659
  if (q === '/user' || q.startsWith('/user ')) {
@@ -1894,7 +2006,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1894
2006
  }
1895
2007
  if (key.ctrl && input === 't') { setShowReasoning((v) => !v); return; }
1896
2008
  if (key.ctrl && input === 'l' && !busy) { clearAll(); return; }
1897
- if (key.ctrl && input === 'f' && !busy) { openSearch(); return; }
2009
+ if (key.ctrl && input === 'f' && !busy && !composerText.trim()) { openSearch(); return; }
1898
2010
  if (key.ctrl && input === 'o' && !busy && !composerText.trim()) { showExpanded('recent items', items.slice(-5)); return; }
1899
2011
  if (key.ctrl && input === 'e' && !busy && !composerText.trim()) { showExpanded('all items', items); return; }
1900
2012
  if (key.ctrl && input === 'n' && chips.length) { setActiveChip((i) => (i + 1) % chips.length); return; }
@@ -1904,6 +2016,25 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1904
2016
  const tabs = ['session', 'devices', 'maps', 'alarms'];
1905
2017
  return tabs[(tabs.indexOf(t) + 1) % tabs.length];
1906
2018
  });
2019
+ return;
2020
+ }
2021
+ // ←/→ arrow tab switching when the composer is empty (no cursor to move).
2022
+ // The outer early-return already guards against choice/search/sessionPicker/diffState.
2023
+ if (!composerText.trim()) {
2024
+ if (key.leftArrow) {
2025
+ setActiveTab((t) => {
2026
+ const tabs = ['session', 'devices', 'maps', 'alarms'];
2027
+ return tabs[(tabs.indexOf(t) - 1 + tabs.length) % tabs.length];
2028
+ });
2029
+ return;
2030
+ }
2031
+ if (key.rightArrow) {
2032
+ setActiveTab((t) => {
2033
+ const tabs = ['session', 'devices', 'maps', 'alarms'];
2034
+ return tabs[(tabs.indexOf(t) + 1) % tabs.length];
2035
+ });
2036
+ return;
2037
+ }
1907
2038
  }
1908
2039
  // Alt+↑/↓ for viewport scrolling (alternative to PgUp/PgDn)
1909
2040
  if (key.meta && key.upArrow && !choice && !search && !sessionPicker && !diffState) {
@@ -1936,7 +2067,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1936
2067
  const c = it.cols || 80;
1937
2068
  switch (it.type) {
1938
2069
  case 'user': return html`<${UserMessage} key=${it.id} text=${it.text} cols=${c} ts=${it.ts} />`;
1939
- case 'assistant': return html`<${AssistantMessage} key=${it.id} text=${it.text} cols=${c} />`;
2070
+ case 'assistant': return html`<${AssistantMessage} key=${it.id} text=${it.text} cols=${c} ts=${it.ts} />`;
1940
2071
  case 'plan': return html`<${Plan} key=${it.id} reply=${it.reply} />`;
1941
2072
  case 'connect': return html`<${Connect} key=${it.id} reply=${it.reply} />`;
1942
2073
  case 'activity': return html`<${StatusLines} key=${it.id} lines=${it.status} busy=${false} />`;
@@ -1958,7 +2089,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1958
2089
  const footerRightEstLen = (modelLabel.length || 0) + (tokenLabel.length || 0) + 8;
1959
2090
  const cwdMaxLen = Math.max(12, cols - footerRightEstLen - (branch ? branch.length + 6 : 0) - 4);
1960
2091
  const cwd = shortenPath(process.cwd(), cwdMaxLen);
1961
- const footerLeft = [cwd, branch ? `${glyphs.gitBranch}${branch}` : null].filter(Boolean).join(' ');
2092
+ const footerLeft = [cwd, branch ? `${glyphs.gitBranch}${branch}` : null].filter(Boolean).join(midDot());
1962
2093
  const footerRight = hint
1963
2094
  ? hint
1964
2095
  : streamerMode
@@ -1979,17 +2110,19 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1979
2110
 
1980
2111
  const liveArea = live
1981
2112
  ? html`<${Box} flexDirection="column">
1982
- ${live.text ? html`<${AssistantMessage} text=${live.text} cols=${cols} ts=${startedAt || Date.now()} streaming=${true} />` : null}
2113
+ <${StatusLines} lines=${live.status} busy=${busy} />
1983
2114
  ${live.reasoning ? html`<${Reasoning} text=${live.reasoning} show=${showReasoning} />` : null}
2115
+ ${live.text ? html`<${AssistantMessage} text=${live.text} cols=${cols} ts=${startedAt || Date.now()} streaming=${true} />` : null}
1984
2116
  ${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
1985
- <${StatusLines} lines=${live.status} busy=${busy} />
1986
2117
  <${Thinking} label=${live.reply ? 'Planning' : live.text ? 'Responding' : 'Working'} startedAt=${startedAt} />
1987
2118
  <//>`
1988
2119
  : null;
1989
2120
 
2121
+ const scrollPct = maxScrollRef.current > 0 ? Math.round((scrollOffset / maxScrollRef.current) * 100) : 100;
1990
2122
  const scrollIndicator = hasAbove
1991
- ? html`<${Box} paddingX=${1}>
1992
- <${Text} color=${C.muted} dimColor>${glyphs.up} ${aboveCount} message${aboveCount === 1 ? '' : 's'} above ${midDot()} PgUp/PgDn or Alt+↑/↓ to scroll<//>
2123
+ ? html`<${Box} paddingX=${1} justifyContent="space-between" width=${cols}>
2124
+ <${Text} color=${C.muted} dimColor>${glyphs.up} ${aboveCount} above ${midDot()} PgUp/PgDn ${midDot()} scroll<//>
2125
+ <${Text} color=${C.muted} dimColor>${scrollPct}%<//>
1993
2126
  <//>`
1994
2127
  : null;
1995
2128
 
@@ -2022,20 +2155,11 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
2022
2155
  ${expandedItems.items.map((it) => renderItem(it))}
2023
2156
  <//>`
2024
2157
  : tabPanel
2025
- ? html`<${Box} flexDirection="column" paddingX=${1} paddingY=${1}>
2026
- <${Text} color=${C.brand} bold>${tabPanel.title} — Quick Commands<//>\n
2027
- ${tabPanel.commands.map((c, i) =>
2028
- html`<${Box} key=${i}>
2029
- <${Text} color=${C.accent} bold>${String(i + 1)}. <//>
2030
- <${Text} color=${C.slash}>${c.cmd.padEnd(22)}<//>
2031
- <${Text} color=${C.muted}>${c.desc}<//>
2032
- <//>`)}
2033
- \n<${Text} color=${C.muted}>${tabPanel.hint}<//>
2034
- <//>`
2158
+ ? html`<${TabView} panel=${tabPanel} cols=${cols} activeTab=${activeTab} />`
2035
2159
  : html`<${Box} flexDirection="column">
2036
2160
  ${showEmptyBanner
2037
2161
  ? html`<${Box} flexDirection="column" alignItems="center" width=${cols}>
2038
- <${Banner} cfg=${cfg} model=${model} width=${cols} compact=${true} streamerMode=${streamerMode} />
2162
+ <${Banner} cfg=${cfg} model=${model} width=${cols} compact=${!showBanner} streamerMode=${streamerMode} />
2039
2163
  <//>`
2040
2164
  : null}
2041
2165
  ${scrollIndicator}
@@ -2045,17 +2169,19 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
2045
2169
 
2046
2170
  return html`
2047
2171
  <${Box} flexDirection="column" width=${cols} height=${rows}>
2048
- <${TabBar} active=${activeTab} cols=${cols} mode=${composerMode} />
2172
+ <${TabBar} active=${activeTab} cols=${cols} />
2049
2173
 
2050
2174
  <${Box} flexDirection="column" flexGrow=${1} overflow="hidden">
2051
2175
  ${timelineContent}
2052
2176
  <//>
2053
2177
 
2054
- ${search ? html`<${SearchOverlay} query=${search.query} matches=${searchMatches} active=${search.active} cols=${cols} />` : null}
2178
+ <${Box} flexDirection="column">
2179
+ ${search ? html`<${SearchOverlay} query=${search.query} matches=${searchMatches} active=${search.active} cols=${cols} />` : null}
2055
2180
 
2056
- ${!busy && chips.length ? html`<${FollowupChips} chips=${chips} active=${activeChip} />` : null}
2181
+ ${!busy && chips.length && !choice && !search && !sessionPicker && !diffState
2182
+ ? html`<${FollowupChips} chips=${chips} active=${activeChip} />`
2183
+ : null}
2057
2184
 
2058
- <${Box} flexDirection="column">
2059
2185
  ${choice
2060
2186
  ? html`<${Choice} ...${choice} cols=${cols} />`
2061
2187
  : sessionPicker
@@ -106,45 +106,33 @@ function SpeakerLine({ label, color, glyphColor, cols = 80, ts, rightLabel = ''
106
106
  }
107
107
 
108
108
  // ── Tab Bar ─────────────────────────────────────────────────────────────────
109
- const TABS = [
110
- { id: 'session', label: 'Session' },
111
- { id: 'devices', label: 'Devices' },
112
- { id: 'maps', label: 'Network Maps' },
113
- { id: 'alarms', label: 'Alarms' },
109
+ export const TABS = [
110
+ { id: 'session', label: 'Session' },
111
+ { id: 'devices', label: 'Devices' },
112
+ { id: 'maps', label: 'Maps' },
113
+ { id: 'alarms', label: 'Alarms' },
114
114
  ];
115
115
 
116
- export function TabBar({ active = 'session', cols = 80, mode = 'ask' }) {
117
- const modeBadge = mode !== 'ask'
118
- ? ` ${mode === 'autopilot' ? '⚡ AUTO' : mode === 'shell' ? '⌘ SHELL' : '📋 PLAN'}`
119
- : '';
120
- const badgeLen = modeBadge ? modeBadge.length + 2 : 0;
121
- const available = cols - 2 - badgeLen; // -2 for paddingX={1}
122
-
123
- // §11: clip tab labels when terminal is narrow (~50 cols)
124
- // Full: [Session] Devices Customers Alarms = ~42 chars
125
- // Short: [Ses] Dev Cus Alm = ~26 chars
126
- const fullWidth = TABS.reduce((sum, t, i) => {
127
- const w = t.id === active ? t.label.length + 2 : t.label.length;
128
- return sum + (i > 0 ? 3 : 0) + w;
129
- }, 0);
130
- const abbreviated = fullWidth > available;
116
+ export const TAB_W = 14;
131
117
 
118
+ export function TabBar({ active = 'session', cols = 80 }) {
132
119
  return html`
133
- <${Box} width=${cols} backgroundColor="blue">
134
- <${Box} paddingX=${1} flexGrow=${1}>
135
- ${TABS.map((t, i) => {
136
- const on = t.id === active;
137
- const lbl = abbreviated ? t.label.slice(0, 3) : t.label;
120
+ <${Box} flexDirection="column" width=${cols}>
121
+ <${Box} flexDirection="row" width=${cols}>
122
+ ${TABS.map((tab, i) => {
123
+ const on = tab.id === active;
124
+ const label = ` ${i + 1} ${tab.label} `.padEnd(TAB_W);
138
125
  return html`
139
- <${Box} key=${t.id}>
140
- ${i > 0 ? html`<${Text} backgroundColor="blue"> <//>` : null}
141
- ${on
142
- ? html`<${Text} color="white" bold backgroundColor="blue">[${lbl}]<//>`
143
- : html`<${Text} color="white" dimColor backgroundColor="blue">${lbl}<//>`}
126
+ <${Box} key=${tab.id} width=${TAB_W}>
127
+ <${Text} bold=${on} color=${on ? 'cyan' : 'white'} dimColor=${!on} inverse=${on}>
128
+ ${label}
129
+ <//>
144
130
  <//>`;
145
131
  })}
132
+ <${Box} flexGrow=${1} />
133
+ <${Text} color=${C.muted} dimColor>click ${midDot()} ←/→<//>
146
134
  <//>
147
- ${modeBadge ? html`<${Box} paddingRight=${1}><${Text} color="yellow" bold backgroundColor="blue">${modeBadge}<//><//>` : null}
135
+ <${Text} color=${C.separator}>${'─'.repeat(Math.max(1, cols))}<//>
148
136
  <//>`;
149
137
  }
150
138
 
@@ -152,73 +140,62 @@ export function FollowupChips({ chips = [], active = 0 }) {
152
140
  if (!chips.length) return null;
153
141
  return html`
154
142
  <${Box} paddingX=${1} flexWrap="wrap">
155
- <${Text} color=${C.muted}>Next: <//>
156
- ${chips.map((chip, i) => {
143
+ <${Text} color=${C.muted}>Next <//>
144
+ ${chips.slice(0, 4).map((chip, i) => {
157
145
  const on = i === active;
158
146
  return html`
159
- <${Box} key=${'ch' + i}>
160
- ${i > 0 ? html`<${Text} color=${C.muted}> <//>` : null}
161
- <${Text} color=${on ? C.success : C.muted} bold=${on}>[${chip}]<//>
147
+ <${Box} key=${'ch' + i} marginRight=${1}>
148
+ <${Text} color=${on ? C.success : 'white'} inverse=${on}> ${chip} <//>
162
149
  <//>`;
163
150
  })}
164
- <${Text} color=${C.muted}> ↵ run Ctrl+N/P cycle Esc dismiss<//>
165
151
  <//>`;
166
152
  }
167
153
 
168
154
  export function Banner({ cfg = {}, model, width = 80, compact = false, streamerMode = false }) {
155
+ const [frame, setFrame] = useState(0);
156
+
157
+ useEffect(() => {
158
+ const iv = setInterval(() => setFrame((f) => (f + 1) % 4), 120);
159
+ return () => clearInterval(iv);
160
+ }, []);
161
+
162
+ const colors = ['cyan', 'blue', C.success, C.accent];
163
+ const logoColor = colors[frame] ?? 'cyan';
164
+
169
165
  const m = shortModel(model || cfg._model || 'IspBills AI');
170
- const wide = !compact && width >= 60;
171
- const meta = [
172
- ['model', streamerMode ? 'hidden' : m], ['user', cfg.user?.name], ['role', cfg.user?.role],
173
- ].filter(([, v]) => v);
174
- // §3: show /login instruction when unauthenticated
175
166
  const isAuth = Boolean(cfg.user?.name || cfg.user?.email || cfg.apiKey || cfg._token);
176
167
 
177
168
  if (compact) {
178
169
  return html`
179
170
  <${Box} marginBottom=${1}>
180
- <${Text} color=${C.accent} bold>iCopilot <//>
181
- ${meta.map(([label, val], i) => html`
182
- <${Box} key=${'m' + i}>
183
- <${Text} color=${C.muted}>${midDot()}<//>
184
- <${Text} color=${label === 'model' ? C.accent : C.brand}>${val}<//>
185
- <//>`)}
171
+ <${Text} color=${C.accent} bold>${glyphs.ring} iCopilot <//>
172
+ <${Text} color=${C.muted}>${midDot()}<//>
173
+ <${Text} color=${C.muted}>${m}<//>
174
+ ${cfg.user?.name ? html`<${Text} color=${C.muted}>${midDot()}<//>` : null}
175
+ ${cfg.user?.name ? html`<${Text} color=${C.brand}>${cfg.user.name}<//>` : null}
186
176
  <//>`;
187
177
  }
188
178
 
189
179
  return html`
190
180
  <${Box} flexDirection="column" marginBottom=${1} alignItems="center" width=${width}>
191
- ${wide
192
- ? html`<${Box} flexDirection="column" alignItems="center" width=${width}>
193
- ${LOGO.map((l, i) => html`<${Text} key=${'l' + i} color=${C.accent} bold>${l}<//>`)}
194
- <//>`
195
- : html`<${Text} color=${C.accent} bold>◈ iCopilot ${html`<${Text} color=${C.muted}>${dash()} IspBills AI terminal<//>`}<//>` }
196
-
197
- ${wide ? html`<${Text} color=${C.separator}>${'─'.repeat(Math.max(1, width))}<//>\n` : null}
181
+ <${Box} flexDirection="column" alignItems="center" width=${width}>
182
+ ${LOGO.map((l, i) => html`<${Text} key=${'l' + i} color=${logoColor} bold>${l}<//>`)}
183
+ <//>
198
184
 
199
- <${Box} marginTop=${wide ? 0 : 1} justifyContent="center">
200
- <${Text} color=${C.accent} bold>◈ iCopilot<//>
185
+ <${Box} marginTop=${1} justifyContent="center">
186
+ <${Text} color=${C.accent} bold>${glyphs.ring} iCopilot<//>
201
187
  <${Text} color=${C.muted}> ${dash()} IspBills network engineer in your terminal<//>
202
188
  <//>
203
189
 
204
- ${wide ? html`<${Text} color=${C.separator}>${'─'.repeat(Math.max(1, width))}<//>\n` : null}
205
-
206
- ${meta.length ? html`<${Box} marginTop=${1} justifyContent="center">
207
- ${meta.map(([label, val], i) => html`
208
- <${Box} key=${'m' + i}>
209
- ${i > 0 ? html`<${Text} color=${C.muted}>${midDot()}<//>` : null}
210
- <${Text} color=${C.muted}>${label} <//>
211
- <${Text} color=${label === 'model' ? C.accent : C.brand}>${val}<//>
212
- <//>`)}
213
- <//>` : null}
214
-
215
- ${cfg.url ? html`<${Box} justifyContent="center"><${Text} color=${C.muted}>${cfg.url}<//><//>` : null}
216
-
217
- <${Box} flexDirection="column" marginTop=${1} alignItems="center" width=${width}>
218
- <${Box} paddingX=${2} flexDirection="column">
219
- <${Text} color=${C.muted} dimColor> ${' @ '}${html`<${Text} color=${C.accent}>@<//>`} mention files · ${html`<${Text} color=${C.accent}>/<//>`} commands · ${html`<${Text} color=${C.accent}>?<//>`} help · ${html`<${Text} color=${C.accent}>!<//>` } shell<//>
220
- <//>
221
- <//>
190
+ ${model || cfg._model ? html`
191
+ <${Box} marginTop=${1} justifyContent="center">
192
+ <${Text} color=${C.muted}>model <//>
193
+ <${Text} color=${C.accent}>${m}<//>
194
+ ${cfg.user?.name ? html`<${Text} color=${C.muted}>${midDot()}<//>` : null}
195
+ ${cfg.user?.name ? html`<${Text} color=${C.muted}>user <//>` : null}
196
+ ${cfg.user?.name ? html`<${Text} color=${C.brand}>${cfg.user.name}<//>` : null}
197
+ ${cfg.url ? html`<${Text} color=${C.muted}>${midDot()}${cfg.url}<//>` : null}
198
+ <//>` : null}
222
199
 
223
200
  ${!isAuth
224
201
  ? html`<${Box} marginTop=${1} justifyContent="center">
@@ -226,6 +203,10 @@ export function Banner({ cfg = {}, model, width = 80, compact = false, streamerM
226
203
  <${Text} color=${C.slash} bold>/login<//>
227
204
  <${Text} color=${C.warning}> to connect.<//><//>`
228
205
  : null}
206
+
207
+ <${Box} marginTop=${1} justifyContent="center">
208
+ <${Text} color=${C.muted} dimColor>@ mention / commands ? help ! shell Shift+Tab cycle mode<//>
209
+ <//>
229
210
  <//>`;
230
211
  }
231
212
 
@@ -245,12 +226,15 @@ function Sep({ cols = 80 }) {
245
226
  }
246
227
 
247
228
  export function UserMessage({ text, cols = 80, ts }) {
229
+ const stamp = ts ? new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '';
248
230
  return html`
249
- <${Box} flexDirection="column" marginTop=${1}>
250
- <${Sep} cols=${cols} />
251
- <${SpeakerLine} label="You" color=${C.brand} cols=${cols} ts=${ts} />
252
- <${Box} paddingX=${1}>
253
- <${Text}>${withMentions(text)}<//>
231
+ <${Box} flexDirection="column" marginBottom=${1}>
232
+ <${Box} flexDirection="row" justifyContent="space-between" width=${cols} paddingX=${1}>
233
+ <${Text} bold color="cyan">You<//>
234
+ <${Text} color=${C.muted}>${stamp}<//>
235
+ <//>
236
+ <${Box} paddingX=${1} marginLeft=${2} flexWrap="wrap">
237
+ ${withMentions(text)}
254
238
  <//>
255
239
  <//>`;
256
240
  }
@@ -278,14 +262,17 @@ function StreamingText({ text = '' }) {
278
262
  return rendered;
279
263
  }
280
264
 
281
- export function AssistantMessage({ text, cols = 80, ts, label = `${glyphs.ring} iCopilot`, streaming = false }) {
282
- // §11: response content box capped at 100 columns; separators stay full-width (§6).
265
+ export function AssistantMessage({ text, cols = 80, ts, label, streaming = false }) {
266
+ const stamp = ts ? new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '';
267
+ const speakerLabel = streaming ? 'iCopilot ●' : 'iCopilot';
283
268
  const bodyWidth = Math.min(cols, 100);
284
269
  return html`
285
- <${Box} flexDirection="column" marginTop=${1}>
286
- <${Sep} cols=${cols} />
287
- <${SpeakerLine} label=${label} color="white" glyphColor=${C.accent} cols=${cols} />
288
- <${Box} paddingX=${1} flexDirection="column" width=${bodyWidth}>
270
+ <${Box} flexDirection="column" marginBottom=${1}>
271
+ <${Box} flexDirection="row" justifyContent="space-between" width=${cols} paddingX=${1}>
272
+ <${Text} bold color=${C.success}>${speakerLabel}<//>
273
+ <${Text} color=${C.muted}>${stamp}<//>
274
+ <//>
275
+ <${Box} paddingX=${1} marginLeft=${2} flexDirection="column" width=${bodyWidth}>
289
276
  ${streaming ? html`<${StreamingText} text=${text} />` : html`<${Markdown} content=${text} />`}
290
277
  <//>
291
278
  <//>`;
@@ -294,9 +281,8 @@ export function AssistantMessage({ text, cols = 80, ts, label = `${glyphs.ring}
294
281
  export function ShellOutput({ command, output, cols = 80, ts, code = 0 }) {
295
282
  return html`
296
283
  <${Box} flexDirection="column" marginTop=${1}>
297
- <${Sep} cols=${cols} />
298
284
  <${SpeakerLine}
299
- label=" Shell"
285
+ label=${"$ Shell"}
300
286
  color=${code === 0 ? C.warning : C.error}
301
287
  cols=${cols}
302
288
  ts=${ts}
@@ -314,44 +300,49 @@ const LEAD_EMOJI = /^\s*([\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\
314
300
 
315
301
  export function StatusLines({ lines = [], busy = false }) {
316
302
  if (!lines.length) return null;
317
- const subLines = lines.filter((l) => SUBAGENT.test(l));
318
303
  const lastIdx = lines.length - 1;
319
304
  return html`
320
305
  <${Box} flexDirection="column" marginTop=${1}>
321
306
  ${lines.map((line, i) => {
322
- const sm = String(line).match(SUBAGENT);
307
+ const lineStr = String(line);
308
+ const sm = lineStr.match(SUBAGENT);
323
309
  if (sm) {
324
310
  const [, idx, total, host, result] = sm;
311
+ // A subagent line is still running when busy and hasn't got a ✓/✗ prefix yet
312
+ const isCompleted = /^\s*[✓✗x]/.test(lineStr);
313
+ const running = busy && !isCompleted;
325
314
  const good = !/fail|error|down|offline|unreachable|timeout|deny/i.test(result);
326
- const isLast = subLines[subLines.length - 1] === line;
327
- const running = busy && isLast && Number(idx) < Number(total);
328
315
  const branch = Number(idx) === Number(total) ? glyphs.branchEnd : glyphs.branchMid;
316
+ const hostTrunc = host.length > 24 ? host.slice(0, 22) + '…' : host;
317
+ const resultTrunc = result.length > 32 ? result.slice(0, 30) + '…' : result;
329
318
  return html`
330
319
  <${Box} key=${'s' + i}>
331
320
  <${Text} color=${C.gray}> <//>
332
- <${Text} color=${C.magenta}>${branch} <//>
333
- <${Text} color=${C.gray}>${idx}/${total} <//>
334
- <${Text} color=${C.white} bold>${host}<//>
335
- <${Text} color=${C.gray}> ${dash()} <//>
321
+ <${Text} color=${C.accent}>${branch} <//>
322
+ <${Text} color=${C.muted}>${idx}/${total} <//>
323
+ <${Text} color=${C.white} bold>${hostTrunc}<//>
324
+ <${Text} color=${C.muted}> ${dash()} <//>
336
325
  ${running
337
- ? html`<${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /> working<//>`
338
- : html`<${Text} color=${good ? C.green : C.red} bold>${good ? glyphs.check : glyphs.cross} ${result}<//>`}
326
+ ? html`<${Text} color=${C.warning}><${Spinner} type=${spinnerType()} /><//> <${Text} color=${C.muted}>running<//>`
327
+ : html`<${Text} color=${good ? C.success : C.error} bold>${good ? glyphs.check : glyphs.cross} ${resultTrunc}<//>`}
339
328
  <//>`;
340
329
  }
341
330
 
342
331
  const running = busy && i === lastIdx;
343
- const em = String(line).match(LEAD_EMOJI);
344
- const body = em ? String(line).slice(em[0].length) : String(line).replace(/^[\s◈◆●○◎⇢→>*✓✗×!⚠·-]+/u, '');
332
+ const em = lineStr.match(LEAD_EMOJI);
333
+ const body = em ? lineStr.slice(em[0].length) : lineStr.replace(/^[\s◈◆●○◎⇢→>*✓✗×!⚠·-]+/u, '');
345
334
  const ci = body.indexOf(':');
346
335
  const action = ci > -1 ? body.slice(0, ci).trim() : body.trim();
347
336
  const detail = ci > -1 ? body.slice(ci + 1).trim() : '';
348
- // §6/§23: render as [Tool call: name detail] ✓/spinner chip
349
- const chipLabel = detail ? `${em ? em[1] : ''}${action} ${detail}` : `${em ? em[1] : ''}${action}`;
337
+ const chipLabel = detail
338
+ ? `${em ? em[1] : ''}${action}: ${detail.slice(0, 40)}`
339
+ : `${em ? em[1] : ''}${action}`;
350
340
  return html`
351
341
  <${Box} key=${'s' + i} marginTop=${0}>
352
342
  <${Text} color=${C.muted}> <//>
353
343
  <${Text} color=${running ? C.warning : C.muted}>[<//>
354
- <${Text} color=${running ? C.white : C.muted} bold=${running}>${chipLabel.trim()}<//><${Text} color=${running ? C.warning : C.muted}>]<//><${Text}> <//>
344
+ <${Text} color=${running ? C.white : C.muted} bold=${running}>${chipLabel.trim().slice(0, 60)}<//><${Text} color=${running ? C.warning : C.muted}>]<//>
345
+ <${Text}> <//>
355
346
  ${running
356
347
  ? html`<${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /><//>`
357
348
  : html`<${Text} color=${C.success}>${glyphs.check}<//>`}
@@ -429,7 +420,6 @@ export function Notice({ text, color = C.gray }) {
429
420
  export function LabeledNotice({ label, text, color = C.gray, cols = 80, ts, bodyColor = C.white }) {
430
421
  return html`
431
422
  <${Box} flexDirection="column" marginTop=${1}>
432
- <${Sep} cols=${cols} />
433
423
  <${SpeakerLine} label=${label} color=${color} cols=${cols} ts=${ts} />
434
424
  <${Box} paddingX=${1}>
435
425
  <${Text} color=${bodyColor}>${text}<//>
@@ -534,7 +524,7 @@ export function Thinking({ label = 'Working', startedAt }) {
534
524
  <${Box} marginTop=${1} paddingX=${1}>
535
525
  <${Text} color=${C.warning}><${Spinner} type=${spinnerType()} /><//>
536
526
  <${Text} color=${C.warning} bold> ${label}<//>
537
- <${Text} color=${C.muted}>… (${secs}s ${midDot()}esc to interrupt)<//>
527
+ <${Text} color=${C.muted}>… (${secs}s ${midDot()} esc to cancel)<//>
538
528
  <//>`;
539
529
  }
540
530
 
@@ -543,11 +533,9 @@ export function Choice({ title, note, options = [], sel = 0, allowText = false,
543
533
  if (isToolApproval) {
544
534
  return html`
545
535
  <${Box} flexDirection="column" marginTop=${1}>
546
- <${Sep} cols=${cols} />
547
536
  <${Box} paddingX=${2} marginTop=${1}>
548
- <${Text} color=${C.accent} bold>⚙ Tool request<//>
537
+ <${Text} color=${C.accent} bold>${glyphs.bolt} Tool request<//>
549
538
  <//>
550
- <${Sep} cols=${cols} />
551
539
  ${note ? html`<${Box} paddingX=${2} marginTop=${1}><${Text} color=${C.muted}>${note}<//><//>` : null}
552
540
  <${Box} flexDirection="column" marginTop=${1} paddingX=${2}>
553
541
  ${options.map((o, i) => {
@@ -466,13 +466,13 @@ export function Composer({
466
466
  const promptGlyph = glyphs.prompt;
467
467
  const promptColor = busy ? C.warning : C.success;
468
468
  const ghostHint = busy ? 'Working…' : 'Enter @ to mention files or / for commands…';
469
- const modePrefix = mode === 'plan'
470
- ? '[plan] '
469
+ const modeLabel = mode === 'plan'
470
+ ? '[plan]'
471
471
  : mode === 'autopilot'
472
- ? '[auto] '
472
+ ? '[auto]'
473
473
  : mode === 'shell'
474
- ? '[shell] '
475
- : '';
474
+ ? '[shell]'
475
+ : '[ask]';
476
476
 
477
477
  const paletteRows = (() => {
478
478
  if (!menuOpen) return null;
@@ -483,12 +483,14 @@ export function Composer({
483
483
  return window.map((s, wi) => {
484
484
  const i = start + wi;
485
485
  const on = i === sel;
486
- const kk = slashMode ? (s.cmd + (s.hint ? ' ' + s.hint : '')) : s.tag;
486
+ const kk = (slashMode ? (s.cmd + (s.hint ? ' ' + s.hint : '')) : s.tag).slice(0, 22);
487
487
  const label = kk.padEnd(22);
488
+ const descWidth = Math.max(8, width - 28);
489
+ const desc = s.desc ? s.desc.slice(0, descWidth) : '';
488
490
  return html`<${Box} key=${'m' + i}>
489
491
  <${Text} color=${on ? C.success : C.muted}>${on ? glyphs.prompt + ' ' : ' '}<//>
490
492
  <${Text} color=${on ? C.success : slashMode ? C.slash : C.accent} bold=${on}>${label}<//>
491
- <${Text} color=${C.muted}>${s.desc}<//>
493
+ <${Text} color=${C.muted}>${desc}<//>
492
494
  <//>`;
493
495
  });
494
496
  })();
@@ -505,20 +507,22 @@ export function Composer({
505
507
  <//>`
506
508
  : null}
507
509
 
508
- <${Text} color=${C.accent}>${'_'.repeat(Math.max(1, width))}<//>
509
-
510
- <${Box} width=${width} paddingX=${1}>
511
- <${Text} color=${promptColor} bold>${promptGlyph} <//>
512
- ${busy ? html`<${Text} color=${C.muted}>(working…) <//>` : null}
513
- ${modePrefix ? html`<${Text} color=${mode === 'shell' ? C.warning : C.accent} bold>${modePrefix}<//>` : null}
514
- ${empty
515
- ? html`<${Box} flexGrow=${1}>
516
- <${Text} color="white" inverse> <//>
517
- <${Text} color=${C.muted} italic> ${ghostHint}<//>
518
- <//>`
519
- : html`<${Box} flexGrow=${1}>
520
- <${Text}>${before}<//><${Text} inverse>${at}<//><${Text}>${after}<//><${Text} color=${C.muted} italic>${ghost}<//>
521
- <//>`}
510
+ <${Text} color=${C.muted}>${''.repeat(Math.max(1, width))}<//>
511
+
512
+ <${Box} width=${width} paddingX=${1} justifyContent="space-between">
513
+ <${Box} flexGrow=${1}>
514
+ <${Text} color=${promptColor} bold>${promptGlyph} <//>
515
+ ${busy ? html`<${Text} color=${C.muted}>(working…) <//>` : null}
516
+ ${empty
517
+ ? html`<${Box} flexGrow=${1}>
518
+ <${Text} color="white" inverse> <//>
519
+ <${Text} color=${C.muted} italic> ${ghostHint}<//>
520
+ <//>`
521
+ : html`<${Box} flexGrow=${1}>
522
+ <${Text}>${before}<//><${Text} inverse>${at}<//><${Text}>${after}<//><${Text} color=${C.muted} italic>${ghost}<//>
523
+ <//>`}
524
+ <//>
525
+ <${Text} color=${C.muted}>${modeLabel}<//>
522
526
  <//>
523
527
 
524
528
  ${menuOpen
@@ -533,7 +537,5 @@ export function Composer({
533
537
  ${paletteRows}
534
538
  <//>`
535
539
  : null}
536
-
537
- <${Text} color=${C.separator}>${(asciiSafe() ? '-' : '─').repeat(Math.max(1, width))}<//>
538
540
  <//>`;
539
541
  }
@@ -0,0 +1,57 @@
1
+ import { useEffect, useRef } from './dom.js';
2
+ import { useStdin } from 'ink';
3
+
4
+ function parseMouseData(chunk, emit) {
5
+ let i = 0;
6
+ while (i < chunk.length) {
7
+ if (chunk.startsWith('\x1b[<', i)) {
8
+ const match = chunk.slice(i).match(/^\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
9
+ if (!match) return chunk.slice(i);
10
+ const btn = Number(match[1]);
11
+ const x = Math.max(0, Number(match[2]) - 1);
12
+ const y = Math.max(0, Number(match[3]) - 1);
13
+ emit({ btn, x, y, press: match[4] === 'M' });
14
+ i += match[0].length;
15
+ continue;
16
+ }
17
+ if (chunk.startsWith('\x1b[M', i)) {
18
+ if (i + 6 > chunk.length) return chunk.slice(i);
19
+ const btn = chunk.charCodeAt(i + 3) - 32;
20
+ const x = Math.max(0, chunk.charCodeAt(i + 4) - 33);
21
+ const y = Math.max(0, chunk.charCodeAt(i + 5) - 33);
22
+ emit({ btn, x, y, press: btn !== 3 });
23
+ i += 6;
24
+ continue;
25
+ }
26
+ i += 1;
27
+ }
28
+ return '';
29
+ }
30
+
31
+ export function useMouse(handler, enabled = true) {
32
+ const { stdin } = useStdin();
33
+ const handlerRef = useRef(handler);
34
+ const bufferRef = useRef('');
35
+
36
+ handlerRef.current = handler;
37
+
38
+ useEffect(() => {
39
+ if (!enabled || !stdin) return;
40
+
41
+ try { process.stdout.write('\x1b[?1000h\x1b[?1006h'); } catch {}
42
+
43
+ const onData = (chunk) => {
44
+ const text = Buffer.isBuffer(chunk) ? chunk.toString('latin1') : String(chunk);
45
+ bufferRef.current = parseMouseData(bufferRef.current + text, (event) => {
46
+ handlerRef.current?.(event);
47
+ });
48
+ };
49
+
50
+ stdin.on('data', onData);
51
+ return () => {
52
+ stdin.off('data', onData);
53
+ bufferRef.current = '';
54
+ try { process.stdout.write('\x1b[?1000l\x1b[?1006l'); } catch {}
55
+ };
56
+ }, [enabled, stdin]);
57
+ }
package/src/tui/theme.js CHANGED
@@ -24,6 +24,7 @@ export const C = {
24
24
  muted: '#8B949E', // grey
25
25
  separator: '#30363D', // dark
26
26
  slash: '#E3B341', // gold
27
+ bgTabBar: '#0D1117', // tab bar background
27
28
  // Legacy aliases (keep existing code working)
28
29
  gray: '#8B949E',
29
30
  white: 'white',