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