ispbills-icli 8.6.10 → 8.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ispbills-icli",
3
- "version": "8.6.10",
3
+ "version": "8.7.1",
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';
@@ -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,10 +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=1, composer=2, footer=1, buffer=3).
381
- const viewportRows = Math.max(3, rows - 7);
382
- const scrollPage = Math.max(4, viewportRows - 2);
383
-
384
410
  const [items, setItems] = useState(() => renderConversationItems(initialMessages, cols));
385
411
  const [live, setLive] = useState(null);
386
412
  const [busy, setBusy] = useState(false);
@@ -406,6 +432,11 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
406
432
  const [trusted, setTrusted] = useState(isTrustedDir(cfg, process.cwd()));
407
433
  const [sessionPicker, setSessionPicker] = useState(null);
408
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 + 2; // +2 for two footer rows
438
+ const viewportRows = Math.max(3, rows - chromeRows);
439
+ const scrollPage = Math.max(4, viewportRows - 2);
409
440
 
410
441
  const convo = useRef(initialMessages);
411
442
  const plain = useRef([]);
@@ -426,6 +457,23 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
426
457
  const sessionCreatedAtRef = useRef(initialSession?.createdAt || new Date().toISOString());
427
458
  const sessionNameRef = useRef(initialSession?.name || sessionAutoName(initialMessages));
428
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
+
429
477
  useEffect(() => {
430
478
  try { process.stdout.write('\x1b[?2004h'); } catch {}
431
479
  return () => { try { process.stdout.write('\x1b[?2004l'); } catch {} };
@@ -1196,7 +1244,10 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1196
1244
  ' Ctrl+Home / End jump to start/end\n' +
1197
1245
  ' Ctrl+D shutdown\n' +
1198
1246
  ' Ctrl+C × 2 exit\n' +
1199
- ' 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'
1200
1251
  );
1201
1252
  return;
1202
1253
  }
@@ -1386,7 +1437,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1386
1437
  ' research — Deep research across codebase, repos, web with citations',
1387
1438
  ' rubber-duck — Constructive critic; consulted automatically on non-trivial tasks',
1388
1439
  '',
1389
- 'Use: /rubber-duck [prompt] · /research TOPIC · /fleet [prompt]',
1440
+ 'Use: /search QUERY · /fetch URL · /rubber-duck [prompt] · /research TOPIC · /fleet [prompt]',
1390
1441
  ].join('\n'));
1391
1442
  return;
1392
1443
  }
@@ -1428,6 +1479,68 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1428
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.')}`);
1429
1480
  return;
1430
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
+ }
1431
1544
  if (q === '/research' || q.startsWith('/research ')) {
1432
1545
  const topic = q.slice('/research'.length).trim();
1433
1546
  if (!topic) { pushNotice('Usage: /research TOPIC'); return; }
@@ -1540,7 +1653,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1540
1653
  return;
1541
1654
  }
1542
1655
  if (q === '/app') {
1543
- 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);
1544
1657
  return;
1545
1658
  }
1546
1659
  if (q === '/user' || q.startsWith('/user ')) {
@@ -1893,7 +2006,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1893
2006
  }
1894
2007
  if (key.ctrl && input === 't') { setShowReasoning((v) => !v); return; }
1895
2008
  if (key.ctrl && input === 'l' && !busy) { clearAll(); return; }
1896
- if (key.ctrl && input === 'f' && !busy) { openSearch(); return; }
2009
+ if (key.ctrl && input === 'f' && !busy && !composerText.trim()) { openSearch(); return; }
1897
2010
  if (key.ctrl && input === 'o' && !busy && !composerText.trim()) { showExpanded('recent items', items.slice(-5)); return; }
1898
2011
  if (key.ctrl && input === 'e' && !busy && !composerText.trim()) { showExpanded('all items', items); return; }
1899
2012
  if (key.ctrl && input === 'n' && chips.length) { setActiveChip((i) => (i + 1) % chips.length); return; }
@@ -1903,6 +2016,25 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1903
2016
  const tabs = ['session', 'devices', 'maps', 'alarms'];
1904
2017
  return tabs[(tabs.indexOf(t) + 1) % tabs.length];
1905
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
+ }
1906
2038
  }
1907
2039
  // Alt+↑/↓ for viewport scrolling (alternative to PgUp/PgDn)
1908
2040
  if (key.meta && key.upArrow && !choice && !search && !sessionPicker && !diffState) {
@@ -1935,7 +2067,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1935
2067
  const c = it.cols || 80;
1936
2068
  switch (it.type) {
1937
2069
  case 'user': return html`<${UserMessage} key=${it.id} text=${it.text} cols=${c} ts=${it.ts} />`;
1938
- 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} />`;
1939
2071
  case 'plan': return html`<${Plan} key=${it.id} reply=${it.reply} />`;
1940
2072
  case 'connect': return html`<${Connect} key=${it.id} reply=${it.reply} />`;
1941
2073
  case 'activity': return html`<${StatusLines} key=${it.id} lines=${it.status} busy=${false} />`;
@@ -1957,12 +2089,16 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1957
2089
  const footerRightEstLen = (modelLabel.length || 0) + (tokenLabel.length || 0) + 8;
1958
2090
  const cwdMaxLen = Math.max(12, cols - footerRightEstLen - (branch ? branch.length + 6 : 0) - 4);
1959
2091
  const cwd = shortenPath(process.cwd(), cwdMaxLen);
1960
- const footerLeft = [cwd, branch ? `${glyphs.gitBranch}${branch}` : null].filter(Boolean).join(' ');
1961
- const footerRight = hint
2092
+ const footerLeft = [cwd, branch ? `${glyphs.gitBranch}${branch}` : null].filter(Boolean).join(midDot());
2093
+ const footerInfoRight = streamerMode ? '' : [modelLabel, tokenLabel].filter(Boolean).join(midDot());
2094
+ // Status row: working indicator when busy, key hints when idle, hint message when set
2095
+ const footerStatusLeft = busy
2096
+ ? `${glyphs.spinner} Working\u2026`
2097
+ : hint
1962
2098
  ? hint
1963
- : streamerMode
1964
- ? ''
1965
- : [modelLabel, tokenLabel].filter(Boolean).join(midDot());
2099
+ : `? help ${midDot()} / commands ${midDot()} \u2191\u2193 history ${midDot()} Tab mode`;
2100
+ const footerStatusColor = busy ? C.warning : hint ? C.warning : C.muted;
2101
+ const footerStatusRight = busy ? `Ctrl+C to cancel` : '';
1966
2102
  const composerMode = shellMode ? 'shell' : mode;
1967
2103
 
1968
2104
  // ── Viewport slicing ───────────────────────────────────────────────────────
@@ -1978,17 +2114,19 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1978
2114
 
1979
2115
  const liveArea = live
1980
2116
  ? html`<${Box} flexDirection="column">
1981
- ${live.text ? html`<${AssistantMessage} text=${live.text} cols=${cols} ts=${startedAt || Date.now()} streaming=${true} />` : null}
2117
+ <${StatusLines} lines=${live.status} busy=${busy} />
1982
2118
  ${live.reasoning ? html`<${Reasoning} text=${live.reasoning} show=${showReasoning} />` : null}
2119
+ ${live.text ? html`<${AssistantMessage} text=${live.text} cols=${cols} ts=${startedAt || Date.now()} streaming=${true} />` : null}
1983
2120
  ${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
1984
- <${StatusLines} lines=${live.status} busy=${busy} />
1985
2121
  <${Thinking} label=${live.reply ? 'Planning' : live.text ? 'Responding' : 'Working'} startedAt=${startedAt} />
1986
2122
  <//>`
1987
2123
  : null;
1988
2124
 
2125
+ const scrollPct = maxScrollRef.current > 0 ? Math.round((scrollOffset / maxScrollRef.current) * 100) : 100;
1989
2126
  const scrollIndicator = hasAbove
1990
- ? html`<${Box} paddingX=${1}>
1991
- <${Text} color=${C.muted} dimColor>${glyphs.up} ${aboveCount} message${aboveCount === 1 ? '' : 's'} above ${midDot()} PgUp/PgDn or Alt+↑/↓ to scroll<//>
2127
+ ? html`<${Box} paddingX=${1} justifyContent="space-between" width=${cols}>
2128
+ <${Text} color=${C.muted} dimColor>${glyphs.up} ${aboveCount} above ${midDot()} PgUp/PgDn ${midDot()} scroll<//>
2129
+ <${Text} color=${C.muted} dimColor>${scrollPct}%<//>
1992
2130
  <//>`
1993
2131
  : null;
1994
2132
 
@@ -2021,20 +2159,11 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
2021
2159
  ${expandedItems.items.map((it) => renderItem(it))}
2022
2160
  <//>`
2023
2161
  : tabPanel
2024
- ? html`<${Box} flexDirection="column" paddingX=${1} paddingY=${1}>
2025
- <${Text} color=${C.brand} bold>${tabPanel.title} — Quick Commands<//>\n
2026
- ${tabPanel.commands.map((c, i) =>
2027
- html`<${Box} key=${i}>
2028
- <${Text} color=${C.accent} bold>${String(i + 1)}. <//>
2029
- <${Text} color=${C.slash}>${c.cmd.padEnd(22)}<//>
2030
- <${Text} color=${C.muted}>${c.desc}<//>
2031
- <//>`)}
2032
- \n<${Text} color=${C.muted}>${tabPanel.hint}<//>
2033
- <//>`
2162
+ ? html`<${TabView} panel=${tabPanel} cols=${cols} activeTab=${activeTab} />`
2034
2163
  : html`<${Box} flexDirection="column">
2035
2164
  ${showEmptyBanner
2036
2165
  ? html`<${Box} flexDirection="column" alignItems="center" width=${cols}>
2037
- <${Banner} cfg=${cfg} model=${model} width=${cols} compact=${true} streamerMode=${streamerMode} />
2166
+ <${Banner} cfg=${cfg} model=${model} width=${cols} compact=${!showBanner} streamerMode=${streamerMode} />
2038
2167
  <//>`
2039
2168
  : null}
2040
2169
  ${scrollIndicator}
@@ -2044,17 +2173,19 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
2044
2173
 
2045
2174
  return html`
2046
2175
  <${Box} flexDirection="column" width=${cols} height=${rows}>
2047
- <${TabBar} active=${activeTab} cols=${cols} mode=${composerMode} />
2176
+ <${TabBar} active=${activeTab} cols=${cols} />
2048
2177
 
2049
2178
  <${Box} flexDirection="column" flexGrow=${1} overflow="hidden">
2050
2179
  ${timelineContent}
2051
2180
  <//>
2052
2181
 
2053
- ${search ? html`<${SearchOverlay} query=${search.query} matches=${searchMatches} active=${search.active} cols=${cols} />` : null}
2182
+ <${Box} flexDirection="column">
2183
+ ${search ? html`<${SearchOverlay} query=${search.query} matches=${searchMatches} active=${search.active} cols=${cols} />` : null}
2054
2184
 
2055
- ${!busy && chips.length ? html`<${FollowupChips} chips=${chips} active=${activeChip} />` : null}
2185
+ ${!busy && chips.length && !choice && !search && !sessionPicker && !diffState
2186
+ ? html`<${FollowupChips} chips=${chips} active=${activeChip} />`
2187
+ : null}
2056
2188
 
2057
- <${Box} flexDirection="column">
2058
2189
  ${choice
2059
2190
  ? html`<${Choice} ...${choice} cols=${cols} />`
2060
2191
  : sessionPicker
@@ -2083,9 +2214,16 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
2083
2214
 
2084
2215
  <${Box} paddingX=${1} width=${cols}>
2085
2216
  <${Box} flexGrow=${1}>
2086
- <${Text} color=${C.muted} wrap="truncate-end">${footerLeft}<//>
2217
+ <${Text} color=${footerStatusColor} wrap="truncate-end">${footerStatusLeft}<//>
2218
+ <//>
2219
+ <${Text} color=${footerStatusColor} dimColor wrap="truncate-end">${footerStatusRight}<//>
2220
+ <//>
2221
+
2222
+ <${Box} paddingX=${1} width=${cols}>
2223
+ <${Box} flexGrow=${1}>
2224
+ <${Text} color=${C.muted} dimColor wrap="truncate-end">${footerLeft}<//>
2087
2225
  <//>
2088
- <${Text} color=${hint ? C.warning : C.muted} wrap="truncate-end">${footerRight}<//>
2226
+ <${Text} color=${C.muted} dimColor wrap="truncate-end">${footerInfoRight}<//>
2089
2227
  <//>
2090
2228
  <//>
2091
2229
  <//>`;
@@ -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,11 +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
- <${SpeakerLine} label="You" color=${C.brand} cols=${cols} ts=${ts} />
251
- <${Box} paddingX=${1}>
252
- <${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)}
253
238
  <//>
254
239
  <//>`;
255
240
  }
@@ -277,13 +262,17 @@ function StreamingText({ text = '' }) {
277
262
  return rendered;
278
263
  }
279
264
 
280
- export function AssistantMessage({ text, cols = 80, ts, label = `${glyphs.ring} iCopilot`, streaming = false }) {
281
- // §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';
282
268
  const bodyWidth = Math.min(cols, 100);
283
269
  return html`
284
- <${Box} flexDirection="column" marginTop=${1}>
285
- <${SpeakerLine} label=${label} color="white" glyphColor=${C.accent} cols=${cols} />
286
- <${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}>
287
276
  ${streaming ? html`<${StreamingText} text=${text} />` : html`<${Markdown} content=${text} />`}
288
277
  <//>
289
278
  <//>`;
@@ -293,7 +282,7 @@ export function ShellOutput({ command, output, cols = 80, ts, code = 0 }) {
293
282
  return html`
294
283
  <${Box} flexDirection="column" marginTop=${1}>
295
284
  <${SpeakerLine}
296
- label=" Shell"
285
+ label=${"$ Shell"}
297
286
  color=${code === 0 ? C.warning : C.error}
298
287
  cols=${cols}
299
288
  ts=${ts}
@@ -311,44 +300,49 @@ const LEAD_EMOJI = /^\s*([\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\
311
300
 
312
301
  export function StatusLines({ lines = [], busy = false }) {
313
302
  if (!lines.length) return null;
314
- const subLines = lines.filter((l) => SUBAGENT.test(l));
315
303
  const lastIdx = lines.length - 1;
316
304
  return html`
317
305
  <${Box} flexDirection="column" marginTop=${1}>
318
306
  ${lines.map((line, i) => {
319
- const sm = String(line).match(SUBAGENT);
307
+ const lineStr = String(line);
308
+ const sm = lineStr.match(SUBAGENT);
320
309
  if (sm) {
321
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;
322
314
  const good = !/fail|error|down|offline|unreachable|timeout|deny/i.test(result);
323
- const isLast = subLines[subLines.length - 1] === line;
324
- const running = busy && isLast && Number(idx) < Number(total);
325
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;
326
318
  return html`
327
319
  <${Box} key=${'s' + i}>
328
320
  <${Text} color=${C.gray}> <//>
329
- <${Text} color=${C.magenta}>${branch} <//>
330
- <${Text} color=${C.gray}>${idx}/${total} <//>
331
- <${Text} color=${C.white} bold>${host}<//>
332
- <${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()} <//>
333
325
  ${running
334
- ? html`<${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /> working<//>`
335
- : 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}<//>`}
336
328
  <//>`;
337
329
  }
338
330
 
339
331
  const running = busy && i === lastIdx;
340
- const em = String(line).match(LEAD_EMOJI);
341
- 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, '');
342
334
  const ci = body.indexOf(':');
343
335
  const action = ci > -1 ? body.slice(0, ci).trim() : body.trim();
344
336
  const detail = ci > -1 ? body.slice(ci + 1).trim() : '';
345
- // §6/§23: render as [Tool call: name detail] ✓/spinner chip
346
- 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}`;
347
340
  return html`
348
341
  <${Box} key=${'s' + i} marginTop=${0}>
349
342
  <${Text} color=${C.muted}> <//>
350
343
  <${Text} color=${running ? C.warning : C.muted}>[<//>
351
- <${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}> <//>
352
346
  ${running
353
347
  ? html`<${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /><//>`
354
348
  : html`<${Text} color=${C.success}>${glyphs.check}<//>`}
@@ -530,7 +524,7 @@ export function Thinking({ label = 'Working', startedAt }) {
530
524
  <${Box} marginTop=${1} paddingX=${1}>
531
525
  <${Text} color=${C.warning}><${Spinner} type=${spinnerType()} /><//>
532
526
  <${Text} color=${C.warning} bold> ${label}<//>
533
- <${Text} color=${C.muted}>… (${secs}s ${midDot()}esc to interrupt)<//>
527
+ <${Text} color=${C.muted}>… (${secs}s ${midDot()} esc to cancel)<//>
534
528
  <//>`;
535
529
  }
536
530
 
@@ -540,7 +534,7 @@ export function Choice({ title, note, options = [], sel = 0, allowText = false,
540
534
  return html`
541
535
  <${Box} flexDirection="column" marginTop=${1}>
542
536
  <${Box} paddingX=${2} marginTop=${1}>
543
- <${Text} color=${C.accent} bold>⚙ Tool request<//>
537
+ <${Text} color=${C.accent} bold>${glyphs.bolt} Tool request<//>
544
538
  <//>
545
539
  ${note ? html`<${Box} paddingX=${2} marginTop=${1}><${Text} color=${C.muted}>${note}<//><//>` : null}
546
540
  <${Box} flexDirection="column" marginTop=${1} paddingX=${2}>
@@ -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
@@ -0,0 +1,57 @@
1
+ import { useEffect, useRef } from './dom.js';
2
+
3
+ function parseMouseData(chunk, emit) {
4
+ let i = 0;
5
+ while (i < chunk.length) {
6
+ if (chunk.startsWith('\x1b[<', i)) {
7
+ const match = chunk.slice(i).match(/^\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
8
+ if (!match) return chunk.slice(i);
9
+ const btn = Number(match[1]);
10
+ const x = Math.max(0, Number(match[2]) - 1);
11
+ const y = Math.max(0, Number(match[3]) - 1);
12
+ emit({ btn, x, y, press: match[4] === 'M' });
13
+ i += match[0].length;
14
+ continue;
15
+ }
16
+ if (chunk.startsWith('\x1b[M', i)) {
17
+ if (i + 6 > chunk.length) return chunk.slice(i);
18
+ const btn = chunk.charCodeAt(i + 3) - 32;
19
+ const x = Math.max(0, chunk.charCodeAt(i + 4) - 33);
20
+ const y = Math.max(0, chunk.charCodeAt(i + 5) - 33);
21
+ emit({ btn, x, y, press: btn !== 3 });
22
+ i += 6;
23
+ continue;
24
+ }
25
+ i += 1;
26
+ }
27
+ return '';
28
+ }
29
+
30
+ // Read directly from process.stdin (before Ink's filtered stream) so we see
31
+ // the raw mouse escape sequences that the filter strips out in run.js.
32
+ export function useMouse(handler, enabled = true) {
33
+ const handlerRef = useRef(handler);
34
+ const bufferRef = useRef('');
35
+
36
+ handlerRef.current = handler;
37
+
38
+ useEffect(() => {
39
+ if (!enabled) 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
+ process.stdin.on('data', onData);
51
+ return () => {
52
+ process.stdin.off('data', onData);
53
+ bufferRef.current = '';
54
+ try { process.stdout.write('\x1b[?1000l\x1b[?1006l'); } catch {}
55
+ };
56
+ }, [enabled]);
57
+ }
package/src/tui/run.js CHANGED
@@ -1,9 +1,58 @@
1
1
  // Ink render entrypoint. Runs the TUI in a full-screen ALT-SCREEN viewport.
2
2
  import { html } from './dom.js';
3
3
  import { render } from 'ink';
4
+ import { Transform } from 'stream';
4
5
  import { App } from './app.js';
5
6
  import { saveConfig } from '../config.js';
6
7
 
8
+ // ── Mouse sequence filter ────────────────────────────────────────────────────
9
+ // Strips SGR (\x1b[<N;N;N[Mm]) and X10 (\x1b[M + 3 bytes) mouse escape
10
+ // sequences from the stdin stream before Ink sees it. Without this, unrecognised
11
+ // CSI sequences leak into the composer as raw characters when clicking/scrolling.
12
+ // mouse.js reads from process.stdin directly (before this filter) to get events.
13
+ class MouseFilterTransform extends Transform {
14
+ constructor() {
15
+ super();
16
+ this.isTTY = process.stdin.isTTY;
17
+ this.setRawMode = process.stdin.setRawMode
18
+ ? (mode) => process.stdin.setRawMode(mode)
19
+ : undefined;
20
+ this._buf = '';
21
+ }
22
+
23
+ _transform(chunk, _enc, cb) {
24
+ const text = Buffer.isBuffer(chunk) ? chunk.toString('latin1') : String(chunk);
25
+ this._buf += text;
26
+ let out = '';
27
+ let i = 0;
28
+ while (i < this._buf.length) {
29
+ // SGR mouse: \x1b[<N;N;N[Mm]
30
+ if (this._buf[i] === '\x1b' && this._buf[i + 1] === '[' && this._buf[i + 2] === '<') {
31
+ const rest = this._buf.slice(i);
32
+ const m = rest.match(/^\x1b\[<\d+;\d+;\d+[Mm]/);
33
+ if (m) { i += m[0].length; continue; }
34
+ if (rest.length < 32) break; // incomplete – wait for more bytes
35
+ out += this._buf[i++]; // give up, pass through
36
+ continue;
37
+ }
38
+ // X10 mouse: \x1b[M + 3 bytes
39
+ if (this._buf[i] === '\x1b' && this._buf[i + 1] === '[' && this._buf[i + 2] === 'M') {
40
+ if (i + 6 <= this._buf.length) { i += 6; continue; }
41
+ break; // incomplete
42
+ }
43
+ out += this._buf[i++];
44
+ }
45
+ this._buf = this._buf.slice(i);
46
+ if (out) this.push(out);
47
+ cb();
48
+ }
49
+
50
+ _flush(cb) {
51
+ if (this._buf) { this.push(this._buf); this._buf = ''; }
52
+ cb();
53
+ }
54
+ }
55
+
7
56
  // ── Alt-screen management ────────────────────────────────────────────────────
8
57
  // Switch to the alternate terminal buffer so the TUI owns the full viewport and
9
58
  // nothing leaks into the user's native scrollback. Must run BEFORE Ink renders.
@@ -124,6 +173,11 @@ export async function runTui(cfg, opts = {}) {
124
173
  try { saveConfig(cfg); } catch {}
125
174
  }
126
175
 
176
+ // Pipe raw stdin through the mouse filter so Ink never sees mouse escape
177
+ // sequences. mouse.js reads from process.stdin directly to capture events.
178
+ const mouseFilter = new MouseFilterTransform();
179
+ process.stdin.pipe(mouseFilter);
180
+
127
181
  const instance = render(
128
182
  html`<${App}
129
183
  cfg=${cfg}
@@ -134,7 +188,7 @@ export async function runTui(cfg, opts = {}) {
134
188
  resumeMode=${resumeMode}
135
189
  onExternal=${(a) => { externalAction = a; onExternal?.(a); }}
136
190
  />`,
137
- { exitOnCtrlC: false },
191
+ { exitOnCtrlC: false, stdin: mouseFilter },
138
192
  );
139
193
 
140
194
  try {
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',