ispbills-icli 8.5.5 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ispbills-icli",
3
- "version": "8.5.5",
3
+ "version": "8.5.6",
4
4
  "description": "iCli — IspBills AI network engineer in your terminal",
5
5
  "keywords": [
6
6
  "ispbills",
package/src/session.js CHANGED
@@ -1,9 +1,50 @@
1
- import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'fs';
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'fs';
2
2
  import { homedir } from 'os';
3
3
  import { join } from 'path';
4
4
 
5
5
  const SESSION_DIR = join(homedir(), '.config', 'ispbills-icli', 'sessions');
6
6
 
7
+ function safeJson(raw, fallback = null) {
8
+ try {
9
+ return JSON.parse(raw);
10
+ } catch {
11
+ return fallback;
12
+ }
13
+ }
14
+
15
+ function normalizeMessages(messages = []) {
16
+ return messages
17
+ .filter((m) => m && typeof m.role === 'string')
18
+ .map((m) => ({ role: m.role, content: String(m.content ?? '') }));
19
+ }
20
+
21
+ function autoNameFromMessages(messages = []) {
22
+ const firstUser = messages.find((m) => m?.role === 'user' && String(m.content || '').trim());
23
+ const text = String(firstUser?.content || '').replace(/\s+/g, ' ').trim();
24
+ if (!text) return 'Untitled session';
25
+ return text.slice(0, 40).trim();
26
+ }
27
+
28
+ function normalizeSession(session = {}) {
29
+ const now = new Date().toISOString();
30
+ const messages = normalizeMessages(session.messages);
31
+ const id = String(session.id || Date.now().toString(36));
32
+ return {
33
+ id,
34
+ name: String(session.name || autoNameFromMessages(messages) || 'Untitled session'),
35
+ cwd: String(session.cwd || process.cwd()),
36
+ branch: String(session.branch || ''),
37
+ model: String(session.model || ''),
38
+ createdAt: session.createdAt || now,
39
+ updatedAt: session.updatedAt || now,
40
+ messages,
41
+ };
42
+ }
43
+
44
+ function sessionPathFor(id) {
45
+ return join(SESSION_DIR, `${id}.json`);
46
+ }
47
+
7
48
  export function sessionDir() {
8
49
  return SESSION_DIR;
9
50
  }
@@ -12,30 +53,105 @@ export function initSessionDir() {
12
53
  if (!existsSync(SESSION_DIR)) mkdirSync(SESSION_DIR, { recursive: true });
13
54
  }
14
55
 
15
- export function newSessionPath() {
16
- const id = new Date().toISOString().replace(/[:.]/g, '-');
17
- return join(SESSION_DIR, `${id}.jsonl`);
56
+ export function saveSession(session) {
57
+ try {
58
+ initSessionDir();
59
+ const existing = loadSessionById(session?.id);
60
+ const normalized = normalizeSession({
61
+ ...existing,
62
+ ...session,
63
+ id: session?.id || existing?.id,
64
+ createdAt: session?.createdAt || existing?.createdAt,
65
+ updatedAt: new Date().toISOString(),
66
+ name: session?.name || existing?.name,
67
+ });
68
+ if (!normalized.name || normalized.name === 'Untitled session') {
69
+ normalized.name = autoNameFromMessages(normalized.messages);
70
+ }
71
+ writeFileSync(sessionPathFor(normalized.id), JSON.stringify(normalized, null, 2) + '\n');
72
+ return normalized;
73
+ } catch {
74
+ return null;
75
+ }
18
76
  }
19
77
 
20
- /** Append a single message to the JSONL session log (best-effort). */
21
- export function saveMessage(sessionPath, message) {
78
+ export function loadSessionById(id) {
79
+ if (!id) return null;
80
+ const path = sessionPathFor(id);
81
+ if (!existsSync(path)) return null;
82
+ const loaded = safeJson(readFileSync(path, 'utf8'), null);
83
+ return loaded ? normalizeSession(loaded) : null;
84
+ }
85
+
86
+ export function deleteSession(id) {
22
87
  try {
23
- appendFileSync(sessionPath, JSON.stringify({ timestamp: new Date().toISOString(), message }) + '\n');
88
+ const path = sessionPathFor(id);
89
+ if (!existsSync(path)) return false;
90
+ unlinkSync(path);
91
+ return true;
24
92
  } catch {
25
- /* non-fatal — session logging is a convenience only */
93
+ return false;
26
94
  }
27
95
  }
28
96
 
29
- export function listSessions() {
97
+ export function listSessionsDetailed() {
30
98
  if (!existsSync(SESSION_DIR)) return [];
31
- return readdirSync(SESSION_DIR).filter((f) => f.endsWith('.jsonl')).sort();
99
+ return readdirSync(SESSION_DIR)
100
+ .filter((f) => f.endsWith('.json'))
101
+ .map((file) => {
102
+ const full = join(SESSION_DIR, file);
103
+ const session = safeJson(readFileSync(full, 'utf8'), null);
104
+ if (!session) return null;
105
+ const normalized = normalizeSession(session);
106
+ const stats = statSync(full);
107
+ return {
108
+ id: normalized.id,
109
+ name: normalized.name || autoNameFromMessages(normalized.messages),
110
+ cwd: normalized.cwd,
111
+ branch: normalized.branch,
112
+ model: normalized.model,
113
+ createdAt: normalized.createdAt,
114
+ updatedAt: normalized.updatedAt,
115
+ messageCount: normalized.messages.length,
116
+ messages: normalized.messages,
117
+ mtimeMs: stats.mtimeMs,
118
+ };
119
+ })
120
+ .filter(Boolean)
121
+ .sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime());
32
122
  }
33
123
 
34
- export function loadSession(sessionPath) {
35
- if (!existsSync(sessionPath)) return [];
36
- return readFileSync(sessionPath, 'utf-8')
37
- .split('\n')
38
- .filter(Boolean)
39
- .map((line) => { try { return JSON.parse(line).message; } catch { return null; } })
40
- .filter(Boolean);
124
+ export function loadMostRecent() {
125
+ const [session] = listSessionsDetailed();
126
+ if (!session?.id) return null;
127
+ return loadSessionById(session.id);
128
+ }
129
+
130
+ export function listSessions() {
131
+ return listSessionsDetailed().map((s) => s.id);
132
+ }
133
+
134
+ export function loadSession(sessionPathOrId) {
135
+ if (!sessionPathOrId) return [];
136
+ if (sessionPathOrId.endsWith?.('.json')) {
137
+ const session = safeJson(readFileSync(sessionPathOrId, 'utf8'), null);
138
+ return normalizeSession(session || {}).messages;
139
+ }
140
+ return loadSessionById(sessionPathOrId)?.messages || [];
141
+ }
142
+
143
+ export function newSessionPath() {
144
+ initSessionDir();
145
+ return sessionPathFor(Date.now().toString(36));
146
+ }
147
+
148
+ export function saveMessage(sessionPathOrId, message) {
149
+ if (!message) return null;
150
+ const id = String(sessionPathOrId || Date.now().toString(36)).replace(/\.json$/, '');
151
+ const existing = loadSessionById(id);
152
+ return saveSession({
153
+ ...(existing || {}),
154
+ id,
155
+ messages: [...(existing?.messages || []), message],
156
+ });
41
157
  }
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';
@@ -114,14 +236,24 @@ const MODES = ['ask', 'plan', 'autopilot'];
114
236
 
115
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 {}
@@ -168,15 +306,6 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
168
306
  try { saveConfig(cfg); } catch {}
169
307
  }, [cfg]);
170
308
 
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
309
  const setModeState = useCallback((next) => {
181
310
  modeRef.current = next;
182
311
  apRef.current = next === 'autopilot';
@@ -200,14 +329,15 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
200
329
 
201
330
  useEffect(() => {
202
331
  for (const item of items) {
203
- 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;
204
333
  const timer = setTimeout(() => removeItem(item.id), 4000);
205
334
  dismissTimers.current.set(item.id, timer);
206
335
  }
207
336
  }, [items, removeItem]);
208
337
 
209
- const pushNotice = useCallback((text, color = C.gray, autoDismiss = false) => {
210
- 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 });
211
341
  }, [push]);
212
342
 
213
343
  const log = (line) => plain.current.push(line);
@@ -220,6 +350,43 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
220
350
  setClearEpoch((n) => n + 1);
221
351
  }, []);
222
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
+
223
390
  const queueSubmission = useCallback((text) => {
224
391
  const q = String(text || '').trim();
225
392
  if (!q) { pushNotice('Nothing to queue.', C.muted, true); return; }
@@ -313,6 +480,64 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
313
480
  push({ type: 'shell', command, output, code: res.status ?? 0 });
314
481
  }, [log, push, pushNotice, shellMode]);
315
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
+
316
541
  const runTurn = useCallback(async (question, opts = {}) => {
317
542
  const { depth = 0, archive = null, label = '', synthetic = false } = opts;
318
543
  activeTurns.current += 1;
@@ -321,9 +546,11 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
321
546
  log(`\n${glyphs.prompt} ${question}\n`);
322
547
  }
323
548
  convo.current.push({ role: 'user', content: question });
549
+ if (!sessionNameRef.current || sessionNameRef.current === 'Untitled session') {
550
+ sessionNameRef.current = sessionAutoName(convo.current);
551
+ }
324
552
 
325
553
  // Auto-compact at ~95% of context budget (approx 100k token limit)
326
- const TOKEN_LIMIT = 100000;
327
554
  if (approxTokens(convo.current) > TOKEN_LIMIT * 0.95) {
328
555
  pushNotice(`${glyphs.arrow} Context at 95% — auto-compacting history…`, C.warning, true);
329
556
  const snapshot = convo.current;
@@ -352,7 +579,11 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
352
579
 
353
580
  try {
354
581
  const preamble = memoryPreamble(cfg);
355
- 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;
356
587
  const { reply, text, meta } = await streamChat(cfg, outbound, {
357
588
  signal: abort.signal,
358
589
  context,
@@ -387,8 +618,10 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
387
618
  push({ type: 'assistant', text: text || '(no response)' });
388
619
  log(text || '');
389
620
  }
390
- if (text) convo.current.push({ role: 'assistant', content: text });
391
- 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();
392
625
 
393
626
  if (Array.isArray(reply.suggestions) && reply.suggestions.length) {
394
627
  setChips(reply.suggestions.slice(0, 4));
@@ -444,7 +677,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
444
677
  activeTurns.current = Math.max(0, activeTurns.current - 1);
445
678
  maybeProcessQueue();
446
679
  }
447
- }, [allowAll, cfg, log, maybeProcessQueue, push, pushNotice]);
680
+ }, [allowAll, cfg, log, maybeProcessQueue, push, pushNotice, saveCurrentSession]);
448
681
 
449
682
  const showExpanded = useCallback((kind, sourceItems) => {
450
683
  if (!sourceItems.length) {
@@ -473,6 +706,9 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
473
706
  if (q === '/exit' || q === 'exit' || q === 'quit') { doExit(); return; }
474
707
  if (q === '/clear') { clearAll(); return; }
475
708
  if (q === '/new') {
709
+ sessionIdRef.current = Date.now().toString(36);
710
+ sessionCreatedAtRef.current = new Date().toISOString();
711
+ sessionNameRef.current = 'Untitled session';
476
712
  convo.current = [];
477
713
  clearAll();
478
714
  pushNotice(`${glyphs.check} New conversation started.`, C.green);
@@ -499,11 +735,24 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
499
735
  pushNotice(`Mode: ${next} ${dash()} Shift+Tab to cycle.`, C.accent);
500
736
  return;
501
737
  }
502
- if (q === '/context' || q === '/usage') {
738
+ if (q === '/context') {
503
739
  const tokens = approxTokens(convo.current);
504
740
  pushNotice(`Context: ~${tokens.toLocaleString()} tokens (${convo.current.length} messages). /compact to summarise.`);
505
741
  return;
506
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
+ }
507
756
  if (q === '/compact' || q.startsWith('/compact ')) {
508
757
  const focus = q.slice('/compact'.length).trim();
509
758
  const prompt = focus
@@ -522,9 +771,10 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
522
771
  const tokens = approxTokens(convo.current);
523
772
  const branch = branchRef.current;
524
773
  const cwd = shortenPath(process.cwd());
525
- const name = cfg?.tui?.sessionNames?.[process.cwd()] || '(unnamed)';
774
+ const name = sessionNameRef.current || sessionAutoName(convo.current);
526
775
  pushNotice([
527
776
  `Session ${dash()} ${name}`,
777
+ `ID ${dash()} ${sessionIdRef.current}`,
528
778
  `Path ${dash()} ${cwd}${branch ? ' [' + branch + ']' : ''}`,
529
779
  `Messages ${dash()} ${convo.current.length}`,
530
780
  `Tokens ${dash()} ~${tokens.toLocaleString()} (approx)`,
@@ -533,15 +783,18 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
533
783
  ].join('\n'));
534
784
  return;
535
785
  }
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'));
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}`);
545
798
  return;
546
799
  }
547
800
  if (q === '/share') {
@@ -569,6 +822,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
569
822
  setModel(name);
570
823
  cfg._model = name;
571
824
  persistCfg();
825
+ saveCurrentSession();
572
826
  pushNotice(`${glyphs.check} Model set to ${name}. It applies to your next message.`, C.green);
573
827
  }
574
828
  return;
@@ -588,6 +842,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
588
842
  const ok = isTrustedDir(cfg, target);
589
843
  setTrusted(ok);
590
844
  if (!ok) openTrustDialog(target);
845
+ saveCurrentSession();
591
846
  pushNotice(`Changed directory to ${shortenPath(target)}`, C.green, true);
592
847
  } catch (e) {
593
848
  pushNotice(`Could not change directory: ${e.message}`, C.red);
@@ -606,10 +861,9 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
606
861
  return;
607
862
  }
608
863
  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();
864
+ const name = q.slice('/rename'.length).trim() || sessionAutoName(convo.current);
865
+ sessionNameRef.current = name;
866
+ saveCurrentSession({ name });
613
867
  pushNotice(`${glyphs.check} Session renamed to ${name}.`, C.green);
614
868
  return;
615
869
  }
@@ -655,10 +909,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
655
909
  return;
656
910
  }
657
911
  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
- });
912
+ loadDiffState({ staged: false, whitespace: false });
662
913
  return;
663
914
  }
664
915
  if (q === '/review' || q.startsWith('/review ')) {
@@ -1078,6 +1329,9 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1078
1329
  return;
1079
1330
  }
1080
1331
  if (q === '/reset') {
1332
+ sessionIdRef.current = Date.now().toString(36);
1333
+ sessionCreatedAtRef.current = new Date().toISOString();
1334
+ sessionNameRef.current = 'Untitled session';
1081
1335
  convo.current = [];
1082
1336
  clearAll();
1083
1337
  pushNotice(`${glyphs.check} Conversation reset.`, C.green);
@@ -1096,19 +1350,6 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1096
1350
  }
1097
1351
  return;
1098
1352
  }
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
1353
  const turnOpts = {};
1113
1354
  let question = q;
1114
1355
  if (q.startsWith('/')) {
@@ -1125,7 +1366,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1125
1366
  }
1126
1367
  }
1127
1368
  runTurn(question, turnOpts);
1128
- }, [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]);
1129
1370
 
1130
1371
  useEffect(() => {
1131
1372
  dispatchSubmitRef.current = dispatchSubmit;
@@ -1138,14 +1379,73 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1138
1379
  const pickChoice = useCallback((value) => {
1139
1380
  const act = choiceActionRef.current;
1140
1381
  choiceActionRef.current = null;
1382
+ const currentChoice = choice;
1141
1383
  setChoice(null);
1142
1384
  if (act) { act(value); return; }
1143
- 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
+ }
1144
1401
  runTurn(String(value));
1145
- }, [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
+ });
1146
1445
 
1147
1446
  useInput((input, key) => {
1148
1447
  if (!choice) return;
1448
+ if (sessionPicker || diffState) return;
1149
1449
  if (key.escape || (key.ctrl && input === 'c')) { setChoice(null); return; }
1150
1450
  if (key.tab && choice.allowText) { setChoice((c) => ({ ...c, focusText: !c.focusText })); return; }
1151
1451
  if (key.upArrow) { setChoice((c) => ({ ...c, focusText: false, sel: Math.max(0, c.sel - 1) })); return; }
@@ -1171,6 +1471,96 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1171
1471
  }
1172
1472
  });
1173
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
+
1174
1564
  const openSearch = useCallback(() => {
1175
1565
  setSearch({ query: '', active: 0 });
1176
1566
  setHint('Timeline search');
@@ -1201,7 +1591,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1201
1591
  });
1202
1592
 
1203
1593
  useInput((input, key) => {
1204
- if (choice || search) return;
1594
+ if (choice || search || sessionPicker || diffState) return;
1205
1595
  if (key.ctrl && input === 'c') {
1206
1596
  if (busy) { abortRef.current?.abort(); setHint(''); return; }
1207
1597
  const now = Date.now();
@@ -1215,6 +1605,17 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1215
1605
  if (key.escape && shellMode && !composerText.trim()) { setShellMode(false); pushNotice('Exited shell mode.', C.muted, true); return; }
1216
1606
  if (key.escape && expandedItems) { setExpandedItems(null); return; }
1217
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
+ }
1218
1619
  if (key.tab && key.shift && !busy) {
1219
1620
  const cur = MODES.indexOf(modeRef.current);
1220
1621
  const next = MODES[(cur + 1) % MODES.length];
@@ -1251,7 +1652,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1251
1652
  { cmd: '/offline', desc: 'All offline devices' },
1252
1653
  { cmd: '/flapping [device]', desc: 'Flapping interfaces' },
1253
1654
  ],
1254
- hint: 'Type a command or press Enter on Session tab to interact',
1655
+ hint: 'Press Enter to switch back to Session and use the composer',
1255
1656
  },
1256
1657
  customers: {
1257
1658
  title: 'Customers',
@@ -1263,7 +1664,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1263
1664
  { cmd: '/onu [id]', desc: 'ONU signal and status' },
1264
1665
  { cmd: '/bandwidth [device]', desc: 'Traffic / bandwidth' },
1265
1666
  ],
1266
- hint: 'Switch to Session tab (Tab) to send commands',
1667
+ hint: 'Press Enter to switch back to Session and send commands',
1267
1668
  },
1268
1669
  alarms: {
1269
1670
  title: 'Alarms',
@@ -1275,7 +1676,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1275
1676
  { cmd: '/optical [device]', desc: 'Optical RX/TX (dBm)' },
1276
1677
  { cmd: '/reachable', desc: 'Reachability sweep' },
1277
1678
  ],
1278
- hint: 'Switch to Session tab (Tab) and type a command',
1679
+ hint: 'Press Enter to switch back to Session and type a command',
1279
1680
  },
1280
1681
  };
1281
1682
 
@@ -1290,6 +1691,8 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1290
1691
  case 'connect': return html`<${Connect} key=${it.id} reply=${it.reply} />`;
1291
1692
  case 'activity': return html`<${StatusLines} key=${it.id} lines=${it.status} busy=${false} />`;
1292
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} />`;
1293
1696
  case 'shell': return html`<${ShellOutput} key=${it.id} command=${it.command} output=${it.output} code=${it.code} cols=${c} ts=${it.ts} />`;
1294
1697
  case 'help': return html`<${HelpOverlay} key=${it.id} cols=${c} ts=${it.ts} />`;
1295
1698
  default: return null;
@@ -1299,7 +1702,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1299
1702
  const cwd = shortenPath(process.cwd());
1300
1703
  const branch = branchRef.current;
1301
1704
  const tokenCount = approxTokens(convo.current);
1302
- const tokenLabel = tokenCount > 0 ? `~${Math.max(1, Math.round(tokenCount / 1000))}k ctx` : '';
1705
+ const tokenLabel = contextUsageLabel(tokenCount);
1303
1706
  const modelLabel = streamerMode ? '' : shortModel(model || modelRef.current || 'server default');
1304
1707
  const footerLeft = [cwd, branch ? `${glyphs.gitBranch}${branch}` : null].filter(Boolean).join(' ');
1305
1708
  const footerRight = hint
@@ -1342,9 +1745,34 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1342
1745
 
1343
1746
  ${search ? html`<${SearchOverlay} query=${search.query} matches=${searchMatches} active=${search.active} cols=${cols} />` : null}
1344
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
+
1345
1773
  <${TabBar} active=${activeTab} cols=${cols} mode=${composerMode} />
1346
1774
 
1347
- ${tabPanel
1775
+ ${tabPanel && !diffState
1348
1776
  ? html`<${Box} flexDirection="column" paddingX=${1} paddingY=${1}>
1349
1777
  <${Text} color=${C.brand} bold>${tabPanel.title} — Quick Commands<//>\n
1350
1778
  ${tabPanel.commands.map((c, i) =>
@@ -1352,7 +1780,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1352
1780
  <${Text} color=${C.slash}>${c.cmd.padEnd(24)}<//>
1353
1781
  <${Text} color=${C.muted}>${c.desc}<//>
1354
1782
  <//>`)}
1355
- \n<${Text} color=${C.muted}>${tabPanel.hint}<//>
1783
+ \n<${Text} color=${C.muted}>${tabPanel.hint} · Press Enter to return to Session · Press c to start a #reference<//>
1356
1784
  <//>`
1357
1785
  : null}
1358
1786
 
@@ -1361,6 +1789,8 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1361
1789
  <${Box} flexDirection="column">
1362
1790
  ${choice
1363
1791
  ? html`<${Choice} ...${choice} />`
1792
+ : sessionPicker
1793
+ ? html`<${SessionPicker} sessions=${sessionPicker.sessions} sel=${sessionPicker.sel} sort=${sessionPicker.sort} cols=${cols} />`
1364
1794
  : html`<${Composer}
1365
1795
  onSubmit=${dispatchSubmit}
1366
1796
  onSubmitKeep=${dispatchSubmit}
@@ -1368,14 +1798,18 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1368
1798
  onSearch=${openSearch}
1369
1799
  onNotice=${(text, color = C.muted) => pushNotice(text, color, true)}
1370
1800
  onEmptyEsc=${() => { if (shellMode) setShellMode(false); else if (expandedItems) setExpandedItems(null); }}
1801
+ onActivateSession=${() => { setActiveTab('session'); setHint('Session tab active'); }}
1371
1802
  onValueChange=${setComposerText}
1372
1803
  onEmptySubmit=${() => {
1373
1804
  if (chips.length) dispatchSubmit(chips[activeChip]);
1374
1805
  }}
1375
1806
  busy=${busy}
1376
- blocked=${Boolean(choice || search || !trusted)}
1807
+ blocked=${Boolean(choice || search || sessionPicker || diffState || !trusted)}
1377
1808
  width=${cols}
1378
1809
  mode=${composerMode}
1810
+ activeTab=${activeTab}
1811
+ presetText=${composerPreset.text}
1812
+ presetToken=${composerPreset.token}
1379
1813
  clearToken=${composerClearToken}
1380
1814
  />`}
1381
1815
 
@@ -16,20 +16,51 @@ const LOGO = [
16
16
  ];
17
17
 
18
18
  const HELP_ROWS = [
19
- ['Shift+Tab', 'cycle ask / plan / autopilot'],
20
- ['Ctrl+S', 'run while preserving input'],
21
- ['Ctrl+Enter / Ctrl+Q', 'queue follow-up while busy'],
22
- ['Ctrl+G', 'edit prompt in external editor'],
23
- ['Ctrl+F', 'search timeline'],
24
- ['Ctrl+O / Ctrl+E', 'expand recent / all timeline items'],
25
- ['Ctrl+T', 'toggle reasoning'],
26
- ['Ctrl+R', 'reverse-search input history'],
27
- ['Ctrl+V', 'paste attachment help'],
28
- ['Ctrl+Z', 'suspend to background'],
29
- ['!', 'shell command / shell mode'],
30
- ['?', 'quick help overlay'],
31
- ['@mention', 'insert ISP entities'],
32
- ['#123', 'reference customer / ticket id'],
19
+ '┌─────────────────────────────────────────────────────────┐',
20
+ '│ iCLI QUICK REFERENCE │',
21
+ '├────────────────────┬────────────────────────────────────┤',
22
+ '│ LAUNCH │ icli [--banner] [--experimental] │',
23
+ '│ ONE-SHOT │ icli -p "prompt" [--allow-all] │',
24
+ '│ RESUME LAST │ icli --continue │',
25
+ '├────────────────────┼────────────────────────────────────┤',
26
+ '│ CYCLE MODES │ Shift+Tab │',
27
+ '│ CANCEL │ Esc (or Ctrl+C once) │',
28
+ '│ EXIT │ Ctrl+C twice or /exit │',
29
+ '│ SHUTDOWN │ Ctrl+D │',
30
+ '│ CLEAR SCREEN │ Ctrl+L │',
31
+ '│ CLEAR HISTORY │ /clear │',
32
+ '├────────────────────┼────────────────────────────────────┤',
33
+ '│ REASONING │ Ctrl+T │',
34
+ '│ EXPAND RECENT │ Ctrl+O │',
35
+ '│ EXPAND ALL │ Ctrl+E │',
36
+ '│ TIMELINE SEARCH │ Ctrl+F │',
37
+ '│ PAGE SCROLL │ Page Up / Page Down │',
38
+ '├────────────────────┼────────────────────────────────────┤',
39
+ '│ FILE MENTION │ @path/to/file │',
40
+ '│ ISP REFERENCE │ #NUMBER │',
41
+ '│ SHELL BYPASS │ !command │',
42
+ '│ QUICK HELP │ ? (empty input) │',
43
+ '│ EXTERNAL EDITOR │ Ctrl+G │',
44
+ '│ PASTE ATTACHMENT │ Ctrl+V │',
45
+ '│ QUEUE MESSAGE │ Ctrl+Enter while busy │',
46
+ '├────────────────────┼────────────────────────────────────┤',
47
+ '│ LINE START/END │ Ctrl+A / Ctrl+E │',
48
+ '│ WORD JUMP │ Alt+← / Alt+→ │',
49
+ '│ DELETE WORD │ Ctrl+W │',
50
+ '│ DELETE TO END │ Ctrl+K │',
51
+ '│ DELETE TO START │ Ctrl+U │',
52
+ '│ HISTORY NAV │ ↑ / ↓ │',
53
+ '│ ACCEPT MENU │ Tab │',
54
+ '│ MULTILINE INPUT │ Shift+Enter │',
55
+ '├────────────────────┼────────────────────────────────────┤',
56
+ '│ MODEL │ /model │',
57
+ '│ CONTEXT / USAGE │ /context or /usage │',
58
+ '│ COMPACT HISTORY │ /compact [focus] │',
59
+ '│ DIFF VIEW │ /diff │',
60
+ '│ SESSION INFO │ /session │',
61
+ '│ RESUME SESSION │ /resume │',
62
+ '│ HELP │ /help │',
63
+ '└────────────────────┴────────────────────────────────────┘',
33
64
  ];
34
65
 
35
66
  function formatTs(ts) {
@@ -39,6 +70,21 @@ function formatTs(ts) {
39
70
  } catch {
40
71
  return '';
41
72
  }
73
+
74
+ function formatDate(ts) {
75
+ if (!ts) return '';
76
+ try {
77
+ return new Date(ts).toLocaleString([], {
78
+ year: 'numeric',
79
+ month: 'short',
80
+ day: '2-digit',
81
+ hour: '2-digit',
82
+ minute: '2-digit',
83
+ });
84
+ } catch {
85
+ return '';
86
+ }
87
+ }
42
88
  }
43
89
 
44
90
  function SpeakerLine({ label, color, cols = 80, ts, rightLabel = '' }) {
@@ -310,14 +356,24 @@ export function Notice({ text, color = C.gray }) {
310
356
  return html`<${Box} marginTop=${1}><${Text} color=${color}>${text}<//><//>`;
311
357
  }
312
358
 
359
+ export function LabeledNotice({ label, text, color = C.gray, cols = 80, ts, bodyColor = C.white }) {
360
+ return html`
361
+ <${Box} flexDirection="column" marginTop=${1}>
362
+ <${Sep} cols=${cols} />
363
+ <${SpeakerLine} label=${label} color=${color} cols=${cols} ts=${ts} />
364
+ <${Box} paddingX=${2}>
365
+ <${Text} color=${bodyColor}>${text}<//>
366
+ <//>
367
+ <//>`;
368
+ }
369
+
313
370
  export function HelpOverlay({ cols = 80, ts }) {
314
371
  return html`
315
372
  <${Box} flexDirection="column" marginTop=${1} borderStyle=${borderStyle()} borderColor=${C.accent} paddingX=${1} width=${cols}>
316
373
  <${SpeakerLine} label="? Help" color=${C.accent} cols=${Math.max(10, cols - 2)} ts=${ts} />
317
- ${HELP_ROWS.map(([key, desc]) => html`
318
- <${Box} key=${key} paddingX=${1}>
319
- <${Text} color=${C.success} bold>${key.padEnd(22)}<//>
320
- <${Text} color=${C.muted}>${desc}<//>
374
+ ${HELP_ROWS.map((line, i) => html`
375
+ <${Box} key=${'help-' + i} paddingX=${1}>
376
+ <${Text} color=${i === 0 || i === HELP_ROWS.length - 1 || /├|┬|┴/.test(line) ? C.muted : C.white}>${line}<//>
321
377
  <//>`)}
322
378
  <//>`;
323
379
  }
@@ -341,6 +397,62 @@ export function SearchOverlay({ query = '', matches = [], active = 0, cols = 80
341
397
  <//>`;
342
398
  }
343
399
 
400
+ export function SessionPicker({ sessions = [], sel = 0, cols = 80, sort = 'relevance' }) {
401
+ return html`
402
+ <${Box} flexDirection="column" marginTop=${1} borderStyle=${borderStyle()} borderColor=${C.accent} paddingX=${1} width=${cols}>
403
+ <${Text} color=${C.accent} bold>${glyphs.arrow} Resume session<//>
404
+ <${Text} color=${C.muted}>Sort: ${sort} · Enter open · d delete · s sort · Esc close<//>
405
+ <${Box} marginTop=${1}>
406
+ <${Text} color=${C.muted} bold>${'Name'.padEnd(24)} ${'Path'.padEnd(Math.max(12, cols - 54))} ${'Updated'.padEnd(18)} Msgs<//>
407
+ <//>
408
+ ${sessions.length
409
+ ? sessions.map((session, i) => {
410
+ const on = i === sel;
411
+ const name = String(session.name || 'Untitled session').slice(0, 24).padEnd(24);
412
+ const path = String(session.cwd || '').slice(0, Math.max(12, cols - 54)).padEnd(Math.max(12, cols - 54));
413
+ const updated = formatDate(session.updatedAt).slice(0, 18).padEnd(18);
414
+ return html`
415
+ <${Box} key=${session.id}>
416
+ <${Text} color=${on ? C.accent : C.muted}>${on ? glyphs.prompt + ' ' : ' '}<//>
417
+ <${Text} color=${on ? C.white : C.brand} bold=${on}>${name}<//>
418
+ <${Text} color=${C.muted}> ${path} ${updated} ${String(session.messageCount || 0).padStart(4)}<//>
419
+ <//>`;
420
+ })
421
+ : html`<${Text} color=${C.muted}>No saved sessions.<//>`}
422
+ <//>`;
423
+ }
424
+
425
+ export function DiffViewer({ diff = [], sel = 0, staged = false, whitespace = false, cols = 80, height = 18, commentCount = 0 }) {
426
+ const maxVisible = Math.max(6, height);
427
+ const start = Math.max(0, Math.min(sel - Math.floor(maxVisible / 2), Math.max(0, diff.length - maxVisible)));
428
+ const visible = diff.slice(start, start + maxVisible);
429
+ return html`
430
+ <${Box} flexDirection="column" marginTop=${1} borderStyle=${borderStyle()} borderColor=${C.accent} paddingX=${1} width=${cols}>
431
+ <${Text} color=${C.accent} bold>${glyphs.expand} Diff viewer<//>
432
+ <${Text} color=${C.muted}>${staged ? 'staged (git diff --cached)' : 'unstaged (git diff)'} · whitespace ${whitespace ? 'ignored' : 'shown'} · comments ${commentCount}<//>
433
+ ${visible.map((line, i) => {
434
+ const idx = start + i;
435
+ const on = idx === sel;
436
+ const prefix = String(line.content || '').slice(0, Math.max(1, cols - 6));
437
+ const color = line.type === 'add'
438
+ ? C.success
439
+ : line.type === 'del'
440
+ ? C.error
441
+ : line.type === 'header'
442
+ ? C.accent
443
+ : line.type === 'hunk'
444
+ ? C.muted
445
+ : C.white;
446
+ return html`
447
+ <${Box} key=${String(idx) + prefix}>
448
+ <${Text} color=${on ? C.warning : C.muted}>${on ? glyphs.prompt + ' ' : ' '}<//>
449
+ <${Text} color=${color} bold=${line.type === 'header'} inverse=${on}>${prefix}<//>
450
+ <//>`;
451
+ })}
452
+ <${Text} color=${C.muted}>j/k move PgUp/PgDn scroll h/l file g/G ends b staged w whitespace c comment Enter submit Esc close<//>
453
+ <//>`;
454
+ }
455
+
344
456
  export function Thinking({ label = 'Working', startedAt }) {
345
457
  const [, tick] = useState(0);
346
458
  useEffect(() => {
@@ -85,11 +85,15 @@ export function Composer({
85
85
  onSearch,
86
86
  onNotice,
87
87
  onEmptyEsc,
88
+ onActivateSession,
88
89
  onValueChange,
89
90
  busy,
90
91
  blocked = false,
91
92
  width = 80,
92
93
  mode = 'ask',
94
+ activeTab = 'session',
95
+ presetText = '',
96
+ presetToken = 0,
93
97
  clearToken = 0,
94
98
  }) {
95
99
  const [value, setValue] = useState('');
@@ -124,6 +128,16 @@ export function Composer({
124
128
  setHistSearchSel(0);
125
129
  }, [clearToken]);
126
130
 
131
+ useEffect(() => {
132
+ if (!presetToken) return;
133
+ setValue(String(presetText || ''));
134
+ setCursor(String(presetText || '').length);
135
+ setHistIdx(-1);
136
+ setHistSearch(false);
137
+ setHistSearchQ('');
138
+ setHistSearchSel(0);
139
+ }, [presetText, presetToken]);
140
+
127
141
  useEffect(() => {
128
142
  onValueChange?.(value);
129
143
  }, [value, onValueChange]);
@@ -222,6 +236,10 @@ export function Composer({
222
236
  }
223
237
  if (blocked) return;
224
238
  if (busy) return;
239
+ if (activeTab !== 'session' && enter) {
240
+ onActivateSession?.();
241
+ return;
242
+ }
225
243
 
226
244
  if (histSearch) {
227
245
  if (key.escape) { setHistSearch(false); setHistSearchQ(''); setHistSearchSel(0); return; }
@@ -465,6 +483,7 @@ export function Composer({
465
483
 
466
484
  <${Box} width=${width} paddingX=${1}>
467
485
  <${Text} color=${promptColor} bold>${promptGlyph} <//>
486
+ ${busy ? html`<${Text} color=${C.muted}>(working…) <//>` : null}
468
487
  ${modePrefix ? html`<${Text} color=${mode === 'shell' ? C.warning : C.accent} bold>${modePrefix}<//>` : null}
469
488
  ${empty
470
489
  ? html`<${Box} flexGrow=${1}>