ispbills-icli 8.4.11 → 8.4.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/icli.js CHANGED
@@ -315,33 +315,13 @@ async function main() {
315
315
  // plain readline REPL when stdout/stdin is not a TTY (pipes, dumb terminals).
316
316
  if (output.isTTY && input.isTTY) {
317
317
  const { runTui } = await import('../src/tui/run.js');
318
- const { loadResume } = await import('../src/resume.js');
319
-
320
- // Hot-reload resume: if started with --resume <file>, restore the saved session.
321
- const resumeFile = flag('--resume');
322
- const resumeSession = loadResume(resumeFile);
323
-
324
- const action = await runTui(cfg, { display, version: pkgVersion(), resumeSession });
325
-
326
- const actionName = typeof action === 'string' ? action : action?.action;
327
- if (actionName === 'logout') {
318
+ const action = await runTui(cfg, { display });
319
+ if (action === 'logout') {
328
320
  const cleared = clearConfig();
329
321
  console.log(`\n ${cleared ? GREEN + glyphs.check + RESET + ' Logged out — credentials cleared.' : YELLOW + 'Already logged out.' + RESET}`);
330
322
  console.log(` ${DIM}Run ${RESET}${ACCENT}icli login${RESET}${DIM} to sign in again.${RESET}\n`);
331
- } else if (actionName === 'update') {
332
- const ok = selfUpdate();
333
- if (ok && action?.resumeFile) {
334
- // Re-exec with the new binary, passing --resume so the session is restored.
335
- const { execFileSync } = await import('child_process');
336
- const newBin = process.argv[1]; // same script path — npm replaced the file in-place
337
- const baseArgs = process.argv.slice(2).filter((a) => a !== '--resume' && a !== action.resumeFile);
338
- process.stdout.write(`\n ${ACCENT}Reloading iCli with your session restored…${RESET}\n\n`);
339
- try {
340
- execFileSync(process.execPath, [newBin, ...baseArgs, '--resume', action.resumeFile], {
341
- stdio: 'inherit',
342
- });
343
- } catch { /* user Ctrl+C'd the resumed session — that's fine */ }
344
- }
323
+ } else if (action === 'update') {
324
+ selfUpdate();
345
325
  }
346
326
  return;
347
327
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ispbills-icli",
3
- "version": "8.4.11",
3
+ "version": "8.4.13",
4
4
  "description": "iCli — IspBills AI network engineer in your terminal",
5
5
  "keywords": [
6
6
  "ispbills",
package/src/banner.js CHANGED
@@ -22,7 +22,6 @@ const LOGO_WIDTH = Math.max(...LOGO.map((l) => l.length));
22
22
  export function printBanner(cfg = {}, opts = {}) {
23
23
  const width = cols();
24
24
  const model = opts.model || cfg._model || 'IspBills AI';
25
- const version = opts.version || '';
26
25
 
27
26
  console.log();
28
27
  if (width >= LOGO_WIDTH) {
@@ -37,11 +36,10 @@ export function printBanner(cfg = {}, opts = {}) {
37
36
  parts.push(`${DIM}model${RESET} ${CYAN}${shortModel(model)}${RESET}`);
38
37
  if (cfg.user?.name) parts.push(`${DIM}user${RESET} ${WHITE}${cfg.user.name}${RESET}`);
39
38
  if (cfg.user?.role) parts.push(`${DIM}role${RESET} ${WHITE}${cfg.user.role}${RESET}`);
40
- if (version) parts.push(`${DIM}v${RESET} ${WHITE}${version}${RESET}`);
41
39
  console.log(' ' + parts.join(` ${GRAY}·${RESET} `));
42
40
  if (cfg.url) console.log(` ${DIM}url${RESET} ${ACCENT}${cfg.url}${RESET}`);
43
41
  console.log();
44
- console.log(` ${DIM}Type a message to start. ${RESET}${ACCENT}/help${RESET}${DIM} for commands · ${RESET}${ACCENT}!cmd${RESET}${DIM} to run shell · ${RESET}${ACCENT}Ctrl+C${RESET}${DIM} to exit${RESET}`);
42
+ console.log(` ${DIM}Type a message to start. ${RESET}${ACCENT}/help${RESET}${DIM} for commands · ${RESET}${ACCENT}Ctrl+C${RESET}${DIM} to exit${RESET}`);
45
43
  console.log();
46
44
  hr();
47
45
  console.log();
package/src/clipboard.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Cross-platform "copy to system clipboard" helper. Best-effort: resolves to
2
2
  // false (rather than throwing) when no clipboard tool is available so the TUI
3
3
  // can show a friendly notice.
4
- import { spawn, execFile } from 'child_process';
4
+ import { spawn } from 'child_process';
5
5
 
6
6
  function candidates() {
7
7
  if (process.platform === 'darwin') return [['pbcopy', []]];
@@ -40,37 +40,3 @@ export async function copyToClipboard(text) {
40
40
  }
41
41
  return null;
42
42
  }
43
-
44
- // ── Read from clipboard ─────────────────────────────────────────────────────
45
-
46
- function readCandidates() {
47
- if (process.platform === 'darwin') return [['pbpaste', []]];
48
- if (process.platform === 'win32') return [['powershell', ['-noprofile', '-command', 'Get-Clipboard']]];
49
- return [
50
- ['wl-paste', ['--no-newline']],
51
- ['xclip', ['-selection', 'clipboard', '-o']],
52
- ['xsel', ['--clipboard', '--output']],
53
- ];
54
- }
55
-
56
- /**
57
- * Read text from the system clipboard.
58
- * Returns the clipboard string on success, or null when no tool is available
59
- * or the clipboard is empty.
60
- */
61
- export function readFromClipboard() {
62
- return new Promise((resolve) => {
63
- const cands = readCandidates();
64
- let i = 0;
65
- function tryNext() {
66
- if (i >= cands.length) { resolve(null); return; }
67
- const [cmd, args] = cands[i++];
68
- execFile(cmd, args, { timeout: 3000, maxBuffer: 1024 * 512 }, (err, stdout) => {
69
- if (err) { tryNext(); return; }
70
- const text = stdout.trimEnd();
71
- resolve(text || null);
72
- });
73
- }
74
- tryNext();
75
- });
76
- }
package/src/commands.js CHANGED
@@ -52,12 +52,9 @@ export const SLASH_COMMANDS = [
52
52
  { cmd: '/copy', hint: '', desc: 'Copy the last AI answer to the clipboard', group: 'Memory', local: true },
53
53
 
54
54
  // ── Session & app ──
55
- { cmd: '/usage', hint: '', desc: 'Show conversation stats (turns, ~tokens, model)', group: 'Session', local: true },
56
55
  { cmd: '/autopilot', hint: '', desc: 'Toggle autopilot (auto-apply fixes)', group: 'Session', local: true },
57
56
  { cmd: '/model', hint: '[name]', desc: 'Show or set the AI model', group: 'Session', local: true },
58
57
  { cmd: '/reasoning', hint: '', desc: 'Toggle reasoning display (Ctrl+T)', group: 'Session', local: true },
59
- { cmd: '/paste', hint: '', desc: 'Paste multiline text from clipboard into input', group: 'Session', local: true },
60
- { cmd: '/about', hint: '', desc: 'About iCopilot — version, server, user', group: 'Session', local: true },
61
58
  { cmd: '/new', hint: '', desc: 'Start a fresh conversation', group: 'Session', local: true },
62
59
  { cmd: '/history', hint: '', desc: 'Show conversation history', group: 'Session', local: true },
63
60
  { cmd: '/clear', hint: '', desc: 'Clear the screen', group: 'Session', local: true },
package/src/tui/app.js CHANGED
@@ -3,20 +3,12 @@
3
3
  // items are flushed to the terminal's native scrollback via Ink <Static> (so
4
4
  // you can scroll back through history), and only the live/streaming area plus
5
5
  // the input composer are re-rendered in place at the bottom.
6
- //
7
- // Mouse support (via src/tui/mouse.js + run.js):
8
- // • Scroll UP → sends \x1b[3S to scroll the terminal viewport upward
9
- // so the native scrollback buffer shows conversation history.
10
- // • Scroll DOWN → swallowed; prevents drifting below the live/composer area.
11
- // • Left click on tab bar row 1 → switches tabs by x-coordinate range.
12
- import { html, useState, useEffect, useRef, useCallback, useMemo } from './dom.js';
6
+ import { html, useState, useEffect, useRef, useCallback } from './dom.js';
13
7
  import { Box, Text, Static, useStdout, useApp, useInput } from 'ink';
14
8
  import { C, glyphs, midDot, dash } from './theme.js';
15
- import { mouseEvents } from './mouse.js';
16
9
  import {
17
10
  Banner, UserMessage, AssistantMessage, StatusLines, Reasoning,
18
- Plan, Connect, Notice, Thinking, Choice, ShellInput, ShellOut,
19
- TabBar, MonitoringView, IssuesView, GistsView,
11
+ Plan, Connect, Notice, Thinking, Choice,
20
12
  } from './components.js';
21
13
  import { Composer } from './composer.js';
22
14
  import { streamChat } from '../client.js';
@@ -24,19 +16,9 @@ import { slashToQuestion, SLASH_COMMANDS } from '../commands.js';
24
16
  import { saveConfig } from '../config.js';
25
17
  import { shortModel } from '../ui.js';
26
18
  import { loadNotes, addNote, forgetNote, memoryPreamble } from '../memory.js';
27
- import { copyToClipboard, readFromClipboard } from '../clipboard.js';
19
+ import { copyToClipboard } from '../clipboard.js';
28
20
  import { createGist } from '../gist.js';
29
21
  import { git as gitCmd, commitBackup } from '../gitrepo.js';
30
- import { runShell } from '../shell.js';
31
- import { checkLatestVersion } from '../version-check.js';
32
- import { saveResume } from '../resume.js';
33
- import { basename } from 'path';
34
-
35
- // CWD basename — computed once at startup (before any cd).
36
- const APP_CWD = basename(process.cwd());
37
-
38
- // Tab identifiers (order = display order).
39
- export const TABS = ['Session', 'Monitoring', 'Issues', 'Gists'];
40
22
 
41
23
  // Split a command argument string into argv, honouring "quoted" segments.
42
24
  function splitArgs(s) {
@@ -65,7 +47,7 @@ function useTerminalSize() {
65
47
  let itemId = 0;
66
48
  const nextId = () => ++itemId;
67
49
 
68
- export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, version = '', resumeSession = null }) {
50
+ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion }) {
69
51
  const { exit } = useApp();
70
52
  const { cols, rows } = useTerminalSize();
71
53
 
@@ -79,9 +61,6 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
79
61
  const [startedAt, setStartedAt] = useState(0);
80
62
  const [choice, setChoice] = useState(null); // { title, note, options, allowText, sel, text, focusText, onPick }
81
63
  const [clearEpoch, setClearEpoch] = useState(0); // bump to remount <Static> on /clear /new
82
- const [prefillText, setPrefillText] = useState(null); // clipboard paste → pre-fills Composer
83
- const [activeTab, setActiveTab] = useState(0); // 0=Session 1=Monitoring 2=Issues 3=Gists
84
- const [latestVersion, setLatestVersion] = useState(null); // { latest, isNewer } from npm
85
64
 
86
65
  const convo = useRef([]); // [{role, content}] for the backend
87
66
  const plain = useRef([]); // plain-text transcript for exit reprint
@@ -91,7 +70,6 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
91
70
  const modelRef = useRef(cfg._model); // preferred model to request
92
71
  const lastAnswerRef = useRef(''); // most recent assistant answer (for /copy, /gist)
93
72
  const choiceActionRef = useRef(null); // action to run when a non-AI Choice is picked
94
- const issuesRef = useRef([]); // AI-flagged issues accumulated across session
95
73
 
96
74
  // Enable terminal bracketed-paste so multi-line pastes (mouse right-click or
97
75
  // Cmd/Ctrl+V) arrive wrapped in markers as one block — the Composer inserts
@@ -101,91 +79,6 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
101
79
  return () => { try { process.stdout.write('\x1b[?2004l'); } catch {} };
102
80
  }, []);
103
81
 
104
- // Background version check — runs once after mount, non-blocking.
105
- useEffect(() => {
106
- if (!version || version === 'unknown') return;
107
- checkLatestVersion(version).then((result) => {
108
- if (result?.isNewer) setLatestVersion(result);
109
- });
110
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
111
-
112
- // Restore session after a hot-reload /update.
113
- useEffect(() => {
114
- if (!resumeSession) return;
115
- convo.current = resumeSession.convo || [];
116
- plain.current = resumeSession.plain || [];
117
- // Re-hydrate items list from plain text so the user sees their history.
118
- if (resumeSession.items?.length) {
119
- setItems(resumeSession.items.map((it) => ({ id: nextId(), ...it })));
120
- }
121
- push({ type: 'notice', text: `${glyphs.check} Session restored after update — conversation history preserved.`, color: C.green });
122
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
123
-
124
- // Tab switching: Ctrl+1/2/3/4.
125
- useInput((input, key) => {
126
- if (!key.ctrl) return;
127
- if (input === '1') { setActiveTab(0); return; }
128
- if (input === '2') { setActiveTab(1); return; }
129
- if (input === '3') { setActiveTab(2); return; }
130
- if (input === '4') { setActiveTab(3); return; }
131
- });
132
-
133
- // ── Mouse event handling ────────────────────────────────────────────────────
134
- // Tab bar x-ranges (1-indexed). Layout: paddingX=1 on the TabBar outer Box
135
- // so text starts at column 2. Each tab = 2-char prefix (' ' or '❯ ') +
136
- // label text, followed by marginRight=2 before the next tab.
137
- const tabClickRanges = useMemo(() => {
138
- let x = 2; // paddingX=1 → first tab starts at column 2
139
- return TABS.map((label) => {
140
- const start = x;
141
- const w = 2 + label.length; // prefix (2 chars) + label
142
- x += w + 2; // advance past tab + marginRight=2
143
- return { start, end: start + w - 1 };
144
- });
145
- }, []);
146
-
147
- useEffect(() => {
148
- const onMouse = ({ btn, x, y, press }) => {
149
- // ── Scroll wheel UP → scroll viewport to reveal history ──────────────
150
- if (btn === 64) {
151
- try { process.stdout.write('\x1b[3S'); } catch {}
152
- return;
153
- }
154
- // ── Scroll wheel DOWN → swallowed ─────────────────────────────────────
155
- if (btn === 65) return;
156
-
157
- // ── Left click → tab bar ─────────────────────────────────────────────
158
- // Ink renders INLINE at the bottom of the terminal — NOT at y=1.
159
- // SGR y coordinates are 1-indexed from the terminal top.
160
- //
161
- // Layout rows at idle (bottom-to-top):
162
- // rows = footer hint
163
- // rows-1 = composer bottom border
164
- // rows-2 = composer input
165
- // rows-3 = composer top border
166
- // rows-4 = blank (marginTop=1 before composer)
167
- // rows-5 = tab separator
168
- // rows-6 = tab labels ← TAB_ROW at idle
169
- // rows-7 = compact header
170
- //
171
- // When there is live content of height h between tab separator and
172
- // composer, each row shifts up by h: TAB_ROW = rows - 6 - liveH.
173
- if (btn === 0 && press) {
174
- const liveH = live
175
- ? 1 + Math.min((live.status?.length || 0) + (live.text ? 2 : 0) + (!live.text && !live.reply?.steps ? 1 : 0), 12)
176
- : 0;
177
- const tabRowY = rows - 6 - liveH;
178
- if (Math.abs(y - tabRowY) <= 1) { // ±1 row tolerance
179
- const idx = tabClickRanges.findIndex((r) => x >= r.start && x <= r.end);
180
- if (idx >= 0) setActiveTab(idx);
181
- }
182
- }
183
- };
184
-
185
- mouseEvents.on('event', onMouse);
186
- return () => mouseEvents.off('event', onMouse);
187
- }, [tabClickRanges, rows, live]);
188
-
189
82
  const setAutopilotMode = useCallback((next) => {
190
83
  apRef.current = next;
191
84
  setAutopilot(next);
@@ -348,22 +241,6 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
348
241
  if (!q) return;
349
242
  setHint('');
350
243
 
351
- // ── ! shell command execution (GitHub Copilot CLI parity) ──────────────
352
- if (q.startsWith('!')) {
353
- const cmd = q.slice(1).trim();
354
- if (!cmd) return;
355
- push({ type: 'shell_in', cmd });
356
- log(`$ ${cmd}`);
357
- runShell(cmd).then((result) => {
358
- const out = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
359
- push({ type: 'shell_out', ok: result.ok, output: out || `(exit code ${result.exitCode})` });
360
- if (out) log(out);
361
- }).catch((e) => {
362
- push({ type: 'notice', text: `${glyphs.cross} Shell error: ${e.message}`, color: C.red });
363
- });
364
- return;
365
- }
366
-
367
244
  if (q === '/exit' || q === 'exit' || q === 'quit') { doExit(); return; }
368
245
  if (q === '/clear') { clearAll(); return; }
369
246
  if (q === '/new') {
@@ -413,31 +290,8 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
413
290
  push({ type: 'notice', text: us.length ? us.map((m, i) => ` ${i + 1}. ${m.content.slice(0, 80)}`).join('\n') : 'No history yet.' });
414
291
  return;
415
292
  }
416
- if (q === '/usage') {
417
- const total = convo.current.length;
418
- const userTurns = convo.current.filter((m) => m.role === 'user').length;
419
- const chars = convo.current.reduce((acc, m) => acc + m.content.length, 0);
420
- const approxTokens = Math.round(chars / 4);
421
- const lines = [
422
- `Conversation stats:`,
423
- ` turns ${userTurns}`,
424
- ` messages ${total}`,
425
- ` ~tokens ${approxTokens.toLocaleString()} (estimated)`,
426
- ` ~chars ${chars.toLocaleString()}`,
427
- ` model ${model || cfg._model || 'server default'}`,
428
- ` cwd ${APP_CWD}`,
429
- ];
430
- push({ type: 'notice', text: lines.join('\n'), color: C.cyan });
431
- return;
432
- }
433
293
  if (q === '/logout') { onExternal?.('logout'); doExit(); return; }
434
- if (q === '/update') {
435
- // Save current conversation so the new binary can resume it.
436
- const resumeFile = saveResume(convo.current, plain.current, items);
437
- onExternal?.({ action: 'update', resumeFile });
438
- doExit();
439
- return;
440
- }
294
+ if (q === '/update') { onExternal?.('update'); doExit(); return; }
441
295
 
442
296
  // ── Memory ──
443
297
  if (q === '/remember' || q.startsWith('/remember ')) {
@@ -465,39 +319,8 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
465
319
  }
466
320
 
467
321
  // ── Clipboard ──
468
- // ── /about ─────────────────────────────────────────────────────────────
469
- if (q === '/about') {
470
- const lines = [
471
- `iCopilot CLI v${version || '?'}`,
472
- `Server ${cfg.url || '(unknown)'}`,
473
- `User ${cfg.user?.name || '—'} (${cfg.user?.role || '—'})`,
474
- `Model ${shortModel(model || cfg._model || '—')}`,
475
- '',
476
- 'Type /help for all commands · Ctrl+C to exit',
477
- 'npm: ispbills-icli · github: lupael/IspBills',
478
- ];
479
- push({ type: 'notice', text: lines.join('\n'), color: C.accent });
480
- return;
481
- }
482
-
483
- // ── /paste ─────────────────────────────────────────────────────────────
484
- if (q === '/paste') {
485
- push({ type: 'notice', text: `${glyphs.arrow} Reading clipboard…`, color: C.gray });
486
- readFromClipboard().then((text) => {
487
- if (!text) {
488
- push({ type: 'notice', text: `${glyphs.cross} Clipboard is empty or no clipboard tool found.\nInstall xclip / wl-clipboard (Linux), or use macOS/Windows.`, color: C.red });
489
- return;
490
- }
491
- const lines = text.split('\n').length;
492
- push({ type: 'notice',
493
- text: `${glyphs.check} Pasted ${lines} line${lines !== 1 ? 's' : ''} (${text.length} chars) into the input box — review and press Enter to send.`,
494
- color: C.green });
495
- setPrefillText(text);
496
- });
497
- return;
498
- }
499
-
500
- if (q === '/copy') { const ans = lastAnswerRef.current;
322
+ if (q === '/copy') {
323
+ const ans = lastAnswerRef.current;
501
324
  if (!ans) { push({ type: 'notice', text: 'Nothing to copy yet — ask something first.', color: C.gray }); return; }
502
325
  copyToClipboard(ans).then((tool) => push({ type: 'notice',
503
326
  text: tool ? `${glyphs.check} Copied last answer (${ans.length} chars) to the clipboard.`
@@ -631,93 +454,48 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
631
454
  case 'connect': return html`<${Connect} key=${it.id} reply=${it.reply} />`;
632
455
  case 'activity': return html`<${StatusLines} key=${it.id} lines=${it.status} busy=${false} />`;
633
456
  case 'notice': return html`<${Notice} key=${it.id} text=${it.text} color=${it.color} />`;
634
- case 'shell_in': return html`<${ShellInput} key=${it.id} cmd=${it.cmd} />`;
635
- case 'shell_out': return html`<${ShellOut} key=${it.id} ok=${it.ok} output=${it.output} />`;
636
457
  default: return null;
637
458
  }
638
459
  };
639
460
 
640
- const ctx = [model ? shortModel(model) : null, APP_CWD, cfg.user?.name, cfg.user?.role].filter(Boolean).join(midDot());
641
- // Footer shows a concise key-hints bar. Autopilot badge is a right-aligned label.
642
- const baseHint = hint || `/help${midDot()}Shift+Tab autopilot${midDot()} scroll history${midDot()}Ctrl+C exit`;
461
+ const ctx = [model ? shortModel(model) : null, cfg.user?.name, cfg.user?.role].filter(Boolean).join(midDot());
462
+ const modeTag = autopilot ? `${glyphs.bolt} autopilot ${midDot()} ` : '';
463
+ const footerRight = hint || `${modeTag}/help ${midDot()} Shift+Tab autopilot ${midDot()} Ctrl+C exit`;
643
464
  const compact = cols < 60;
644
465
 
645
466
  // The banner is the first <Static> item so it prints once at the very top and
646
467
  // scrolls away with history; completed transcript items follow it. Ink flushes
647
468
  // each <Static> item to the terminal's normal buffer exactly once, leaving the
648
469
  // native scrollback intact so earlier conversation can be scrolled back to.
649
- // Scroll UP (mouse wheel) reveals this history; scroll DOWN is blocked.
650
470
  const staticItems = [{ type: 'banner', id: `banner-${clearEpoch}` }, ...items];
651
471
 
652
- // ── Main render ─────────────────────────────────────────────────────────────
653
- // Layout (top to bottom):
654
- // 1. Compact persistent header — "iCopilot" brand always visible
655
- // 2. Tab bar — Session / Monitoring / Issues / Gists (mouse-clickable)
656
- // 3. Session pane — Static history + live streaming area (scrollback via ↑)
657
- // 4. Composer + footer — always pinned to the bottom
658
472
  return html`
659
473
  <${Box} flexDirection="column" width=${cols}>
660
-
661
- ${/* ── 1. Compact header: brand + active context ─────────────────── */null}
662
- <${Box} paddingX=${1} paddingTop=${0}>
663
- <${Text} color=${C.accent} bold>iCopilot<//>
664
- <${Text} color=${C.gray}> ${dash()} <//>
665
- <${Text} dimColor>${ctx}<//>
666
- ${latestVersion?.isNewer
667
- ? html`<${Box} flexGrow=${1} justifyContent="flex-end"><${Text} color=${C.yellow}> 🔔 v${latestVersion.latest} /update<//> <//>`
668
- : null}
474
+ <${Static} key=${clearEpoch} items=${staticItems}>
475
+ ${(it) => it.type === 'banner'
476
+ ? html`<${Box} key=${it.id} flexDirection="column" alignItems="center" marginBottom=${1}>
477
+ <${Banner} cfg=${cfg} model=${model} width=${cols} compact=${compact} />
478
+ <//>`
479
+ : renderItem(it)}
669
480
  <//>
670
481
 
671
- ${/* ── 2. Tab bar (mouse-clickable + Ctrl+1–4) ──────────────────── */null}
672
- <${TabBar} tabs=${TABS} active=${activeTab} onSelect=${setActiveTab} width=${cols} />
673
-
674
- ${/* ── 3a. Session pane ────────────────────────────────────────────── */null}
675
- ${activeTab === 0
482
+ ${live
676
483
  ? html`<${Box} flexDirection="column">
677
- <${Static} key=${clearEpoch} items=${staticItems}>
678
- ${(it) => it.type === 'banner'
679
- ? html`<${Box} key=${it.id} flexDirection="column" alignItems="center" marginBottom=${2}>
680
- <${Banner} cfg=${cfg} model=${model} width=${cols} compact=${compact} version=${version} cwd=${APP_CWD} />
681
- <//>`
682
- : renderItem(it)}
683
- <//>
684
- ${/* Gap between history and live area — gives breathing room */null}
685
- ${live
686
- ? html`<${Box} flexDirection="column" marginTop=${1}>
687
- <${StatusLines} lines=${live.status} busy=${busy} />
688
- ${showReasoning && live.reasoning ? html`<${Reasoning} text=${live.reasoning} />` : null}
689
- ${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
690
- ${live.text
691
- ? html`<${Box} marginTop=${live.status.length ? 0 : 1}>
692
- <${AssistantMessage} text=${live.text} streaming=${true} />
693
- <//>` : null}
694
- ${!live.text && !live.reply?.steps
695
- ? html`<${Thinking}
696
- label=${live.status.length ? 'Working' : 'Thinking'}
697
- startedAt=${startedAt} />`
698
- : null}
699
- <//>`
700
- : null}
484
+ <${StatusLines} lines=${live.status} busy=${busy} />
485
+ ${showReasoning && live.reasoning ? html`<${Reasoning} text=${live.reasoning} />` : null}
486
+ ${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
487
+ ${live.text ? html`<${Box} marginTop=${1}><${AssistantMessage} text=${live.text} /><//>` : null}
488
+ <${Thinking} label=${live.reply ? 'Planning' : live.text ? 'Responding' : 'Working'} startedAt=${startedAt} />
701
489
  <//>`
702
490
  : null}
703
491
 
704
- ${/* ── 3b. Other tabs ──────────────────────────────────────────────── */null}
705
- ${activeTab === 1 ? html`<${MonitoringView} cfg=${cfg} />` : null}
706
- ${activeTab === 2 ? html`<${IssuesView} issues=${issuesRef.current} />` : null}
707
- ${activeTab === 3 ? html`<${GistsView} cfg=${cfg} />` : null}
708
-
709
- ${/* ── 4. Composer + footer — fixed at bottom ─────────────────────── */null}
710
492
  <${Box} flexDirection="column" marginTop=${1}>
711
- ${activeTab === 0
712
- ? (choice
713
- ? html`<${Choice} ...${choice} />`
714
- : html`<${Composer} onSubmit=${onSubmit} busy=${busy} width=${cols} prefill=${prefillText} onPrefillConsumed=${() => setPrefillText(null)} />`)
715
- : html`<${Box} paddingX=${1}><${Text} dimColor>Click a tab or Ctrl+1–4 to switch · Ctrl+1 for Session<//> <//>` }
493
+ ${choice
494
+ ? html`<${Choice} ...${choice} />`
495
+ : html`<${Composer} onSubmit=${onSubmit} busy=${busy} width=${cols} />`}
716
496
  <${Box} paddingX=${1} width=${cols}>
717
- <${Box} flexGrow=${1}>
718
- <${Text} dimColor wrap="truncate-end">${hint ? hint : baseHint}<//><//>
719
- ${autopilot
720
- ? html`<${Text} color=${C.yellow} bold wrap="truncate-end"> ${glyphs.bolt} autopilot ON<//>` : null}
497
+ <${Box} flexGrow=${1}><${Text} color=${C.gray} wrap="truncate-end">${ctx}<//><//>
498
+ <${Text} color=${hint || autopilot ? C.yellow : C.gray} wrap="truncate-end">${footerRight}<//>
721
499
  <//>
722
500
  <//>
723
501
  <//>`;