ispbills-icli 8.5.3 → 8.5.4

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
@@ -1,14 +1,10 @@
1
- // Root Ink application. Renders an INLINE chat UI (GitHub Copilot CLI style):
2
- // the banner prints once at the top and scrolls away, completed transcript
3
- // items are flushed to the terminal's native scrollback via Ink <Static> (so
4
- // you can scroll back through history), and only the live/streaming area plus
5
- // the input composer are re-rendered in place at the bottom.
1
+ // Root Ink application. Renders an INLINE chat UI (GitHub Copilot CLI style).
6
2
  import { html, useState, useEffect, useRef, useCallback } from './dom.js';
7
3
  import { Box, Text, Static, useStdout, useApp, useInput } from 'ink';
8
4
  import { C, glyphs, midDot, dash } from './theme.js';
9
5
  import {
10
6
  Banner, TabBar, FollowupChips, UserMessage, AssistantMessage, StatusLines, Reasoning,
11
- Plan, Connect, Notice, Thinking, Choice,
7
+ Plan, Connect, Notice, Thinking, Choice, ShellOutput, SearchOverlay, HelpOverlay,
12
8
  } from './components.js';
13
9
  import { Composer } from './composer.js';
14
10
  import { streamChat } from '../client.js';
@@ -19,11 +15,11 @@ import { loadNotes, addNote, forgetNote, memoryPreamble } from '../memory.js';
19
15
  import { copyToClipboard } from '../clipboard.js';
20
16
  import { createGist } from '../gist.js';
21
17
  import { git as gitCmd, commitBackup } from '../gitrepo.js';
22
- import { execSync } from 'child_process';
18
+ import { execSync, spawnSync } from 'child_process';
23
19
  import { homedir } from 'os';
24
- import { relative } from 'path';
20
+ import { dirname, isAbsolute, relative, resolve } from 'path';
21
+ import { existsSync, readFileSync } from 'fs';
25
22
 
26
- // Split a command argument string into argv, honouring "quoted" segments.
27
23
  function splitArgs(s) {
28
24
  const out = [];
29
25
  const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
@@ -32,7 +28,6 @@ function splitArgs(s) {
32
28
  return out;
33
29
  }
34
30
 
35
- // Shorten a path with ~ for home directory.
36
31
  function shortenPath(p) {
37
32
  try {
38
33
  const home = homedir();
@@ -42,29 +37,68 @@ function shortenPath(p) {
42
37
  return p;
43
38
  }
44
39
 
45
- // Get the current git branch name (synchronous, non-throwing).
46
- function gitBranch() {
40
+ function gitBranch(cwd = process.cwd()) {
47
41
  try {
48
42
  return execSync('git rev-parse --abbrev-ref HEAD', {
49
- cwd: process.cwd(), stdio: ['ignore', 'pipe', 'ignore'], timeout: 1000,
43
+ cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: 1000,
50
44
  }).toString().trim();
51
45
  } catch {
52
46
  return '';
53
47
  }
54
48
  }
55
49
 
56
- // Approximate token count from text (~4 chars per token).
57
50
  function approxTokens(messages) {
58
51
  const total = messages.reduce((n, m) => n + (m.content?.length || 0), 0);
59
52
  return Math.round(total / 4);
60
53
  }
61
54
 
55
+ function pkgVersion() {
56
+ try {
57
+ return JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version || 'unknown';
58
+ } catch {
59
+ return 'unknown';
60
+ }
61
+ }
62
+
63
+ function feedbackUrl() {
64
+ try {
65
+ const raw = execSync('git config --get remote.origin.url', {
66
+ cwd: process.cwd(), stdio: ['ignore', 'pipe', 'ignore'], timeout: 1000,
67
+ }).toString().trim();
68
+ if (!raw) return 'https://github.com/issues/new';
69
+ const gh = raw
70
+ .replace(/^git@github\.com:/, 'https://github.com/')
71
+ .replace(/\.git$/, '');
72
+ if (/^https:\/\/github\.com\//.test(gh)) return `${gh}/issues/new`;
73
+ } catch {}
74
+ return 'https://github.com/issues/new';
75
+ }
76
+
77
+ function isTrustedDir(cfg, dir) {
78
+ const trusted = cfg?.tui?.trustedDirs || {};
79
+ let cur = resolve(dir);
80
+ while (true) {
81
+ if (trusted[cur]) return true;
82
+ const parent = dirname(cur);
83
+ if (parent === cur) break;
84
+ cur = parent;
85
+ }
86
+ return false;
87
+ }
88
+
89
+ function itemPreview(it) {
90
+ if (!it) return '';
91
+ if (it.type === 'user' || it.type === 'assistant' || it.type === 'notice' || it.type === 'help') return String(it.text || it.type);
92
+ if (it.type === 'shell') return `$ ${it.command}\n${it.output || ''}`;
93
+ if (it.type === 'plan') return `Plan ${it.reply?.summary || ''}`;
94
+ if (it.type === 'connect') return `Connect ${it.reply?.device?.kind || ''}`;
95
+ if (it.type === 'activity') return (it.status || []).join('\n');
96
+ return JSON.stringify(it);
97
+ }
98
+
62
99
  function useTerminalSize() {
63
100
  const { stdout } = useStdout();
64
- const [size, setSize] = useState({
65
- cols: stdout?.columns || 80,
66
- rows: stdout?.rows || 24,
67
- });
101
+ const [size, setSize] = useState({ cols: stdout?.columns || 80, rows: stdout?.rows || 24 });
68
102
  useEffect(() => {
69
103
  if (!stdout) return;
70
104
  const on = () => setSize({ cols: stdout.columns || 80, rows: stdout.rows || 24 });
@@ -76,68 +110,203 @@ function useTerminalSize() {
76
110
 
77
111
  let itemId = 0;
78
112
  const nextId = () => ++itemId;
79
-
80
- // Mode cycle: ask → plan → autopilot → ask
81
113
  const MODES = ['ask', 'plan', 'autopilot'];
82
114
 
83
- export function App({ cfg, display = {}, onExternal, onExit, initialQuestion }) {
115
+ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, showBanner = true }) {
84
116
  const { exit } = useApp();
85
- const { cols, rows } = useTerminalSize();
117
+ const { cols } = useTerminalSize();
86
118
  const colsRef = useRef(cols);
87
119
  useEffect(() => { colsRef.current = cols; }, [cols]);
88
120
 
89
121
  const [items, setItems] = useState([]);
90
- const [live, setLive] = useState(null); // { status:[], reasoning:'', text:'' }
122
+ const [live, setLive] = useState(null);
91
123
  const [busy, setBusy] = useState(false);
92
124
  const [model, setModel] = useState(cfg._model);
93
- const [showReasoning, setShowReasoning] = useState(false); // collapsed by default per spec
94
- const [mode, setMode] = useState('ask'); // 'ask' | 'plan' | 'autopilot'
125
+ const [showReasoning, setShowReasoning] = useState(false);
126
+ const [mode, setMode] = useState('ask');
95
127
  const [activeTab, setActiveTab] = useState('session');
96
- const [chips, setChips] = useState([]); // followup suggestion strings
128
+ const [chips, setChips] = useState([]);
97
129
  const [activeChip, setActiveChip] = useState(0);
98
130
  const [hint, setHint] = useState('');
99
131
  const [startedAt, setStartedAt] = useState(0);
100
- const [choice, setChoice] = useState(null); // { title, note, options, allowText, sel, text, focusText, onPick }
101
- const [clearEpoch, setClearEpoch] = useState(0); // bump to remount <Static> on /clear /new
102
-
103
- const convo = useRef([]); // [{role, content}] for the backend
104
- const plain = useRef([]); // plain-text transcript for exit reprint
105
- const abortRef = useRef(null); // AbortController of the in-flight turn
106
- const ctrlC = useRef(0); // timestamp of the last lone Ctrl+C
107
- const apRef = useRef(false); // live autopilot flag (read inside runTurn)
108
- const modeRef = useRef('ask'); // live mode (read inside runTurn)
109
- const modelRef = useRef(cfg._model); // preferred model to request
110
- const lastAnswerRef = useRef(''); // most recent assistant answer (for /copy, /gist)
111
- const choiceActionRef = useRef(null); // action to run when a non-AI Choice is picked
112
- const branchRef = useRef(gitBranch()); // current git branch (detected once at start)
113
-
114
- // Enable terminal bracketed-paste so multi-line pastes (mouse right-click or
115
- // Cmd/Ctrl+V) arrive wrapped in markers as one block — the Composer inserts
116
- // them with newlines preserved instead of submitting on the first newline.
132
+ const [choice, setChoice] = useState(null);
133
+ const [clearEpoch, setClearEpoch] = useState(0);
134
+ const [composerClearToken, setComposerClearToken] = useState(0);
135
+ const [composerText, setComposerText] = useState('');
136
+ const [search, setSearch] = useState(null);
137
+ const [expandedItems, setExpandedItems] = useState(null);
138
+ const [allowAll, setAllowAll] = useState(Boolean(cfg?.tui?.allowAll));
139
+ const [streamerMode, setStreamerMode] = useState(Boolean(cfg?.tui?.streamerMode));
140
+ const [shellMode, setShellMode] = useState(false);
141
+ const [trusted, setTrusted] = useState(isTrustedDir(cfg, process.cwd()));
142
+
143
+ const convo = useRef([]);
144
+ const plain = useRef([]);
145
+ const abortRef = useRef(null);
146
+ const ctrlC = useRef(0);
147
+ const apRef = useRef(false);
148
+ const modeRef = useRef('ask');
149
+ const modelRef = useRef(cfg._model);
150
+ const lastAnswerRef = useRef('');
151
+ const choiceActionRef = useRef(null);
152
+ const branchRef = useRef(gitBranch());
153
+ const queuedRef = useRef(null);
154
+ const activeTurns = useRef(0);
155
+ const dismissTimers = useRef(new Map());
156
+ const dispatchSubmitRef = useRef(null);
157
+
117
158
  useEffect(() => {
118
159
  try { process.stdout.write('\x1b[?2004h'); } catch {}
119
160
  return () => { try { process.stdout.write('\x1b[?2004l'); } catch {} };
120
161
  }, []);
121
162
 
163
+ useEffect(() => () => {
164
+ for (const timer of dismissTimers.current.values()) clearTimeout(timer);
165
+ }, []);
166
+
167
+ const persistCfg = useCallback(() => {
168
+ try { saveConfig(cfg); } catch {}
169
+ }, [cfg]);
170
+
122
171
  const setModeState = useCallback((next) => {
123
172
  modeRef.current = next;
124
173
  apRef.current = next === 'autopilot';
125
174
  setMode(next);
126
175
  }, []);
127
176
 
128
- const push = useCallback((item) => setItems((xs) => [...xs, { id: nextId(), cols: colsRef.current, ...item }]), []);
177
+ const push = useCallback((item) => {
178
+ const next = { id: nextId(), cols: colsRef.current, ts: item.ts ?? Date.now(), ...item };
179
+ setItems((xs) => [...xs, next]);
180
+ return next;
181
+ }, []);
182
+
183
+ const removeItem = useCallback((id) => {
184
+ setItems((xs) => xs.filter((x) => x.id !== id));
185
+ const timer = dismissTimers.current.get(id);
186
+ if (timer) {
187
+ clearTimeout(timer);
188
+ dismissTimers.current.delete(id);
189
+ }
190
+ }, []);
191
+
192
+ useEffect(() => {
193
+ for (const item of items) {
194
+ if (item.type !== 'notice' || !item.autoDismiss || dismissTimers.current.has(item.id)) continue;
195
+ const timer = setTimeout(() => removeItem(item.id), 4000);
196
+ dismissTimers.current.set(item.id, timer);
197
+ }
198
+ }, [items, removeItem]);
199
+
200
+ const pushNotice = useCallback((text, color = C.gray, autoDismiss = false) => {
201
+ push({ type: 'notice', text, color, autoDismiss });
202
+ }, [push]);
203
+
129
204
  const log = (line) => plain.current.push(line);
130
205
 
131
- // Clear the visible transcript: wipe the screen + native scrollback and
132
- // remount <Static> (via clearEpoch) so nothing is re-flushed above the input.
133
206
  const clearAll = useCallback(() => {
134
207
  try { process.stdout.write('\x1b[2J\x1b[3J\x1b[H'); } catch {}
135
208
  setItems([]);
209
+ setExpandedItems(null);
210
+ setSearch(null);
136
211
  setClearEpoch((n) => n + 1);
137
212
  }, []);
138
213
 
214
+ const queueSubmission = useCallback((text) => {
215
+ const q = String(text || '').trim();
216
+ if (!q) { pushNotice('Nothing to queue.', C.muted, true); return; }
217
+ queuedRef.current = q;
218
+ pushNotice(`[queued] ${q}`, C.warning, true);
219
+ }, [pushNotice]);
220
+
221
+ const openTrustDialog = useCallback((dir = process.cwd()) => {
222
+ choiceActionRef.current = (value) => {
223
+ if (value === 'session') {
224
+ setTrusted(true);
225
+ pushNotice(`Trusted for this session: ${shortenPath(dir)}`, C.success, true);
226
+ return;
227
+ }
228
+ if (value === 'remember') {
229
+ cfg.tui = cfg.tui || {};
230
+ cfg.tui.trustedDirs = { ...(cfg.tui.trustedDirs || {}), [resolve(dir)]: true };
231
+ persistCfg();
232
+ setTrusted(true);
233
+ pushNotice(`Trusted and remembered: ${shortenPath(dir)}`, C.success, true);
234
+ return;
235
+ }
236
+ doExit();
237
+ };
238
+ setChoice({
239
+ title: 'Do you trust files in this folder?',
240
+ note: shortenPath(dir),
241
+ sel: 0,
242
+ text: '',
243
+ focusText: false,
244
+ allowText: false,
245
+ options: [
246
+ { label: '1. Yes', value: 'session' },
247
+ { label: '2. Yes, remember', value: 'remember' },
248
+ { label: '3. No, exit', value: 'exit', danger: true },
249
+ ],
250
+ });
251
+ }, [cfg, persistCfg]);
252
+
253
+ useEffect(() => {
254
+ if (!trusted && !choice) openTrustDialog(process.cwd());
255
+ }, [trusted, choice, openTrustDialog]);
256
+
257
+ const doExit = useCallback(() => { onExit?.(plain.current); exit(); }, [exit, onExit]);
258
+
259
+ const maybeProcessQueue = useCallback(() => {
260
+ if (activeTurns.current !== 0 || !queuedRef.current) return;
261
+ const next = queuedRef.current;
262
+ queuedRef.current = null;
263
+ setComposerClearToken((n) => n + 1);
264
+ setTimeout(() => dispatchSubmitRef.current?.(next, { queued: true }), 0);
265
+ }, []);
266
+
267
+ const doGist = useCallback(async (kind) => {
268
+ let content; let filename; let description;
269
+ if (kind === 'transcript') {
270
+ content = convo.current.map((m) => `### ${m.role}\n\n${m.content}`).join('\n\n---\n\n') || '(empty)';
271
+ filename = 'icli-transcript.md';
272
+ description = 'iCli conversation transcript';
273
+ } else {
274
+ content = lastAnswerRef.current || '(no answer)';
275
+ filename = 'icli-answer.md';
276
+ description = 'iCli answer';
277
+ }
278
+ pushNotice(`${glyphs.arrow} Creating secret gist…`, C.gray);
279
+ try {
280
+ const { url } = await createGist(cfg, { filename, content, description, isPublic: false });
281
+ const tool = await copyToClipboard(url);
282
+ pushNotice(`${glyphs.check} Gist created: ${url}${tool ? ' (URL copied)' : ''}`, C.green);
283
+ } catch (e) {
284
+ pushNotice(`${glyphs.cross} ${e.message}`, C.red);
285
+ }
286
+ }, [cfg, pushNotice]);
287
+
288
+ const runShellCommand = useCallback((rawCommand) => {
289
+ const command = String(rawCommand || '').trim();
290
+ if (!command) {
291
+ setShellMode(true);
292
+ pushNotice('Shell mode enabled. Press Esc to exit.', C.warning, true);
293
+ return;
294
+ }
295
+ if (shellMode && /^(exit|quit)$/i.test(command)) {
296
+ setShellMode(false);
297
+ pushNotice('Exited shell mode.', C.muted, true);
298
+ return;
299
+ }
300
+ push({ type: 'user', text: shellMode ? command : `!${command}` });
301
+ log(`\n$ ${command}\n`);
302
+ const res = spawnSync(command, { shell: true, encoding: 'utf8', cwd: process.cwd(), maxBuffer: 1024 * 1024 });
303
+ const output = [res.stdout || '', res.stderr || ''].join('').trim() || '(no output)';
304
+ push({ type: 'shell', command, output, code: res.status ?? 0 });
305
+ }, [log, push, pushNotice, shellMode]);
306
+
139
307
  const runTurn = useCallback(async (question, opts = {}) => {
140
308
  const { depth = 0, archive = null, label = '', synthetic = false } = opts;
309
+ activeTurns.current += 1;
141
310
  if (!synthetic) {
142
311
  push({ type: 'user', text: question });
143
312
  log(`\n${glyphs.prompt} ${question}\n`);
@@ -146,15 +315,17 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
146
315
  setBusy(true);
147
316
  setHint('');
148
317
  setChips([]);
318
+ setExpandedItems(null);
319
+ setSearch(null);
149
320
  setStartedAt(Date.now());
150
321
  const s = { status: [], reasoning: '', text: '', reply: null };
151
322
  setLive({ ...s });
152
323
 
153
324
  const abort = new AbortController();
154
325
  abortRef.current = abort;
155
-
156
326
  const context = {
157
327
  autopilot: apRef.current,
328
+ allowAll,
158
329
  client: 'icli',
159
330
  ui: { tables: true, prompts: true, markdown: true },
160
331
  };
@@ -176,55 +347,51 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
176
347
  });
177
348
  if (meta.model) setModel(meta.model);
178
349
  const type = reply.type ?? 'message';
179
- // Persist the tool / sub-agent activity trace inline (Copilot-CLI style)
180
- // so it stays in history instead of vanishing with the live region.
181
350
  if (s.status.length) push({ type: 'activity', status: s.status });
182
- if (type === 'plan') { push({ type: 'plan', reply }); log('[plan] ' + (reply.summary || '')); }
183
- else if (type === 'connect') {
184
- push({ type: 'connect', reply }); log('[connect] ' + (reply.device?.kind || ''));
185
- // The icli has no interactive terminal — send a synthetic session-event
186
- // so the backend continues to the plan/answer (rule 4). Depth-guard to
187
- // prevent a loop if the backend keeps emitting connect (shouldn't happen).
351
+ if (type === 'plan') {
352
+ push({ type: 'plan', reply });
353
+ log('[plan] ' + (reply.summary || ''));
354
+ } else if (type === 'connect') {
355
+ push({ type: 'connect', reply });
356
+ log('[connect] ' + (reply.device?.kind || ''));
188
357
  if (depth < 2) {
189
358
  const dev = reply.device || {};
190
359
  const goal = convo.current.filter((m) => m.role === 'user').slice(-1)[0]?.content || '';
191
360
  const sessionEvent = `[session-event] Terminal session opened on ${dev.kind || 'device'} id=${dev.id || '?'} proto=${dev.proto || 'ssh'}. vendor=auto. OPERATOR GOAL: ${goal}`;
192
- setLive(null); setBusy(false); abortRef.current = null;
361
+ setLive(null);
193
362
  return runTurn(sessionEvent, { depth: depth + 1, synthetic: true });
194
363
  }
364
+ } else if (type === 'prompt' || type === 'choice') {
365
+ push({ type: 'assistant', text: text || reply.question || '' });
366
+ log(text || reply.question || '');
367
+ } else {
368
+ push({ type: 'assistant', text: text || '(no response)' });
369
+ log(text || '');
195
370
  }
196
- else if (type === 'prompt' || type === 'choice') { push({ type: 'assistant', text: text || reply.question || '' }); log(text || reply.question || ''); }
197
- else { push({ type: 'assistant', text: text || '(no response)' }); log(text || ''); }
198
371
  if (text) convo.current.push({ role: 'assistant', content: text });
199
372
  lastAnswerRef.current = text || reply.summary || reply.question || lastAnswerRef.current;
200
373
 
201
- // Populate followup chips from reply.suggestions (up to 4).
202
374
  if (Array.isArray(reply.suggestions) && reply.suggestions.length) {
203
375
  setChips(reply.suggestions.slice(0, 4));
204
376
  setActiveChip(0);
205
377
  }
206
378
 
207
- // Archive a /backup answer to the per-user git repo.
208
379
  if (archive === 'backup' && text) {
209
380
  try {
210
381
  const r = await commitBackup(cfg, label, text);
211
- push({ type: 'notice',
212
- text: `${glyphs.check} Backup saved to git repo (${r.file})${r.committed ? '' : ' — nothing new to commit'}.`,
213
- color: C.green });
382
+ pushNotice(`${glyphs.check} Backup saved to git repo (${r.file})${r.committed ? '' : ' — nothing new to commit'}.`, C.green);
214
383
  } catch (e) {
215
- push({ type: 'notice', text: `${glyphs.warn} Could not archive backup: ${e.message}`, color: C.yellow });
384
+ pushNotice(`${glyphs.warn} Could not archive backup: ${e.message}`, C.warning);
216
385
  }
217
386
  }
218
387
 
219
- // Autopilot: auto-confirm a proposed plan without waiting for the user.
220
388
  if (type === 'plan' && apRef.current && depth < 3) {
221
- push({ type: 'notice', text: `${glyphs.bolt} autopilot ${dash()} applying fix…`, color: C.yellow });
222
- setLive(null); setBusy(false); abortRef.current = null;
389
+ pushNotice(`${glyphs.bolt} autopilot ${dash()} applying fix…`, C.warning, true);
390
+ setLive(null);
223
391
  return runTurn('apply fix', { depth: depth + 1 });
224
392
  }
225
393
 
226
- // Otherwise surface an interactive prompt for the user to choose/answer.
227
- setLive(null); setBusy(false); abortRef.current = null;
394
+ setLive(null);
228
395
  if (type === 'plan') {
229
396
  const destructive = (reply.steps || []).some((st) => /destruct|high/.test(st.risk || ''));
230
397
  setChoice({
@@ -249,77 +416,73 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
249
416
  });
250
417
  }
251
418
  } catch (e) {
252
- if (abort.signal.aborted) push({ type: 'notice', text: `${glyphs.cross} Cancelled.`, color: C.yellow });
253
- else push({ type: 'notice', text: `${glyphs.cross} ${e.message}`, color: C.red });
254
- setLive(null); setBusy(false); abortRef.current = null;
419
+ if (abort.signal.aborted) pushNotice(`${glyphs.cross} Cancelled.`, C.warning);
420
+ else pushNotice(`${glyphs.cross} ${e.message}`, C.red);
255
421
  } finally {
256
422
  abortRef.current = null;
257
423
  setLive(null);
258
424
  setBusy(false);
425
+ activeTurns.current = Math.max(0, activeTurns.current - 1);
426
+ maybeProcessQueue();
259
427
  }
260
- }, [cfg, push]);
428
+ }, [allowAll, cfg, log, maybeProcessQueue, push, pushNotice]);
261
429
 
262
- const doExit = useCallback(() => { onExit?.(plain.current); exit(); }, [exit, onExit]);
263
-
264
- // Create a gist from the last answer or the whole transcript (invoked by the
265
- // /gist Choice below).
266
- const doGist = useCallback(async (kind) => {
267
- let content, filename, description;
268
- if (kind === 'transcript') {
269
- content = convo.current.map((m) => `### ${m.role}\n\n${m.content}`).join('\n\n---\n\n') || '(empty)';
270
- filename = 'icli-transcript.md';
271
- description = 'iCli conversation transcript';
272
- } else {
273
- content = lastAnswerRef.current || '(no answer)';
274
- filename = 'icli-answer.md';
275
- description = 'iCli answer';
276
- }
277
- push({ type: 'notice', text: `${glyphs.arrow} Creating secret gist…`, color: C.gray });
278
- try {
279
- const { url } = await createGist(cfg, { filename, content, description, isPublic: false });
280
- const tool = await copyToClipboard(url);
281
- push({ type: 'notice', text: `${glyphs.check} Gist created: ${url}${tool ? ' (URL copied)' : ''}`, color: C.green });
282
- } catch (e) {
283
- push({ type: 'notice', text: `${glyphs.cross} ${e.message}`, color: C.red });
430
+ const showExpanded = useCallback((kind, sourceItems) => {
431
+ if (!sourceItems.length) {
432
+ pushNotice('Nothing to expand yet.', C.muted, true);
433
+ return;
284
434
  }
285
- }, [cfg, push]);
435
+ setExpandedItems({
436
+ kind,
437
+ items: sourceItems.map((it, idx) => ({ ...it, id: `expanded-${kind}-${idx}-${it.id}` })),
438
+ });
439
+ pushNotice(`Expanded ${kind}.`, C.muted, true);
440
+ }, [pushNotice]);
286
441
 
287
- const onSubmit = useCallback((raw) => {
288
- const q = raw.trim();
289
- if (!q) return;
442
+ const dispatchSubmit = useCallback((raw, opts = {}) => {
443
+ const q = String(raw || '').trim();
444
+ if (!q || !trusted) return;
290
445
  setHint('');
446
+ setExpandedItems(null);
447
+ setSearch(null);
448
+
449
+ if (shellMode) { runShellCommand(q); return; }
450
+ if (q === '!') { runShellCommand(''); return; }
451
+ if (q.startsWith('!')) { runShellCommand(q.slice(1)); return; }
452
+ if (q === '?') { push({ type: 'help', text: 'help' }); return; }
291
453
 
292
454
  if (q === '/exit' || q === 'exit' || q === 'quit') { doExit(); return; }
293
455
  if (q === '/clear') { clearAll(); return; }
294
456
  if (q === '/new') {
295
457
  convo.current = [];
296
458
  clearAll();
297
- push({ type: 'notice', text: `${glyphs.check} New conversation started.`, color: C.green });
459
+ pushNotice(`${glyphs.check} New conversation started.`, C.green);
298
460
  return;
299
461
  }
300
462
  if (q === '/reasoning') {
301
- setShowReasoning((v) => { push({ type: 'notice', text: `Reasoning display ${!v ? 'expanded' : 'collapsed'}.` }); return !v; });
463
+ setShowReasoning((v) => { pushNotice(`Reasoning display ${!v ? 'expanded' : 'collapsed'}.`); return !v; });
302
464
  return;
303
465
  }
304
466
  if (q === '/autopilot' || q === '/auto') {
305
467
  const next = modeRef.current !== 'autopilot' ? 'autopilot' : 'ask';
306
468
  setModeState(next);
307
- push({ type: 'notice',
308
- text: next === 'autopilot'
469
+ pushNotice(
470
+ next === 'autopilot'
309
471
  ? `${glyphs.bolt} Autopilot ON ${dash()} proposed fixes will be applied automatically. Shift+Tab to cycle modes.`
310
472
  : `Mode: ask ${dash()} fixes will wait for your confirmation.`,
311
- color: next === 'autopilot' ? C.warning : C.muted });
473
+ next === 'autopilot' ? C.warning : C.muted,
474
+ );
312
475
  return;
313
476
  }
314
477
  if (q === '/plan') {
315
478
  const next = modeRef.current !== 'plan' ? 'plan' : 'ask';
316
479
  setModeState(next);
317
- push({ type: 'notice', text: `Mode: ${next} ${dash()} Shift+Tab to cycle.`, color: C.accent });
480
+ pushNotice(`Mode: ${next} ${dash()} Shift+Tab to cycle.`, C.accent);
318
481
  return;
319
482
  }
320
483
  if (q === '/context' || q === '/usage') {
321
484
  const tokens = approxTokens(convo.current);
322
- push({ type: 'notice', text: `Context: ~${tokens.toLocaleString()} tokens (${convo.current.length} messages). /compact to summarise.` });
485
+ pushNotice(`Context: ~${tokens.toLocaleString()} tokens (${convo.current.length} messages). /compact to summarise.`);
323
486
  return;
324
487
  }
325
488
  if (q === '/compact' || q.startsWith('/compact ')) {
@@ -328,30 +491,43 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
328
491
  ? `Summarise our conversation so far, focusing on: ${focus}`
329
492
  : 'Summarise our conversation so far in a concise way, preserving key facts and decisions.';
330
493
  convo.current = [];
331
- push({ type: 'notice', text: `${glyphs.arrow} Compacting history…`, color: C.muted });
494
+ pushNotice(`${glyphs.arrow} Compacting history…`, C.muted);
332
495
  runTurn(prompt);
333
496
  return;
334
497
  }
335
498
  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.' });
499
+ pushNotice('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
500
  return;
338
501
  }
339
502
  if (q === '/session') {
340
503
  const tokens = approxTokens(convo.current);
341
504
  const branch = branchRef.current;
342
505
  const cwd = shortenPath(process.cwd());
343
- push({ type: 'notice', text: [
344
- `Session ${dash()} ${cwd}${branch ? ' [' + branch + ']' : ''}`,
506
+ const name = cfg?.tui?.sessionNames?.[process.cwd()] || '(unnamed)';
507
+ pushNotice([
508
+ `Session ${dash()} ${name}`,
509
+ `Path ${dash()} ${cwd}${branch ? ' [' + branch + ']' : ''}`,
345
510
  `Messages ${dash()} ${convo.current.length}`,
346
511
  `Tokens ${dash()} ~${tokens.toLocaleString()} (approx)`,
347
512
  `Model ${dash()} ${model || modelRef.current || 'server default'}`,
348
513
  `Mode ${dash()} ${modeRef.current}`,
349
- ].join('\n') });
514
+ ].join('\n'));
515
+ return;
516
+ }
517
+ if (q === '/resume' || q.startsWith('/resume ') || q === '/continue' || q.startsWith('/continue ')) {
518
+ const name = cfg?.tui?.sessionNames?.[process.cwd()] || '(unnamed)';
519
+ pushNotice([
520
+ `Resume (local stub)`,
521
+ `Name ${dash()} ${name}`,
522
+ `Path ${dash()} ${shortenPath(process.cwd())}`,
523
+ `Messages ${dash()} ${convo.current.length}`,
524
+ `Last AI ${dash()} ${(lastAnswerRef.current || '(none)').slice(0, 120)}`,
525
+ ].join('\n'));
350
526
  return;
351
527
  }
352
528
  if (q === '/share') {
353
529
  if (!lastAnswerRef.current && convo.current.length === 0) {
354
- push({ type: 'notice', text: 'Nothing to share yet — ask something first.', color: C.muted }); return;
530
+ pushNotice('Nothing to share yet — ask something first.', C.muted); return;
355
531
  }
356
532
  choiceActionRef.current = (v) => doGist(v);
357
533
  setChoice({
@@ -368,80 +544,169 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
368
544
  if (q === '/model' || q.startsWith('/model ')) {
369
545
  const name = q.slice('/model'.length).trim();
370
546
  if (!name) {
371
- push({ type: 'notice', text: `Current model: ${model || modelRef.current || 'server default'}.\nUse /model <name> to request a different model.` });
547
+ pushNotice(`Current model: ${model || modelRef.current || 'server default'}.\nUse /model <name> to request a different model.`);
372
548
  } else {
373
549
  modelRef.current = name;
374
550
  setModel(name);
375
551
  cfg._model = name;
376
- try { saveConfig(cfg); } catch {}
377
- push({ type: 'notice', text: `${glyphs.check} Model set to ${name}. It applies to your next message.`, color: C.green });
552
+ persistCfg();
553
+ pushNotice(`${glyphs.check} Model set to ${name}. It applies to your next message.`, C.green);
378
554
  }
379
555
  return;
380
556
  }
557
+ if (q === '/cwd') {
558
+ pushNotice(process.cwd());
559
+ return;
560
+ }
561
+ if (q === '/cd' || q.startsWith('/cd ')) {
562
+ const arg = q.slice('/cd'.length).trim();
563
+ if (!arg) { pushNotice(`cwd ${dash()} ${process.cwd()}`); return; }
564
+ const target = isAbsolute(arg) ? arg : resolve(process.cwd(), arg);
565
+ if (!existsSync(target)) { pushNotice(`Directory not found: ${target}`, C.red); return; }
566
+ try {
567
+ process.chdir(target);
568
+ branchRef.current = gitBranch(target);
569
+ const ok = isTrustedDir(cfg, target);
570
+ setTrusted(ok);
571
+ if (!ok) openTrustDialog(target);
572
+ pushNotice(`Changed directory to ${shortenPath(target)}`, C.green, true);
573
+ } catch (e) {
574
+ pushNotice(`Could not change directory: ${e.message}`, C.red);
575
+ }
576
+ return;
577
+ }
578
+ if (q === '/allow-all' || q.startsWith('/allow-all ')) {
579
+ const arg = q.slice('/allow-all'.length).trim().toLowerCase();
580
+ const next = arg === 'show' ? allowAll : arg === 'on' ? true : arg === 'off' ? false : !allowAll;
581
+ if (arg !== 'show') {
582
+ cfg.tui = { ...(cfg.tui || {}), allowAll: next };
583
+ persistCfg();
584
+ setAllowAll(next);
585
+ }
586
+ pushNotice(`allow-all ${dash()} ${next ? 'ON' : 'OFF'}`, next ? C.warning : C.muted, arg !== 'show');
587
+ return;
588
+ }
589
+ if (q === '/rename' || q.startsWith('/rename ')) {
590
+ const name = q.slice('/rename'.length).trim() || `session-${new Date().toISOString().slice(0, 16).replace(/[T:]/g, '-')}`;
591
+ cfg.tui = cfg.tui || {};
592
+ cfg.tui.sessionNames = { ...(cfg.tui.sessionNames || {}), [process.cwd()]: name };
593
+ persistCfg();
594
+ pushNotice(`${glyphs.check} Session renamed to ${name}.`, C.green);
595
+ return;
596
+ }
597
+ if (q === '/changelog' || q.startsWith('/changelog')) {
598
+ try {
599
+ const raw = execSync('npm view ispbills-icli version time --json', {
600
+ cwd: process.cwd(), stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000,
601
+ }).toString();
602
+ const data = JSON.parse(raw);
603
+ const versions = Object.entries(data.time || {})
604
+ .filter(([k]) => /^\d/.test(k))
605
+ .slice(-5)
606
+ .map(([k, v]) => ` ${k} ${dash()} ${String(v).slice(0, 10)}`)
607
+ .join('\n');
608
+ pushNotice(`ispbills-icli ${dash()} current ${pkgVersion()}\nRecent npm releases:\n${versions || ' unavailable'}`);
609
+ } catch {
610
+ pushNotice(`ispbills-icli ${dash()} current ${pkgVersion()}\nRecent changelog unavailable offline.`);
611
+ }
612
+ return;
613
+ }
614
+ if (q === '/feedback' || q === '/bug') {
615
+ pushNotice(`Feedback: ${feedbackUrl()}`);
616
+ return;
617
+ }
618
+ if (q === '/env') {
619
+ pushNotice([
620
+ `url ${dash()} ${cfg.url}`,
621
+ `user ${dash()} ${cfg.user?.name || cfg.user?.email || 'unknown'}`,
622
+ `model ${dash()} ${model || modelRef.current || 'server default'}`,
623
+ `mode ${dash()} ${shellMode ? 'shell' : modeRef.current}`,
624
+ `memory ${dash()} ${loadNotes(cfg).length} notes`,
625
+ `allowAll ${dash()} ${allowAll ? 'on' : 'off'}`,
626
+ `streamer ${dash()} ${streamerMode ? 'on' : 'off'}`,
627
+ ].join('\n'));
628
+ return;
629
+ }
630
+ if (q === '/streamer-mode') {
631
+ const next = !streamerMode;
632
+ cfg.tui = { ...(cfg.tui || {}), streamerMode: next };
633
+ persistCfg();
634
+ setStreamerMode(next);
635
+ pushNotice(`streamer-mode ${dash()} ${next ? 'ON' : 'OFF'}`, next ? C.warning : C.muted, true);
636
+ return;
637
+ }
638
+ if (q === '/diff') {
639
+ gitCmd(cfg, ['diff', '--stat']).then((r) => {
640
+ const body = (r.stdout || '').trim() || (r.stderr || '').trim() || 'No recent diff in the backup repo.';
641
+ pushNotice(`backup repo diff\n${body.slice(0, 3500)}`, r.ok ? C.muted : C.red);
642
+ });
643
+ return;
644
+ }
645
+ if (q === '/review' || q.startsWith('/review ')) {
646
+ if (!lastAnswerRef.current) { pushNotice('Nothing to review yet — ask something first.', C.muted); return; }
647
+ const focus = q.slice('/review'.length).trim();
648
+ const reviewPrompt = `${focus ? `${focus}. ` : ''}Review the following last answer for correctness, risks, and missing details in the ISP context:\n\n${lastAnswerRef.current}`;
649
+ runTurn(reviewPrompt, { synthetic: false });
650
+ return;
651
+ }
381
652
  if (q === '/help' || q === 'help') {
382
653
  let group = null;
383
654
  const lines = SLASH_COMMANDS.map((c) => {
384
655
  const head = c.group !== group ? ((group = c.group), `\n ${c.group}\n`) : '';
385
656
  return head + ` ${(c.cmd + (c.hint ? ' ' + c.hint : '')).padEnd(22)}${c.desc}`;
386
657
  }).join('\n');
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' });
658
+ pushNotice('Commands:' + lines + '\n\n Keys: Shift+Tab cycle modes · Ctrl+T toggle reasoning · Ctrl+L clear · Ctrl+N/P followup chips · Ctrl+D exit · Ctrl+S preserve input · Ctrl+G external editor');
388
659
  return;
389
660
  }
390
661
  if (q === '/history') {
391
662
  const us = convo.current.filter((m) => m.role === 'user');
392
- push({ type: 'notice', text: us.length ? us.map((m, i) => ` ${i + 1}. ${m.content.slice(0, 80)}`).join('\n') : 'No history yet.' });
663
+ pushNotice(us.length ? us.map((m, i) => ` ${i + 1}. ${m.content.slice(0, 80)}`).join('\n') : 'No history yet.');
393
664
  return;
394
665
  }
395
666
  if (q === '/logout') { onExternal?.('logout'); doExit(); return; }
396
667
  if (q === '/update') { onExternal?.('update'); doExit(); return; }
397
668
 
398
- // ── Memory ──
399
669
  if (q === '/remember' || q.startsWith('/remember ')) {
400
670
  const note = q.slice('/remember'.length).trim();
401
- if (!note) { push({ type: 'notice', text: 'Usage: /remember <note>' }); return; }
671
+ if (!note) { pushNotice('Usage: /remember <note>'); return; }
402
672
  const notes = addNote(cfg, note);
403
- push({ type: 'notice', text: `${glyphs.check} Remembered (${notes.length} note${notes.length > 1 ? 's' : ''}). I'll keep this in mind.`, color: C.green });
673
+ pushNotice(`${glyphs.check} Remembered (${notes.length} note${notes.length > 1 ? 's' : ''}). I'll keep this in mind.`, C.green);
404
674
  return;
405
675
  }
406
676
  if (q === '/memory') {
407
677
  const notes = loadNotes(cfg);
408
- push({ type: 'notice', text: notes.length
678
+ pushNotice(notes.length
409
679
  ? 'Memory notes:\n' + notes.map((n, i) => ` ${i + 1}. ${n.text}`).join('\n') + '\n\n /forget <n|all> to remove.'
410
- : 'No memory notes yet. Add one with /remember <note>.' });
680
+ : 'No memory notes yet. Add one with /remember <note>.');
411
681
  return;
412
682
  }
413
683
  if (q === '/forget' || q.startsWith('/forget ')) {
414
684
  const which = q.slice('/forget'.length).trim();
415
- if (!which) { push({ type: 'notice', text: 'Usage: /forget <number | all | text>' }); return; }
685
+ if (!which) { pushNotice('Usage: /forget <number | all | text>'); return; }
416
686
  const { notes, removed } = forgetNote(cfg, which);
417
- push({ type: 'notice',
418
- text: removed ? `${glyphs.check} Forgot ${removed} note(s). ${notes.length} remain.` : 'No matching note found.',
419
- color: removed ? C.green : C.gray });
687
+ pushNotice(removed ? `${glyphs.check} Forgot ${removed} note(s). ${notes.length} remain.` : 'No matching note found.', removed ? C.green : C.gray);
420
688
  return;
421
689
  }
422
-
423
- // ── Clipboard ──
424
690
  if (q === '/copy') {
425
691
  const ans = lastAnswerRef.current;
426
- if (!ans) { push({ type: 'notice', text: 'Nothing to copy yet — ask something first.', color: C.gray }); return; }
427
- copyToClipboard(ans).then((tool) => push({ type: 'notice',
428
- text: tool ? `${glyphs.check} Copied last answer (${ans.length} chars) to the clipboard.`
429
- : `${glyphs.cross} No clipboard tool found. Install xclip / wl-clipboard (Linux), or use macOS/Windows.`,
430
- color: tool ? C.green : C.red }));
692
+ if (!ans) { pushNotice('Nothing to copy yet — ask something first.', C.gray); return; }
693
+ copyToClipboard(ans).then((tool) => pushNotice(
694
+ tool ? `${glyphs.check} Copied last answer (${ans.length} chars) to the clipboard.`
695
+ : `${glyphs.cross} No clipboard tool found. Install xclip / wl-clipboard (Linux), or use macOS/Windows.`,
696
+ tool ? C.green : C.red,
697
+ ));
431
698
  return;
432
699
  }
433
-
434
- // ── GitHub gist ──
435
700
  if (q === '/gist' || q.startsWith('/gist ')) {
436
701
  const rest = q.slice('/gist'.length).trim();
437
702
  if (rest.startsWith('token ')) {
438
703
  cfg.github_token = rest.slice('token '.length).trim();
439
- try { saveConfig(cfg); } catch {}
440
- push({ type: 'notice', text: `${glyphs.check} GitHub token saved. /gist will use it (needs the "gist" scope).`, color: C.green });
704
+ persistCfg();
705
+ pushNotice(`${glyphs.check} GitHub token saved. /gist will use it (needs the "gist" scope).`, C.green);
441
706
  return;
442
707
  }
443
708
  if (!lastAnswerRef.current && convo.current.length === 0) {
444
- push({ type: 'notice', text: 'Nothing to save yet — ask something first.', color: C.gray });
709
+ pushNotice('Nothing to save yet — ask something first.', C.gray);
445
710
  return;
446
711
  }
447
712
  choiceActionRef.current = (v) => doGist(v);
@@ -456,42 +721,50 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
456
721
  });
457
722
  return;
458
723
  }
459
-
460
- // ── Local git repo ──
461
724
  if (q === '/git' || q.startsWith('/git ')) {
462
725
  const args = q.slice('/git'.length).trim();
463
- if (!args) { push({ type: 'notice', text: 'Usage: /git <status | log | commit -m "msg" | push | diff | remote add origin <url>>' }); return; }
726
+ if (!args) { pushNotice('Usage: /git <status | log | commit -m "msg" | push | diff | remote add origin <url>>'); return; }
464
727
  gitCmd(cfg, splitArgs(args)).then((r) => {
465
728
  const body = (r.stdout || '').trim() || (r.stderr || '').trim() || (r.ok ? '(done)' : '(no output)');
466
- push({ type: 'notice', text: `$ git ${args}\n${body.slice(0, 3500)}`, color: r.ok ? undefined : C.red });
729
+ pushNotice(`$ git ${args}\n${body.slice(0, 3500)}`, r.ok ? C.muted : C.red);
467
730
  });
468
731
  return;
469
732
  }
470
733
 
471
734
  let question = q;
472
- const opts = {};
735
+ const turnOpts = {};
473
736
  if (q.startsWith('/')) {
474
737
  const mapped = slashToQuestion(q);
475
738
  if (mapped) {
476
739
  question = mapped;
477
740
  if (q === '/backup' || q.startsWith('/backup ')) {
478
- opts.archive = 'backup';
479
- opts.label = q.slice('/backup'.length).trim() || 'fleet';
741
+ turnOpts.archive = 'backup';
742
+ turnOpts.label = q.slice('/backup'.length).trim() || 'fleet';
480
743
  }
481
- } else { push({ type: 'notice', text: `Unknown command: ${q}. Type /help.` }); return; }
744
+ } else {
745
+ pushNotice(`Unknown command: ${q}. Type /help.`);
746
+ return;
747
+ }
482
748
  }
483
- runTurn(question, opts);
484
- }, [doExit, push, runTurn, onExternal, setModeState, model, cfg, doGist]);
749
+ runTurn(question, turnOpts);
750
+ }, [allowAll, cfg, clearAll, doExit, doGist, modeRef, model, onExternal, openTrustDialog, persistCfg, push, pushNotice, runShellCommand, runTurn, setModeState, shellMode, streamerMode, trusted]);
751
+
752
+ useEffect(() => {
753
+ dispatchSubmitRef.current = dispatchSubmit;
754
+ }, [dispatchSubmit]);
755
+
756
+ useEffect(() => {
757
+ if (initialQuestion && trusted && !choice && convo.current.length === 0) dispatchSubmit(initialQuestion);
758
+ }, [initialQuestion, trusted, choice, dispatchSubmit]);
485
759
 
486
- // Interactive prompt/choice handling (owns the keyboard while open).
487
760
  const pickChoice = useCallback((value) => {
488
761
  const act = choiceActionRef.current;
489
762
  choiceActionRef.current = null;
490
763
  setChoice(null);
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; }
764
+ if (act) { act(value); return; }
765
+ if (value == null) { pushNotice('Dismissed.', C.muted, true); return; }
493
766
  runTurn(String(value));
494
- }, [push, runTurn]);
767
+ }, [pushNotice, runTurn]);
495
768
 
496
769
  useInput((input, key) => {
497
770
  if (!choice) return;
@@ -520,9 +793,37 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
520
793
  }
521
794
  });
522
795
 
523
- // Global keys (Composer handles text editing; here we handle app-level keys).
796
+ const openSearch = useCallback(() => {
797
+ setSearch({ query: '', active: 0 });
798
+ setHint('Timeline search');
799
+ }, []);
800
+
801
+ const searchMatches = search
802
+ ? items
803
+ .map((it) => ({ ...it, preview: itemPreview(it) }))
804
+ .filter((it) => !search.query || it.preview.toLowerCase().includes(search.query.toLowerCase()))
805
+ : [];
806
+
524
807
  useInput((input, key) => {
525
- if (choice) return; // the Choice prompt owns the keyboard
808
+ if (!search) return;
809
+ if (key.escape || (key.ctrl && input === 'c')) { setSearch(null); setHint(''); return; }
810
+ if (key.backspace || key.delete) { setSearch((s) => ({ ...s, query: s.query.slice(0, -1), active: 0 })); return; }
811
+ if (key.upArrow) { setSearch((s) => ({ ...s, active: Math.max(0, s.active - 1) })); return; }
812
+ if (key.downArrow || key.tab) { setSearch((s) => ({ ...s, active: Math.min(Math.max(0, searchMatches.length - 1), s.active + 1) })); return; }
813
+ if (key.return) {
814
+ if (searchMatches.length) showExpanded('search results', [searchMatches[search.active] || searchMatches[0]]);
815
+ setSearch(null);
816
+ setHint('');
817
+ return;
818
+ }
819
+ if (input && !key.ctrl && !key.meta) {
820
+ const printable = input.replace(/[\x00-\x1f\x7f]/g, '');
821
+ if (printable) setSearch((s) => ({ ...s, query: s.query + printable, active: 0 }));
822
+ }
823
+ });
824
+
825
+ useInput((input, key) => {
826
+ if (choice || search) return;
526
827
  if (key.ctrl && input === 'c') {
527
828
  if (busy) { abortRef.current?.abort(); setHint(''); return; }
528
829
  const now = Date.now();
@@ -531,11 +832,12 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
531
832
  setHint('Press Ctrl+C again to exit');
532
833
  return;
533
834
  }
534
- if (key.ctrl && input === 'd') { doExit(); return; } // Ctrl+D shutdown
835
+ if (key.ctrl && input === 'd') { doExit(); return; }
535
836
  if (key.escape && busy) { abortRef.current?.abort(); return; }
536
- if (key.escape && chips.length) { setChips([]); return; } // Esc dismisses chips
837
+ if (key.escape && shellMode && !composerText.trim()) { setShellMode(false); pushNotice('Exited shell mode.', C.muted, true); return; }
838
+ if (key.escape && expandedItems) { setExpandedItems(null); return; }
839
+ if (key.escape && chips.length) { setChips([]); return; }
537
840
  if (key.tab && key.shift && !busy) {
538
- // Cycle: ask → plan → autopilot → ask
539
841
  const cur = MODES.indexOf(modeRef.current);
540
842
  const next = MODES[(cur + 1) % MODES.length];
541
843
  setModeState(next);
@@ -544,75 +846,57 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
544
846
  }
545
847
  if (key.ctrl && input === 't') { setShowReasoning((v) => !v); return; }
546
848
  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) {
849
+ if (key.ctrl && input === 'f' && !busy) { openSearch(); return; }
850
+ if (key.ctrl && input === 'o' && !busy && !composerText.trim()) { showExpanded('recent items', items.slice(-5)); return; }
851
+ if (key.ctrl && input === 'e' && !busy && !composerText.trim()) { showExpanded('all items', items); return; }
852
+ if (key.ctrl && input === 'n' && chips.length) { setActiveChip((i) => (i + 1) % chips.length); return; }
853
+ if (key.ctrl && input === 'p' && chips.length) { setActiveChip((i) => (i - 1 + chips.length) % chips.length); return; }
854
+ if (key.tab && !key.shift && !busy && !composerText.trim()) {
556
855
  setActiveTab((t) => {
557
856
  const tabs = ['session', 'devices', 'customers', 'alarms'];
558
857
  return tabs[(tabs.indexOf(t) + 1) % tabs.length];
559
858
  });
560
- return;
561
859
  }
562
860
  });
563
861
 
564
- // Auto-run a first question if launched with one.
565
- const started = useRef(false);
566
- useEffect(() => {
567
- if (initialQuestion && !started.current) { started.current = true; onSubmit(initialQuestion); }
568
- }, [initialQuestion, onSubmit]);
569
-
570
862
  const renderItem = (it) => {
571
863
  const c = it.cols || 80;
572
864
  switch (it.type) {
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} />`;
865
+ case 'user': return html`<${UserMessage} key=${it.id} text=${it.text} cols=${c} ts=${it.ts} />`;
866
+ case 'assistant': return html`<${AssistantMessage} key=${it.id} text=${it.text} cols=${c} ts=${it.ts} />`;
575
867
  case 'plan': return html`<${Plan} key=${it.id} reply=${it.reply} />`;
576
868
  case 'connect': return html`<${Connect} key=${it.id} reply=${it.reply} />`;
577
869
  case 'activity': return html`<${StatusLines} key=${it.id} lines=${it.status} busy=${false} />`;
578
870
  case 'notice': return html`<${Notice} key=${it.id} text=${it.text} color=${it.color} />`;
871
+ case 'shell': return html`<${ShellOutput} key=${it.id} command=${it.command} output=${it.output} code=${it.code} cols=${c} ts=${it.ts} />`;
872
+ case 'help': return html`<${HelpOverlay} key=${it.id} cols=${c} ts=${it.ts} />`;
579
873
  default: return null;
580
874
  }
581
875
  };
582
876
 
583
- // ── Footer ───────────────────────────────────────────────────────────────────
584
- // Left: ~/cwd ⏎ branch mode-badge
585
- // Right: model ~Nk ctx
586
877
  const cwd = shortenPath(process.cwd());
587
878
  const branch = branchRef.current;
588
879
  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
-
880
+ const tokenLabel = tokenCount > 0 ? `~${Math.max(1, Math.round(tokenCount / 1000))}k ctx` : '';
881
+ const modelLabel = streamerMode ? '' : shortModel(model || modelRef.current || 'server default');
882
+ const footerLeft = [cwd, branch ? `${glyphs.gitBranch}${branch}` : null].filter(Boolean).join(' ');
598
883
  const footerRight = hint
599
884
  ? hint
600
- : [modelLabel, tokenLabel].filter(Boolean).join(` ${midDot()} `);
601
-
885
+ : streamerMode
886
+ ? ''
887
+ : [modelLabel, tokenLabel].filter(Boolean).join(midDot());
602
888
  const compact = cols < 60;
603
-
604
- // The banner is the first <Static> item so it prints once at the very top and
605
- // scrolls away with history; completed transcript items follow it. Ink flushes
606
- // each <Static> item to the terminal's normal buffer exactly once, leaving the
607
- // native scrollback intact so earlier conversation can be scrolled back to.
608
- const staticItems = [{ type: 'banner', id: `banner-${clearEpoch}`, cols }, ...items];
889
+ const composerMode = shellMode ? 'shell' : mode;
890
+ const staticItems = showBanner
891
+ ? [{ type: 'banner', id: `banner-${clearEpoch}`, cols }, ...items]
892
+ : items;
609
893
 
610
894
  return html`
611
895
  <${Box} flexDirection="column" width=${cols}>
612
896
  <${Static} key=${clearEpoch} items=${staticItems}>
613
897
  ${(it) => it.type === 'banner'
614
898
  ? html`<${Box} key=${it.id} flexDirection="column" alignItems="center" marginBottom=${1}>
615
- <${Banner} cfg=${cfg} model=${model} width=${cols} compact=${compact} />
899
+ <${Banner} cfg=${cfg} model=${model} width=${cols} compact=${compact} streamerMode=${streamerMode} />
616
900
  <//>`
617
901
  : renderItem(it)}
618
902
  <//>
@@ -622,24 +906,48 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
622
906
  <${StatusLines} lines=${live.status} busy=${busy} />
623
907
  ${live.reasoning ? html`<${Reasoning} text=${live.reasoning} show=${showReasoning} />` : null}
624
908
  ${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
625
- ${live.text ? html`<${AssistantMessage} text=${live.text} cols=${cols} />` : null}
909
+ ${live.text ? html`<${AssistantMessage} text=${live.text} cols=${cols} ts=${startedAt || Date.now()} />` : null}
626
910
  <${Thinking} label=${live.reply ? 'Planning' : live.text ? 'Responding' : 'Working'} startedAt=${startedAt} />
627
911
  <//>`
628
912
  : null}
629
913
 
630
- <${TabBar} active=${activeTab} cols=${cols} mode=${mode} />
914
+ ${expandedItems
915
+ ? html`<${Box} flexDirection="column">
916
+ <${Notice} text=${`${glyphs.expand} ${expandedItems.kind}`} color=${C.muted} />
917
+ ${expandedItems.items.map((it) => renderItem(it))}
918
+ <//>`
919
+ : null}
920
+
921
+ ${search ? html`<${SearchOverlay} query=${search.query} matches=${searchMatches} active=${search.active} cols=${cols} />` : null}
922
+
923
+ <${TabBar} active=${activeTab} cols=${cols} mode=${composerMode} />
631
924
 
632
925
  ${!busy && chips.length ? html`<${FollowupChips} chips=${chips} active=${activeChip} />` : null}
633
926
 
634
927
  <${Box} flexDirection="column">
635
928
  ${choice
636
929
  ? html`<${Choice} ...${choice} />`
637
- : html`<${Composer} onSubmit=${onSubmit} busy=${busy} width=${cols} />`}
930
+ : html`<${Composer}
931
+ onSubmit=${dispatchSubmit}
932
+ onSubmitKeep=${dispatchSubmit}
933
+ onQueue=${queueSubmission}
934
+ onSearch=${openSearch}
935
+ onNotice=${(text, color = C.muted) => pushNotice(text, color, true)}
936
+ onEmptyEsc=${() => { if (shellMode) setShellMode(false); else if (expandedItems) setExpandedItems(null); }}
937
+ onValueChange=${setComposerText}
938
+ onEmptySubmit=${() => {
939
+ if (chips.length) dispatchSubmit(chips[activeChip]);
940
+ }}
941
+ busy=${busy}
942
+ blocked=${Boolean(choice || search || !trusted)}
943
+ width=${cols}
944
+ mode=${composerMode}
945
+ clearToken=${composerClearToken}
946
+ />`}
638
947
 
639
948
  <${Box} paddingX=${1} width=${cols}>
640
949
  <${Box} flexGrow=${1}>
641
950
  <${Text} color=${C.muted} wrap="truncate-end">${footerLeft}<//>
642
- ${modeLabel ? html`<${Text} color=${mode === 'autopilot' ? C.warning : C.accent}>${modeLabel}<//>` : null}
643
951
  <//>
644
952
  <${Text} color=${hint ? C.warning : C.muted} wrap="truncate-end">${footerRight}<//>
645
953
  <//>