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/package.json +1 -1
- package/src/session.js +133 -17
- package/src/tui/app.js +607 -82
- package/src/tui/components.js +208 -23
- package/src/tui/composer.js +49 -6
- package/src/tui/theme.js +1 -1
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
|
-
|
|
36
|
-
} catch {
|
|
37
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
391
|
-
|
|
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: '
|
|
423
|
-
{ label: '
|
|
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'
|
|
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
|
-
|
|
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 =
|
|
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
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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() ||
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
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,
|
|
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) {
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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 =
|
|
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
|
|