ispbills-icli 8.5.5 → 8.5.7

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 = [];
@@ -28,13 +30,25 @@ function splitArgs(s) {
28
30
  return out;
29
31
  }
30
32
 
31
- function shortenPath(p) {
33
+ function shortenPath(p, maxLen = Infinity) {
34
+ let result;
32
35
  try {
33
36
  const home = homedir();
34
37
  const rel = relative(home, p);
35
- if (!rel.startsWith('..')) return '~/' + rel;
36
- } catch {}
37
- return p;
38
+ result = rel.startsWith('..') ? p : '~/' + rel;
39
+ } catch {
40
+ result = p;
41
+ }
42
+ if (maxLen === Infinity || result.length <= maxLen) return result;
43
+ // §8: progressive shortening ~/…/sub/path
44
+ const withoutHome = result.replace(/^~\//, '');
45
+ const segs = withoutHome.split('/');
46
+ while (segs.length > 1) {
47
+ segs.shift();
48
+ const candidate = '~/\u2026/' + segs.join('/');
49
+ if (candidate.length <= maxLen) return candidate;
50
+ }
51
+ return '~/\u2026/' + segs[segs.length - 1];
38
52
  }
39
53
 
40
54
  function gitBranch(cwd = process.cwd()) {
@@ -52,6 +66,126 @@ function approxTokens(messages) {
52
66
  return Math.round(total / 4);
53
67
  }
54
68
 
69
+ const TOKEN_LIMIT = 100000;
70
+ const SESSION_SORTS = ['relevance', 'last used', 'created', 'name'];
71
+
72
+ function sessionAutoName(messages = []) {
73
+ const firstUser = messages.find((m) => m?.role === 'user' && String(m.content || '').trim());
74
+ const text = String(firstUser?.content || '').replace(/\s+/g, ' ').trim();
75
+ return text ? text.slice(0, 40).trim() : 'Untitled session';
76
+ }
77
+
78
+ function renderConversationItems(messages = [], cols = 80) {
79
+ return messages
80
+ .filter((m) => m?.role === 'user' || m?.role === 'assistant')
81
+ .map((m) => ({
82
+ id: nextId(),
83
+ cols,
84
+ ts: Date.now(),
85
+ type: m.role,
86
+ text: String(m.content || ''),
87
+ }));
88
+ }
89
+
90
+ function contextUsageLabel(tokens) {
91
+ const ratio = tokens / TOKEN_LIMIT;
92
+ if (ratio >= 0.5) return `~${Math.round(ratio * 100)}% ctx`;
93
+ if (tokens <= 0) return '';
94
+ return `~${Math.max(1, Math.round(tokens / 1000))}k ctx`;
95
+ }
96
+
97
+ function usageBar(value, max, width = 16) {
98
+ const safeMax = Math.max(1, max);
99
+ const filled = Math.max(0, Math.min(width, Math.round((value / safeMax) * width)));
100
+ return `${'■'.repeat(filled)}${'░'.repeat(Math.max(0, width - filled))}`;
101
+ }
102
+
103
+ function formatDuration(ms = 0) {
104
+ const mins = Math.max(0, Math.round(ms / 60000));
105
+ if (mins < 1) return 'under a minute';
106
+ if (mins === 1) return '1 minute';
107
+ if (mins < 60) return `${mins} minutes`;
108
+ const hours = Math.floor(mins / 60);
109
+ const rem = mins % 60;
110
+ return rem ? `${hours}h ${rem}m` : `${hours}h`;
111
+ }
112
+
113
+ function sessionRelevanceScore(session, cwd) {
114
+ const here = resolve(cwd || process.cwd());
115
+ const there = resolve(session?.cwd || here);
116
+ if (there === here) return 4;
117
+ if (here.startsWith(there + '/')) return 3;
118
+ if (there.startsWith(here + '/')) return 2;
119
+ const home = homedir();
120
+ if (relative(home, there).slice(0, 2) !== '..') return 1;
121
+ return 0;
122
+ }
123
+
124
+ function sortSessions(sessions, mode, cwd) {
125
+ const list = [...sessions];
126
+ if (mode === 'created') {
127
+ return list.sort((a, b) => new Date(b.createdAt || 0).getTime() - new Date(a.createdAt || 0).getTime());
128
+ }
129
+ if (mode === 'name') {
130
+ return list.sort((a, b) => {
131
+ const an = String(a.name || '').trim();
132
+ const bn = String(b.name || '').trim();
133
+ if (!an) return 1;
134
+ if (!bn) return -1;
135
+ return an.localeCompare(bn);
136
+ });
137
+ }
138
+ if (mode === 'last used') {
139
+ return list.sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime());
140
+ }
141
+ return list.sort((a, b) => {
142
+ const score = sessionRelevanceScore(b, cwd) - sessionRelevanceScore(a, cwd);
143
+ if (score !== 0) return score;
144
+ return new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime();
145
+ });
146
+ }
147
+
148
+ function parseDiff(raw = '') {
149
+ const lines = [];
150
+ let file = '';
151
+ let oldLine = 0;
152
+ let newLine = 0;
153
+ for (const content of String(raw || '').split('\n')) {
154
+ if (content.startsWith('diff --git ')) {
155
+ file = content.replace(/^diff --git a\/(.+?) b\/.+$/, '$1');
156
+ lines.push({ type: 'header', content, file });
157
+ continue;
158
+ }
159
+ if (content.startsWith('@@')) {
160
+ const match = content.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
161
+ oldLine = Number(match?.[1] || 0);
162
+ newLine = Number(match?.[2] || 0);
163
+ lines.push({ type: 'hunk', content, file, oldLine, newLine });
164
+ continue;
165
+ }
166
+ if (content.startsWith('+++ ') || content.startsWith('--- ') || content.startsWith('index ')) {
167
+ lines.push({ type: 'header', content, file });
168
+ continue;
169
+ }
170
+ if (content.startsWith('+')) {
171
+ lines.push({ type: 'add', content, file, lineNumber: newLine });
172
+ newLine += 1;
173
+ continue;
174
+ }
175
+ if (content.startsWith('-')) {
176
+ lines.push({ type: 'del', content, file, lineNumber: oldLine });
177
+ oldLine += 1;
178
+ continue;
179
+ }
180
+ lines.push({ type: 'ctx', content, file, oldLine, newLine });
181
+ if (!content.startsWith('\\')) {
182
+ oldLine += 1;
183
+ newLine += 1;
184
+ }
185
+ }
186
+ return lines;
187
+ }
188
+
55
189
  function pkgVersion() {
56
190
  try {
57
191
  return JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version || 'unknown';
@@ -114,14 +248,24 @@ const MODES = ['ask', 'plan', 'autopilot'];
114
248
 
115
249
  export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, showBanner = true, agentName, resumeMode }) {
116
250
  const { exit } = useApp();
117
- const { cols } = useTerminalSize();
251
+ const initialResumeRef = useRef(undefined);
252
+ if (initialResumeRef.current === undefined) {
253
+ const resumed = resumeMode ? loadMostRecent() : null;
254
+ if (resumed?.cwd && existsSync(resumed.cwd)) {
255
+ try { process.chdir(resumed.cwd); } catch {}
256
+ }
257
+ initialResumeRef.current = resumed;
258
+ }
259
+ const initialSession = initialResumeRef.current;
260
+ const initialMessages = initialSession?.messages || [];
261
+ const { cols, rows } = useTerminalSize();
118
262
  const colsRef = useRef(cols);
119
263
  useEffect(() => { colsRef.current = cols; }, [cols]);
120
264
 
121
- const [items, setItems] = useState([]);
265
+ const [items, setItems] = useState(() => renderConversationItems(initialMessages, cols));
122
266
  const [live, setLive] = useState(null);
123
267
  const [busy, setBusy] = useState(false);
124
- const [model, setModel] = useState(cfg._model);
268
+ const [model, setModel] = useState(initialSession?.model || cfg._model);
125
269
  const [showReasoning, setShowReasoning] = useState(false);
126
270
  const [mode, setMode] = useState('ask');
127
271
  const [activeTab, setActiveTab] = useState('session');
@@ -133,27 +277,33 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
133
277
  const [clearEpoch, setClearEpoch] = useState(0);
134
278
  const [composerClearToken, setComposerClearToken] = useState(0);
135
279
  const [composerText, setComposerText] = useState('');
280
+ const [composerPreset, setComposerPreset] = useState({ token: 0, text: '' });
136
281
  const [search, setSearch] = useState(null);
137
282
  const [expandedItems, setExpandedItems] = useState(null);
138
283
  const [allowAll, setAllowAll] = useState(Boolean(cfg?.tui?.allowAll));
139
284
  const [streamerMode, setStreamerMode] = useState(Boolean(cfg?.tui?.streamerMode));
140
285
  const [shellMode, setShellMode] = useState(false);
141
286
  const [trusted, setTrusted] = useState(isTrustedDir(cfg, process.cwd()));
287
+ const [sessionPicker, setSessionPicker] = useState(null);
288
+ const [diffState, setDiffState] = useState(null);
142
289
 
143
- const convo = useRef([]);
290
+ const convo = useRef(initialMessages);
144
291
  const plain = useRef([]);
145
292
  const abortRef = useRef(null);
146
293
  const ctrlC = useRef(0);
147
294
  const apRef = useRef(false);
148
295
  const modeRef = useRef('ask');
149
- const modelRef = useRef(cfg._model);
150
- const lastAnswerRef = useRef('');
296
+ const modelRef = useRef(initialSession?.model || cfg._model);
297
+ const lastAnswerRef = useRef([...initialMessages].reverse().find((m) => m.role === 'assistant')?.content || '');
151
298
  const choiceActionRef = useRef(null);
152
- const branchRef = useRef(gitBranch());
299
+ const branchRef = useRef(initialSession?.branch || gitBranch(process.cwd()));
153
300
  const queuedRef = useRef(null);
154
301
  const activeTurns = useRef(0);
155
302
  const dismissTimers = useRef(new Map());
156
303
  const dispatchSubmitRef = useRef(null);
304
+ const sessionIdRef = useRef(initialSession?.id || Date.now().toString(36));
305
+ const sessionCreatedAtRef = useRef(initialSession?.createdAt || new Date().toISOString());
306
+ const sessionNameRef = useRef(initialSession?.name || sessionAutoName(initialMessages));
157
307
 
158
308
  useEffect(() => {
159
309
  try { process.stdout.write('\x1b[?2004h'); } catch {}
@@ -168,15 +318,6 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
168
318
  try { saveConfig(cfg); } catch {}
169
319
  }, [cfg]);
170
320
 
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
-
180
321
  const setModeState = useCallback((next) => {
181
322
  modeRef.current = next;
182
323
  apRef.current = next === 'autopilot';
@@ -200,14 +341,15 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
200
341
 
201
342
  useEffect(() => {
202
343
  for (const item of items) {
203
- if (item.type !== 'notice' || !item.autoDismiss || dismissTimers.current.has(item.id)) continue;
344
+ if (!['notice', 'info', 'error'].includes(item.type) || !item.autoDismiss || dismissTimers.current.has(item.id)) continue;
204
345
  const timer = setTimeout(() => removeItem(item.id), 4000);
205
346
  dismissTimers.current.set(item.id, timer);
206
347
  }
207
348
  }, [items, removeItem]);
208
349
 
209
- const pushNotice = useCallback((text, color = C.gray, autoDismiss = false) => {
210
- push({ type: 'notice', text, color, autoDismiss });
350
+ const pushNotice = useCallback((text, color = C.gray, autoDismiss = false, type = null) => {
351
+ const resolvedType = type || (color === C.red || color === C.error ? 'error' : 'info');
352
+ push({ type: resolvedType, text, color, autoDismiss });
211
353
  }, [push]);
212
354
 
213
355
  const log = (line) => plain.current.push(line);
@@ -220,6 +362,43 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
220
362
  setClearEpoch((n) => n + 1);
221
363
  }, []);
222
364
 
365
+ const replaceTimeline = useCallback((messages = []) => {
366
+ try { process.stdout.write('\x1b[2J\x1b[3J\x1b[H'); } catch {}
367
+ setItems(renderConversationItems(messages, colsRef.current));
368
+ setExpandedItems(null);
369
+ setSearch(null);
370
+ setClearEpoch((n) => n + 1);
371
+ }, []);
372
+
373
+ const saveCurrentSession = useCallback((overrides = {}) => {
374
+ const saved = saveSession({
375
+ id: sessionIdRef.current,
376
+ name: overrides.name || sessionNameRef.current || sessionAutoName(convo.current),
377
+ cwd: process.cwd(),
378
+ branch: branchRef.current,
379
+ model: modelRef.current || model || '',
380
+ createdAt: overrides.createdAt || sessionCreatedAtRef.current,
381
+ updatedAt: new Date().toISOString(),
382
+ messages: overrides.messages || convo.current,
383
+ });
384
+ if (saved?.name) sessionNameRef.current = saved.name;
385
+ if (saved?.createdAt) sessionCreatedAtRef.current = saved.createdAt;
386
+ return saved;
387
+ }, [model]);
388
+
389
+ useEffect(() => {
390
+ if (agentName) {
391
+ setTimeout(() => pushNotice(`${glyphs.bolt} Agent: ${agentName} — delegating to named agent.`, C.accent, true), 200);
392
+ }
393
+ if (resumeMode) {
394
+ if (initialSession?.id) {
395
+ setTimeout(() => pushNotice(`${glyphs.arrow} Resumed session: ${initialSession.name || sessionAutoName(initialMessages)}`, C.muted, true), 200);
396
+ } else {
397
+ setTimeout(() => pushNotice(`${glyphs.arrow} No saved session found to continue.`, C.muted, true), 200);
398
+ }
399
+ }
400
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
401
+
223
402
  const queueSubmission = useCallback((text) => {
224
403
  const q = String(text || '').trim();
225
404
  if (!q) { pushNotice('Nothing to queue.', C.muted, true); return; }
@@ -252,9 +431,9 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
252
431
  focusText: false,
253
432
  allowText: false,
254
433
  options: [
255
- { label: '1. Yes', value: 'session' },
256
- { label: '2. Yes, remember', value: 'remember' },
257
- { label: '3. No, exit', value: 'exit', danger: true },
434
+ { label: '1. Yes, proceed (this session)', value: 'session' },
435
+ { label: '2. Yes, and remember this folder', value: 'remember' },
436
+ { label: '3. No, exit (Esc)', value: 'exit', danger: true },
258
437
  ],
259
438
  });
260
439
  }, [cfg, persistCfg]);
@@ -313,6 +492,64 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
313
492
  push({ type: 'shell', command, output, code: res.status ?? 0 });
314
493
  }, [log, push, pushNotice, shellMode]);
315
494
 
495
+ const loadSessionIntoApp = useCallback((session, notice = 'Session loaded.') => {
496
+ if (!session?.id) return;
497
+ if (session.cwd && existsSync(session.cwd)) {
498
+ try { process.chdir(session.cwd); } catch {}
499
+ }
500
+ branchRef.current = session.branch || gitBranch(process.cwd());
501
+ setTrusted(isTrustedDir(cfg, process.cwd()));
502
+ sessionIdRef.current = session.id;
503
+ sessionCreatedAtRef.current = session.createdAt || new Date().toISOString();
504
+ sessionNameRef.current = session.name || sessionAutoName(session.messages);
505
+ convo.current = session.messages || [];
506
+ lastAnswerRef.current = [...convo.current].reverse().find((m) => m.role === 'assistant')?.content || '';
507
+ modelRef.current = session.model || modelRef.current;
508
+ setModel(session.model || modelRef.current);
509
+ setModeState('ask');
510
+ setSessionPicker(null);
511
+ setDiffState(null);
512
+ replaceTimeline(convo.current);
513
+ setComposerClearToken((n) => n + 1);
514
+ pushNotice(notice, C.green, true);
515
+ saveCurrentSession();
516
+ }, [cfg, pushNotice, replaceTimeline, saveCurrentSession, setModeState]);
517
+
518
+ const openSessionPicker = useCallback(() => {
519
+ const sort = sessionPicker?.sort || 'relevance';
520
+ const sessions = sortSessions(listSessionsDetailed(), sort, process.cwd());
521
+ if (!sessions.length) {
522
+ pushNotice('No saved sessions yet.', C.muted, true);
523
+ return;
524
+ }
525
+ setSessionPicker({ sort, sel: 0, sessions });
526
+ }, [pushNotice, sessionPicker?.sort]);
527
+
528
+ const loadDiffState = useCallback((overrides = {}) => {
529
+ const staged = overrides.staged ?? diffState?.staged ?? false;
530
+ const whitespace = overrides.whitespace ?? diffState?.whitespace ?? false;
531
+ const args = ['diff'];
532
+ if (staged) args.push('--cached');
533
+ if (whitespace) args.push('--ignore-all-space');
534
+ const res = spawnSync('git', args, { cwd: process.cwd(), encoding: 'utf8', maxBuffer: 1024 * 1024 });
535
+ const raw = String(res.stdout || res.stderr || '');
536
+ const lines = parseDiff(raw);
537
+ if (!lines.length) {
538
+ pushNotice('No diff to show.', C.muted, true);
539
+ setDiffState(null);
540
+ return;
541
+ }
542
+ setDiffState((prev) => ({
543
+ lines,
544
+ raw,
545
+ sel: Math.min(prev?.sel || 0, Math.max(0, lines.length - 1)),
546
+ staged,
547
+ whitespace,
548
+ comments: prev?.comments || {},
549
+ commentInput: null,
550
+ }));
551
+ }, [diffState?.staged, diffState?.whitespace, pushNotice]);
552
+
316
553
  const runTurn = useCallback(async (question, opts = {}) => {
317
554
  const { depth = 0, archive = null, label = '', synthetic = false } = opts;
318
555
  activeTurns.current += 1;
@@ -321,9 +558,11 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
321
558
  log(`\n${glyphs.prompt} ${question}\n`);
322
559
  }
323
560
  convo.current.push({ role: 'user', content: question });
561
+ if (!sessionNameRef.current || sessionNameRef.current === 'Untitled session') {
562
+ sessionNameRef.current = sessionAutoName(convo.current);
563
+ }
324
564
 
325
565
  // Auto-compact at ~95% of context budget (approx 100k token limit)
326
- const TOKEN_LIMIT = 100000;
327
566
  if (approxTokens(convo.current) > TOKEN_LIMIT * 0.95) {
328
567
  pushNotice(`${glyphs.arrow} Context at 95% — auto-compacting history…`, C.warning, true);
329
568
  const snapshot = convo.current;
@@ -352,7 +591,11 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
352
591
 
353
592
  try {
354
593
  const preamble = memoryPreamble(cfg);
355
- const outbound = preamble ? [preamble, ...convo.current] : [...convo.current];
594
+ const request = modeRef.current === 'plan' && !synthetic
595
+ ? `[PLAN MODE] Before implementing, ask 2-3 clarifying questions about this request, then wait for answers before creating any implementation plan. Request: ${question}`
596
+ : question;
597
+ const outboundBase = [...convo.current.slice(0, -1), { role: 'user', content: request }];
598
+ const outbound = preamble ? [preamble, ...outboundBase] : outboundBase;
356
599
  const { reply, text, meta } = await streamChat(cfg, outbound, {
357
600
  signal: abort.signal,
358
601
  context,
@@ -387,8 +630,10 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
387
630
  push({ type: 'assistant', text: text || '(no response)' });
388
631
  log(text || '');
389
632
  }
390
- if (text) convo.current.push({ role: 'assistant', content: text });
391
- lastAnswerRef.current = text || reply.summary || reply.question || lastAnswerRef.current;
633
+ const assistantContent = text || reply.summary || reply.question || reply.note || '';
634
+ if (assistantContent) convo.current.push({ role: 'assistant', content: assistantContent });
635
+ lastAnswerRef.current = assistantContent || lastAnswerRef.current;
636
+ saveCurrentSession();
392
637
 
393
638
  if (Array.isArray(reply.suggestions) && reply.suggestions.length) {
394
639
  setChips(reply.suggestions.slice(0, 4));
@@ -413,14 +658,49 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
413
658
  setLive(null);
414
659
  if (type === 'plan') {
415
660
  const destructive = (reply.steps || []).some((st) => /destruct|high/.test(st.risk || ''));
661
+ // §9: choiceActionRef handles autopilot hand-off
662
+ choiceActionRef.current = (value) => {
663
+ if (value === '__autopilot__') {
664
+ apRef.current = true;
665
+ setModeState('autopilot');
666
+ runTurn('apply fix');
667
+ return;
668
+ }
669
+ if (value != null) runTurn(String(value));
670
+ };
416
671
  setChoice({
417
672
  title: 'Apply this fix?',
418
673
  note: reply.summary || '',
419
674
  sel: 0, text: '', focusText: false, allowText: false,
420
675
  options: [
421
- { label: destructive ? 'Apply fix (destructive)' : 'Apply fix', value: 'apply fix', danger: destructive },
422
- { label: 'Explain the plan first', value: 'explain this plan in more detail before applying' },
423
- { label: 'Cancel', value: null },
676
+ { label: destructive ? '1. Apply fix (destructive)' : '1. Apply fix', value: 'apply fix', danger: destructive },
677
+ { label: '2. Accept plan and build on autopilot', value: '__autopilot__' },
678
+ { label: '3. Explain the plan first', value: 'explain this plan in more detail before applying' },
679
+ { label: '4. Cancel', value: null },
680
+ ],
681
+ });
682
+ } else if (type === 'tool_approval' || (reply.toolCall && (type === 'prompt' || type === 'choice'))) {
683
+ // §13: exact tool-approval format
684
+ const toolName = reply.toolCall?.name || 'shell';
685
+ const toolArgs = reply.toolCall?.args || reply.toolCall?.command || '';
686
+ const toolDesc = toolArgs ? `${toolName}: ${toolArgs}` : (reply.note || toolName);
687
+ choiceActionRef.current = (value) => {
688
+ if (value === '__session_allow__') {
689
+ cfg.allowedTools = [...(cfg.allowedTools || []), toolName];
690
+ runTurn('yes, proceed with this tool call');
691
+ return;
692
+ }
693
+ if (value != null) runTurn(String(value));
694
+ };
695
+ setChoice({
696
+ title: '⚙ Tool request',
697
+ note: toolDesc,
698
+ sel: 0, text: '', focusText: false, allowText: false,
699
+ isToolApproval: true,
700
+ options: [
701
+ { label: '1. Yes', value: 'yes, proceed with this tool call' },
702
+ { label: '2. Yes, and approve shell for the rest of the session', value: '__session_allow__' },
703
+ { label: '3. No, and tell Copilot what to do differently (Esc)', value: null },
424
704
  ],
425
705
  });
426
706
  } else if (type === 'prompt' || type === 'choice') {
@@ -444,7 +724,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
444
724
  activeTurns.current = Math.max(0, activeTurns.current - 1);
445
725
  maybeProcessQueue();
446
726
  }
447
- }, [allowAll, cfg, log, maybeProcessQueue, push, pushNotice]);
727
+ }, [allowAll, cfg, log, maybeProcessQueue, push, pushNotice, saveCurrentSession]);
448
728
 
449
729
  const showExpanded = useCallback((kind, sourceItems) => {
450
730
  if (!sourceItems.length) {
@@ -473,6 +753,9 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
473
753
  if (q === '/exit' || q === 'exit' || q === 'quit') { doExit(); return; }
474
754
  if (q === '/clear') { clearAll(); return; }
475
755
  if (q === '/new') {
756
+ sessionIdRef.current = Date.now().toString(36);
757
+ sessionCreatedAtRef.current = new Date().toISOString();
758
+ sessionNameRef.current = 'Untitled session';
476
759
  convo.current = [];
477
760
  clearAll();
478
761
  pushNotice(`${glyphs.check} New conversation started.`, C.green);
@@ -499,11 +782,24 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
499
782
  pushNotice(`Mode: ${next} ${dash()} Shift+Tab to cycle.`, C.accent);
500
783
  return;
501
784
  }
502
- if (q === '/context' || q === '/usage') {
785
+ if (q === '/context') {
503
786
  const tokens = approxTokens(convo.current);
504
787
  pushNotice(`Context: ~${tokens.toLocaleString()} tokens (${convo.current.length} messages). /compact to summarise.`);
505
788
  return;
506
789
  }
790
+ if (q === '/usage') {
791
+ const messages = convo.current.length;
792
+ const tokens = approxTokens(convo.current);
793
+ const durationMs = Date.now() - new Date(sessionCreatedAtRef.current).getTime();
794
+ pushNotice([
795
+ 'Usage ─────────────────────────────────',
796
+ ` Messages ${usageBar(messages, 20)} ${messages} msgs`,
797
+ ` Tokens ${usageBar(tokens, TOKEN_LIMIT)} ${tokens >= 1000 ? `~${(tokens / 1000).toFixed(1)}k` : `~${tokens}`}`,
798
+ ` Session ${formatDuration(durationMs)}`,
799
+ ` Model ${model || modelRef.current || 'server default'}`,
800
+ ].join('\n'));
801
+ return;
802
+ }
507
803
  if (q === '/compact' || q.startsWith('/compact ')) {
508
804
  const focus = q.slice('/compact'.length).trim();
509
805
  const prompt = focus
@@ -514,17 +810,44 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
514
810
  runTurn(prompt);
515
811
  return;
516
812
  }
517
- if (q === '/theme') {
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.');
813
+ if (q === '/theme' || q.startsWith('/theme ')) {
814
+ const arg = q.slice('/theme'.length).trim();
815
+ const themes = ['default', 'dim', 'high-contrast', 'colorblind'];
816
+ if (arg && themes.includes(arg)) {
817
+ cfg.theme = arg;
818
+ persistCfg();
819
+ pushNotice(`${glyphs.check} Theme set to "${arg}". Restart iCli to fully apply.`, C.success);
820
+ return;
821
+ }
822
+ const current = cfg.theme || 'default';
823
+ choiceActionRef.current = (value) => {
824
+ if (!value) return;
825
+ cfg.theme = value;
826
+ persistCfg();
827
+ pushNotice(`${glyphs.check} Theme set to "${value}". Restart iCli to fully apply.`, C.success);
828
+ };
829
+ setChoice({
830
+ title: 'Select colour theme',
831
+ note: `Current: ${current}`,
832
+ sel: Math.max(0, themes.indexOf(current)),
833
+ text: '', focusText: false, allowText: false,
834
+ options: [
835
+ { label: 'default', value: 'default', hint: 'Auto-detected dark or light' },
836
+ { label: 'dim', value: 'dim', hint: 'Reduced brightness, lower contrast' },
837
+ { label: 'high-contrast', value: 'high-contrast', hint: 'Maximum contrast for readability' },
838
+ { label: 'colorblind', value: 'colorblind', hint: 'Adjusted for colour-vision differences' },
839
+ ],
840
+ });
519
841
  return;
520
842
  }
521
843
  if (q === '/session') {
522
844
  const tokens = approxTokens(convo.current);
523
845
  const branch = branchRef.current;
524
846
  const cwd = shortenPath(process.cwd());
525
- const name = cfg?.tui?.sessionNames?.[process.cwd()] || '(unnamed)';
847
+ const name = sessionNameRef.current || sessionAutoName(convo.current);
526
848
  pushNotice([
527
849
  `Session ${dash()} ${name}`,
850
+ `ID ${dash()} ${sessionIdRef.current}`,
528
851
  `Path ${dash()} ${cwd}${branch ? ' [' + branch + ']' : ''}`,
529
852
  `Messages ${dash()} ${convo.current.length}`,
530
853
  `Tokens ${dash()} ~${tokens.toLocaleString()} (approx)`,
@@ -533,15 +856,18 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
533
856
  ].join('\n'));
534
857
  return;
535
858
  }
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'));
859
+ if (q === '/resume' || q === '/continue') {
860
+ openSessionPicker();
861
+ return;
862
+ }
863
+ if (q.startsWith('/resume ') || q.startsWith('/continue ')) {
864
+ const id = q.split(/\s+/).slice(1).join(' ').trim();
865
+ const session = loadSessionById(id);
866
+ if (!session) {
867
+ pushNotice(`Session not found: ${id}`, C.red);
868
+ return;
869
+ }
870
+ loadSessionIntoApp(session, `Session loaded: ${session.name || id}`);
545
871
  return;
546
872
  }
547
873
  if (q === '/share') {
@@ -569,6 +895,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
569
895
  setModel(name);
570
896
  cfg._model = name;
571
897
  persistCfg();
898
+ saveCurrentSession();
572
899
  pushNotice(`${glyphs.check} Model set to ${name}. It applies to your next message.`, C.green);
573
900
  }
574
901
  return;
@@ -588,6 +915,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
588
915
  const ok = isTrustedDir(cfg, target);
589
916
  setTrusted(ok);
590
917
  if (!ok) openTrustDialog(target);
918
+ saveCurrentSession();
591
919
  pushNotice(`Changed directory to ${shortenPath(target)}`, C.green, true);
592
920
  } catch (e) {
593
921
  pushNotice(`Could not change directory: ${e.message}`, C.red);
@@ -606,10 +934,9 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
606
934
  return;
607
935
  }
608
936
  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();
937
+ const name = q.slice('/rename'.length).trim() || sessionAutoName(convo.current);
938
+ sessionNameRef.current = name;
939
+ saveCurrentSession({ name });
613
940
  pushNotice(`${glyphs.check} Session renamed to ${name}.`, C.green);
614
941
  return;
615
942
  }
@@ -655,10 +982,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
655
982
  return;
656
983
  }
657
984
  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
- });
985
+ loadDiffState({ staged: false, whitespace: false });
662
986
  return;
663
987
  }
664
988
  if (q === '/review' || q.startsWith('/review ')) {
@@ -1078,6 +1402,9 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1078
1402
  return;
1079
1403
  }
1080
1404
  if (q === '/reset') {
1405
+ sessionIdRef.current = Date.now().toString(36);
1406
+ sessionCreatedAtRef.current = new Date().toISOString();
1407
+ sessionNameRef.current = 'Untitled session';
1081
1408
  convo.current = [];
1082
1409
  clearAll();
1083
1410
  pushNotice(`${glyphs.check} Conversation reset.`, C.green);
@@ -1096,19 +1423,6 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1096
1423
  }
1097
1424
  return;
1098
1425
  }
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
1426
  const turnOpts = {};
1113
1427
  let question = q;
1114
1428
  if (q.startsWith('/')) {
@@ -1125,7 +1439,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1125
1439
  }
1126
1440
  }
1127
1441
  runTurn(question, turnOpts);
1128
- }, [allowAll, cfg, clearAll, doExit, doGist, modeRef, model, onExternal, openTrustDialog, persistCfg, push, pushNotice, runShellCommand, runTurn, setModeState, shellMode, streamerMode, trusted]);
1442
+ }, [allowAll, cfg, clearAll, doExit, doGist, loadDiffState, loadSessionIntoApp, model, onExternal, openSessionPicker, openTrustDialog, persistCfg, push, pushNotice, runShellCommand, runTurn, saveCurrentSession, setModeState, shellMode, streamerMode, trusted]);
1129
1443
 
1130
1444
  useEffect(() => {
1131
1445
  dispatchSubmitRef.current = dispatchSubmit;
@@ -1138,14 +1452,73 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1138
1452
  const pickChoice = useCallback((value) => {
1139
1453
  const act = choiceActionRef.current;
1140
1454
  choiceActionRef.current = null;
1455
+ const currentChoice = choice;
1141
1456
  setChoice(null);
1142
1457
  if (act) { act(value); return; }
1143
- if (value == null) { pushNotice('Dismissed.', C.muted, true); return; }
1458
+ if (value == null) {
1459
+ if (currentChoice?.isToolApproval || /⚙ Tool request|tool request|approval|shell:/i.test(`${currentChoice?.title || ''} ${currentChoice?.note || ''}`)) {
1460
+ setChoice({
1461
+ title: 'Tell Copilot what to do differently:',
1462
+ note: '',
1463
+ sel: 0,
1464
+ text: '',
1465
+ focusText: true,
1466
+ allowText: true,
1467
+ options: [],
1468
+ });
1469
+ return;
1470
+ }
1471
+ pushNotice('Dismissed.', C.muted, true);
1472
+ return;
1473
+ }
1144
1474
  runTurn(String(value));
1145
- }, [pushNotice, runTurn]);
1475
+ }, [choice, pushNotice, runTurn]);
1476
+
1477
+ useInput((input, key) => {
1478
+ if (!sessionPicker) return;
1479
+ if (key.escape || (key.ctrl && input === 'c')) { setSessionPicker(null); return; }
1480
+ if (key.upArrow || input === 'k') {
1481
+ setSessionPicker((p) => ({ ...p, sel: Math.max(0, p.sel - 1) }));
1482
+ return;
1483
+ }
1484
+ if (key.downArrow || input === 'j') {
1485
+ setSessionPicker((p) => ({ ...p, sel: Math.min(Math.max(0, p.sessions.length - 1), p.sel + 1) }));
1486
+ return;
1487
+ }
1488
+ if (input === 's') {
1489
+ setSessionPicker((p) => {
1490
+ const nextSort = SESSION_SORTS[(SESSION_SORTS.indexOf(p.sort) + 1) % SESSION_SORTS.length];
1491
+ return {
1492
+ ...p,
1493
+ sort: nextSort,
1494
+ sel: 0,
1495
+ sessions: sortSessions(listSessionsDetailed(), nextSort, process.cwd()),
1496
+ };
1497
+ });
1498
+ return;
1499
+ }
1500
+ if (input === 'd') {
1501
+ const current = sessionPicker.sessions[sessionPicker.sel];
1502
+ if (!current) return;
1503
+ const deleted = deleteSession(current.id);
1504
+ const sessions = sortSessions(listSessionsDetailed(), sessionPicker.sort, process.cwd());
1505
+ setSessionPicker(sessions.length ? {
1506
+ ...sessionPicker,
1507
+ sessions,
1508
+ sel: Math.min(sessionPicker.sel, Math.max(0, sessions.length - 1)),
1509
+ } : null);
1510
+ pushNotice(deleted ? `Deleted session: ${current.name || current.id}` : `Could not delete session: ${current.id}`, deleted ? C.green : C.red, true);
1511
+ return;
1512
+ }
1513
+ if (key.return) {
1514
+ const current = sessionPicker.sessions[sessionPicker.sel];
1515
+ if (current) loadSessionIntoApp(loadSessionById(current.id) || current, `Session loaded: ${current.name || current.id}`);
1516
+ }
1517
+ });
1146
1518
 
1147
1519
  useInput((input, key) => {
1148
1520
  if (!choice) return;
1521
+ if (sessionPicker || diffState) return;
1149
1522
  if (key.escape || (key.ctrl && input === 'c')) { setChoice(null); return; }
1150
1523
  if (key.tab && choice.allowText) { setChoice((c) => ({ ...c, focusText: !c.focusText })); return; }
1151
1524
  if (key.upArrow) { setChoice((c) => ({ ...c, focusText: false, sel: Math.max(0, c.sel - 1) })); return; }
@@ -1171,6 +1544,96 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1171
1544
  }
1172
1545
  });
1173
1546
 
1547
+ useInput((input, key) => {
1548
+ if (!diffState) return;
1549
+ if (choice || search || sessionPicker) return;
1550
+ const commenting = diffState.commentInput;
1551
+ if (commenting) {
1552
+ if (key.escape) {
1553
+ setDiffState((d) => ({ ...d, commentInput: null }));
1554
+ return;
1555
+ }
1556
+ if (key.return) {
1557
+ const text = String(commenting.text || '').trim();
1558
+ if (!text) {
1559
+ setDiffState((d) => ({ ...d, commentInput: null }));
1560
+ return;
1561
+ }
1562
+ setDiffState((d) => ({
1563
+ ...d,
1564
+ comments: { ...(d.comments || {}), [commenting.lineIndex]: { ...commenting, text } },
1565
+ commentInput: null,
1566
+ }));
1567
+ pushNotice('Diff comment saved.', C.green, true);
1568
+ return;
1569
+ }
1570
+ if (key.backspace || key.delete) {
1571
+ setDiffState((d) => ({ ...d, commentInput: { ...d.commentInput, text: d.commentInput.text.slice(0, -1) } }));
1572
+ return;
1573
+ }
1574
+ if (input && !key.ctrl && !key.meta) {
1575
+ const printable = input.replace(/[\x00-\x1f\x7f]/g, '');
1576
+ if (printable) setDiffState((d) => ({ ...d, commentInput: { ...d.commentInput, text: d.commentInput.text + printable } }));
1577
+ }
1578
+ return;
1579
+ }
1580
+
1581
+ const page = Math.max(4, rows - 12);
1582
+ const fileIndexes = diffState.lines.map((line, idx) => line.type === 'header' && /^diff --git /.test(line.content) ? idx : -1).filter((idx) => idx >= 0);
1583
+ const moveFile = (dir) => {
1584
+ const current = fileIndexes.findIndex((idx) => idx >= diffState.sel);
1585
+ const nextIndex = dir > 0
1586
+ ? fileIndexes[Math.min(fileIndexes.length - 1, Math.max(0, current + 1))]
1587
+ : fileIndexes[Math.max(0, (current <= 0 ? fileIndexes.length : current) - 1)];
1588
+ if (typeof nextIndex === 'number') setDiffState((d) => ({ ...d, sel: nextIndex }));
1589
+ };
1590
+
1591
+ if (key.escape || (key.ctrl && input === 'c')) { setDiffState(null); return; }
1592
+ if (input === 'j' || key.downArrow) { setDiffState((d) => ({ ...d, sel: Math.min(d.lines.length - 1, d.sel + 1) })); return; }
1593
+ if (input === 'k' || key.upArrow) { setDiffState((d) => ({ ...d, sel: Math.max(0, d.sel - 1) })); return; }
1594
+ if (input === 'g' || key.home) { setDiffState((d) => ({ ...d, sel: 0 })); return; }
1595
+ if (input === 'G' || key.end) { setDiffState((d) => ({ ...d, sel: Math.max(0, d.lines.length - 1) })); return; }
1596
+ if (key.pageDown) { setDiffState((d) => ({ ...d, sel: Math.min(d.lines.length - 1, d.sel + page) })); return; }
1597
+ if (key.pageUp) { setDiffState((d) => ({ ...d, sel: Math.max(0, d.sel - page) })); return; }
1598
+ 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; }
1599
+ if (key.ctrl && input === 'u') { setDiffState((d) => ({ ...d, sel: Math.max(0, d.sel - Math.max(2, Math.floor(page / 2))) })); return; }
1600
+ if (input === 'h' || key.leftArrow) { moveFile(-1); return; }
1601
+ if (input === 'l' || key.rightArrow) { moveFile(1); return; }
1602
+ if (input === 'b') { loadDiffState({ staged: !diffState.staged, whitespace: diffState.whitespace }); return; }
1603
+ if (input === 'w') { loadDiffState({ staged: diffState.staged, whitespace: !diffState.whitespace }); return; }
1604
+ if (input === 'c') {
1605
+ const line = diffState.lines[diffState.sel];
1606
+ if (!line) return;
1607
+ setDiffState((d) => ({
1608
+ ...d,
1609
+ commentInput: {
1610
+ lineIndex: d.sel,
1611
+ file: line.file || 'diff',
1612
+ lineNumber: line.lineNumber || line.newLine || line.oldLine || 0,
1613
+ text: d.comments?.[d.sel]?.text || '',
1614
+ content: line.content,
1615
+ },
1616
+ }));
1617
+ return;
1618
+ }
1619
+ if (input === 's') {
1620
+ const comments = Object.values(diffState.comments || {});
1621
+ pushNotice(comments.length
1622
+ ? comments.map((c, i) => `${i + 1}. ${c.file}:${c.lineNumber || '?'} — ${c.text}`).join('\n')
1623
+ : 'No diff comments yet.', C.muted);
1624
+ return;
1625
+ }
1626
+ if (key.return) {
1627
+ const comments = Object.values(diffState.comments || {});
1628
+ if (!comments.length) return;
1629
+ setDiffState(null);
1630
+ runTurn([
1631
+ 'Review these git diff comments/questions and explain the relevant changes:',
1632
+ ...comments.map((c, i) => `${i + 1}. ${c.file}:${c.lineNumber || '?'} ${c.content}\nComment: ${c.text}`),
1633
+ ].join('\n\n'));
1634
+ }
1635
+ });
1636
+
1174
1637
  const openSearch = useCallback(() => {
1175
1638
  setSearch({ query: '', active: 0 });
1176
1639
  setHint('Timeline search');
@@ -1201,7 +1664,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1201
1664
  });
1202
1665
 
1203
1666
  useInput((input, key) => {
1204
- if (choice || search) return;
1667
+ if (choice || search || sessionPicker || diffState) return;
1205
1668
  if (key.ctrl && input === 'c') {
1206
1669
  if (busy) { abortRef.current?.abort(); setHint(''); return; }
1207
1670
  const now = Date.now();
@@ -1215,6 +1678,31 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1215
1678
  if (key.escape && shellMode && !composerText.trim()) { setShellMode(false); pushNotice('Exited shell mode.', C.muted, true); return; }
1216
1679
  if (key.escape && expandedItems) { setExpandedItems(null); return; }
1217
1680
  if (key.escape && chips.length) { setChips([]); return; }
1681
+ if (activeTab !== 'session' && key.return) {
1682
+ setActiveTab('session');
1683
+ setHint('Session tab active');
1684
+ return;
1685
+ }
1686
+ if (activeTab !== 'session' && input === 'c') {
1687
+ setActiveTab('session');
1688
+ setComposerPreset({ token: Date.now(), text: '#' });
1689
+ pushNotice('Composer primed with an ISP reference. Continue typing after #.', C.muted, true);
1690
+ return;
1691
+ }
1692
+ // §5: o = open in browser, / = open search for this tab
1693
+ if (activeTab !== 'session' && input === 'o') {
1694
+ const openCmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
1695
+ const base = (cfg.url || '').replace(/\/$/, '');
1696
+ const url = base ? `${base}/${activeTab}` : `https://ispbills.local/${activeTab}`;
1697
+ try { spawnSync(openCmd, [url], { detached: true, stdio: 'ignore', shell: false }); } catch {}
1698
+ pushNotice(`Opening ${url} in browser…`, C.muted, true);
1699
+ return;
1700
+ }
1701
+ if (activeTab !== 'session' && input === '/') {
1702
+ openSearch();
1703
+ pushNotice(`Search ${activeTab} tab…`, C.muted, true);
1704
+ return;
1705
+ }
1218
1706
  if (key.tab && key.shift && !busy) {
1219
1707
  const cur = MODES.indexOf(modeRef.current);
1220
1708
  const next = MODES[(cur + 1) % MODES.length];
@@ -1251,7 +1739,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1251
1739
  { cmd: '/offline', desc: 'All offline devices' },
1252
1740
  { cmd: '/flapping [device]', desc: 'Flapping interfaces' },
1253
1741
  ],
1254
- hint: 'Type a command or press Enter on Session tab to interact',
1742
+ hint: 'Press Enter to switch back to Session and use the composer',
1255
1743
  },
1256
1744
  customers: {
1257
1745
  title: 'Customers',
@@ -1263,7 +1751,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1263
1751
  { cmd: '/onu [id]', desc: 'ONU signal and status' },
1264
1752
  { cmd: '/bandwidth [device]', desc: 'Traffic / bandwidth' },
1265
1753
  ],
1266
- hint: 'Switch to Session tab (Tab) to send commands',
1754
+ hint: 'Press Enter to switch back to Session and send commands',
1267
1755
  },
1268
1756
  alarms: {
1269
1757
  title: 'Alarms',
@@ -1275,7 +1763,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1275
1763
  { cmd: '/optical [device]', desc: 'Optical RX/TX (dBm)' },
1276
1764
  { cmd: '/reachable', desc: 'Reachability sweep' },
1277
1765
  ],
1278
- hint: 'Switch to Session tab (Tab) and type a command',
1766
+ hint: 'Press Enter to switch back to Session and type a command',
1279
1767
  },
1280
1768
  };
1281
1769
 
@@ -1290,17 +1778,23 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1290
1778
  case 'connect': return html`<${Connect} key=${it.id} reply=${it.reply} />`;
1291
1779
  case 'activity': return html`<${StatusLines} key=${it.id} lines=${it.status} busy=${false} />`;
1292
1780
  case 'notice': return html`<${Notice} key=${it.id} text=${it.text} color=${it.color} />`;
1781
+ 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} />`;
1782
+ 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} />`;
1783
+ case 'system': return html`<${LabeledNotice} key=${it.id} label="⚙ System" text=${it.text} color=${C.muted} bodyColor=${it.color || C.white} cols=${c} ts=${it.ts} />`;
1293
1784
  case 'shell': return html`<${ShellOutput} key=${it.id} command=${it.command} output=${it.output} code=${it.code} cols=${c} ts=${it.ts} />`;
1294
1785
  case 'help': return html`<${HelpOverlay} key=${it.id} cols=${c} ts=${it.ts} />`;
1295
1786
  default: return null;
1296
1787
  }
1297
1788
  };
1298
1789
 
1299
- const cwd = shortenPath(process.cwd());
1300
1790
  const branch = branchRef.current;
1301
1791
  const tokenCount = approxTokens(convo.current);
1302
- const tokenLabel = tokenCount > 0 ? `~${Math.max(1, Math.round(tokenCount / 1000))}k ctx` : '';
1792
+ const tokenLabel = contextUsageLabel(tokenCount);
1303
1793
  const modelLabel = streamerMode ? '' : shortModel(model || modelRef.current || 'server default');
1794
+ // §8: progressive path shortening — reserve space for right section
1795
+ const footerRightEstLen = (modelLabel.length || 0) + (tokenLabel.length || 0) + 8;
1796
+ const cwdMaxLen = Math.max(12, cols - footerRightEstLen - (branch ? branch.length + 6 : 0) - 4);
1797
+ const cwd = shortenPath(process.cwd(), cwdMaxLen);
1304
1798
  const footerLeft = [cwd, branch ? `${glyphs.gitBranch}${branch}` : null].filter(Boolean).join(' ');
1305
1799
  const footerRight = hint
1306
1800
  ? hint
@@ -1328,7 +1822,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1328
1822
  <${StatusLines} lines=${live.status} busy=${busy} />
1329
1823
  ${live.reasoning ? html`<${Reasoning} text=${live.reasoning} show=${showReasoning} />` : null}
1330
1824
  ${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
1331
- ${live.text ? html`<${AssistantMessage} text=${live.text} cols=${cols} ts=${startedAt || Date.now()} />` : null}
1825
+ ${live.text ? html`<${AssistantMessage} text=${live.text} cols=${cols} ts=${startedAt || Date.now()} streaming=${true} />` : null}
1332
1826
  <${Thinking} label=${live.reply ? 'Planning' : live.text ? 'Responding' : 'Working'} startedAt=${startedAt} />
1333
1827
  <//>`
1334
1828
  : null}
@@ -1342,9 +1836,34 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1342
1836
 
1343
1837
  ${search ? html`<${SearchOverlay} query=${search.query} matches=${searchMatches} active=${search.active} cols=${cols} />` : null}
1344
1838
 
1839
+ ${diffState
1840
+ ? html`<${Box} flexDirection="column">
1841
+ <${DiffViewer}
1842
+ diff=${diffState.lines}
1843
+ sel=${diffState.sel}
1844
+ staged=${diffState.staged}
1845
+ whitespace=${diffState.whitespace}
1846
+ cols=${cols}
1847
+ height=${Math.max(8, rows - 14)}
1848
+ commentCount=${Object.keys(diffState.comments || {}).length}
1849
+ />
1850
+ ${diffState.commentInput
1851
+ ? html`<${Choice}
1852
+ title=${`Tell Copilot what stands out about ${diffState.commentInput.file}:${diffState.commentInput.lineNumber || '?'}`}
1853
+ note=${diffState.commentInput.content}
1854
+ sel=${0}
1855
+ allowText=${true}
1856
+ text=${diffState.commentInput.text}
1857
+ focusText=${true}
1858
+ options=${[]}
1859
+ />`
1860
+ : null}
1861
+ <//>`
1862
+ : null}
1863
+
1345
1864
  <${TabBar} active=${activeTab} cols=${cols} mode=${composerMode} />
1346
1865
 
1347
- ${tabPanel
1866
+ ${tabPanel && !diffState
1348
1867
  ? html`<${Box} flexDirection="column" paddingX=${1} paddingY=${1}>
1349
1868
  <${Text} color=${C.brand} bold>${tabPanel.title} — Quick Commands<//>\n
1350
1869
  ${tabPanel.commands.map((c, i) =>
@@ -1352,7 +1871,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1352
1871
  <${Text} color=${C.slash}>${c.cmd.padEnd(24)}<//>
1353
1872
  <${Text} color=${C.muted}>${c.desc}<//>
1354
1873
  <//>`)}
1355
- \n<${Text} color=${C.muted}>${tabPanel.hint}<//>
1874
+ \n<${Text} color=${C.muted}>${tabPanel.hint} · Press Enter to return to Session · Press c to start a #reference<//>
1356
1875
  <//>`
1357
1876
  : null}
1358
1877
 
@@ -1361,6 +1880,8 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1361
1880
  <${Box} flexDirection="column">
1362
1881
  ${choice
1363
1882
  ? html`<${Choice} ...${choice} />`
1883
+ : sessionPicker
1884
+ ? html`<${SessionPicker} sessions=${sessionPicker.sessions} sel=${sessionPicker.sel} sort=${sessionPicker.sort} cols=${cols} />`
1364
1885
  : html`<${Composer}
1365
1886
  onSubmit=${dispatchSubmit}
1366
1887
  onSubmitKeep=${dispatchSubmit}
@@ -1368,14 +1889,18 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1368
1889
  onSearch=${openSearch}
1369
1890
  onNotice=${(text, color = C.muted) => pushNotice(text, color, true)}
1370
1891
  onEmptyEsc=${() => { if (shellMode) setShellMode(false); else if (expandedItems) setExpandedItems(null); }}
1892
+ onActivateSession=${() => { setActiveTab('session'); setHint('Session tab active'); }}
1371
1893
  onValueChange=${setComposerText}
1372
1894
  onEmptySubmit=${() => {
1373
1895
  if (chips.length) dispatchSubmit(chips[activeChip]);
1374
1896
  }}
1375
1897
  busy=${busy}
1376
- blocked=${Boolean(choice || search || !trusted)}
1377
- width=${cols}
1898
+ blocked=${Boolean(choice || search || sessionPicker || diffState || !trusted)}
1899
+ width=${Math.max(60, cols)}
1378
1900
  mode=${composerMode}
1901
+ activeTab=${activeTab}
1902
+ presetText=${composerPreset.text}
1903
+ presetToken=${composerPreset.token}
1379
1904
  clearToken=${composerClearToken}
1380
1905
  />`}
1381
1906