ispbills-icli 8.4.12 → 8.5.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/src/tui/app.js CHANGED
@@ -7,9 +7,8 @@ import { html, useState, useEffect, useRef, useCallback } from './dom.js';
7
7
  import { Box, Text, Static, useStdout, useApp, useInput } from 'ink';
8
8
  import { C, glyphs, midDot, dash } from './theme.js';
9
9
  import {
10
- Banner, UserMessage, AssistantMessage, StatusLines, Reasoning,
11
- Plan, Connect, Notice, Thinking, Choice, ShellInput, ShellOut,
12
- TabBar, MonitoringView, IssuesView, GistsView,
10
+ Banner, TabBar, FollowupChips, UserMessage, AssistantMessage, StatusLines, Reasoning,
11
+ Plan, Connect, Notice, Thinking, Choice,
13
12
  } from './components.js';
14
13
  import { Composer } from './composer.js';
15
14
  import { streamChat } from '../client.js';
@@ -17,19 +16,12 @@ import { slashToQuestion, SLASH_COMMANDS } from '../commands.js';
17
16
  import { saveConfig } from '../config.js';
18
17
  import { shortModel } from '../ui.js';
19
18
  import { loadNotes, addNote, forgetNote, memoryPreamble } from '../memory.js';
20
- import { copyToClipboard, readFromClipboard } from '../clipboard.js';
19
+ import { copyToClipboard } from '../clipboard.js';
21
20
  import { createGist } from '../gist.js';
22
21
  import { git as gitCmd, commitBackup } from '../gitrepo.js';
23
- import { runShell } from '../shell.js';
24
- import { checkLatestVersion } from '../version-check.js';
25
- import { saveResume } from '../resume.js';
26
- import { basename } from 'path';
27
-
28
- // CWD basename — computed once at startup (before any cd).
29
- const APP_CWD = basename(process.cwd());
30
-
31
- // Tab identifiers (order = display order).
32
- export const TABS = ['Session', 'Monitoring', 'Issues', 'Gists'];
22
+ import { execSync } from 'child_process';
23
+ import { homedir } from 'os';
24
+ import { relative } from 'path';
33
25
 
34
26
  // Split a command argument string into argv, honouring "quoted" segments.
35
27
  function splitArgs(s) {
@@ -40,6 +32,33 @@ function splitArgs(s) {
40
32
  return out;
41
33
  }
42
34
 
35
+ // Shorten a path with ~ for home directory.
36
+ function shortenPath(p) {
37
+ try {
38
+ const home = homedir();
39
+ const rel = relative(home, p);
40
+ if (!rel.startsWith('..')) return '~/' + rel;
41
+ } catch {}
42
+ return p;
43
+ }
44
+
45
+ // Get the current git branch name (synchronous, non-throwing).
46
+ function gitBranch() {
47
+ try {
48
+ return execSync('git rev-parse --abbrev-ref HEAD', {
49
+ cwd: process.cwd(), stdio: ['ignore', 'pipe', 'ignore'], timeout: 1000,
50
+ }).toString().trim();
51
+ } catch {
52
+ return '';
53
+ }
54
+ }
55
+
56
+ // Approximate token count from text (~4 chars per token).
57
+ function approxTokens(messages) {
58
+ const total = messages.reduce((n, m) => n + (m.content?.length || 0), 0);
59
+ return Math.round(total / 4);
60
+ }
61
+
43
62
  function useTerminalSize() {
44
63
  const { stdout } = useStdout();
45
64
  const [size, setSize] = useState({
@@ -58,33 +77,39 @@ function useTerminalSize() {
58
77
  let itemId = 0;
59
78
  const nextId = () => ++itemId;
60
79
 
61
- export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, version = '', resumeSession = null }) {
80
+ // Mode cycle: ask plan autopilot ask
81
+ const MODES = ['ask', 'plan', 'autopilot'];
82
+
83
+ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion }) {
62
84
  const { exit } = useApp();
63
85
  const { cols, rows } = useTerminalSize();
86
+ const colsRef = useRef(cols);
87
+ useEffect(() => { colsRef.current = cols; }, [cols]);
64
88
 
65
89
  const [items, setItems] = useState([]);
66
90
  const [live, setLive] = useState(null); // { status:[], reasoning:'', text:'' }
67
91
  const [busy, setBusy] = useState(false);
68
92
  const [model, setModel] = useState(cfg._model);
69
- const [showReasoning, setShowReasoning] = useState(!!display.reasoning);
70
- const [autopilot, setAutopilot] = useState(false);
93
+ const [showReasoning, setShowReasoning] = useState(false); // collapsed by default per spec
94
+ const [mode, setMode] = useState('ask'); // 'ask' | 'plan' | 'autopilot'
95
+ const [activeTab, setActiveTab] = useState('session');
96
+ const [chips, setChips] = useState([]); // followup suggestion strings
97
+ const [activeChip, setActiveChip] = useState(0);
71
98
  const [hint, setHint] = useState('');
72
99
  const [startedAt, setStartedAt] = useState(0);
73
100
  const [choice, setChoice] = useState(null); // { title, note, options, allowText, sel, text, focusText, onPick }
74
101
  const [clearEpoch, setClearEpoch] = useState(0); // bump to remount <Static> on /clear /new
75
- const [prefillText, setPrefillText] = useState(null); // clipboard paste → pre-fills Composer
76
- const [activeTab, setActiveTab] = useState(0); // 0=Session 1=Monitoring 2=Issues 3=Gists
77
- const [latestVersion, setLatestVersion] = useState(null); // { latest, isNewer } from npm
78
102
 
79
103
  const convo = useRef([]); // [{role, content}] for the backend
80
104
  const plain = useRef([]); // plain-text transcript for exit reprint
81
105
  const abortRef = useRef(null); // AbortController of the in-flight turn
82
106
  const ctrlC = useRef(0); // timestamp of the last lone Ctrl+C
83
107
  const apRef = useRef(false); // live autopilot flag (read inside runTurn)
108
+ const modeRef = useRef('ask'); // live mode (read inside runTurn)
84
109
  const modelRef = useRef(cfg._model); // preferred model to request
85
110
  const lastAnswerRef = useRef(''); // most recent assistant answer (for /copy, /gist)
86
111
  const choiceActionRef = useRef(null); // action to run when a non-AI Choice is picked
87
- const issuesRef = useRef([]); // AI-flagged issues accumulated across session
112
+ const branchRef = useRef(gitBranch()); // current git branch (detected once at start)
88
113
 
89
114
  // Enable terminal bracketed-paste so multi-line pastes (mouse right-click or
90
115
  // Cmd/Ctrl+V) arrive wrapped in markers as one block — the Composer inserts
@@ -94,41 +119,13 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
94
119
  return () => { try { process.stdout.write('\x1b[?2004l'); } catch {} };
95
120
  }, []);
96
121
 
97
- // Background version check runs once after mount, non-blocking.
98
- useEffect(() => {
99
- if (!version || version === 'unknown') return;
100
- checkLatestVersion(version).then((result) => {
101
- if (result?.isNewer) setLatestVersion(result);
102
- });
103
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
104
-
105
- // Restore session after a hot-reload /update.
106
- useEffect(() => {
107
- if (!resumeSession) return;
108
- convo.current = resumeSession.convo || [];
109
- plain.current = resumeSession.plain || [];
110
- // Re-hydrate items list from plain text so the user sees their history.
111
- if (resumeSession.items?.length) {
112
- setItems(resumeSession.items.map((it) => ({ id: nextId(), ...it })));
113
- }
114
- push({ type: 'notice', text: `${glyphs.check} Session restored after update — conversation history preserved.`, color: C.green });
115
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
116
-
117
- // Tab switching: Ctrl+1/2/3/4.
118
- useInput((input, key) => {
119
- if (!key.ctrl) return;
120
- if (input === '1') { setActiveTab(0); return; }
121
- if (input === '2') { setActiveTab(1); return; }
122
- if (input === '3') { setActiveTab(2); return; }
123
- if (input === '4') { setActiveTab(3); return; }
124
- });
125
-
126
- const setAutopilotMode = useCallback((next) => {
127
- apRef.current = next;
128
- setAutopilot(next);
122
+ const setModeState = useCallback((next) => {
123
+ modeRef.current = next;
124
+ apRef.current = next === 'autopilot';
125
+ setMode(next);
129
126
  }, []);
130
127
 
131
- const push = useCallback((item) => setItems((xs) => [...xs, { id: nextId(), ...item }]), []);
128
+ const push = useCallback((item) => setItems((xs) => [...xs, { id: nextId(), cols: colsRef.current, ...item }]), []);
132
129
  const log = (line) => plain.current.push(line);
133
130
 
134
131
  // Clear the visible transcript: wipe the screen + native scrollback and
@@ -148,6 +145,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
148
145
  convo.current.push({ role: 'user', content: question });
149
146
  setBusy(true);
150
147
  setHint('');
148
+ setChips([]);
151
149
  setStartedAt(Date.now());
152
150
  const s = { status: [], reasoning: '', text: '', reply: null };
153
151
  setLive({ ...s });
@@ -200,6 +198,12 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
200
198
  if (text) convo.current.push({ role: 'assistant', content: text });
201
199
  lastAnswerRef.current = text || reply.summary || reply.question || lastAnswerRef.current;
202
200
 
201
+ // Populate followup chips from reply.suggestions (up to 4).
202
+ if (Array.isArray(reply.suggestions) && reply.suggestions.length) {
203
+ setChips(reply.suggestions.slice(0, 4));
204
+ setActiveChip(0);
205
+ }
206
+
203
207
  // Archive a /backup answer to the per-user git repo.
204
208
  if (archive === 'backup' && text) {
205
209
  try {
@@ -285,22 +289,6 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
285
289
  if (!q) return;
286
290
  setHint('');
287
291
 
288
- // ── ! shell command execution (GitHub Copilot CLI parity) ──────────────
289
- if (q.startsWith('!')) {
290
- const cmd = q.slice(1).trim();
291
- if (!cmd) return;
292
- push({ type: 'shell_in', cmd });
293
- log(`$ ${cmd}`);
294
- runShell(cmd).then((result) => {
295
- const out = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
296
- push({ type: 'shell_out', ok: result.ok, output: out || `(exit code ${result.exitCode})` });
297
- if (out) log(out);
298
- }).catch((e) => {
299
- push({ type: 'notice', text: `${glyphs.cross} Shell error: ${e.message}`, color: C.red });
300
- });
301
- return;
302
- }
303
-
304
292
  if (q === '/exit' || q === 'exit' || q === 'quit') { doExit(); return; }
305
293
  if (q === '/clear') { clearAll(); return; }
306
294
  if (q === '/new') {
@@ -310,17 +298,71 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
310
298
  return;
311
299
  }
312
300
  if (q === '/reasoning') {
313
- setShowReasoning((v) => { push({ type: 'notice', text: `Reasoning display ${!v ? 'on' : 'off'}.` }); return !v; });
301
+ setShowReasoning((v) => { push({ type: 'notice', text: `Reasoning display ${!v ? 'expanded' : 'collapsed'}.` }); return !v; });
314
302
  return;
315
303
  }
316
304
  if (q === '/autopilot' || q === '/auto') {
317
- const next = !apRef.current;
318
- setAutopilotMode(next);
305
+ const next = modeRef.current !== 'autopilot' ? 'autopilot' : 'ask';
306
+ setModeState(next);
319
307
  push({ type: 'notice',
320
- text: next
321
- ? `${glyphs.bolt} Autopilot ON ${dash()} proposed fixes will be applied automatically. Toggle with Shift+Tab.`
322
- : `Autopilot OFF ${dash()} fixes will wait for your confirmation.`,
323
- color: next ? C.yellow : C.gray });
308
+ text: next === 'autopilot'
309
+ ? `${glyphs.bolt} Autopilot ON ${dash()} proposed fixes will be applied automatically. Shift+Tab to cycle modes.`
310
+ : `Mode: ask ${dash()} fixes will wait for your confirmation.`,
311
+ color: next === 'autopilot' ? C.warning : C.muted });
312
+ return;
313
+ }
314
+ if (q === '/plan') {
315
+ const next = modeRef.current !== 'plan' ? 'plan' : 'ask';
316
+ setModeState(next);
317
+ push({ type: 'notice', text: `Mode: ${next} ${dash()} Shift+Tab to cycle.`, color: C.accent });
318
+ return;
319
+ }
320
+ if (q === '/context' || q === '/usage') {
321
+ const tokens = approxTokens(convo.current);
322
+ push({ type: 'notice', text: `Context: ~${tokens.toLocaleString()} tokens (${convo.current.length} messages). /compact to summarise.` });
323
+ return;
324
+ }
325
+ if (q === '/compact' || q.startsWith('/compact ')) {
326
+ const focus = q.slice('/compact'.length).trim();
327
+ const prompt = focus
328
+ ? `Summarise our conversation so far, focusing on: ${focus}`
329
+ : 'Summarise our conversation so far in a concise way, preserving key facts and decisions.';
330
+ convo.current = [];
331
+ push({ type: 'notice', text: `${glyphs.arrow} Compacting history…`, color: C.muted });
332
+ runTurn(prompt);
333
+ return;
334
+ }
335
+ if (q === '/theme') {
336
+ push({ type: 'notice', text: 'Theme: dark (default). Palette: purple accent, blue brand, green success, amber warning, red error.\nSet NO_COLOR=1 to disable colour or FORCE_COLOR=1 to force it.' });
337
+ return;
338
+ }
339
+ if (q === '/session') {
340
+ const tokens = approxTokens(convo.current);
341
+ const branch = branchRef.current;
342
+ const cwd = shortenPath(process.cwd());
343
+ push({ type: 'notice', text: [
344
+ `Session ${dash()} ${cwd}${branch ? ' [' + branch + ']' : ''}`,
345
+ `Messages ${dash()} ${convo.current.length}`,
346
+ `Tokens ${dash()} ~${tokens.toLocaleString()} (approx)`,
347
+ `Model ${dash()} ${model || modelRef.current || 'server default'}`,
348
+ `Mode ${dash()} ${modeRef.current}`,
349
+ ].join('\n') });
350
+ return;
351
+ }
352
+ if (q === '/share') {
353
+ if (!lastAnswerRef.current && convo.current.length === 0) {
354
+ push({ type: 'notice', text: 'Nothing to share yet — ask something first.', color: C.muted }); return;
355
+ }
356
+ choiceActionRef.current = (v) => doGist(v);
357
+ setChoice({
358
+ title: 'Share conversation…',
359
+ note: 'Saves as a secret GitHub gist', sel: 0, text: '', focusText: false, allowText: false,
360
+ options: [
361
+ { label: 'Last AI answer', value: 'answer' },
362
+ { label: 'Full conversation transcript', value: 'transcript' },
363
+ { label: 'Cancel', value: null },
364
+ ],
365
+ });
324
366
  return;
325
367
  }
326
368
  if (q === '/model' || q.startsWith('/model ')) {
@@ -342,7 +384,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
342
384
  const head = c.group !== group ? ((group = c.group), `\n ${c.group}\n`) : '';
343
385
  return head + ` ${(c.cmd + (c.hint ? ' ' + c.hint : '')).padEnd(22)}${c.desc}`;
344
386
  }).join('\n');
345
- push({ type: 'notice', text: 'Commands:' + lines + '\n\n Keys: Shift+Tab autopilot · Ctrl+T reasoning · Ctrl+Z/Ctrl+Y undo/redo · Ctrl+L clear · Ctrl+C twice to exit' });
387
+ push({ type: 'notice', text: 'Commands:' + lines + '\n\n Keys: Shift+Tab cycle modes (ask→plan→autopilot) · Ctrl+T toggle reasoning · Ctrl+L clear · Ctrl+N/P followup chips · Ctrl+D exit · Ctrl+C×2 exit' });
346
388
  return;
347
389
  }
348
390
  if (q === '/history') {
@@ -350,31 +392,8 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
350
392
  push({ type: 'notice', text: us.length ? us.map((m, i) => ` ${i + 1}. ${m.content.slice(0, 80)}`).join('\n') : 'No history yet.' });
351
393
  return;
352
394
  }
353
- if (q === '/usage') {
354
- const total = convo.current.length;
355
- const userTurns = convo.current.filter((m) => m.role === 'user').length;
356
- const chars = convo.current.reduce((acc, m) => acc + m.content.length, 0);
357
- const approxTokens = Math.round(chars / 4);
358
- const lines = [
359
- `Conversation stats:`,
360
- ` turns ${userTurns}`,
361
- ` messages ${total}`,
362
- ` ~tokens ${approxTokens.toLocaleString()} (estimated)`,
363
- ` ~chars ${chars.toLocaleString()}`,
364
- ` model ${model || cfg._model || 'server default'}`,
365
- ` cwd ${APP_CWD}`,
366
- ];
367
- push({ type: 'notice', text: lines.join('\n'), color: C.cyan });
368
- return;
369
- }
370
395
  if (q === '/logout') { onExternal?.('logout'); doExit(); return; }
371
- if (q === '/update') {
372
- // Save current conversation so the new binary can resume it.
373
- const resumeFile = saveResume(convo.current, plain.current, items);
374
- onExternal?.({ action: 'update', resumeFile });
375
- doExit();
376
- return;
377
- }
396
+ if (q === '/update') { onExternal?.('update'); doExit(); return; }
378
397
 
379
398
  // ── Memory ──
380
399
  if (q === '/remember' || q.startsWith('/remember ')) {
@@ -402,39 +421,8 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
402
421
  }
403
422
 
404
423
  // ── Clipboard ──
405
- // ── /about ─────────────────────────────────────────────────────────────
406
- if (q === '/about') {
407
- const lines = [
408
- `iCopilot CLI v${version || '?'}`,
409
- `Server ${cfg.url || '(unknown)'}`,
410
- `User ${cfg.user?.name || '—'} (${cfg.user?.role || '—'})`,
411
- `Model ${shortModel(model || cfg._model || '—')}`,
412
- '',
413
- 'Type /help for all commands · Ctrl+C to exit',
414
- 'npm: ispbills-icli · github: lupael/IspBills',
415
- ];
416
- push({ type: 'notice', text: lines.join('\n'), color: C.accent });
417
- return;
418
- }
419
-
420
- // ── /paste ─────────────────────────────────────────────────────────────
421
- if (q === '/paste') {
422
- push({ type: 'notice', text: `${glyphs.arrow} Reading clipboard…`, color: C.gray });
423
- readFromClipboard().then((text) => {
424
- if (!text) {
425
- 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 });
426
- return;
427
- }
428
- const lines = text.split('\n').length;
429
- push({ type: 'notice',
430
- text: `${glyphs.check} Pasted ${lines} line${lines !== 1 ? 's' : ''} (${text.length} chars) into the input box — review and press Enter to send.`,
431
- color: C.green });
432
- setPrefillText(text);
433
- });
434
- return;
435
- }
436
-
437
- if (q === '/copy') { const ans = lastAnswerRef.current;
424
+ if (q === '/copy') {
425
+ const ans = lastAnswerRef.current;
438
426
  if (!ans) { push({ type: 'notice', text: 'Nothing to copy yet — ask something first.', color: C.gray }); return; }
439
427
  copyToClipboard(ans).then((tool) => push({ type: 'notice',
440
428
  text: tool ? `${glyphs.check} Copied last answer (${ans.length} chars) to the clipboard.`
@@ -493,15 +481,15 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
493
481
  } else { push({ type: 'notice', text: `Unknown command: ${q}. Type /help.` }); return; }
494
482
  }
495
483
  runTurn(question, opts);
496
- }, [doExit, push, runTurn, onExternal, setAutopilotMode, model, cfg, doGist]);
484
+ }, [doExit, push, runTurn, onExternal, setModeState, model, cfg, doGist]);
497
485
 
498
486
  // Interactive prompt/choice handling (owns the keyboard while open).
499
487
  const pickChoice = useCallback((value) => {
500
488
  const act = choiceActionRef.current;
501
489
  choiceActionRef.current = null;
502
490
  setChoice(null);
503
- if (act) { if (value != null) act(value); else push({ type: 'notice', text: 'Cancelled.', color: C.gray }); return; }
504
- if (value == null) { push({ type: 'notice', text: 'Dismissed.', color: C.gray }); return; }
491
+ if (act) { if (value != null) act(value); else push({ type: 'notice', text: 'Cancelled.', color: C.muted }); return; }
492
+ if (value == null) { push({ type: 'notice', text: 'Dismissed.', color: C.muted }); return; }
505
493
  runTurn(String(value));
506
494
  }, [push, runTurn]);
507
495
 
@@ -543,15 +531,34 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
543
531
  setHint('Press Ctrl+C again to exit');
544
532
  return;
545
533
  }
534
+ if (key.ctrl && input === 'd') { doExit(); return; } // Ctrl+D shutdown
546
535
  if (key.escape && busy) { abortRef.current?.abort(); return; }
536
+ if (key.escape && chips.length) { setChips([]); return; } // Esc dismisses chips
547
537
  if (key.tab && key.shift && !busy) {
548
- const next = !apRef.current;
549
- setAutopilotMode(next);
550
- setHint(next ? 'Autopilot ON' : 'Autopilot OFF');
538
+ // Cycle: ask → plan → autopilot → ask
539
+ const cur = MODES.indexOf(modeRef.current);
540
+ const next = MODES[(cur + 1) % MODES.length];
541
+ setModeState(next);
542
+ setHint(`Mode: ${next}`);
551
543
  return;
552
544
  }
553
545
  if (key.ctrl && input === 't') { setShowReasoning((v) => !v); return; }
554
546
  if (key.ctrl && input === 'l' && !busy) { clearAll(); return; }
547
+ // Ctrl+N / Ctrl+P — cycle followup chips
548
+ if (key.ctrl && input === 'n' && chips.length) {
549
+ setActiveChip((i) => (i + 1) % chips.length); return;
550
+ }
551
+ if (key.ctrl && input === 'p' && chips.length) {
552
+ setActiveChip((i) => (i - 1 + chips.length) % chips.length); return;
553
+ }
554
+ // Tab cycles the tab bar (when not in slash palette)
555
+ if (key.tab && !key.shift && !busy) {
556
+ setActiveTab((t) => {
557
+ const tabs = ['session', 'devices', 'customers', 'alarms'];
558
+ return tabs[(tabs.indexOf(t) + 1) % tabs.length];
559
+ });
560
+ return;
561
+ }
555
562
  });
556
563
 
557
564
  // Auto-run a first question if launched with one.
@@ -561,85 +568,82 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, ve
561
568
  }, [initialQuestion, onSubmit]);
562
569
 
563
570
  const renderItem = (it) => {
571
+ const c = it.cols || 80;
564
572
  switch (it.type) {
565
- case 'user': return html`<${UserMessage} key=${it.id} text=${it.text} />`;
566
- case 'assistant': return html`<${AssistantMessage} key=${it.id} text=${it.text} />`;
573
+ case 'user': return html`<${UserMessage} key=${it.id} text=${it.text} cols=${c} />`;
574
+ case 'assistant': return html`<${AssistantMessage} key=${it.id} text=${it.text} cols=${c} />`;
567
575
  case 'plan': return html`<${Plan} key=${it.id} reply=${it.reply} />`;
568
576
  case 'connect': return html`<${Connect} key=${it.id} reply=${it.reply} />`;
569
577
  case 'activity': return html`<${StatusLines} key=${it.id} lines=${it.status} busy=${false} />`;
570
578
  case 'notice': return html`<${Notice} key=${it.id} text=${it.text} color=${it.color} />`;
571
- case 'shell_in': return html`<${ShellInput} key=${it.id} cmd=${it.cmd} />`;
572
- case 'shell_out': return html`<${ShellOut} key=${it.id} ok=${it.ok} output=${it.output} />`;
573
579
  default: return null;
574
580
  }
575
581
  };
576
582
 
577
- const ctx = [model ? shortModel(model) : null, APP_CWD, cfg.user?.name, cfg.user?.role].filter(Boolean).join(midDot());
578
- // Footer: left = session context (dimmed), right = hint or key-hints.
579
- // Autopilot mode turns the right side yellow.
580
- const footerHint = hint
583
+ // ── Footer ───────────────────────────────────────────────────────────────────
584
+ // Left: ~/cwd ⏎ branch mode-badge
585
+ // Right: model ~Nk ctx
586
+ const cwd = shortenPath(process.cwd());
587
+ const branch = branchRef.current;
588
+ const tokenCount = approxTokens(convo.current);
589
+ const tokenLabel = tokenCount > 0 ? `~${Math.round(tokenCount / 1000)}k ctx` : '';
590
+ const modelLabel = shortModel(model || modelRef.current || '');
591
+ const modeLabel = mode !== 'ask' ? ` ${midDot()} ${glyphs.bolt} ${mode}` : '';
592
+
593
+ const footerLeft = [
594
+ cwd,
595
+ branch ? `${glyphs.gitBranch}${branch}` : null,
596
+ ].filter(Boolean).join(' ');
597
+
598
+ const footerRight = hint
581
599
  ? hint
582
- : autopilot
583
- ? `${glyphs.bolt} autopilot ON${midDot()}/help${midDot()}Shift+Tab off${midDot()}Ctrl+C exit`
584
- : `/help${midDot()}Shift+Tab autopilot${midDot()}Ctrl+C exit`;
585
- const footerColor = hint ? C.yellow : autopilot ? C.yellow : void 0;
600
+ : [modelLabel, tokenLabel].filter(Boolean).join(` ${midDot()} `);
601
+
586
602
  const compact = cols < 60;
587
603
 
588
604
  // The banner is the first <Static> item so it prints once at the very top and
589
605
  // scrolls away with history; completed transcript items follow it. Ink flushes
590
606
  // each <Static> item to the terminal's normal buffer exactly once, leaving the
591
607
  // native scrollback intact so earlier conversation can be scrolled back to.
592
- const staticItems = [{ type: 'banner', id: `banner-${clearEpoch}` }, ...items];
608
+ const staticItems = [{ type: 'banner', id: `banner-${clearEpoch}`, cols }, ...items];
593
609
 
594
610
  return html`
595
611
  <${Box} flexDirection="column" width=${cols}>
596
- ${/* Tab bar — always pinned above content */null}
597
- <${TabBar} tabs=${TABS} active=${activeTab} onSelect=${setActiveTab} width=${cols} />
612
+ <${Static} key=${clearEpoch} items=${staticItems}>
613
+ ${(it) => it.type === 'banner'
614
+ ? html`<${Box} key=${it.id} flexDirection="column" alignItems="center" marginBottom=${1}>
615
+ <${Banner} cfg=${cfg} model=${model} width=${cols} compact=${compact} />
616
+ <//>`
617
+ : renderItem(it)}
618
+ <//>
598
619
 
599
- ${activeTab === 0
620
+ ${live
600
621
  ? html`<${Box} flexDirection="column">
601
- <${Static} key=${clearEpoch} items=${staticItems}>
602
- ${(it) => it.type === 'banner'
603
- ? html`<${Box} key=${it.id} flexDirection="column" alignItems="center" marginBottom=${1}>
604
- <${Banner} cfg=${cfg} model=${model} width=${cols} compact=${compact} version=${version} cwd=${APP_CWD} />
605
- <//>`
606
- : renderItem(it)}
607
- <//>
608
- ${live
609
- ? html`<${Box} flexDirection="column">
610
- <${StatusLines} lines=${live.status} busy=${busy} />
611
- ${showReasoning && live.reasoning ? html`<${Reasoning} text=${live.reasoning} />` : null}
612
- ${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
613
- ${live.text
614
- ? html`<${Box} marginTop=${live.status.length ? 0 : 1}>
615
- <${AssistantMessage} text=${live.text} streaming=${true} />
616
- <//>` : null}
617
- ${!live.text && !live.reply?.steps
618
- ? html`<${Thinking}
619
- label=${live.status.length ? 'Working' : 'Thinking'}
620
- startedAt=${startedAt} />`
621
- : null}
622
- <//>`
623
- : null}
622
+ <${StatusLines} lines=${live.status} busy=${busy} />
623
+ ${live.reasoning ? html`<${Reasoning} text=${live.reasoning} show=${showReasoning} />` : null}
624
+ ${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
625
+ ${live.text ? html`<${AssistantMessage} text=${live.text} cols=${cols} />` : null}
626
+ <${Thinking} label=${live.reply ? 'Planning' : live.text ? 'Responding' : 'Working'} startedAt=${startedAt} />
624
627
  <//>`
625
628
  : null}
626
- ${activeTab === 1 ? html`<${MonitoringView} cfg=${cfg} />` : null}
627
- ${activeTab === 2 ? html`<${IssuesView} issues=${issuesRef.current} />` : null}
628
- ${activeTab === 3 ? html`<${GistsView} cfg=${cfg} />` : null}
629
-
630
- <${Box} flexDirection="column" marginTop=${1}>
631
- ${activeTab === 0
632
- ? (choice
633
- ? html`<${Choice} ...${choice} />`
634
- : html`<${Composer} onSubmit=${onSubmit} busy=${busy} width=${cols} prefill=${prefillText} onPrefillConsumed=${() => setPrefillText(null)} />`)
635
- : html`<${Box} paddingX=${1}><${Text} dimColor>Ctrl+1 for Session · Ctrl+2/3/4 for other tabs<//> <//>` }
629
+
630
+ <${TabBar} active=${activeTab} cols=${cols} />
631
+
632
+ ${!busy && chips.length ? html`<${FollowupChips} chips=${chips} active=${activeChip} />` : null}
633
+
634
+ <${Box} flexDirection="column">
635
+ ${choice
636
+ ? html`<${Choice} ...${choice} />`
637
+ : html`<${Composer} onSubmit=${onSubmit} busy=${busy} width=${cols} />`}
638
+
639
+ <${Text} color=${C.separator}>${'─'.repeat(Math.max(1, cols))}<//>
640
+
636
641
  <${Box} paddingX=${1} width=${cols}>
637
642
  <${Box} flexGrow=${1}>
638
- <${Text} dimColor wrap="truncate-end">${ctx}<//>
643
+ <${Text} color=${C.muted} wrap="truncate-end">${footerLeft}<//>
644
+ ${modeLabel ? html`<${Text} color=${mode === 'autopilot' ? C.warning : C.accent}>${modeLabel}<//>` : null}
639
645
  <//>
640
- ${latestVersion?.isNewer
641
- ? html`<${Text} color=${C.yellow} wrap="truncate-end">🔔 v${latestVersion.latest} available — /update<//>`
642
- : html`<${Text} color=${footerColor} dimColor=${!footerColor} wrap="truncate-end">${footerHint}<//>` }
646
+ <${Text} color=${hint ? C.warning : C.muted} wrap="truncate-end">${footerRight}<//>
643
647
  <//>
644
648
  <//>
645
649
  <//>`;