ispbills-icli 8.5.3 → 8.5.5

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,85 +110,241 @@ 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, agentName, resumeMode }) {
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
+
171
+ useEffect(() => {
172
+ if (agentName) {
173
+ setTimeout(() => pushNotice(`${glyphs.bolt} Agent: ${agentName} — delegating to named agent.`, C.accent, true), 200);
174
+ }
175
+ if (resumeMode) {
176
+ setTimeout(() => pushNotice(`${glyphs.arrow} Resume mode — last session info will appear in /session`, C.muted, true), 200);
177
+ }
178
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
179
+
122
180
  const setModeState = useCallback((next) => {
123
181
  modeRef.current = next;
124
182
  apRef.current = next === 'autopilot';
125
183
  setMode(next);
126
184
  }, []);
127
185
 
128
- const push = useCallback((item) => setItems((xs) => [...xs, { id: nextId(), cols: colsRef.current, ...item }]), []);
186
+ const push = useCallback((item) => {
187
+ const next = { id: nextId(), cols: colsRef.current, ts: item.ts ?? Date.now(), ...item };
188
+ setItems((xs) => [...xs, next]);
189
+ return next;
190
+ }, []);
191
+
192
+ const removeItem = useCallback((id) => {
193
+ setItems((xs) => xs.filter((x) => x.id !== id));
194
+ const timer = dismissTimers.current.get(id);
195
+ if (timer) {
196
+ clearTimeout(timer);
197
+ dismissTimers.current.delete(id);
198
+ }
199
+ }, []);
200
+
201
+ useEffect(() => {
202
+ for (const item of items) {
203
+ if (item.type !== 'notice' || !item.autoDismiss || dismissTimers.current.has(item.id)) continue;
204
+ const timer = setTimeout(() => removeItem(item.id), 4000);
205
+ dismissTimers.current.set(item.id, timer);
206
+ }
207
+ }, [items, removeItem]);
208
+
209
+ const pushNotice = useCallback((text, color = C.gray, autoDismiss = false) => {
210
+ push({ type: 'notice', text, color, autoDismiss });
211
+ }, [push]);
212
+
129
213
  const log = (line) => plain.current.push(line);
130
214
 
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
215
  const clearAll = useCallback(() => {
134
216
  try { process.stdout.write('\x1b[2J\x1b[3J\x1b[H'); } catch {}
135
217
  setItems([]);
218
+ setExpandedItems(null);
219
+ setSearch(null);
136
220
  setClearEpoch((n) => n + 1);
137
221
  }, []);
138
222
 
223
+ const queueSubmission = useCallback((text) => {
224
+ const q = String(text || '').trim();
225
+ if (!q) { pushNotice('Nothing to queue.', C.muted, true); return; }
226
+ queuedRef.current = q;
227
+ pushNotice(`[queued] ${q}`, C.warning, true);
228
+ }, [pushNotice]);
229
+
230
+ const openTrustDialog = useCallback((dir = process.cwd()) => {
231
+ choiceActionRef.current = (value) => {
232
+ if (value === 'session') {
233
+ setTrusted(true);
234
+ pushNotice(`Trusted for this session: ${shortenPath(dir)}`, C.success, true);
235
+ return;
236
+ }
237
+ if (value === 'remember') {
238
+ cfg.tui = cfg.tui || {};
239
+ cfg.tui.trustedDirs = { ...(cfg.tui.trustedDirs || {}), [resolve(dir)]: true };
240
+ persistCfg();
241
+ setTrusted(true);
242
+ pushNotice(`Trusted and remembered: ${shortenPath(dir)}`, C.success, true);
243
+ return;
244
+ }
245
+ doExit();
246
+ };
247
+ setChoice({
248
+ title: 'Do you trust files in this folder?',
249
+ note: shortenPath(dir),
250
+ sel: 0,
251
+ text: '',
252
+ focusText: false,
253
+ allowText: false,
254
+ options: [
255
+ { label: '1. Yes', value: 'session' },
256
+ { label: '2. Yes, remember', value: 'remember' },
257
+ { label: '3. No, exit', value: 'exit', danger: true },
258
+ ],
259
+ });
260
+ }, [cfg, persistCfg]);
261
+
262
+ useEffect(() => {
263
+ if (!trusted && !choice) openTrustDialog(process.cwd());
264
+ }, [trusted, choice, openTrustDialog]);
265
+
266
+ const doExit = useCallback(() => { onExit?.(plain.current); exit(); }, [exit, onExit]);
267
+
268
+ const maybeProcessQueue = useCallback(() => {
269
+ if (activeTurns.current !== 0 || !queuedRef.current) return;
270
+ const next = queuedRef.current;
271
+ queuedRef.current = null;
272
+ setComposerClearToken((n) => n + 1);
273
+ setTimeout(() => dispatchSubmitRef.current?.(next, { queued: true }), 0);
274
+ }, []);
275
+
276
+ const doGist = useCallback(async (kind) => {
277
+ let content; let filename; let description;
278
+ if (kind === 'transcript') {
279
+ content = convo.current.map((m) => `### ${m.role}\n\n${m.content}`).join('\n\n---\n\n') || '(empty)';
280
+ filename = 'icli-transcript.md';
281
+ description = 'iCli conversation transcript';
282
+ } else {
283
+ content = lastAnswerRef.current || '(no answer)';
284
+ filename = 'icli-answer.md';
285
+ description = 'iCli answer';
286
+ }
287
+ pushNotice(`${glyphs.arrow} Creating secret gist…`, C.gray);
288
+ try {
289
+ const { url } = await createGist(cfg, { filename, content, description, isPublic: false });
290
+ const tool = await copyToClipboard(url);
291
+ pushNotice(`${glyphs.check} Gist created: ${url}${tool ? ' (URL copied)' : ''}`, C.green);
292
+ } catch (e) {
293
+ pushNotice(`${glyphs.cross} ${e.message}`, C.red);
294
+ }
295
+ }, [cfg, pushNotice]);
296
+
297
+ const runShellCommand = useCallback((rawCommand) => {
298
+ const command = String(rawCommand || '').trim();
299
+ if (!command) {
300
+ setShellMode(true);
301
+ pushNotice('Shell mode enabled. Press Esc to exit.', C.warning, true);
302
+ return;
303
+ }
304
+ if (shellMode && /^(exit|quit)$/i.test(command)) {
305
+ setShellMode(false);
306
+ pushNotice('Exited shell mode.', C.muted, true);
307
+ return;
308
+ }
309
+ push({ type: 'user', text: shellMode ? command : `!${command}` });
310
+ log(`\n$ ${command}\n`);
311
+ const res = spawnSync(command, { shell: true, encoding: 'utf8', cwd: process.cwd(), maxBuffer: 1024 * 1024 });
312
+ const output = [res.stdout || '', res.stderr || ''].join('').trim() || '(no output)';
313
+ push({ type: 'shell', command, output, code: res.status ?? 0 });
314
+ }, [log, push, pushNotice, shellMode]);
315
+
139
316
  const runTurn = useCallback(async (question, opts = {}) => {
140
317
  const { depth = 0, archive = null, label = '', synthetic = false } = opts;
318
+ activeTurns.current += 1;
141
319
  if (!synthetic) {
142
320
  push({ type: 'user', text: question });
143
321
  log(`\n${glyphs.prompt} ${question}\n`);
144
322
  }
145
323
  convo.current.push({ role: 'user', content: question });
324
+
325
+ // Auto-compact at ~95% of context budget (approx 100k token limit)
326
+ const TOKEN_LIMIT = 100000;
327
+ if (approxTokens(convo.current) > TOKEN_LIMIT * 0.95) {
328
+ pushNotice(`${glyphs.arrow} Context at 95% — auto-compacting history…`, C.warning, true);
329
+ const snapshot = convo.current;
330
+ convo.current = [];
331
+ convo.current.push({ role: 'user', content: 'Summarise our conversation so far in a concise way, preserving key facts and decisions.' });
332
+ }
333
+
146
334
  setBusy(true);
147
335
  setHint('');
148
336
  setChips([]);
337
+ setExpandedItems(null);
338
+ setSearch(null);
149
339
  setStartedAt(Date.now());
150
340
  const s = { status: [], reasoning: '', text: '', reply: null };
151
341
  setLive({ ...s });
152
342
 
153
343
  const abort = new AbortController();
154
344
  abortRef.current = abort;
155
-
156
345
  const context = {
157
346
  autopilot: apRef.current,
347
+ allowAll,
158
348
  client: 'icli',
159
349
  ui: { tables: true, prompts: true, markdown: true },
160
350
  };
@@ -176,55 +366,51 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
176
366
  });
177
367
  if (meta.model) setModel(meta.model);
178
368
  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
369
  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).
370
+ if (type === 'plan') {
371
+ push({ type: 'plan', reply });
372
+ log('[plan] ' + (reply.summary || ''));
373
+ } else if (type === 'connect') {
374
+ push({ type: 'connect', reply });
375
+ log('[connect] ' + (reply.device?.kind || ''));
188
376
  if (depth < 2) {
189
377
  const dev = reply.device || {};
190
378
  const goal = convo.current.filter((m) => m.role === 'user').slice(-1)[0]?.content || '';
191
379
  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;
380
+ setLive(null);
193
381
  return runTurn(sessionEvent, { depth: depth + 1, synthetic: true });
194
382
  }
383
+ } else if (type === 'prompt' || type === 'choice') {
384
+ push({ type: 'assistant', text: text || reply.question || '' });
385
+ log(text || reply.question || '');
386
+ } else {
387
+ push({ type: 'assistant', text: text || '(no response)' });
388
+ log(text || '');
195
389
  }
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
390
  if (text) convo.current.push({ role: 'assistant', content: text });
199
391
  lastAnswerRef.current = text || reply.summary || reply.question || lastAnswerRef.current;
200
392
 
201
- // Populate followup chips from reply.suggestions (up to 4).
202
393
  if (Array.isArray(reply.suggestions) && reply.suggestions.length) {
203
394
  setChips(reply.suggestions.slice(0, 4));
204
395
  setActiveChip(0);
205
396
  }
206
397
 
207
- // Archive a /backup answer to the per-user git repo.
208
398
  if (archive === 'backup' && text) {
209
399
  try {
210
400
  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 });
401
+ pushNotice(`${glyphs.check} Backup saved to git repo (${r.file})${r.committed ? '' : ' — nothing new to commit'}.`, C.green);
214
402
  } catch (e) {
215
- push({ type: 'notice', text: `${glyphs.warn} Could not archive backup: ${e.message}`, color: C.yellow });
403
+ pushNotice(`${glyphs.warn} Could not archive backup: ${e.message}`, C.warning);
216
404
  }
217
405
  }
218
406
 
219
- // Autopilot: auto-confirm a proposed plan without waiting for the user.
220
407
  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;
408
+ pushNotice(`${glyphs.bolt} autopilot ${dash()} applying fix…`, C.warning, true);
409
+ setLive(null);
223
410
  return runTurn('apply fix', { depth: depth + 1 });
224
411
  }
225
412
 
226
- // Otherwise surface an interactive prompt for the user to choose/answer.
227
- setLive(null); setBusy(false); abortRef.current = null;
413
+ setLive(null);
228
414
  if (type === 'plan') {
229
415
  const destructive = (reply.steps || []).some((st) => /destruct|high/.test(st.risk || ''));
230
416
  setChoice({
@@ -249,77 +435,73 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
249
435
  });
250
436
  }
251
437
  } 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;
438
+ if (abort.signal.aborted) pushNotice(`${glyphs.cross} Cancelled.`, C.warning);
439
+ else pushNotice(`${glyphs.cross} ${e.message}`, C.red);
255
440
  } finally {
256
441
  abortRef.current = null;
257
442
  setLive(null);
258
443
  setBusy(false);
444
+ activeTurns.current = Math.max(0, activeTurns.current - 1);
445
+ maybeProcessQueue();
259
446
  }
260
- }, [cfg, push]);
261
-
262
- const doExit = useCallback(() => { onExit?.(plain.current); exit(); }, [exit, onExit]);
447
+ }, [allowAll, cfg, log, maybeProcessQueue, push, pushNotice]);
263
448
 
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 });
449
+ const showExpanded = useCallback((kind, sourceItems) => {
450
+ if (!sourceItems.length) {
451
+ pushNotice('Nothing to expand yet.', C.muted, true);
452
+ return;
284
453
  }
285
- }, [cfg, push]);
454
+ setExpandedItems({
455
+ kind,
456
+ items: sourceItems.map((it, idx) => ({ ...it, id: `expanded-${kind}-${idx}-${it.id}` })),
457
+ });
458
+ pushNotice(`Expanded ${kind}.`, C.muted, true);
459
+ }, [pushNotice]);
286
460
 
287
- const onSubmit = useCallback((raw) => {
288
- const q = raw.trim();
289
- if (!q) return;
461
+ const dispatchSubmit = useCallback((raw, opts = {}) => {
462
+ const q = String(raw || '').trim();
463
+ if (!q || !trusted) return;
290
464
  setHint('');
465
+ setExpandedItems(null);
466
+ setSearch(null);
467
+
468
+ if (shellMode) { runShellCommand(q); return; }
469
+ if (q === '!') { runShellCommand(''); return; }
470
+ if (q.startsWith('!')) { runShellCommand(q.slice(1)); return; }
471
+ if (q === '?') { push({ type: 'help', text: 'help' }); return; }
291
472
 
292
473
  if (q === '/exit' || q === 'exit' || q === 'quit') { doExit(); return; }
293
474
  if (q === '/clear') { clearAll(); return; }
294
475
  if (q === '/new') {
295
476
  convo.current = [];
296
477
  clearAll();
297
- push({ type: 'notice', text: `${glyphs.check} New conversation started.`, color: C.green });
478
+ pushNotice(`${glyphs.check} New conversation started.`, C.green);
298
479
  return;
299
480
  }
300
481
  if (q === '/reasoning') {
301
- setShowReasoning((v) => { push({ type: 'notice', text: `Reasoning display ${!v ? 'expanded' : 'collapsed'}.` }); return !v; });
482
+ setShowReasoning((v) => { pushNotice(`Reasoning display ${!v ? 'expanded' : 'collapsed'}.`); return !v; });
302
483
  return;
303
484
  }
304
485
  if (q === '/autopilot' || q === '/auto') {
305
486
  const next = modeRef.current !== 'autopilot' ? 'autopilot' : 'ask';
306
487
  setModeState(next);
307
- push({ type: 'notice',
308
- text: next === 'autopilot'
488
+ pushNotice(
489
+ next === 'autopilot'
309
490
  ? `${glyphs.bolt} Autopilot ON ${dash()} proposed fixes will be applied automatically. Shift+Tab to cycle modes.`
310
491
  : `Mode: ask ${dash()} fixes will wait for your confirmation.`,
311
- color: next === 'autopilot' ? C.warning : C.muted });
492
+ next === 'autopilot' ? C.warning : C.muted,
493
+ );
312
494
  return;
313
495
  }
314
496
  if (q === '/plan') {
315
497
  const next = modeRef.current !== 'plan' ? 'plan' : 'ask';
316
498
  setModeState(next);
317
- push({ type: 'notice', text: `Mode: ${next} ${dash()} Shift+Tab to cycle.`, color: C.accent });
499
+ pushNotice(`Mode: ${next} ${dash()} Shift+Tab to cycle.`, C.accent);
318
500
  return;
319
501
  }
320
502
  if (q === '/context' || q === '/usage') {
321
503
  const tokens = approxTokens(convo.current);
322
- push({ type: 'notice', text: `Context: ~${tokens.toLocaleString()} tokens (${convo.current.length} messages). /compact to summarise.` });
504
+ pushNotice(`Context: ~${tokens.toLocaleString()} tokens (${convo.current.length} messages). /compact to summarise.`);
323
505
  return;
324
506
  }
325
507
  if (q === '/compact' || q.startsWith('/compact ')) {
@@ -328,30 +510,43 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
328
510
  ? `Summarise our conversation so far, focusing on: ${focus}`
329
511
  : 'Summarise our conversation so far in a concise way, preserving key facts and decisions.';
330
512
  convo.current = [];
331
- push({ type: 'notice', text: `${glyphs.arrow} Compacting history…`, color: C.muted });
513
+ pushNotice(`${glyphs.arrow} Compacting history…`, C.muted);
332
514
  runTurn(prompt);
333
515
  return;
334
516
  }
335
517
  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.' });
518
+ 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
519
  return;
338
520
  }
339
521
  if (q === '/session') {
340
522
  const tokens = approxTokens(convo.current);
341
523
  const branch = branchRef.current;
342
524
  const cwd = shortenPath(process.cwd());
343
- push({ type: 'notice', text: [
344
- `Session ${dash()} ${cwd}${branch ? ' [' + branch + ']' : ''}`,
525
+ const name = cfg?.tui?.sessionNames?.[process.cwd()] || '(unnamed)';
526
+ pushNotice([
527
+ `Session ${dash()} ${name}`,
528
+ `Path ${dash()} ${cwd}${branch ? ' [' + branch + ']' : ''}`,
345
529
  `Messages ${dash()} ${convo.current.length}`,
346
530
  `Tokens ${dash()} ~${tokens.toLocaleString()} (approx)`,
347
531
  `Model ${dash()} ${model || modelRef.current || 'server default'}`,
348
532
  `Mode ${dash()} ${modeRef.current}`,
349
- ].join('\n') });
533
+ ].join('\n'));
534
+ return;
535
+ }
536
+ if (q === '/resume' || q.startsWith('/resume ') || q === '/continue' || q.startsWith('/continue ')) {
537
+ const name = cfg?.tui?.sessionNames?.[process.cwd()] || '(unnamed)';
538
+ pushNotice([
539
+ `Resume (local stub)`,
540
+ `Name ${dash()} ${name}`,
541
+ `Path ${dash()} ${shortenPath(process.cwd())}`,
542
+ `Messages ${dash()} ${convo.current.length}`,
543
+ `Last AI ${dash()} ${(lastAnswerRef.current || '(none)').slice(0, 120)}`,
544
+ ].join('\n'));
350
545
  return;
351
546
  }
352
547
  if (q === '/share') {
353
548
  if (!lastAnswerRef.current && convo.current.length === 0) {
354
- push({ type: 'notice', text: 'Nothing to share yet — ask something first.', color: C.muted }); return;
549
+ pushNotice('Nothing to share yet — ask something first.', C.muted); return;
355
550
  }
356
551
  choiceActionRef.current = (v) => doGist(v);
357
552
  setChoice({
@@ -368,80 +563,195 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
368
563
  if (q === '/model' || q.startsWith('/model ')) {
369
564
  const name = q.slice('/model'.length).trim();
370
565
  if (!name) {
371
- push({ type: 'notice', text: `Current model: ${model || modelRef.current || 'server default'}.\nUse /model <name> to request a different model.` });
566
+ pushNotice(`Current model: ${model || modelRef.current || 'server default'}.\nUse /model <name> to request a different model.`);
372
567
  } else {
373
568
  modelRef.current = name;
374
569
  setModel(name);
375
570
  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 });
571
+ persistCfg();
572
+ pushNotice(`${glyphs.check} Model set to ${name}. It applies to your next message.`, C.green);
573
+ }
574
+ return;
575
+ }
576
+ if (q === '/cwd') {
577
+ pushNotice(process.cwd());
578
+ return;
579
+ }
580
+ if (q === '/cd' || q.startsWith('/cd ')) {
581
+ const arg = q.slice('/cd'.length).trim();
582
+ if (!arg) { pushNotice(`cwd ${dash()} ${process.cwd()}`); return; }
583
+ const target = isAbsolute(arg) ? arg : resolve(process.cwd(), arg);
584
+ if (!existsSync(target)) { pushNotice(`Directory not found: ${target}`, C.red); return; }
585
+ try {
586
+ process.chdir(target);
587
+ branchRef.current = gitBranch(target);
588
+ const ok = isTrustedDir(cfg, target);
589
+ setTrusted(ok);
590
+ if (!ok) openTrustDialog(target);
591
+ pushNotice(`Changed directory to ${shortenPath(target)}`, C.green, true);
592
+ } catch (e) {
593
+ pushNotice(`Could not change directory: ${e.message}`, C.red);
594
+ }
595
+ return;
596
+ }
597
+ if (q === '/allow-all' || q.startsWith('/allow-all ')) {
598
+ const arg = q.slice('/allow-all'.length).trim().toLowerCase();
599
+ const next = arg === 'show' ? allowAll : arg === 'on' ? true : arg === 'off' ? false : !allowAll;
600
+ if (arg !== 'show') {
601
+ cfg.tui = { ...(cfg.tui || {}), allowAll: next };
602
+ persistCfg();
603
+ setAllowAll(next);
604
+ }
605
+ pushNotice(`allow-all ${dash()} ${next ? 'ON' : 'OFF'}`, next ? C.warning : C.muted, arg !== 'show');
606
+ return;
607
+ }
608
+ if (q === '/rename' || q.startsWith('/rename ')) {
609
+ const name = q.slice('/rename'.length).trim() || `session-${new Date().toISOString().slice(0, 16).replace(/[T:]/g, '-')}`;
610
+ cfg.tui = cfg.tui || {};
611
+ cfg.tui.sessionNames = { ...(cfg.tui.sessionNames || {}), [process.cwd()]: name };
612
+ persistCfg();
613
+ pushNotice(`${glyphs.check} Session renamed to ${name}.`, C.green);
614
+ return;
615
+ }
616
+ if (q === '/changelog' || q.startsWith('/changelog')) {
617
+ try {
618
+ const raw = execSync('npm view ispbills-icli version time --json', {
619
+ cwd: process.cwd(), stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000,
620
+ }).toString();
621
+ const data = JSON.parse(raw);
622
+ const versions = Object.entries(data.time || {})
623
+ .filter(([k]) => /^\d/.test(k))
624
+ .slice(-5)
625
+ .map(([k, v]) => ` ${k} ${dash()} ${String(v).slice(0, 10)}`)
626
+ .join('\n');
627
+ pushNotice(`ispbills-icli ${dash()} current ${pkgVersion()}\nRecent npm releases:\n${versions || ' unavailable'}`);
628
+ } catch {
629
+ pushNotice(`ispbills-icli ${dash()} current ${pkgVersion()}\nRecent changelog unavailable offline.`);
378
630
  }
379
631
  return;
380
632
  }
633
+ if (q === '/feedback' || q === '/bug') {
634
+ pushNotice(`Feedback: ${feedbackUrl()}`);
635
+ return;
636
+ }
637
+ if (q === '/env') {
638
+ pushNotice([
639
+ `url ${dash()} ${cfg.url}`,
640
+ `user ${dash()} ${cfg.user?.name || cfg.user?.email || 'unknown'}`,
641
+ `model ${dash()} ${model || modelRef.current || 'server default'}`,
642
+ `mode ${dash()} ${shellMode ? 'shell' : modeRef.current}`,
643
+ `memory ${dash()} ${loadNotes(cfg).length} notes`,
644
+ `allowAll ${dash()} ${allowAll ? 'on' : 'off'}`,
645
+ `streamer ${dash()} ${streamerMode ? 'on' : 'off'}`,
646
+ ].join('\n'));
647
+ return;
648
+ }
649
+ if (q === '/streamer-mode') {
650
+ const next = !streamerMode;
651
+ cfg.tui = { ...(cfg.tui || {}), streamerMode: next };
652
+ persistCfg();
653
+ setStreamerMode(next);
654
+ pushNotice(`streamer-mode ${dash()} ${next ? 'ON' : 'OFF'}`, next ? C.warning : C.muted, true);
655
+ return;
656
+ }
657
+ if (q === '/diff') {
658
+ gitCmd(cfg, ['diff', '--stat']).then((r) => {
659
+ const body = (r.stdout || '').trim() || (r.stderr || '').trim() || 'No recent diff in the backup repo.';
660
+ pushNotice(`backup repo diff\n${body.slice(0, 3500)}`, r.ok ? C.muted : C.red);
661
+ });
662
+ return;
663
+ }
664
+ if (q === '/review' || q.startsWith('/review ')) {
665
+ if (!lastAnswerRef.current) { pushNotice('Nothing to review yet — ask something first.', C.muted); return; }
666
+ const focus = q.slice('/review'.length).trim();
667
+ const reviewPrompt = `${focus ? `${focus}. ` : ''}Review the following last answer for correctness, risks, and missing details in the ISP context:\n\n${lastAnswerRef.current}`;
668
+ runTurn(reviewPrompt, { synthetic: false });
669
+ return;
670
+ }
381
671
  if (q === '/help' || q === 'help') {
382
672
  let group = null;
383
673
  const lines = SLASH_COMMANDS.map((c) => {
384
674
  const head = c.group !== group ? ((group = c.group), `\n ${c.group}\n`) : '';
385
- return head + ` ${(c.cmd + (c.hint ? ' ' + c.hint : '')).padEnd(22)}${c.desc}`;
675
+ return head + ` ${(c.cmd + (c.hint ? ' ' + c.hint : '')).padEnd(30)}${c.desc}`;
386
676
  }).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' });
677
+ pushNotice(
678
+ 'Commands:' + lines +
679
+ '\n\n Keyboard shortcuts:\n' +
680
+ ' Shift+Tab cycle modes: ask → plan → autopilot\n' +
681
+ ' Ctrl+T toggle reasoning display\n' +
682
+ ' Ctrl+L clear screen\n' +
683
+ ' Ctrl+R reverse history search\n' +
684
+ ' Ctrl+F timeline search\n' +
685
+ ' Ctrl+O expand recent items\n' +
686
+ ' Ctrl+E expand all items\n' +
687
+ ' Ctrl+G open $EDITOR\n' +
688
+ ' Ctrl+X e open $EDITOR (alt)\n' +
689
+ ' Ctrl+X / slash picker mid-prompt\n' +
690
+ ' Ctrl+X b promote task to background\n' +
691
+ ' Ctrl+X o open most recent link\n' +
692
+ ' Ctrl+S run and preserve input\n' +
693
+ ' Ctrl+N / Ctrl+P cycle followup chips\n' +
694
+ ' Ctrl+Enter queue message while busy\n' +
695
+ ' Ctrl+V paste attachment\n' +
696
+ ' Ctrl+Z suspend (Unix)\n' +
697
+ ' Shift+Enter insert newline\n' +
698
+ ' Alt+← / Alt+→ jump word\n' +
699
+ ' Ctrl+Home / End jump to start/end\n' +
700
+ ' Ctrl+D shutdown\n' +
701
+ ' Ctrl+C × 2 exit\n' +
702
+ ' Esc cancel / exit mode\n'
703
+ );
388
704
  return;
389
705
  }
390
706
  if (q === '/history') {
391
707
  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.' });
708
+ pushNotice(us.length ? us.map((m, i) => ` ${i + 1}. ${m.content.slice(0, 80)}`).join('\n') : 'No history yet.');
393
709
  return;
394
710
  }
395
711
  if (q === '/logout') { onExternal?.('logout'); doExit(); return; }
396
712
  if (q === '/update') { onExternal?.('update'); doExit(); return; }
397
713
 
398
- // ── Memory ──
399
714
  if (q === '/remember' || q.startsWith('/remember ')) {
400
715
  const note = q.slice('/remember'.length).trim();
401
- if (!note) { push({ type: 'notice', text: 'Usage: /remember <note>' }); return; }
716
+ if (!note) { pushNotice('Usage: /remember <note>'); return; }
402
717
  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 });
718
+ pushNotice(`${glyphs.check} Remembered (${notes.length} note${notes.length > 1 ? 's' : ''}). I'll keep this in mind.`, C.green);
404
719
  return;
405
720
  }
406
721
  if (q === '/memory') {
407
722
  const notes = loadNotes(cfg);
408
- push({ type: 'notice', text: notes.length
723
+ pushNotice(notes.length
409
724
  ? '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>.' });
725
+ : 'No memory notes yet. Add one with /remember <note>.');
411
726
  return;
412
727
  }
413
728
  if (q === '/forget' || q.startsWith('/forget ')) {
414
729
  const which = q.slice('/forget'.length).trim();
415
- if (!which) { push({ type: 'notice', text: 'Usage: /forget <number | all | text>' }); return; }
730
+ if (!which) { pushNotice('Usage: /forget <number | all | text>'); return; }
416
731
  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 });
732
+ pushNotice(removed ? `${glyphs.check} Forgot ${removed} note(s). ${notes.length} remain.` : 'No matching note found.', removed ? C.green : C.gray);
420
733
  return;
421
734
  }
422
-
423
- // ── Clipboard ──
424
735
  if (q === '/copy') {
425
736
  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 }));
737
+ if (!ans) { pushNotice('Nothing to copy yet — ask something first.', C.gray); return; }
738
+ copyToClipboard(ans).then((tool) => pushNotice(
739
+ tool ? `${glyphs.check} Copied last answer (${ans.length} chars) to the clipboard.`
740
+ : `${glyphs.cross} No clipboard tool found. Install xclip / wl-clipboard (Linux), or use macOS/Windows.`,
741
+ tool ? C.green : C.red,
742
+ ));
431
743
  return;
432
744
  }
433
-
434
- // ── GitHub gist ──
435
745
  if (q === '/gist' || q.startsWith('/gist ')) {
436
746
  const rest = q.slice('/gist'.length).trim();
437
747
  if (rest.startsWith('token ')) {
438
748
  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 });
749
+ persistCfg();
750
+ pushNotice(`${glyphs.check} GitHub token saved. /gist will use it (needs the "gist" scope).`, C.green);
441
751
  return;
442
752
  }
443
753
  if (!lastAnswerRef.current && convo.current.length === 0) {
444
- push({ type: 'notice', text: 'Nothing to save yet — ask something first.', color: C.gray });
754
+ pushNotice('Nothing to save yet — ask something first.', C.gray);
445
755
  return;
446
756
  }
447
757
  choiceActionRef.current = (v) => doGist(v);
@@ -456,42 +766,383 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
456
766
  });
457
767
  return;
458
768
  }
459
-
460
- // ── Local git repo ──
461
769
  if (q === '/git' || q.startsWith('/git ')) {
462
770
  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; }
771
+ if (!args) { pushNotice('Usage: /git <status | log | commit -m "msg" | push | diff | remote add origin <url>>'); return; }
464
772
  gitCmd(cfg, splitArgs(args)).then((r) => {
465
773
  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 });
774
+ pushNotice(`$ git ${args}\n${body.slice(0, 3500)}`, r.ok ? C.muted : C.red);
467
775
  });
468
776
  return;
469
777
  }
470
778
 
779
+ // ── Terminal setup ──────────────────────────────────────────────────────
780
+ if (q === '/terminal-setup') {
781
+ pushNotice([
782
+ 'Terminal setup — enable Shift+Enter for multi-line input:',
783
+ '',
784
+ ' iTerm2 (macOS): Preferences → Keys → Add: Shift+Enter → Send escape sequence: \\n',
785
+ ' Kitty: kitty.conf → map shift+return send_text all \\n',
786
+ ' WezTerm: Auto-supported.',
787
+ ' Windows Terminal: Add JSON keybinding for shiftEnter → newline.',
788
+ ' VS Code terminal: Already supported.',
789
+ ' Other: Try Alt+Enter (sends \\n in most terminals).',
790
+ '',
791
+ 'If your terminal supports bracketed paste, multi-line paste also works.',
792
+ ].join('\n'));
793
+ return;
794
+ }
795
+
796
+ // ── Experimental ────────────────────────────────────────────────────────
797
+ if (q === '/experimental' || q.startsWith('/experimental ')) {
798
+ const arg = q.slice('/experimental'.length).trim().toLowerCase();
799
+ const current = Boolean(cfg?.tui?.experimental);
800
+ const next = arg === 'show' ? current : arg === 'on' ? true : arg === 'off' ? false : !current;
801
+ if (arg !== 'show') {
802
+ cfg.tui = { ...(cfg.tui || {}), experimental: next };
803
+ persistCfg();
804
+ }
805
+ pushNotice(`experimental features ${dash()} ${next ? 'ON' : 'OFF'}`, next ? C.warning : C.muted, arg !== 'show');
806
+ return;
807
+ }
808
+
809
+ // ── Permissions ──────────────────────────────────────────────────────────
810
+ if (q === '/permissions' || q.startsWith('/permissions ')) {
811
+ const arg = q.slice('/permissions'.length).trim().toLowerCase();
812
+ if (arg === 'reset') {
813
+ cfg.tui = { ...(cfg.tui || {}), allowedTools: [], allowAll: false };
814
+ persistCfg();
815
+ setAllowAll(false);
816
+ pushNotice(`${glyphs.check} All permissions reset.`, C.green);
817
+ return;
818
+ }
819
+ const tools = cfg?.tui?.allowedTools || [];
820
+ pushNotice([
821
+ `Permissions ${dash()} ${shortenPath(process.cwd())}`,
822
+ `allow-all ${dash()} ${allowAll ? 'ON' : 'off'}`,
823
+ `tools ${dash()} ${tools.length ? tools.join(', ') : 'none pre-approved'}`,
824
+ `dirs ${dash()} ${Object.keys(cfg?.tui?.trustedDirs || {}).map(shortenPath).join(', ') || 'none'}`,
825
+ '',
826
+ 'Use /reset-allowed-tools to clear, /allow-all to toggle, /add-dir PATH to add.',
827
+ ].join('\n'));
828
+ return;
829
+ }
830
+ if (q === '/reset-allowed-tools') {
831
+ cfg.tui = { ...(cfg.tui || {}), allowedTools: [] };
832
+ persistCfg();
833
+ pushNotice(`${glyphs.check} In-session tool approvals cleared.`, C.green);
834
+ return;
835
+ }
836
+ if (q === '/allow-tool' || q.startsWith('/allow-tool ')) {
837
+ const tool = q.slice('/allow-tool'.length).trim();
838
+ if (!tool) { pushNotice('Usage: /allow-tool TOOL (e.g. shell(git))'); return; }
839
+ const tools = [...(cfg?.tui?.allowedTools || []), tool];
840
+ cfg.tui = { ...(cfg.tui || {}), allowedTools: tools };
841
+ persistCfg();
842
+ pushNotice(`${glyphs.check} Pre-approved: ${tool}`, C.green);
843
+ return;
844
+ }
845
+ if (q === '/sandbox' || q.startsWith('/sandbox ')) {
846
+ const arg = q.slice('/sandbox'.length).trim().toLowerCase();
847
+ const current = Boolean(cfg?.tui?.sandbox);
848
+ const next = arg === 'enable' ? true : arg === 'disable' ? false : !current;
849
+ cfg.tui = { ...(cfg.tui || {}), sandbox: next };
850
+ persistCfg();
851
+ pushNotice(`sandbox mode ${dash()} ${next ? 'ENABLED — restricted filesystem/network access' : 'disabled'}`, next ? C.warning : C.muted, true);
852
+ return;
853
+ }
854
+ if (q === '/add-dir' || q.startsWith('/add-dir ')) {
855
+ const p = q.slice('/add-dir'.length).trim();
856
+ if (!p) { pushNotice('Usage: /add-dir PATH'); return; }
857
+ const target = isAbsolute(p) ? p : resolve(process.cwd(), p);
858
+ if (!existsSync(target)) { pushNotice(`Directory not found: ${target}`, C.red); return; }
859
+ cfg.tui = { ...(cfg.tui || {}), trustedDirs: { ...(cfg.tui?.trustedDirs || {}), [target]: true } };
860
+ persistCfg();
861
+ pushNotice(`${glyphs.check} Added to allowed directories: ${shortenPath(target)}`, C.green);
862
+ return;
863
+ }
864
+ if (q === '/list-dirs') {
865
+ const dirs = Object.keys(cfg?.tui?.trustedDirs || {});
866
+ pushNotice(dirs.length
867
+ ? 'Allowed directories:\n' + dirs.map((d, i) => ` ${i + 1}. ${shortenPath(d)}`).join('\n')
868
+ : 'No directories added yet. Use /add-dir PATH or trust a directory at launch.');
869
+ return;
870
+ }
871
+ if (q === '/init') {
872
+ const fp = resolve(process.cwd(), '.github/copilot-instructions.md');
873
+ if (existsSync(fp)) {
874
+ pushNotice(`Copilot instructions already exist at ${shortenPath(fp)}. Edit it directly.`);
875
+ } else {
876
+ runTurn('Create a .github/copilot-instructions.md file for this repository with sensible defaults for an ISP NOC tool.');
877
+ }
878
+ return;
879
+ }
880
+
881
+ // ── Agents & tools ───────────────────────────────────────────────────────
882
+ if (q === '/agent') {
883
+ pushNotice([
884
+ 'Built-in agents:',
885
+ ' explore — Quick codebase analysis; questions without polluting main context',
886
+ ' task — Runs builds, tests, linters with brief success/full failure output',
887
+ ' general — Complex multi-step tasks with full toolset, separate context',
888
+ ' code-review — Surfaces only genuine issues; minimal noise',
889
+ ' research — Deep research across codebase, repos, web with citations',
890
+ ' rubber-duck — Constructive critic; consulted automatically on non-trivial tasks',
891
+ '',
892
+ 'Use: /rubber-duck [prompt] · /research TOPIC · /fleet [prompt]',
893
+ ].join('\n'));
894
+ return;
895
+ }
896
+ if (q === '/skills') {
897
+ pushNotice([
898
+ 'Skills (ISP NOC context):',
899
+ ' optical — Optical power analysis and ONU diagnostics (enabled)',
900
+ ' fleet-check — Parallel multi-device scanning (enabled)',
901
+ ' backup — Automated config backup via git (enabled)',
902
+ ' customers — Customer lookup and ticket integration (enabled)',
903
+ ' alarms — Real-time alarm monitoring (enabled)',
904
+ '',
905
+ 'Use /experimental to unlock additional skills.',
906
+ ].join('\n'));
907
+ return;
908
+ }
909
+ if (q === '/tasks') {
910
+ const active = activeTurns.current;
911
+ const queued = queuedRef.current ? 1 : 0;
912
+ pushNotice([
913
+ `Background tasks ${dash()} ${active > 0 ? `${active} running` : 'idle'}`,
914
+ `Queued messages ${dash()} ${queued}`,
915
+ `Shell mode ${dash()} ${shellMode ? 'active' : 'off'}`,
916
+ '',
917
+ 'Use Ctrl+Enter to queue a message while busy. Ctrl+X b promotes to background.',
918
+ ].join('\n'));
919
+ return;
920
+ }
921
+ if (q === '/fleet' || q.startsWith('/fleet ')) {
922
+ const prompt = q.slice('/fleet'.length).trim();
923
+ runTurn(prompt
924
+ ? `Fleet mode: spawn parallel subagents to work on: ${prompt}. Report combined results.`
925
+ : 'Fleet mode: spawn parallel subagents to check status of all devices and report combined results.');
926
+ return;
927
+ }
928
+ if (q === '/rubber-duck' || q.startsWith('/rubber-duck ')) {
929
+ const prompt = q.slice('/rubber-duck'.length).trim();
930
+ const context = lastAnswerRef.current ? `\nContext from last answer:\n${lastAnswerRef.current.slice(0, 1000)}` : '';
931
+ runTurn(`Play the role of a rubber-duck reviewer. Give a second opinion on the following, focusing on what could go wrong, what's missing, and what risks exist. Be concise and high-signal.${context ? context : ' ' + (prompt || 'Review our conversation so far.')}`);
932
+ return;
933
+ }
934
+ if (q === '/research' || q.startsWith('/research ')) {
935
+ const topic = q.slice('/research'.length).trim();
936
+ if (!topic) { pushNotice('Usage: /research TOPIC'); return; }
937
+ runTurn(`Run deep research on: ${topic}. Search GitHub repos, docs, and known ISP NOC resources. Produce a report with citations.`);
938
+ return;
939
+ }
940
+ if (q === '/mcp' || q.startsWith('/mcp ')) {
941
+ const sub = q.slice('/mcp'.length).trim();
942
+ pushNotice([
943
+ `MCP servers ${dash()} ${sub || 'show'}`,
944
+ ' GitHub MCP server is preconfigured (github.com resources: repos, issues, PRs).',
945
+ ' Config: ~/.config/ispbills-icli/mcp-config.json',
946
+ '',
947
+ sub === 'show' || !sub
948
+ ? ' No additional MCP servers configured. Use /mcp add to add one.'
949
+ : ` Subcommand '${sub}' — MCP management UI coming soon.`,
950
+ ].join('\n'));
951
+ return;
952
+ }
953
+ if (q === '/plugin' || q.startsWith('/plugin ')) {
954
+ pushNotice(`Plugin marketplace — coming soon. Use npm to install ispbills-icli-* plugins.`);
955
+ return;
956
+ }
957
+ if (q === '/lsp' || q.startsWith('/lsp ')) {
958
+ pushNotice(`LSP integration — configure ~/.config/ispbills-icli/lsp-config.json\nSubcommands: show, test, reload, help.`);
959
+ return;
960
+ }
961
+ if (q === '/ide') {
962
+ pushNotice(`IDE connection — connect via VS Code extension. Install "IspBills AI" from the VS Code marketplace.`);
963
+ return;
964
+ }
965
+ if (q === '/pr' || q.startsWith('/pr ')) {
966
+ const sub = q.slice('/pr'.length).trim() || 'view';
967
+ runTurn(`GitHub PR ${sub}: check the current branch's pull request status, list any open PRs, and summarise review status.`);
968
+ return;
969
+ }
970
+ if (q === '/delegate' || q.startsWith('/delegate ')) {
971
+ const prompt = q.slice('/delegate'.length).trim();
972
+ runTurn(prompt
973
+ ? `Delegate the following changes to a remote repo with an AI-generated PR: ${prompt}`
974
+ : 'Summarise what changes could be delegated to a remote PR and ask which to proceed with.');
975
+ return;
976
+ }
977
+ if (q === '/instructions') {
978
+ const paths = [
979
+ resolve(process.cwd(), '.github/copilot-instructions.md'),
980
+ resolve(process.cwd(), 'AGENTS.md'),
981
+ resolve(process.cwd(), 'CLAUDE.md'),
982
+ resolve(homedir(), '.copilot/copilot-instructions.md'),
983
+ ];
984
+ const found = paths.filter(existsSync).map(shortenPath);
985
+ pushNotice(found.length
986
+ ? 'Custom instruction files:\n' + found.map((p, i) => ` ${i + 1}. ${p}`).join('\n') + '\n\n Edit any to customise AI behaviour.'
987
+ : 'No custom instruction files found.\n Use /init to create .github/copilot-instructions.md.');
988
+ return;
989
+ }
990
+ if (q === '/remote' || q.startsWith('/remote ')) {
991
+ pushNotice('Remote steering — allows steering this session from another device via a shared link. Coming soon.');
992
+ return;
993
+ }
994
+ if (q === '/chronicle' || q.startsWith('/chronicle ')) {
995
+ const sub = q.slice('/chronicle'.length).trim() || 'standup';
996
+ const prompts = {
997
+ standup: 'Generate a concise standup summary from this session: what was worked on, what was completed, and any blockers.',
998
+ tips: 'Based on this session, what best practices or tips would improve my ISP NOC workflow?',
999
+ improve: 'Analyse this session and suggest concrete improvements to my approach or tooling.',
1000
+ reindex: 'Reindex and categorise all actions taken in this session.',
1001
+ };
1002
+ runTurn(prompts[sub] || `Chronicle ${sub}: summarise this session focusing on ${sub}.`);
1003
+ return;
1004
+ }
1005
+ if (q === '/keep-alive' || q.startsWith('/keep-alive ')) {
1006
+ const arg = q.slice('/keep-alive'.length).trim().toLowerCase();
1007
+ pushNotice(`keep-alive ${dash()} ${arg || 'status'}. Note: keep-alive prevents machine sleep. Use /keep-alive on|off|busy.`, C.muted);
1008
+ return;
1009
+ }
1010
+ if (q === '/ask' || q.startsWith('/ask ')) {
1011
+ const question2 = q.slice('/ask'.length).trim();
1012
+ if (!question2) { pushNotice('Usage: /ask QUESTION — quick side-question without adding to history'); return; }
1013
+ pushNotice(`[ask] ${question2}`, C.muted, false);
1014
+ // Run as an isolated turn that doesn't add to main history
1015
+ const savedConvo = [...convo.current];
1016
+ convo.current = [];
1017
+ runTurn(question2).then(() => { convo.current = savedConvo; });
1018
+ return;
1019
+ }
1020
+ if (q === '/after' || q.startsWith('/after ')) {
1021
+ pushNotice('Scheduled prompts (/after, /every) are experimental. Feature coming in a future release.', C.muted);
1022
+ return;
1023
+ }
1024
+ if (q === '/every' || q.startsWith('/every ')) {
1025
+ pushNotice('Scheduled prompts (/every) are experimental. Feature coming in a future release.', C.muted);
1026
+ return;
1027
+ }
1028
+
1029
+ // ── Misc system ──────────────────────────────────────────────────────────
1030
+ if (q === '/restart') {
1031
+ pushNotice(`${glyphs.arrow} Restarting iCli…`, C.muted, true);
1032
+ setTimeout(() => { try { process.exit(0); } catch {} }, 500);
1033
+ return;
1034
+ }
1035
+ if (q === '/downgrade' || q.startsWith('/downgrade ')) {
1036
+ const ver = q.slice('/downgrade'.length).trim();
1037
+ if (!ver) { pushNotice('Usage: /downgrade VERSION (e.g. /downgrade 8.5.3)'); return; }
1038
+ pushNotice(`${glyphs.arrow} Rolling back to ispbills-icli@${ver}…`, C.warning, true);
1039
+ const res = spawnSync('npm', ['install', '-g', `ispbills-icli@${ver}`], { stdio: 'pipe', encoding: 'utf8' });
1040
+ pushNotice(res.status === 0
1041
+ ? `${glyphs.check} Downgraded to ${ver}. Restart iCli.`
1042
+ : `${glyphs.cross} Downgrade failed: ${(res.stderr || '').slice(0, 200)}`, res.status === 0 ? C.green : C.red);
1043
+ return;
1044
+ }
1045
+ if (q === '/app') {
1046
+ pushNotice('Launching GitHub Copilot desktop app… (stub — install from github.com/github/copilot-for-desktop)', C.muted);
1047
+ return;
1048
+ }
1049
+ if (q === '/user' || q.startsWith('/user ')) {
1050
+ const u = cfg.user;
1051
+ pushNotice([
1052
+ `User ${dash()} ${u?.name || u?.email || 'unknown'}`,
1053
+ `Email ${dash()} ${u?.email || 'n/a'}`,
1054
+ `Role ${dash()} ${u?.role || 'operator'}`,
1055
+ `URL ${dash()} ${cfg.url || 'n/a'}`,
1056
+ ].join('\n'));
1057
+ return;
1058
+ }
1059
+ if (q === '/login') {
1060
+ pushNotice('Run `icli login` from a new terminal to re-authenticate. /logout then restart to force re-login.', C.muted);
1061
+ return;
1062
+ }
1063
+ if (q === '/whoami') {
1064
+ const u = cfg.user;
1065
+ pushNotice([
1066
+ `${u?.name || u?.email || 'unknown'} ${dash()} ${u?.role || 'operator'}`,
1067
+ u?.email ? `email ${dash()} ${u.email}` : '',
1068
+ `url ${dash()} ${cfg.url || 'n/a'}`,
1069
+ ].filter(Boolean).join('\n'));
1070
+ return;
1071
+ }
1072
+ if (q === '/clikit' || q.startsWith('/clikit ')) {
1073
+ pushNotice('clikit component preview — UI component library. Coming soon.', C.muted);
1074
+ return;
1075
+ }
1076
+ if (q === '/extensions' || q.startsWith('/extensions ')) {
1077
+ pushNotice('Extensions — manage CLI extensions at .github/extensions/. Coming soon.', C.muted);
1078
+ return;
1079
+ }
1080
+ if (q === '/reset') {
1081
+ convo.current = [];
1082
+ clearAll();
1083
+ pushNotice(`${glyphs.check} Conversation reset.`, C.green);
1084
+ return;
1085
+ }
1086
+ if (q === '/models' || q.startsWith('/models ')) {
1087
+ const name = q.slice('/models'.length).trim();
1088
+ if (!name) {
1089
+ pushNotice(`Current model: ${model || modelRef.current || 'server default'}.\nUse /model <name> or /models <name> to set.`);
1090
+ } else {
1091
+ modelRef.current = name;
1092
+ setModel(name);
1093
+ cfg._model = name;
1094
+ persistCfg();
1095
+ pushNotice(`${glyphs.check} Model set to ${name}.`, C.green);
1096
+ }
1097
+ return;
1098
+ }
1099
+ if (q === '/continue' || q.startsWith('/continue ')) {
1100
+ // same as /resume
1101
+ const name = cfg?.tui?.sessionNames?.[process.cwd()] || '(unnamed)';
1102
+ pushNotice([
1103
+ `Session ${dash()} ${name}`,
1104
+ `Path ${dash()} ${shortenPath(process.cwd())}`,
1105
+ `Msgs ${dash()} ${convo.current.length}`,
1106
+ `Last AI ${dash()} ${(lastAnswerRef.current || '(none)').slice(0, 120)}`,
1107
+ '',
1108
+ 'Session persistence coming in a future release. Use /session for current session info.',
1109
+ ].join('\n'));
1110
+ return;
1111
+ }
1112
+ const turnOpts = {};
471
1113
  let question = q;
472
- const opts = {};
473
1114
  if (q.startsWith('/')) {
474
1115
  const mapped = slashToQuestion(q);
475
1116
  if (mapped) {
476
1117
  question = mapped;
477
1118
  if (q === '/backup' || q.startsWith('/backup ')) {
478
- opts.archive = 'backup';
479
- opts.label = q.slice('/backup'.length).trim() || 'fleet';
1119
+ turnOpts.archive = 'backup';
1120
+ turnOpts.label = q.slice('/backup'.length).trim() || 'fleet';
480
1121
  }
481
- } else { push({ type: 'notice', text: `Unknown command: ${q}. Type /help.` }); return; }
1122
+ } else {
1123
+ pushNotice(`Unknown command: ${q}. Type /help.`);
1124
+ return;
1125
+ }
482
1126
  }
483
- runTurn(question, opts);
484
- }, [doExit, push, runTurn, onExternal, setModeState, model, cfg, doGist]);
1127
+ runTurn(question, turnOpts);
1128
+ }, [allowAll, cfg, clearAll, doExit, doGist, modeRef, model, onExternal, openTrustDialog, persistCfg, push, pushNotice, runShellCommand, runTurn, setModeState, shellMode, streamerMode, trusted]);
1129
+
1130
+ useEffect(() => {
1131
+ dispatchSubmitRef.current = dispatchSubmit;
1132
+ }, [dispatchSubmit]);
1133
+
1134
+ useEffect(() => {
1135
+ if (initialQuestion && trusted && !choice && convo.current.length === 0) dispatchSubmit(initialQuestion);
1136
+ }, [initialQuestion, trusted, choice, dispatchSubmit]);
485
1137
 
486
- // Interactive prompt/choice handling (owns the keyboard while open).
487
1138
  const pickChoice = useCallback((value) => {
488
1139
  const act = choiceActionRef.current;
489
1140
  choiceActionRef.current = null;
490
1141
  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; }
1142
+ if (act) { act(value); return; }
1143
+ if (value == null) { pushNotice('Dismissed.', C.muted, true); return; }
493
1144
  runTurn(String(value));
494
- }, [push, runTurn]);
1145
+ }, [pushNotice, runTurn]);
495
1146
 
496
1147
  useInput((input, key) => {
497
1148
  if (!choice) return;
@@ -520,9 +1171,37 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
520
1171
  }
521
1172
  });
522
1173
 
523
- // Global keys (Composer handles text editing; here we handle app-level keys).
1174
+ const openSearch = useCallback(() => {
1175
+ setSearch({ query: '', active: 0 });
1176
+ setHint('Timeline search');
1177
+ }, []);
1178
+
1179
+ const searchMatches = search
1180
+ ? items
1181
+ .map((it) => ({ ...it, preview: itemPreview(it) }))
1182
+ .filter((it) => !search.query || it.preview.toLowerCase().includes(search.query.toLowerCase()))
1183
+ : [];
1184
+
1185
+ useInput((input, key) => {
1186
+ if (!search) return;
1187
+ if (key.escape || (key.ctrl && input === 'c')) { setSearch(null); setHint(''); return; }
1188
+ if (key.backspace || key.delete) { setSearch((s) => ({ ...s, query: s.query.slice(0, -1), active: 0 })); return; }
1189
+ if (key.upArrow) { setSearch((s) => ({ ...s, active: Math.max(0, s.active - 1) })); return; }
1190
+ if (key.downArrow || key.tab) { setSearch((s) => ({ ...s, active: Math.min(Math.max(0, searchMatches.length - 1), s.active + 1) })); return; }
1191
+ if (key.return) {
1192
+ if (searchMatches.length) showExpanded('search results', [searchMatches[search.active] || searchMatches[0]]);
1193
+ setSearch(null);
1194
+ setHint('');
1195
+ return;
1196
+ }
1197
+ if (input && !key.ctrl && !key.meta) {
1198
+ const printable = input.replace(/[\x00-\x1f\x7f]/g, '');
1199
+ if (printable) setSearch((s) => ({ ...s, query: s.query + printable, active: 0 }));
1200
+ }
1201
+ });
1202
+
524
1203
  useInput((input, key) => {
525
- if (choice) return; // the Choice prompt owns the keyboard
1204
+ if (choice || search) return;
526
1205
  if (key.ctrl && input === 'c') {
527
1206
  if (busy) { abortRef.current?.abort(); setHint(''); return; }
528
1207
  const now = Date.now();
@@ -531,11 +1210,12 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
531
1210
  setHint('Press Ctrl+C again to exit');
532
1211
  return;
533
1212
  }
534
- if (key.ctrl && input === 'd') { doExit(); return; } // Ctrl+D shutdown
1213
+ if (key.ctrl && input === 'd') { doExit(); return; }
535
1214
  if (key.escape && busy) { abortRef.current?.abort(); return; }
536
- if (key.escape && chips.length) { setChips([]); return; } // Esc dismisses chips
1215
+ if (key.escape && shellMode && !composerText.trim()) { setShellMode(false); pushNotice('Exited shell mode.', C.muted, true); return; }
1216
+ if (key.escape && expandedItems) { setExpandedItems(null); return; }
1217
+ if (key.escape && chips.length) { setChips([]); return; }
537
1218
  if (key.tab && key.shift && !busy) {
538
- // Cycle: ask → plan → autopilot → ask
539
1219
  const cur = MODES.indexOf(modeRef.current);
540
1220
  const next = MODES[(cur + 1) % MODES.length];
541
1221
  setModeState(next);
@@ -544,75 +1224,101 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
544
1224
  }
545
1225
  if (key.ctrl && input === 't') { setShowReasoning((v) => !v); return; }
546
1226
  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) {
1227
+ if (key.ctrl && input === 'f' && !busy) { openSearch(); return; }
1228
+ if (key.ctrl && input === 'o' && !busy && !composerText.trim()) { showExpanded('recent items', items.slice(-5)); return; }
1229
+ if (key.ctrl && input === 'e' && !busy && !composerText.trim()) { showExpanded('all items', items); return; }
1230
+ if (key.ctrl && input === 'n' && chips.length) { setActiveChip((i) => (i + 1) % chips.length); return; }
1231
+ if (key.ctrl && input === 'p' && chips.length) { setActiveChip((i) => (i - 1 + chips.length) % chips.length); return; }
1232
+ if (key.tab && !key.shift && !busy && !composerText.trim()) {
556
1233
  setActiveTab((t) => {
557
1234
  const tabs = ['session', 'devices', 'customers', 'alarms'];
558
1235
  return tabs[(tabs.indexOf(t) + 1) % tabs.length];
559
1236
  });
560
- return;
561
1237
  }
562
1238
  });
563
1239
 
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]);
1240
+ // Tab-specific content panels (shown when not on Session tab)
1241
+ const TAB_CONTENT = {
1242
+ devices: {
1243
+ title: 'Devices',
1244
+ commands: [
1245
+ { cmd: '/status', desc: 'Fleet health summary' },
1246
+ { cmd: '/reachable', desc: 'Who is up / down' },
1247
+ { cmd: '/alarms', desc: 'Active alarms' },
1248
+ { cmd: '/check [device]', desc: 'Full device diagnostics' },
1249
+ { cmd: '/uptime [device]', desc: 'Uptime and last reboot' },
1250
+ { cmd: '/cpu [device]', desc: 'CPU / memory load' },
1251
+ { cmd: '/offline', desc: 'All offline devices' },
1252
+ { cmd: '/flapping [device]', desc: 'Flapping interfaces' },
1253
+ ],
1254
+ hint: 'Type a command or press Enter on Session tab to interact',
1255
+ },
1256
+ customers: {
1257
+ title: 'Customers',
1258
+ commands: [
1259
+ { cmd: '/customer [name]', desc: 'Look up a customer' },
1260
+ { cmd: '/online', desc: 'Online PPPoE sessions' },
1261
+ { cmd: '/pppoe [user]', desc: 'PPPoE session detail' },
1262
+ { cmd: '/dhcp', desc: 'DHCP leases & pool usage' },
1263
+ { cmd: '/onu [id]', desc: 'ONU signal and status' },
1264
+ { cmd: '/bandwidth [device]', desc: 'Traffic / bandwidth' },
1265
+ ],
1266
+ hint: 'Switch to Session tab (Tab) to send commands',
1267
+ },
1268
+ alarms: {
1269
+ title: 'Alarms',
1270
+ commands: [
1271
+ { cmd: '/alarms', desc: 'Active alarms & recent faults' },
1272
+ { cmd: '/offline', desc: 'All offline devices' },
1273
+ { cmd: '/down [circuit]', desc: 'Down circuits / uplinks' },
1274
+ { cmd: '/flapping [device]', desc: 'Flapping interfaces' },
1275
+ { cmd: '/optical [device]', desc: 'Optical RX/TX (dBm)' },
1276
+ { cmd: '/reachable', desc: 'Reachability sweep' },
1277
+ ],
1278
+ hint: 'Switch to Session tab (Tab) and type a command',
1279
+ },
1280
+ };
1281
+
1282
+ const tabPanel = activeTab !== 'session' ? TAB_CONTENT[activeTab] : null;
569
1283
 
570
1284
  const renderItem = (it) => {
571
1285
  const c = it.cols || 80;
572
1286
  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} />`;
1287
+ case 'user': return html`<${UserMessage} key=${it.id} text=${it.text} cols=${c} ts=${it.ts} />`;
1288
+ case 'assistant': return html`<${AssistantMessage} key=${it.id} text=${it.text} cols=${c} ts=${it.ts} />`;
575
1289
  case 'plan': return html`<${Plan} key=${it.id} reply=${it.reply} />`;
576
1290
  case 'connect': return html`<${Connect} key=${it.id} reply=${it.reply} />`;
577
1291
  case 'activity': return html`<${StatusLines} key=${it.id} lines=${it.status} busy=${false} />`;
578
1292
  case 'notice': return html`<${Notice} key=${it.id} text=${it.text} color=${it.color} />`;
1293
+ case 'shell': return html`<${ShellOutput} key=${it.id} command=${it.command} output=${it.output} code=${it.code} cols=${c} ts=${it.ts} />`;
1294
+ case 'help': return html`<${HelpOverlay} key=${it.id} cols=${c} ts=${it.ts} />`;
579
1295
  default: return null;
580
1296
  }
581
1297
  };
582
1298
 
583
- // ── Footer ───────────────────────────────────────────────────────────────────
584
- // Left: ~/cwd ⏎ branch mode-badge
585
- // Right: model ~Nk ctx
586
1299
  const cwd = shortenPath(process.cwd());
587
1300
  const branch = branchRef.current;
588
1301
  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
-
1302
+ const tokenLabel = tokenCount > 0 ? `~${Math.max(1, Math.round(tokenCount / 1000))}k ctx` : '';
1303
+ const modelLabel = streamerMode ? '' : shortModel(model || modelRef.current || 'server default');
1304
+ const footerLeft = [cwd, branch ? `${glyphs.gitBranch}${branch}` : null].filter(Boolean).join(' ');
598
1305
  const footerRight = hint
599
1306
  ? hint
600
- : [modelLabel, tokenLabel].filter(Boolean).join(` ${midDot()} `);
601
-
1307
+ : streamerMode
1308
+ ? ''
1309
+ : [modelLabel, tokenLabel].filter(Boolean).join(midDot());
602
1310
  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];
1311
+ const composerMode = shellMode ? 'shell' : mode;
1312
+ const staticItems = showBanner
1313
+ ? [{ type: 'banner', id: `banner-${clearEpoch}`, cols }, ...items]
1314
+ : items;
609
1315
 
610
1316
  return html`
611
1317
  <${Box} flexDirection="column" width=${cols}>
612
1318
  <${Static} key=${clearEpoch} items=${staticItems}>
613
1319
  ${(it) => it.type === 'banner'
614
1320
  ? html`<${Box} key=${it.id} flexDirection="column" alignItems="center" marginBottom=${1}>
615
- <${Banner} cfg=${cfg} model=${model} width=${cols} compact=${compact} />
1321
+ <${Banner} cfg=${cfg} model=${model} width=${cols} compact=${compact} streamerMode=${streamerMode} />
616
1322
  <//>`
617
1323
  : renderItem(it)}
618
1324
  <//>
@@ -622,24 +1328,60 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
622
1328
  <${StatusLines} lines=${live.status} busy=${busy} />
623
1329
  ${live.reasoning ? html`<${Reasoning} text=${live.reasoning} show=${showReasoning} />` : null}
624
1330
  ${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
625
- ${live.text ? html`<${AssistantMessage} text=${live.text} cols=${cols} />` : null}
1331
+ ${live.text ? html`<${AssistantMessage} text=${live.text} cols=${cols} ts=${startedAt || Date.now()} />` : null}
626
1332
  <${Thinking} label=${live.reply ? 'Planning' : live.text ? 'Responding' : 'Working'} startedAt=${startedAt} />
627
1333
  <//>`
628
1334
  : null}
629
1335
 
630
- <${TabBar} active=${activeTab} cols=${cols} mode=${mode} />
1336
+ ${expandedItems
1337
+ ? html`<${Box} flexDirection="column">
1338
+ <${Notice} text=${`${glyphs.expand} ${expandedItems.kind}`} color=${C.muted} />
1339
+ ${expandedItems.items.map((it) => renderItem(it))}
1340
+ <//>`
1341
+ : null}
1342
+
1343
+ ${search ? html`<${SearchOverlay} query=${search.query} matches=${searchMatches} active=${search.active} cols=${cols} />` : null}
1344
+
1345
+ <${TabBar} active=${activeTab} cols=${cols} mode=${composerMode} />
1346
+
1347
+ ${tabPanel
1348
+ ? html`<${Box} flexDirection="column" paddingX=${1} paddingY=${1}>
1349
+ <${Text} color=${C.brand} bold>${tabPanel.title} — Quick Commands<//>\n
1350
+ ${tabPanel.commands.map((c, i) =>
1351
+ html`<${Box} key=${i}>
1352
+ <${Text} color=${C.slash}>${c.cmd.padEnd(24)}<//>
1353
+ <${Text} color=${C.muted}>${c.desc}<//>
1354
+ <//>`)}
1355
+ \n<${Text} color=${C.muted}>${tabPanel.hint}<//>
1356
+ <//>`
1357
+ : null}
631
1358
 
632
1359
  ${!busy && chips.length ? html`<${FollowupChips} chips=${chips} active=${activeChip} />` : null}
633
1360
 
634
1361
  <${Box} flexDirection="column">
635
1362
  ${choice
636
1363
  ? html`<${Choice} ...${choice} />`
637
- : html`<${Composer} onSubmit=${onSubmit} busy=${busy} width=${cols} />`}
1364
+ : html`<${Composer}
1365
+ onSubmit=${dispatchSubmit}
1366
+ onSubmitKeep=${dispatchSubmit}
1367
+ onQueue=${queueSubmission}
1368
+ onSearch=${openSearch}
1369
+ onNotice=${(text, color = C.muted) => pushNotice(text, color, true)}
1370
+ onEmptyEsc=${() => { if (shellMode) setShellMode(false); else if (expandedItems) setExpandedItems(null); }}
1371
+ onValueChange=${setComposerText}
1372
+ onEmptySubmit=${() => {
1373
+ if (chips.length) dispatchSubmit(chips[activeChip]);
1374
+ }}
1375
+ busy=${busy}
1376
+ blocked=${Boolean(choice || search || !trusted)}
1377
+ width=${cols}
1378
+ mode=${composerMode}
1379
+ clearToken=${composerClearToken}
1380
+ />`}
638
1381
 
639
1382
  <${Box} paddingX=${1} width=${cols}>
640
1383
  <${Box} flexGrow=${1}>
641
1384
  <${Text} color=${C.muted} wrap="truncate-end">${footerLeft}<//>
642
- ${modeLabel ? html`<${Text} color=${mode === 'autopilot' ? C.warning : C.accent}>${modeLabel}<//>` : null}
643
1385
  <//>
644
1386
  <${Text} color=${hint ? C.warning : C.muted} wrap="truncate-end">${footerRight}<//>
645
1387
  <//>