ispbills-icli 8.5.4 → 8.5.6

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
@@ -5,6 +5,7 @@ import { C, glyphs, midDot, dash } from './theme.js';
5
5
  import {
6
6
  Banner, TabBar, FollowupChips, UserMessage, AssistantMessage, StatusLines, Reasoning,
7
7
  Plan, Connect, Notice, Thinking, Choice, ShellOutput, SearchOverlay, HelpOverlay,
8
+ SessionPicker, DiffViewer, LabeledNotice,
8
9
  } from './components.js';
9
10
  import { Composer } from './composer.js';
10
11
  import { streamChat } from '../client.js';
@@ -19,6 +20,7 @@ import { execSync, spawnSync } from 'child_process';
19
20
  import { homedir } from 'os';
20
21
  import { dirname, isAbsolute, relative, resolve } from 'path';
21
22
  import { existsSync, readFileSync } from 'fs';
23
+ import { deleteSession, loadMostRecent, loadSessionById, saveSession, listSessionsDetailed } from '../session.js';
22
24
 
23
25
  function splitArgs(s) {
24
26
  const out = [];
@@ -52,6 +54,126 @@ function approxTokens(messages) {
52
54
  return Math.round(total / 4);
53
55
  }
54
56
 
57
+ const TOKEN_LIMIT = 100000;
58
+ const SESSION_SORTS = ['relevance', 'last used', 'created', 'name'];
59
+
60
+ function sessionAutoName(messages = []) {
61
+ const firstUser = messages.find((m) => m?.role === 'user' && String(m.content || '').trim());
62
+ const text = String(firstUser?.content || '').replace(/\s+/g, ' ').trim();
63
+ return text ? text.slice(0, 40).trim() : 'Untitled session';
64
+ }
65
+
66
+ function renderConversationItems(messages = [], cols = 80) {
67
+ return messages
68
+ .filter((m) => m?.role === 'user' || m?.role === 'assistant')
69
+ .map((m) => ({
70
+ id: nextId(),
71
+ cols,
72
+ ts: Date.now(),
73
+ type: m.role,
74
+ text: String(m.content || ''),
75
+ }));
76
+ }
77
+
78
+ function contextUsageLabel(tokens) {
79
+ const ratio = tokens / TOKEN_LIMIT;
80
+ if (ratio >= 0.5) return `~${Math.round(ratio * 100)}% ctx`;
81
+ if (tokens <= 0) return '';
82
+ return `~${Math.max(1, Math.round(tokens / 1000))}k ctx`;
83
+ }
84
+
85
+ function usageBar(value, max, width = 16) {
86
+ const safeMax = Math.max(1, max);
87
+ const filled = Math.max(0, Math.min(width, Math.round((value / safeMax) * width)));
88
+ return `${'■'.repeat(filled)}${'░'.repeat(Math.max(0, width - filled))}`;
89
+ }
90
+
91
+ function formatDuration(ms = 0) {
92
+ const mins = Math.max(0, Math.round(ms / 60000));
93
+ if (mins < 1) return 'under a minute';
94
+ if (mins === 1) return '1 minute';
95
+ if (mins < 60) return `${mins} minutes`;
96
+ const hours = Math.floor(mins / 60);
97
+ const rem = mins % 60;
98
+ return rem ? `${hours}h ${rem}m` : `${hours}h`;
99
+ }
100
+
101
+ function sessionRelevanceScore(session, cwd) {
102
+ const here = resolve(cwd || process.cwd());
103
+ const there = resolve(session?.cwd || here);
104
+ if (there === here) return 4;
105
+ if (here.startsWith(there + '/')) return 3;
106
+ if (there.startsWith(here + '/')) return 2;
107
+ const home = homedir();
108
+ if (relative(home, there).slice(0, 2) !== '..') return 1;
109
+ return 0;
110
+ }
111
+
112
+ function sortSessions(sessions, mode, cwd) {
113
+ const list = [...sessions];
114
+ if (mode === 'created') {
115
+ return list.sort((a, b) => new Date(b.createdAt || 0).getTime() - new Date(a.createdAt || 0).getTime());
116
+ }
117
+ if (mode === 'name') {
118
+ return list.sort((a, b) => {
119
+ const an = String(a.name || '').trim();
120
+ const bn = String(b.name || '').trim();
121
+ if (!an) return 1;
122
+ if (!bn) return -1;
123
+ return an.localeCompare(bn);
124
+ });
125
+ }
126
+ if (mode === 'last used') {
127
+ return list.sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime());
128
+ }
129
+ return list.sort((a, b) => {
130
+ const score = sessionRelevanceScore(b, cwd) - sessionRelevanceScore(a, cwd);
131
+ if (score !== 0) return score;
132
+ return new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime();
133
+ });
134
+ }
135
+
136
+ function parseDiff(raw = '') {
137
+ const lines = [];
138
+ let file = '';
139
+ let oldLine = 0;
140
+ let newLine = 0;
141
+ for (const content of String(raw || '').split('\n')) {
142
+ if (content.startsWith('diff --git ')) {
143
+ file = content.replace(/^diff --git a\/(.+?) b\/.+$/, '$1');
144
+ lines.push({ type: 'header', content, file });
145
+ continue;
146
+ }
147
+ if (content.startsWith('@@')) {
148
+ const match = content.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
149
+ oldLine = Number(match?.[1] || 0);
150
+ newLine = Number(match?.[2] || 0);
151
+ lines.push({ type: 'hunk', content, file, oldLine, newLine });
152
+ continue;
153
+ }
154
+ if (content.startsWith('+++ ') || content.startsWith('--- ') || content.startsWith('index ')) {
155
+ lines.push({ type: 'header', content, file });
156
+ continue;
157
+ }
158
+ if (content.startsWith('+')) {
159
+ lines.push({ type: 'add', content, file, lineNumber: newLine });
160
+ newLine += 1;
161
+ continue;
162
+ }
163
+ if (content.startsWith('-')) {
164
+ lines.push({ type: 'del', content, file, lineNumber: oldLine });
165
+ oldLine += 1;
166
+ continue;
167
+ }
168
+ lines.push({ type: 'ctx', content, file, oldLine, newLine });
169
+ if (!content.startsWith('\\')) {
170
+ oldLine += 1;
171
+ newLine += 1;
172
+ }
173
+ }
174
+ return lines;
175
+ }
176
+
55
177
  function pkgVersion() {
56
178
  try {
57
179
  return JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version || 'unknown';
@@ -112,16 +234,26 @@ let itemId = 0;
112
234
  const nextId = () => ++itemId;
113
235
  const MODES = ['ask', 'plan', 'autopilot'];
114
236
 
115
- export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, showBanner = true }) {
237
+ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, showBanner = true, agentName, resumeMode }) {
116
238
  const { exit } = useApp();
117
- const { cols } = useTerminalSize();
239
+ const initialResumeRef = useRef(undefined);
240
+ if (initialResumeRef.current === undefined) {
241
+ const resumed = resumeMode ? loadMostRecent() : null;
242
+ if (resumed?.cwd && existsSync(resumed.cwd)) {
243
+ try { process.chdir(resumed.cwd); } catch {}
244
+ }
245
+ initialResumeRef.current = resumed;
246
+ }
247
+ const initialSession = initialResumeRef.current;
248
+ const initialMessages = initialSession?.messages || [];
249
+ const { cols, rows } = useTerminalSize();
118
250
  const colsRef = useRef(cols);
119
251
  useEffect(() => { colsRef.current = cols; }, [cols]);
120
252
 
121
- const [items, setItems] = useState([]);
253
+ const [items, setItems] = useState(() => renderConversationItems(initialMessages, cols));
122
254
  const [live, setLive] = useState(null);
123
255
  const [busy, setBusy] = useState(false);
124
- const [model, setModel] = useState(cfg._model);
256
+ const [model, setModel] = useState(initialSession?.model || cfg._model);
125
257
  const [showReasoning, setShowReasoning] = useState(false);
126
258
  const [mode, setMode] = useState('ask');
127
259
  const [activeTab, setActiveTab] = useState('session');
@@ -133,27 +265,33 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
133
265
  const [clearEpoch, setClearEpoch] = useState(0);
134
266
  const [composerClearToken, setComposerClearToken] = useState(0);
135
267
  const [composerText, setComposerText] = useState('');
268
+ const [composerPreset, setComposerPreset] = useState({ token: 0, text: '' });
136
269
  const [search, setSearch] = useState(null);
137
270
  const [expandedItems, setExpandedItems] = useState(null);
138
271
  const [allowAll, setAllowAll] = useState(Boolean(cfg?.tui?.allowAll));
139
272
  const [streamerMode, setStreamerMode] = useState(Boolean(cfg?.tui?.streamerMode));
140
273
  const [shellMode, setShellMode] = useState(false);
141
274
  const [trusted, setTrusted] = useState(isTrustedDir(cfg, process.cwd()));
275
+ const [sessionPicker, setSessionPicker] = useState(null);
276
+ const [diffState, setDiffState] = useState(null);
142
277
 
143
- const convo = useRef([]);
278
+ const convo = useRef(initialMessages);
144
279
  const plain = useRef([]);
145
280
  const abortRef = useRef(null);
146
281
  const ctrlC = useRef(0);
147
282
  const apRef = useRef(false);
148
283
  const modeRef = useRef('ask');
149
- const modelRef = useRef(cfg._model);
150
- const lastAnswerRef = useRef('');
284
+ const modelRef = useRef(initialSession?.model || cfg._model);
285
+ const lastAnswerRef = useRef([...initialMessages].reverse().find((m) => m.role === 'assistant')?.content || '');
151
286
  const choiceActionRef = useRef(null);
152
- const branchRef = useRef(gitBranch());
287
+ const branchRef = useRef(initialSession?.branch || gitBranch(process.cwd()));
153
288
  const queuedRef = useRef(null);
154
289
  const activeTurns = useRef(0);
155
290
  const dismissTimers = useRef(new Map());
156
291
  const dispatchSubmitRef = useRef(null);
292
+ const sessionIdRef = useRef(initialSession?.id || Date.now().toString(36));
293
+ const sessionCreatedAtRef = useRef(initialSession?.createdAt || new Date().toISOString());
294
+ const sessionNameRef = useRef(initialSession?.name || sessionAutoName(initialMessages));
157
295
 
158
296
  useEffect(() => {
159
297
  try { process.stdout.write('\x1b[?2004h'); } catch {}
@@ -191,14 +329,15 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
191
329
 
192
330
  useEffect(() => {
193
331
  for (const item of items) {
194
- if (item.type !== 'notice' || !item.autoDismiss || dismissTimers.current.has(item.id)) continue;
332
+ if (!['notice', 'info', 'error'].includes(item.type) || !item.autoDismiss || dismissTimers.current.has(item.id)) continue;
195
333
  const timer = setTimeout(() => removeItem(item.id), 4000);
196
334
  dismissTimers.current.set(item.id, timer);
197
335
  }
198
336
  }, [items, removeItem]);
199
337
 
200
- const pushNotice = useCallback((text, color = C.gray, autoDismiss = false) => {
201
- push({ type: 'notice', text, color, autoDismiss });
338
+ const pushNotice = useCallback((text, color = C.gray, autoDismiss = false, type = null) => {
339
+ const resolvedType = type || (color === C.red || color === C.error ? 'error' : 'info');
340
+ push({ type: resolvedType, text, color, autoDismiss });
202
341
  }, [push]);
203
342
 
204
343
  const log = (line) => plain.current.push(line);
@@ -211,6 +350,43 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
211
350
  setClearEpoch((n) => n + 1);
212
351
  }, []);
213
352
 
353
+ const replaceTimeline = useCallback((messages = []) => {
354
+ try { process.stdout.write('\x1b[2J\x1b[3J\x1b[H'); } catch {}
355
+ setItems(renderConversationItems(messages, colsRef.current));
356
+ setExpandedItems(null);
357
+ setSearch(null);
358
+ setClearEpoch((n) => n + 1);
359
+ }, []);
360
+
361
+ const saveCurrentSession = useCallback((overrides = {}) => {
362
+ const saved = saveSession({
363
+ id: sessionIdRef.current,
364
+ name: overrides.name || sessionNameRef.current || sessionAutoName(convo.current),
365
+ cwd: process.cwd(),
366
+ branch: branchRef.current,
367
+ model: modelRef.current || model || '',
368
+ createdAt: overrides.createdAt || sessionCreatedAtRef.current,
369
+ updatedAt: new Date().toISOString(),
370
+ messages: overrides.messages || convo.current,
371
+ });
372
+ if (saved?.name) sessionNameRef.current = saved.name;
373
+ if (saved?.createdAt) sessionCreatedAtRef.current = saved.createdAt;
374
+ return saved;
375
+ }, [model]);
376
+
377
+ useEffect(() => {
378
+ if (agentName) {
379
+ setTimeout(() => pushNotice(`${glyphs.bolt} Agent: ${agentName} — delegating to named agent.`, C.accent, true), 200);
380
+ }
381
+ if (resumeMode) {
382
+ if (initialSession?.id) {
383
+ setTimeout(() => pushNotice(`${glyphs.arrow} Resumed session: ${initialSession.name || sessionAutoName(initialMessages)}`, C.muted, true), 200);
384
+ } else {
385
+ setTimeout(() => pushNotice(`${glyphs.arrow} No saved session found to continue.`, C.muted, true), 200);
386
+ }
387
+ }
388
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
389
+
214
390
  const queueSubmission = useCallback((text) => {
215
391
  const q = String(text || '').trim();
216
392
  if (!q) { pushNotice('Nothing to queue.', C.muted, true); return; }
@@ -304,6 +480,64 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
304
480
  push({ type: 'shell', command, output, code: res.status ?? 0 });
305
481
  }, [log, push, pushNotice, shellMode]);
306
482
 
483
+ const loadSessionIntoApp = useCallback((session, notice = 'Session loaded.') => {
484
+ if (!session?.id) return;
485
+ if (session.cwd && existsSync(session.cwd)) {
486
+ try { process.chdir(session.cwd); } catch {}
487
+ }
488
+ branchRef.current = session.branch || gitBranch(process.cwd());
489
+ setTrusted(isTrustedDir(cfg, process.cwd()));
490
+ sessionIdRef.current = session.id;
491
+ sessionCreatedAtRef.current = session.createdAt || new Date().toISOString();
492
+ sessionNameRef.current = session.name || sessionAutoName(session.messages);
493
+ convo.current = session.messages || [];
494
+ lastAnswerRef.current = [...convo.current].reverse().find((m) => m.role === 'assistant')?.content || '';
495
+ modelRef.current = session.model || modelRef.current;
496
+ setModel(session.model || modelRef.current);
497
+ setModeState('ask');
498
+ setSessionPicker(null);
499
+ setDiffState(null);
500
+ replaceTimeline(convo.current);
501
+ setComposerClearToken((n) => n + 1);
502
+ pushNotice(notice, C.green, true);
503
+ saveCurrentSession();
504
+ }, [cfg, pushNotice, replaceTimeline, saveCurrentSession, setModeState]);
505
+
506
+ const openSessionPicker = useCallback(() => {
507
+ const sort = sessionPicker?.sort || 'relevance';
508
+ const sessions = sortSessions(listSessionsDetailed(), sort, process.cwd());
509
+ if (!sessions.length) {
510
+ pushNotice('No saved sessions yet.', C.muted, true);
511
+ return;
512
+ }
513
+ setSessionPicker({ sort, sel: 0, sessions });
514
+ }, [pushNotice, sessionPicker?.sort]);
515
+
516
+ const loadDiffState = useCallback((overrides = {}) => {
517
+ const staged = overrides.staged ?? diffState?.staged ?? false;
518
+ const whitespace = overrides.whitespace ?? diffState?.whitespace ?? false;
519
+ const args = ['diff'];
520
+ if (staged) args.push('--cached');
521
+ if (whitespace) args.push('--ignore-all-space');
522
+ const res = spawnSync('git', args, { cwd: process.cwd(), encoding: 'utf8', maxBuffer: 1024 * 1024 });
523
+ const raw = String(res.stdout || res.stderr || '');
524
+ const lines = parseDiff(raw);
525
+ if (!lines.length) {
526
+ pushNotice('No diff to show.', C.muted, true);
527
+ setDiffState(null);
528
+ return;
529
+ }
530
+ setDiffState((prev) => ({
531
+ lines,
532
+ raw,
533
+ sel: Math.min(prev?.sel || 0, Math.max(0, lines.length - 1)),
534
+ staged,
535
+ whitespace,
536
+ comments: prev?.comments || {},
537
+ commentInput: null,
538
+ }));
539
+ }, [diffState?.staged, diffState?.whitespace, pushNotice]);
540
+
307
541
  const runTurn = useCallback(async (question, opts = {}) => {
308
542
  const { depth = 0, archive = null, label = '', synthetic = false } = opts;
309
543
  activeTurns.current += 1;
@@ -312,6 +546,18 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
312
546
  log(`\n${glyphs.prompt} ${question}\n`);
313
547
  }
314
548
  convo.current.push({ role: 'user', content: question });
549
+ if (!sessionNameRef.current || sessionNameRef.current === 'Untitled session') {
550
+ sessionNameRef.current = sessionAutoName(convo.current);
551
+ }
552
+
553
+ // Auto-compact at ~95% of context budget (approx 100k token limit)
554
+ if (approxTokens(convo.current) > TOKEN_LIMIT * 0.95) {
555
+ pushNotice(`${glyphs.arrow} Context at 95% — auto-compacting history…`, C.warning, true);
556
+ const snapshot = convo.current;
557
+ convo.current = [];
558
+ convo.current.push({ role: 'user', content: 'Summarise our conversation so far in a concise way, preserving key facts and decisions.' });
559
+ }
560
+
315
561
  setBusy(true);
316
562
  setHint('');
317
563
  setChips([]);
@@ -333,7 +579,11 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
333
579
 
334
580
  try {
335
581
  const preamble = memoryPreamble(cfg);
336
- const outbound = preamble ? [preamble, ...convo.current] : [...convo.current];
582
+ const request = modeRef.current === 'plan' && !synthetic
583
+ ? `[PLAN MODE] Before implementing, ask 2-3 clarifying questions about this request, then wait for answers before creating any implementation plan. Request: ${question}`
584
+ : question;
585
+ const outboundBase = [...convo.current.slice(0, -1), { role: 'user', content: request }];
586
+ const outbound = preamble ? [preamble, ...outboundBase] : outboundBase;
337
587
  const { reply, text, meta } = await streamChat(cfg, outbound, {
338
588
  signal: abort.signal,
339
589
  context,
@@ -368,8 +618,10 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
368
618
  push({ type: 'assistant', text: text || '(no response)' });
369
619
  log(text || '');
370
620
  }
371
- if (text) convo.current.push({ role: 'assistant', content: text });
372
- lastAnswerRef.current = text || reply.summary || reply.question || lastAnswerRef.current;
621
+ const assistantContent = text || reply.summary || reply.question || reply.note || '';
622
+ if (assistantContent) convo.current.push({ role: 'assistant', content: assistantContent });
623
+ lastAnswerRef.current = assistantContent || lastAnswerRef.current;
624
+ saveCurrentSession();
373
625
 
374
626
  if (Array.isArray(reply.suggestions) && reply.suggestions.length) {
375
627
  setChips(reply.suggestions.slice(0, 4));
@@ -425,7 +677,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
425
677
  activeTurns.current = Math.max(0, activeTurns.current - 1);
426
678
  maybeProcessQueue();
427
679
  }
428
- }, [allowAll, cfg, log, maybeProcessQueue, push, pushNotice]);
680
+ }, [allowAll, cfg, log, maybeProcessQueue, push, pushNotice, saveCurrentSession]);
429
681
 
430
682
  const showExpanded = useCallback((kind, sourceItems) => {
431
683
  if (!sourceItems.length) {
@@ -454,6 +706,9 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
454
706
  if (q === '/exit' || q === 'exit' || q === 'quit') { doExit(); return; }
455
707
  if (q === '/clear') { clearAll(); return; }
456
708
  if (q === '/new') {
709
+ sessionIdRef.current = Date.now().toString(36);
710
+ sessionCreatedAtRef.current = new Date().toISOString();
711
+ sessionNameRef.current = 'Untitled session';
457
712
  convo.current = [];
458
713
  clearAll();
459
714
  pushNotice(`${glyphs.check} New conversation started.`, C.green);
@@ -480,11 +735,24 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
480
735
  pushNotice(`Mode: ${next} ${dash()} Shift+Tab to cycle.`, C.accent);
481
736
  return;
482
737
  }
483
- if (q === '/context' || q === '/usage') {
738
+ if (q === '/context') {
484
739
  const tokens = approxTokens(convo.current);
485
740
  pushNotice(`Context: ~${tokens.toLocaleString()} tokens (${convo.current.length} messages). /compact to summarise.`);
486
741
  return;
487
742
  }
743
+ if (q === '/usage') {
744
+ const messages = convo.current.length;
745
+ const tokens = approxTokens(convo.current);
746
+ const durationMs = Date.now() - new Date(sessionCreatedAtRef.current).getTime();
747
+ pushNotice([
748
+ 'Usage ─────────────────────────────────',
749
+ ` Messages ${usageBar(messages, 20)} ${messages} msgs`,
750
+ ` Tokens ${usageBar(tokens, TOKEN_LIMIT)} ${tokens >= 1000 ? `~${(tokens / 1000).toFixed(1)}k` : `~${tokens}`}`,
751
+ ` Session ${formatDuration(durationMs)}`,
752
+ ` Model ${model || modelRef.current || 'server default'}`,
753
+ ].join('\n'));
754
+ return;
755
+ }
488
756
  if (q === '/compact' || q.startsWith('/compact ')) {
489
757
  const focus = q.slice('/compact'.length).trim();
490
758
  const prompt = focus
@@ -503,9 +771,10 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
503
771
  const tokens = approxTokens(convo.current);
504
772
  const branch = branchRef.current;
505
773
  const cwd = shortenPath(process.cwd());
506
- const name = cfg?.tui?.sessionNames?.[process.cwd()] || '(unnamed)';
774
+ const name = sessionNameRef.current || sessionAutoName(convo.current);
507
775
  pushNotice([
508
776
  `Session ${dash()} ${name}`,
777
+ `ID ${dash()} ${sessionIdRef.current}`,
509
778
  `Path ${dash()} ${cwd}${branch ? ' [' + branch + ']' : ''}`,
510
779
  `Messages ${dash()} ${convo.current.length}`,
511
780
  `Tokens ${dash()} ~${tokens.toLocaleString()} (approx)`,
@@ -514,15 +783,18 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
514
783
  ].join('\n'));
515
784
  return;
516
785
  }
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'));
786
+ if (q === '/resume' || q === '/continue') {
787
+ openSessionPicker();
788
+ return;
789
+ }
790
+ if (q.startsWith('/resume ') || q.startsWith('/continue ')) {
791
+ const id = q.split(/\s+/).slice(1).join(' ').trim();
792
+ const session = loadSessionById(id);
793
+ if (!session) {
794
+ pushNotice(`Session not found: ${id}`, C.red);
795
+ return;
796
+ }
797
+ loadSessionIntoApp(session, `Session loaded: ${session.name || id}`);
526
798
  return;
527
799
  }
528
800
  if (q === '/share') {
@@ -550,6 +822,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
550
822
  setModel(name);
551
823
  cfg._model = name;
552
824
  persistCfg();
825
+ saveCurrentSession();
553
826
  pushNotice(`${glyphs.check} Model set to ${name}. It applies to your next message.`, C.green);
554
827
  }
555
828
  return;
@@ -569,6 +842,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
569
842
  const ok = isTrustedDir(cfg, target);
570
843
  setTrusted(ok);
571
844
  if (!ok) openTrustDialog(target);
845
+ saveCurrentSession();
572
846
  pushNotice(`Changed directory to ${shortenPath(target)}`, C.green, true);
573
847
  } catch (e) {
574
848
  pushNotice(`Could not change directory: ${e.message}`, C.red);
@@ -587,10 +861,9 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
587
861
  return;
588
862
  }
589
863
  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();
864
+ const name = q.slice('/rename'.length).trim() || sessionAutoName(convo.current);
865
+ sessionNameRef.current = name;
866
+ saveCurrentSession({ name });
594
867
  pushNotice(`${glyphs.check} Session renamed to ${name}.`, C.green);
595
868
  return;
596
869
  }
@@ -636,10 +909,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
636
909
  return;
637
910
  }
638
911
  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
- });
912
+ loadDiffState({ staged: false, whitespace: false });
643
913
  return;
644
914
  }
645
915
  if (q === '/review' || q.startsWith('/review ')) {
@@ -653,9 +923,35 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
653
923
  let group = null;
654
924
  const lines = SLASH_COMMANDS.map((c) => {
655
925
  const head = c.group !== group ? ((group = c.group), `\n ${c.group}\n`) : '';
656
- return head + ` ${(c.cmd + (c.hint ? ' ' + c.hint : '')).padEnd(22)}${c.desc}`;
926
+ return head + ` ${(c.cmd + (c.hint ? ' ' + c.hint : '')).padEnd(30)}${c.desc}`;
657
927
  }).join('\n');
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');
928
+ pushNotice(
929
+ 'Commands:' + lines +
930
+ '\n\n Keyboard shortcuts:\n' +
931
+ ' Shift+Tab cycle modes: ask → plan → autopilot\n' +
932
+ ' Ctrl+T toggle reasoning display\n' +
933
+ ' Ctrl+L clear screen\n' +
934
+ ' Ctrl+R reverse history search\n' +
935
+ ' Ctrl+F timeline search\n' +
936
+ ' Ctrl+O expand recent items\n' +
937
+ ' Ctrl+E expand all items\n' +
938
+ ' Ctrl+G open $EDITOR\n' +
939
+ ' Ctrl+X e open $EDITOR (alt)\n' +
940
+ ' Ctrl+X / slash picker mid-prompt\n' +
941
+ ' Ctrl+X b promote task to background\n' +
942
+ ' Ctrl+X o open most recent link\n' +
943
+ ' Ctrl+S run and preserve input\n' +
944
+ ' Ctrl+N / Ctrl+P cycle followup chips\n' +
945
+ ' Ctrl+Enter queue message while busy\n' +
946
+ ' Ctrl+V paste attachment\n' +
947
+ ' Ctrl+Z suspend (Unix)\n' +
948
+ ' Shift+Enter insert newline\n' +
949
+ ' Alt+← / Alt+→ jump word\n' +
950
+ ' Ctrl+Home / End jump to start/end\n' +
951
+ ' Ctrl+D shutdown\n' +
952
+ ' Ctrl+C × 2 exit\n' +
953
+ ' Esc cancel / exit mode\n'
954
+ );
659
955
  return;
660
956
  }
661
957
  if (q === '/history') {
@@ -731,8 +1027,331 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
731
1027
  return;
732
1028
  }
733
1029
 
734
- let question = q;
1030
+ // ── Terminal setup ──────────────────────────────────────────────────────
1031
+ if (q === '/terminal-setup') {
1032
+ pushNotice([
1033
+ 'Terminal setup — enable Shift+Enter for multi-line input:',
1034
+ '',
1035
+ ' iTerm2 (macOS): Preferences → Keys → Add: Shift+Enter → Send escape sequence: \\n',
1036
+ ' Kitty: kitty.conf → map shift+return send_text all \\n',
1037
+ ' WezTerm: Auto-supported.',
1038
+ ' Windows Terminal: Add JSON keybinding for shiftEnter → newline.',
1039
+ ' VS Code terminal: Already supported.',
1040
+ ' Other: Try Alt+Enter (sends \\n in most terminals).',
1041
+ '',
1042
+ 'If your terminal supports bracketed paste, multi-line paste also works.',
1043
+ ].join('\n'));
1044
+ return;
1045
+ }
1046
+
1047
+ // ── Experimental ────────────────────────────────────────────────────────
1048
+ if (q === '/experimental' || q.startsWith('/experimental ')) {
1049
+ const arg = q.slice('/experimental'.length).trim().toLowerCase();
1050
+ const current = Boolean(cfg?.tui?.experimental);
1051
+ const next = arg === 'show' ? current : arg === 'on' ? true : arg === 'off' ? false : !current;
1052
+ if (arg !== 'show') {
1053
+ cfg.tui = { ...(cfg.tui || {}), experimental: next };
1054
+ persistCfg();
1055
+ }
1056
+ pushNotice(`experimental features ${dash()} ${next ? 'ON' : 'OFF'}`, next ? C.warning : C.muted, arg !== 'show');
1057
+ return;
1058
+ }
1059
+
1060
+ // ── Permissions ──────────────────────────────────────────────────────────
1061
+ if (q === '/permissions' || q.startsWith('/permissions ')) {
1062
+ const arg = q.slice('/permissions'.length).trim().toLowerCase();
1063
+ if (arg === 'reset') {
1064
+ cfg.tui = { ...(cfg.tui || {}), allowedTools: [], allowAll: false };
1065
+ persistCfg();
1066
+ setAllowAll(false);
1067
+ pushNotice(`${glyphs.check} All permissions reset.`, C.green);
1068
+ return;
1069
+ }
1070
+ const tools = cfg?.tui?.allowedTools || [];
1071
+ pushNotice([
1072
+ `Permissions ${dash()} ${shortenPath(process.cwd())}`,
1073
+ `allow-all ${dash()} ${allowAll ? 'ON' : 'off'}`,
1074
+ `tools ${dash()} ${tools.length ? tools.join(', ') : 'none pre-approved'}`,
1075
+ `dirs ${dash()} ${Object.keys(cfg?.tui?.trustedDirs || {}).map(shortenPath).join(', ') || 'none'}`,
1076
+ '',
1077
+ 'Use /reset-allowed-tools to clear, /allow-all to toggle, /add-dir PATH to add.',
1078
+ ].join('\n'));
1079
+ return;
1080
+ }
1081
+ if (q === '/reset-allowed-tools') {
1082
+ cfg.tui = { ...(cfg.tui || {}), allowedTools: [] };
1083
+ persistCfg();
1084
+ pushNotice(`${glyphs.check} In-session tool approvals cleared.`, C.green);
1085
+ return;
1086
+ }
1087
+ if (q === '/allow-tool' || q.startsWith('/allow-tool ')) {
1088
+ const tool = q.slice('/allow-tool'.length).trim();
1089
+ if (!tool) { pushNotice('Usage: /allow-tool TOOL (e.g. shell(git))'); return; }
1090
+ const tools = [...(cfg?.tui?.allowedTools || []), tool];
1091
+ cfg.tui = { ...(cfg.tui || {}), allowedTools: tools };
1092
+ persistCfg();
1093
+ pushNotice(`${glyphs.check} Pre-approved: ${tool}`, C.green);
1094
+ return;
1095
+ }
1096
+ if (q === '/sandbox' || q.startsWith('/sandbox ')) {
1097
+ const arg = q.slice('/sandbox'.length).trim().toLowerCase();
1098
+ const current = Boolean(cfg?.tui?.sandbox);
1099
+ const next = arg === 'enable' ? true : arg === 'disable' ? false : !current;
1100
+ cfg.tui = { ...(cfg.tui || {}), sandbox: next };
1101
+ persistCfg();
1102
+ pushNotice(`sandbox mode ${dash()} ${next ? 'ENABLED — restricted filesystem/network access' : 'disabled'}`, next ? C.warning : C.muted, true);
1103
+ return;
1104
+ }
1105
+ if (q === '/add-dir' || q.startsWith('/add-dir ')) {
1106
+ const p = q.slice('/add-dir'.length).trim();
1107
+ if (!p) { pushNotice('Usage: /add-dir PATH'); return; }
1108
+ const target = isAbsolute(p) ? p : resolve(process.cwd(), p);
1109
+ if (!existsSync(target)) { pushNotice(`Directory not found: ${target}`, C.red); return; }
1110
+ cfg.tui = { ...(cfg.tui || {}), trustedDirs: { ...(cfg.tui?.trustedDirs || {}), [target]: true } };
1111
+ persistCfg();
1112
+ pushNotice(`${glyphs.check} Added to allowed directories: ${shortenPath(target)}`, C.green);
1113
+ return;
1114
+ }
1115
+ if (q === '/list-dirs') {
1116
+ const dirs = Object.keys(cfg?.tui?.trustedDirs || {});
1117
+ pushNotice(dirs.length
1118
+ ? 'Allowed directories:\n' + dirs.map((d, i) => ` ${i + 1}. ${shortenPath(d)}`).join('\n')
1119
+ : 'No directories added yet. Use /add-dir PATH or trust a directory at launch.');
1120
+ return;
1121
+ }
1122
+ if (q === '/init') {
1123
+ const fp = resolve(process.cwd(), '.github/copilot-instructions.md');
1124
+ if (existsSync(fp)) {
1125
+ pushNotice(`Copilot instructions already exist at ${shortenPath(fp)}. Edit it directly.`);
1126
+ } else {
1127
+ runTurn('Create a .github/copilot-instructions.md file for this repository with sensible defaults for an ISP NOC tool.');
1128
+ }
1129
+ return;
1130
+ }
1131
+
1132
+ // ── Agents & tools ───────────────────────────────────────────────────────
1133
+ if (q === '/agent') {
1134
+ pushNotice([
1135
+ 'Built-in agents:',
1136
+ ' explore — Quick codebase analysis; questions without polluting main context',
1137
+ ' task — Runs builds, tests, linters with brief success/full failure output',
1138
+ ' general — Complex multi-step tasks with full toolset, separate context',
1139
+ ' code-review — Surfaces only genuine issues; minimal noise',
1140
+ ' research — Deep research across codebase, repos, web with citations',
1141
+ ' rubber-duck — Constructive critic; consulted automatically on non-trivial tasks',
1142
+ '',
1143
+ 'Use: /rubber-duck [prompt] · /research TOPIC · /fleet [prompt]',
1144
+ ].join('\n'));
1145
+ return;
1146
+ }
1147
+ if (q === '/skills') {
1148
+ pushNotice([
1149
+ 'Skills (ISP NOC context):',
1150
+ ' optical — Optical power analysis and ONU diagnostics (enabled)',
1151
+ ' fleet-check — Parallel multi-device scanning (enabled)',
1152
+ ' backup — Automated config backup via git (enabled)',
1153
+ ' customers — Customer lookup and ticket integration (enabled)',
1154
+ ' alarms — Real-time alarm monitoring (enabled)',
1155
+ '',
1156
+ 'Use /experimental to unlock additional skills.',
1157
+ ].join('\n'));
1158
+ return;
1159
+ }
1160
+ if (q === '/tasks') {
1161
+ const active = activeTurns.current;
1162
+ const queued = queuedRef.current ? 1 : 0;
1163
+ pushNotice([
1164
+ `Background tasks ${dash()} ${active > 0 ? `${active} running` : 'idle'}`,
1165
+ `Queued messages ${dash()} ${queued}`,
1166
+ `Shell mode ${dash()} ${shellMode ? 'active' : 'off'}`,
1167
+ '',
1168
+ 'Use Ctrl+Enter to queue a message while busy. Ctrl+X b promotes to background.',
1169
+ ].join('\n'));
1170
+ return;
1171
+ }
1172
+ if (q === '/fleet' || q.startsWith('/fleet ')) {
1173
+ const prompt = q.slice('/fleet'.length).trim();
1174
+ runTurn(prompt
1175
+ ? `Fleet mode: spawn parallel subagents to work on: ${prompt}. Report combined results.`
1176
+ : 'Fleet mode: spawn parallel subagents to check status of all devices and report combined results.');
1177
+ return;
1178
+ }
1179
+ if (q === '/rubber-duck' || q.startsWith('/rubber-duck ')) {
1180
+ const prompt = q.slice('/rubber-duck'.length).trim();
1181
+ const context = lastAnswerRef.current ? `\nContext from last answer:\n${lastAnswerRef.current.slice(0, 1000)}` : '';
1182
+ 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.')}`);
1183
+ return;
1184
+ }
1185
+ if (q === '/research' || q.startsWith('/research ')) {
1186
+ const topic = q.slice('/research'.length).trim();
1187
+ if (!topic) { pushNotice('Usage: /research TOPIC'); return; }
1188
+ runTurn(`Run deep research on: ${topic}. Search GitHub repos, docs, and known ISP NOC resources. Produce a report with citations.`);
1189
+ return;
1190
+ }
1191
+ if (q === '/mcp' || q.startsWith('/mcp ')) {
1192
+ const sub = q.slice('/mcp'.length).trim();
1193
+ pushNotice([
1194
+ `MCP servers ${dash()} ${sub || 'show'}`,
1195
+ ' GitHub MCP server is preconfigured (github.com resources: repos, issues, PRs).',
1196
+ ' Config: ~/.config/ispbills-icli/mcp-config.json',
1197
+ '',
1198
+ sub === 'show' || !sub
1199
+ ? ' No additional MCP servers configured. Use /mcp add to add one.'
1200
+ : ` Subcommand '${sub}' — MCP management UI coming soon.`,
1201
+ ].join('\n'));
1202
+ return;
1203
+ }
1204
+ if (q === '/plugin' || q.startsWith('/plugin ')) {
1205
+ pushNotice(`Plugin marketplace — coming soon. Use npm to install ispbills-icli-* plugins.`);
1206
+ return;
1207
+ }
1208
+ if (q === '/lsp' || q.startsWith('/lsp ')) {
1209
+ pushNotice(`LSP integration — configure ~/.config/ispbills-icli/lsp-config.json\nSubcommands: show, test, reload, help.`);
1210
+ return;
1211
+ }
1212
+ if (q === '/ide') {
1213
+ pushNotice(`IDE connection — connect via VS Code extension. Install "IspBills AI" from the VS Code marketplace.`);
1214
+ return;
1215
+ }
1216
+ if (q === '/pr' || q.startsWith('/pr ')) {
1217
+ const sub = q.slice('/pr'.length).trim() || 'view';
1218
+ runTurn(`GitHub PR ${sub}: check the current branch's pull request status, list any open PRs, and summarise review status.`);
1219
+ return;
1220
+ }
1221
+ if (q === '/delegate' || q.startsWith('/delegate ')) {
1222
+ const prompt = q.slice('/delegate'.length).trim();
1223
+ runTurn(prompt
1224
+ ? `Delegate the following changes to a remote repo with an AI-generated PR: ${prompt}`
1225
+ : 'Summarise what changes could be delegated to a remote PR and ask which to proceed with.');
1226
+ return;
1227
+ }
1228
+ if (q === '/instructions') {
1229
+ const paths = [
1230
+ resolve(process.cwd(), '.github/copilot-instructions.md'),
1231
+ resolve(process.cwd(), 'AGENTS.md'),
1232
+ resolve(process.cwd(), 'CLAUDE.md'),
1233
+ resolve(homedir(), '.copilot/copilot-instructions.md'),
1234
+ ];
1235
+ const found = paths.filter(existsSync).map(shortenPath);
1236
+ pushNotice(found.length
1237
+ ? 'Custom instruction files:\n' + found.map((p, i) => ` ${i + 1}. ${p}`).join('\n') + '\n\n Edit any to customise AI behaviour.'
1238
+ : 'No custom instruction files found.\n Use /init to create .github/copilot-instructions.md.');
1239
+ return;
1240
+ }
1241
+ if (q === '/remote' || q.startsWith('/remote ')) {
1242
+ pushNotice('Remote steering — allows steering this session from another device via a shared link. Coming soon.');
1243
+ return;
1244
+ }
1245
+ if (q === '/chronicle' || q.startsWith('/chronicle ')) {
1246
+ const sub = q.slice('/chronicle'.length).trim() || 'standup';
1247
+ const prompts = {
1248
+ standup: 'Generate a concise standup summary from this session: what was worked on, what was completed, and any blockers.',
1249
+ tips: 'Based on this session, what best practices or tips would improve my ISP NOC workflow?',
1250
+ improve: 'Analyse this session and suggest concrete improvements to my approach or tooling.',
1251
+ reindex: 'Reindex and categorise all actions taken in this session.',
1252
+ };
1253
+ runTurn(prompts[sub] || `Chronicle ${sub}: summarise this session focusing on ${sub}.`);
1254
+ return;
1255
+ }
1256
+ if (q === '/keep-alive' || q.startsWith('/keep-alive ')) {
1257
+ const arg = q.slice('/keep-alive'.length).trim().toLowerCase();
1258
+ pushNotice(`keep-alive ${dash()} ${arg || 'status'}. Note: keep-alive prevents machine sleep. Use /keep-alive on|off|busy.`, C.muted);
1259
+ return;
1260
+ }
1261
+ if (q === '/ask' || q.startsWith('/ask ')) {
1262
+ const question2 = q.slice('/ask'.length).trim();
1263
+ if (!question2) { pushNotice('Usage: /ask QUESTION — quick side-question without adding to history'); return; }
1264
+ pushNotice(`[ask] ${question2}`, C.muted, false);
1265
+ // Run as an isolated turn that doesn't add to main history
1266
+ const savedConvo = [...convo.current];
1267
+ convo.current = [];
1268
+ runTurn(question2).then(() => { convo.current = savedConvo; });
1269
+ return;
1270
+ }
1271
+ if (q === '/after' || q.startsWith('/after ')) {
1272
+ pushNotice('Scheduled prompts (/after, /every) are experimental. Feature coming in a future release.', C.muted);
1273
+ return;
1274
+ }
1275
+ if (q === '/every' || q.startsWith('/every ')) {
1276
+ pushNotice('Scheduled prompts (/every) are experimental. Feature coming in a future release.', C.muted);
1277
+ return;
1278
+ }
1279
+
1280
+ // ── Misc system ──────────────────────────────────────────────────────────
1281
+ if (q === '/restart') {
1282
+ pushNotice(`${glyphs.arrow} Restarting iCli…`, C.muted, true);
1283
+ setTimeout(() => { try { process.exit(0); } catch {} }, 500);
1284
+ return;
1285
+ }
1286
+ if (q === '/downgrade' || q.startsWith('/downgrade ')) {
1287
+ const ver = q.slice('/downgrade'.length).trim();
1288
+ if (!ver) { pushNotice('Usage: /downgrade VERSION (e.g. /downgrade 8.5.3)'); return; }
1289
+ pushNotice(`${glyphs.arrow} Rolling back to ispbills-icli@${ver}…`, C.warning, true);
1290
+ const res = spawnSync('npm', ['install', '-g', `ispbills-icli@${ver}`], { stdio: 'pipe', encoding: 'utf8' });
1291
+ pushNotice(res.status === 0
1292
+ ? `${glyphs.check} Downgraded to ${ver}. Restart iCli.`
1293
+ : `${glyphs.cross} Downgrade failed: ${(res.stderr || '').slice(0, 200)}`, res.status === 0 ? C.green : C.red);
1294
+ return;
1295
+ }
1296
+ if (q === '/app') {
1297
+ pushNotice('Launching GitHub Copilot desktop app… (stub — install from github.com/github/copilot-for-desktop)', C.muted);
1298
+ return;
1299
+ }
1300
+ if (q === '/user' || q.startsWith('/user ')) {
1301
+ const u = cfg.user;
1302
+ pushNotice([
1303
+ `User ${dash()} ${u?.name || u?.email || 'unknown'}`,
1304
+ `Email ${dash()} ${u?.email || 'n/a'}`,
1305
+ `Role ${dash()} ${u?.role || 'operator'}`,
1306
+ `URL ${dash()} ${cfg.url || 'n/a'}`,
1307
+ ].join('\n'));
1308
+ return;
1309
+ }
1310
+ if (q === '/login') {
1311
+ pushNotice('Run `icli login` from a new terminal to re-authenticate. /logout then restart to force re-login.', C.muted);
1312
+ return;
1313
+ }
1314
+ if (q === '/whoami') {
1315
+ const u = cfg.user;
1316
+ pushNotice([
1317
+ `${u?.name || u?.email || 'unknown'} ${dash()} ${u?.role || 'operator'}`,
1318
+ u?.email ? `email ${dash()} ${u.email}` : '',
1319
+ `url ${dash()} ${cfg.url || 'n/a'}`,
1320
+ ].filter(Boolean).join('\n'));
1321
+ return;
1322
+ }
1323
+ if (q === '/clikit' || q.startsWith('/clikit ')) {
1324
+ pushNotice('clikit component preview — UI component library. Coming soon.', C.muted);
1325
+ return;
1326
+ }
1327
+ if (q === '/extensions' || q.startsWith('/extensions ')) {
1328
+ pushNotice('Extensions — manage CLI extensions at .github/extensions/. Coming soon.', C.muted);
1329
+ return;
1330
+ }
1331
+ if (q === '/reset') {
1332
+ sessionIdRef.current = Date.now().toString(36);
1333
+ sessionCreatedAtRef.current = new Date().toISOString();
1334
+ sessionNameRef.current = 'Untitled session';
1335
+ convo.current = [];
1336
+ clearAll();
1337
+ pushNotice(`${glyphs.check} Conversation reset.`, C.green);
1338
+ return;
1339
+ }
1340
+ if (q === '/models' || q.startsWith('/models ')) {
1341
+ const name = q.slice('/models'.length).trim();
1342
+ if (!name) {
1343
+ pushNotice(`Current model: ${model || modelRef.current || 'server default'}.\nUse /model <name> or /models <name> to set.`);
1344
+ } else {
1345
+ modelRef.current = name;
1346
+ setModel(name);
1347
+ cfg._model = name;
1348
+ persistCfg();
1349
+ pushNotice(`${glyphs.check} Model set to ${name}.`, C.green);
1350
+ }
1351
+ return;
1352
+ }
735
1353
  const turnOpts = {};
1354
+ let question = q;
736
1355
  if (q.startsWith('/')) {
737
1356
  const mapped = slashToQuestion(q);
738
1357
  if (mapped) {
@@ -747,7 +1366,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
747
1366
  }
748
1367
  }
749
1368
  runTurn(question, turnOpts);
750
- }, [allowAll, cfg, clearAll, doExit, doGist, modeRef, model, onExternal, openTrustDialog, persistCfg, push, pushNotice, runShellCommand, runTurn, setModeState, shellMode, streamerMode, trusted]);
1369
+ }, [allowAll, cfg, clearAll, doExit, doGist, loadDiffState, loadSessionIntoApp, model, onExternal, openSessionPicker, openTrustDialog, persistCfg, push, pushNotice, runShellCommand, runTurn, saveCurrentSession, setModeState, shellMode, streamerMode, trusted]);
751
1370
 
752
1371
  useEffect(() => {
753
1372
  dispatchSubmitRef.current = dispatchSubmit;
@@ -760,14 +1379,73 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
760
1379
  const pickChoice = useCallback((value) => {
761
1380
  const act = choiceActionRef.current;
762
1381
  choiceActionRef.current = null;
1382
+ const currentChoice = choice;
763
1383
  setChoice(null);
764
1384
  if (act) { act(value); return; }
765
- if (value == null) { pushNotice('Dismissed.', C.muted, true); return; }
1385
+ if (value == null) {
1386
+ if (/tool request|approval|shell:/i.test(`${currentChoice?.title || ''} ${currentChoice?.note || ''}`)) {
1387
+ setChoice({
1388
+ title: 'Tell Copilot what to do differently:',
1389
+ note: '',
1390
+ sel: 0,
1391
+ text: '',
1392
+ focusText: true,
1393
+ allowText: true,
1394
+ options: [],
1395
+ });
1396
+ return;
1397
+ }
1398
+ pushNotice('Dismissed.', C.muted, true);
1399
+ return;
1400
+ }
766
1401
  runTurn(String(value));
767
- }, [pushNotice, runTurn]);
1402
+ }, [choice, pushNotice, runTurn]);
1403
+
1404
+ useInput((input, key) => {
1405
+ if (!sessionPicker) return;
1406
+ if (key.escape || (key.ctrl && input === 'c')) { setSessionPicker(null); return; }
1407
+ if (key.upArrow || input === 'k') {
1408
+ setSessionPicker((p) => ({ ...p, sel: Math.max(0, p.sel - 1) }));
1409
+ return;
1410
+ }
1411
+ if (key.downArrow || input === 'j') {
1412
+ setSessionPicker((p) => ({ ...p, sel: Math.min(Math.max(0, p.sessions.length - 1), p.sel + 1) }));
1413
+ return;
1414
+ }
1415
+ if (input === 's') {
1416
+ setSessionPicker((p) => {
1417
+ const nextSort = SESSION_SORTS[(SESSION_SORTS.indexOf(p.sort) + 1) % SESSION_SORTS.length];
1418
+ return {
1419
+ ...p,
1420
+ sort: nextSort,
1421
+ sel: 0,
1422
+ sessions: sortSessions(listSessionsDetailed(), nextSort, process.cwd()),
1423
+ };
1424
+ });
1425
+ return;
1426
+ }
1427
+ if (input === 'd') {
1428
+ const current = sessionPicker.sessions[sessionPicker.sel];
1429
+ if (!current) return;
1430
+ const deleted = deleteSession(current.id);
1431
+ const sessions = sortSessions(listSessionsDetailed(), sessionPicker.sort, process.cwd());
1432
+ setSessionPicker(sessions.length ? {
1433
+ ...sessionPicker,
1434
+ sessions,
1435
+ sel: Math.min(sessionPicker.sel, Math.max(0, sessions.length - 1)),
1436
+ } : null);
1437
+ pushNotice(deleted ? `Deleted session: ${current.name || current.id}` : `Could not delete session: ${current.id}`, deleted ? C.green : C.red, true);
1438
+ return;
1439
+ }
1440
+ if (key.return) {
1441
+ const current = sessionPicker.sessions[sessionPicker.sel];
1442
+ if (current) loadSessionIntoApp(loadSessionById(current.id) || current, `Session loaded: ${current.name || current.id}`);
1443
+ }
1444
+ });
768
1445
 
769
1446
  useInput((input, key) => {
770
1447
  if (!choice) return;
1448
+ if (sessionPicker || diffState) return;
771
1449
  if (key.escape || (key.ctrl && input === 'c')) { setChoice(null); return; }
772
1450
  if (key.tab && choice.allowText) { setChoice((c) => ({ ...c, focusText: !c.focusText })); return; }
773
1451
  if (key.upArrow) { setChoice((c) => ({ ...c, focusText: false, sel: Math.max(0, c.sel - 1) })); return; }
@@ -793,6 +1471,96 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
793
1471
  }
794
1472
  });
795
1473
 
1474
+ useInput((input, key) => {
1475
+ if (!diffState) return;
1476
+ if (choice || search || sessionPicker) return;
1477
+ const commenting = diffState.commentInput;
1478
+ if (commenting) {
1479
+ if (key.escape) {
1480
+ setDiffState((d) => ({ ...d, commentInput: null }));
1481
+ return;
1482
+ }
1483
+ if (key.return) {
1484
+ const text = String(commenting.text || '').trim();
1485
+ if (!text) {
1486
+ setDiffState((d) => ({ ...d, commentInput: null }));
1487
+ return;
1488
+ }
1489
+ setDiffState((d) => ({
1490
+ ...d,
1491
+ comments: { ...(d.comments || {}), [commenting.lineIndex]: { ...commenting, text } },
1492
+ commentInput: null,
1493
+ }));
1494
+ pushNotice('Diff comment saved.', C.green, true);
1495
+ return;
1496
+ }
1497
+ if (key.backspace || key.delete) {
1498
+ setDiffState((d) => ({ ...d, commentInput: { ...d.commentInput, text: d.commentInput.text.slice(0, -1) } }));
1499
+ return;
1500
+ }
1501
+ if (input && !key.ctrl && !key.meta) {
1502
+ const printable = input.replace(/[\x00-\x1f\x7f]/g, '');
1503
+ if (printable) setDiffState((d) => ({ ...d, commentInput: { ...d.commentInput, text: d.commentInput.text + printable } }));
1504
+ }
1505
+ return;
1506
+ }
1507
+
1508
+ const page = Math.max(4, rows - 12);
1509
+ const fileIndexes = diffState.lines.map((line, idx) => line.type === 'header' && /^diff --git /.test(line.content) ? idx : -1).filter((idx) => idx >= 0);
1510
+ const moveFile = (dir) => {
1511
+ const current = fileIndexes.findIndex((idx) => idx >= diffState.sel);
1512
+ const nextIndex = dir > 0
1513
+ ? fileIndexes[Math.min(fileIndexes.length - 1, Math.max(0, current + 1))]
1514
+ : fileIndexes[Math.max(0, (current <= 0 ? fileIndexes.length : current) - 1)];
1515
+ if (typeof nextIndex === 'number') setDiffState((d) => ({ ...d, sel: nextIndex }));
1516
+ };
1517
+
1518
+ if (key.escape || (key.ctrl && input === 'c')) { setDiffState(null); return; }
1519
+ if (input === 'j' || key.downArrow) { setDiffState((d) => ({ ...d, sel: Math.min(d.lines.length - 1, d.sel + 1) })); return; }
1520
+ if (input === 'k' || key.upArrow) { setDiffState((d) => ({ ...d, sel: Math.max(0, d.sel - 1) })); return; }
1521
+ if (input === 'g' || key.home) { setDiffState((d) => ({ ...d, sel: 0 })); return; }
1522
+ if (input === 'G' || key.end) { setDiffState((d) => ({ ...d, sel: Math.max(0, d.lines.length - 1) })); return; }
1523
+ if (key.pageDown) { setDiffState((d) => ({ ...d, sel: Math.min(d.lines.length - 1, d.sel + page) })); return; }
1524
+ if (key.pageUp) { setDiffState((d) => ({ ...d, sel: Math.max(0, d.sel - page) })); return; }
1525
+ if (key.ctrl && input === 'd') { setDiffState((d) => ({ ...d, sel: Math.min(d.lines.length - 1, d.sel + Math.max(2, Math.floor(page / 2))) })); return; }
1526
+ if (key.ctrl && input === 'u') { setDiffState((d) => ({ ...d, sel: Math.max(0, d.sel - Math.max(2, Math.floor(page / 2))) })); return; }
1527
+ if (input === 'h' || key.leftArrow) { moveFile(-1); return; }
1528
+ if (input === 'l' || key.rightArrow) { moveFile(1); return; }
1529
+ if (input === 'b') { loadDiffState({ staged: !diffState.staged, whitespace: diffState.whitespace }); return; }
1530
+ if (input === 'w') { loadDiffState({ staged: diffState.staged, whitespace: !diffState.whitespace }); return; }
1531
+ if (input === 'c') {
1532
+ const line = diffState.lines[diffState.sel];
1533
+ if (!line) return;
1534
+ setDiffState((d) => ({
1535
+ ...d,
1536
+ commentInput: {
1537
+ lineIndex: d.sel,
1538
+ file: line.file || 'diff',
1539
+ lineNumber: line.lineNumber || line.newLine || line.oldLine || 0,
1540
+ text: d.comments?.[d.sel]?.text || '',
1541
+ content: line.content,
1542
+ },
1543
+ }));
1544
+ return;
1545
+ }
1546
+ if (input === 's') {
1547
+ const comments = Object.values(diffState.comments || {});
1548
+ pushNotice(comments.length
1549
+ ? comments.map((c, i) => `${i + 1}. ${c.file}:${c.lineNumber || '?'} — ${c.text}`).join('\n')
1550
+ : 'No diff comments yet.', C.muted);
1551
+ return;
1552
+ }
1553
+ if (key.return) {
1554
+ const comments = Object.values(diffState.comments || {});
1555
+ if (!comments.length) return;
1556
+ setDiffState(null);
1557
+ runTurn([
1558
+ 'Review these git diff comments/questions and explain the relevant changes:',
1559
+ ...comments.map((c, i) => `${i + 1}. ${c.file}:${c.lineNumber || '?'} ${c.content}\nComment: ${c.text}`),
1560
+ ].join('\n\n'));
1561
+ }
1562
+ });
1563
+
796
1564
  const openSearch = useCallback(() => {
797
1565
  setSearch({ query: '', active: 0 });
798
1566
  setHint('Timeline search');
@@ -823,7 +1591,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
823
1591
  });
824
1592
 
825
1593
  useInput((input, key) => {
826
- if (choice || search) return;
1594
+ if (choice || search || sessionPicker || diffState) return;
827
1595
  if (key.ctrl && input === 'c') {
828
1596
  if (busy) { abortRef.current?.abort(); setHint(''); return; }
829
1597
  const now = Date.now();
@@ -837,6 +1605,17 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
837
1605
  if (key.escape && shellMode && !composerText.trim()) { setShellMode(false); pushNotice('Exited shell mode.', C.muted, true); return; }
838
1606
  if (key.escape && expandedItems) { setExpandedItems(null); return; }
839
1607
  if (key.escape && chips.length) { setChips([]); return; }
1608
+ if (activeTab !== 'session' && key.return) {
1609
+ setActiveTab('session');
1610
+ setHint('Session tab active');
1611
+ return;
1612
+ }
1613
+ if (activeTab !== 'session' && input === 'c') {
1614
+ setActiveTab('session');
1615
+ setComposerPreset({ token: Date.now(), text: '#' });
1616
+ pushNotice('Composer primed with an ISP reference. Continue typing after #.', C.muted, true);
1617
+ return;
1618
+ }
840
1619
  if (key.tab && key.shift && !busy) {
841
1620
  const cur = MODES.indexOf(modeRef.current);
842
1621
  const next = MODES[(cur + 1) % MODES.length];
@@ -859,6 +1638,50 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
859
1638
  }
860
1639
  });
861
1640
 
1641
+ // Tab-specific content panels (shown when not on Session tab)
1642
+ const TAB_CONTENT = {
1643
+ devices: {
1644
+ title: 'Devices',
1645
+ commands: [
1646
+ { cmd: '/status', desc: 'Fleet health summary' },
1647
+ { cmd: '/reachable', desc: 'Who is up / down' },
1648
+ { cmd: '/alarms', desc: 'Active alarms' },
1649
+ { cmd: '/check [device]', desc: 'Full device diagnostics' },
1650
+ { cmd: '/uptime [device]', desc: 'Uptime and last reboot' },
1651
+ { cmd: '/cpu [device]', desc: 'CPU / memory load' },
1652
+ { cmd: '/offline', desc: 'All offline devices' },
1653
+ { cmd: '/flapping [device]', desc: 'Flapping interfaces' },
1654
+ ],
1655
+ hint: 'Press Enter to switch back to Session and use the composer',
1656
+ },
1657
+ customers: {
1658
+ title: 'Customers',
1659
+ commands: [
1660
+ { cmd: '/customer [name]', desc: 'Look up a customer' },
1661
+ { cmd: '/online', desc: 'Online PPPoE sessions' },
1662
+ { cmd: '/pppoe [user]', desc: 'PPPoE session detail' },
1663
+ { cmd: '/dhcp', desc: 'DHCP leases & pool usage' },
1664
+ { cmd: '/onu [id]', desc: 'ONU signal and status' },
1665
+ { cmd: '/bandwidth [device]', desc: 'Traffic / bandwidth' },
1666
+ ],
1667
+ hint: 'Press Enter to switch back to Session and send commands',
1668
+ },
1669
+ alarms: {
1670
+ title: 'Alarms',
1671
+ commands: [
1672
+ { cmd: '/alarms', desc: 'Active alarms & recent faults' },
1673
+ { cmd: '/offline', desc: 'All offline devices' },
1674
+ { cmd: '/down [circuit]', desc: 'Down circuits / uplinks' },
1675
+ { cmd: '/flapping [device]', desc: 'Flapping interfaces' },
1676
+ { cmd: '/optical [device]', desc: 'Optical RX/TX (dBm)' },
1677
+ { cmd: '/reachable', desc: 'Reachability sweep' },
1678
+ ],
1679
+ hint: 'Press Enter to switch back to Session and type a command',
1680
+ },
1681
+ };
1682
+
1683
+ const tabPanel = activeTab !== 'session' ? TAB_CONTENT[activeTab] : null;
1684
+
862
1685
  const renderItem = (it) => {
863
1686
  const c = it.cols || 80;
864
1687
  switch (it.type) {
@@ -868,6 +1691,8 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
868
1691
  case 'connect': return html`<${Connect} key=${it.id} reply=${it.reply} />`;
869
1692
  case 'activity': return html`<${StatusLines} key=${it.id} lines=${it.status} busy=${false} />`;
870
1693
  case 'notice': return html`<${Notice} key=${it.id} text=${it.text} color=${it.color} />`;
1694
+ case 'info': return html`<${LabeledNotice} key=${it.id} label="ℹ Info" text=${it.text} color=${C.muted} bodyColor=${it.color || C.white} cols=${c} ts=${it.ts} />`;
1695
+ case 'error': return html`<${LabeledNotice} key=${it.id} label="✖ Error" text=${it.text} color=${C.error} bodyColor=${it.color || C.white} cols=${c} ts=${it.ts} />`;
871
1696
  case 'shell': return html`<${ShellOutput} key=${it.id} command=${it.command} output=${it.output} code=${it.code} cols=${c} ts=${it.ts} />`;
872
1697
  case 'help': return html`<${HelpOverlay} key=${it.id} cols=${c} ts=${it.ts} />`;
873
1698
  default: return null;
@@ -877,7 +1702,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
877
1702
  const cwd = shortenPath(process.cwd());
878
1703
  const branch = branchRef.current;
879
1704
  const tokenCount = approxTokens(convo.current);
880
- const tokenLabel = tokenCount > 0 ? `~${Math.max(1, Math.round(tokenCount / 1000))}k ctx` : '';
1705
+ const tokenLabel = contextUsageLabel(tokenCount);
881
1706
  const modelLabel = streamerMode ? '' : shortModel(model || modelRef.current || 'server default');
882
1707
  const footerLeft = [cwd, branch ? `${glyphs.gitBranch}${branch}` : null].filter(Boolean).join(' ');
883
1708
  const footerRight = hint
@@ -920,13 +1745,52 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
920
1745
 
921
1746
  ${search ? html`<${SearchOverlay} query=${search.query} matches=${searchMatches} active=${search.active} cols=${cols} />` : null}
922
1747
 
1748
+ ${diffState
1749
+ ? html`<${Box} flexDirection="column">
1750
+ <${DiffViewer}
1751
+ diff=${diffState.lines}
1752
+ sel=${diffState.sel}
1753
+ staged=${diffState.staged}
1754
+ whitespace=${diffState.whitespace}
1755
+ cols=${cols}
1756
+ height=${Math.max(8, rows - 14)}
1757
+ commentCount=${Object.keys(diffState.comments || {}).length}
1758
+ />
1759
+ ${diffState.commentInput
1760
+ ? html`<${Choice}
1761
+ title=${`Tell Copilot what stands out about ${diffState.commentInput.file}:${diffState.commentInput.lineNumber || '?'}`}
1762
+ note=${diffState.commentInput.content}
1763
+ sel=${0}
1764
+ allowText=${true}
1765
+ text=${diffState.commentInput.text}
1766
+ focusText=${true}
1767
+ options=${[]}
1768
+ />`
1769
+ : null}
1770
+ <//>`
1771
+ : null}
1772
+
923
1773
  <${TabBar} active=${activeTab} cols=${cols} mode=${composerMode} />
924
1774
 
1775
+ ${tabPanel && !diffState
1776
+ ? html`<${Box} flexDirection="column" paddingX=${1} paddingY=${1}>
1777
+ <${Text} color=${C.brand} bold>${tabPanel.title} — Quick Commands<//>\n
1778
+ ${tabPanel.commands.map((c, i) =>
1779
+ html`<${Box} key=${i}>
1780
+ <${Text} color=${C.slash}>${c.cmd.padEnd(24)}<//>
1781
+ <${Text} color=${C.muted}>${c.desc}<//>
1782
+ <//>`)}
1783
+ \n<${Text} color=${C.muted}>${tabPanel.hint} · Press Enter to return to Session · Press c to start a #reference<//>
1784
+ <//>`
1785
+ : null}
1786
+
925
1787
  ${!busy && chips.length ? html`<${FollowupChips} chips=${chips} active=${activeChip} />` : null}
926
1788
 
927
1789
  <${Box} flexDirection="column">
928
1790
  ${choice
929
1791
  ? html`<${Choice} ...${choice} />`
1792
+ : sessionPicker
1793
+ ? html`<${SessionPicker} sessions=${sessionPicker.sessions} sel=${sessionPicker.sel} sort=${sessionPicker.sort} cols=${cols} />`
930
1794
  : html`<${Composer}
931
1795
  onSubmit=${dispatchSubmit}
932
1796
  onSubmitKeep=${dispatchSubmit}
@@ -934,14 +1798,18 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
934
1798
  onSearch=${openSearch}
935
1799
  onNotice=${(text, color = C.muted) => pushNotice(text, color, true)}
936
1800
  onEmptyEsc=${() => { if (shellMode) setShellMode(false); else if (expandedItems) setExpandedItems(null); }}
1801
+ onActivateSession=${() => { setActiveTab('session'); setHint('Session tab active'); }}
937
1802
  onValueChange=${setComposerText}
938
1803
  onEmptySubmit=${() => {
939
1804
  if (chips.length) dispatchSubmit(chips[activeChip]);
940
1805
  }}
941
1806
  busy=${busy}
942
- blocked=${Boolean(choice || search || !trusted)}
1807
+ blocked=${Boolean(choice || search || sessionPicker || diffState || !trusted)}
943
1808
  width=${cols}
944
1809
  mode=${composerMode}
1810
+ activeTab=${activeTab}
1811
+ presetText=${composerPreset.text}
1812
+ presetToken=${composerPreset.token}
945
1813
  clearToken=${composerClearToken}
946
1814
  />`}
947
1815