ispbills-icli 8.5.3 → 8.5.5
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 +37 -4
- package/package.json +1 -1
- package/src/commands.js +87 -35
- package/src/tui/app.js +942 -200
- package/src/tui/components.js +99 -48
- package/src/tui/composer.js +319 -92
- package/src/tui/run.js +36 -7
package/src/tui/app.js
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
|
-
// Root Ink application. Renders an INLINE chat UI (GitHub Copilot CLI style)
|
|
2
|
-
// the banner prints once at the top and scrolls away, completed transcript
|
|
3
|
-
// items are flushed to the terminal's native scrollback via Ink <Static> (so
|
|
4
|
-
// you can scroll back through history), and only the live/streaming area plus
|
|
5
|
-
// the input composer are re-rendered in place at the bottom.
|
|
1
|
+
// Root Ink application. Renders an INLINE chat UI (GitHub Copilot CLI style).
|
|
6
2
|
import { html, useState, useEffect, useRef, useCallback } from './dom.js';
|
|
7
3
|
import { Box, Text, Static, useStdout, useApp, useInput } from 'ink';
|
|
8
4
|
import { C, glyphs, midDot, dash } from './theme.js';
|
|
9
5
|
import {
|
|
10
6
|
Banner, TabBar, FollowupChips, UserMessage, AssistantMessage, StatusLines, Reasoning,
|
|
11
|
-
Plan, Connect, Notice, Thinking, Choice,
|
|
7
|
+
Plan, Connect, Notice, Thinking, Choice, ShellOutput, SearchOverlay, HelpOverlay,
|
|
12
8
|
} from './components.js';
|
|
13
9
|
import { Composer } from './composer.js';
|
|
14
10
|
import { streamChat } from '../client.js';
|
|
@@ -19,11 +15,11 @@ import { loadNotes, addNote, forgetNote, memoryPreamble } from '../memory.js';
|
|
|
19
15
|
import { copyToClipboard } from '../clipboard.js';
|
|
20
16
|
import { createGist } from '../gist.js';
|
|
21
17
|
import { git as gitCmd, commitBackup } from '../gitrepo.js';
|
|
22
|
-
import { execSync } from 'child_process';
|
|
18
|
+
import { execSync, spawnSync } from 'child_process';
|
|
23
19
|
import { homedir } from 'os';
|
|
24
|
-
import { relative } from 'path';
|
|
20
|
+
import { dirname, isAbsolute, relative, resolve } from 'path';
|
|
21
|
+
import { existsSync, readFileSync } from 'fs';
|
|
25
22
|
|
|
26
|
-
// Split a command argument string into argv, honouring "quoted" segments.
|
|
27
23
|
function splitArgs(s) {
|
|
28
24
|
const out = [];
|
|
29
25
|
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
@@ -32,7 +28,6 @@ function splitArgs(s) {
|
|
|
32
28
|
return out;
|
|
33
29
|
}
|
|
34
30
|
|
|
35
|
-
// Shorten a path with ~ for home directory.
|
|
36
31
|
function shortenPath(p) {
|
|
37
32
|
try {
|
|
38
33
|
const home = homedir();
|
|
@@ -42,29 +37,68 @@ function shortenPath(p) {
|
|
|
42
37
|
return p;
|
|
43
38
|
}
|
|
44
39
|
|
|
45
|
-
|
|
46
|
-
function gitBranch() {
|
|
40
|
+
function gitBranch(cwd = process.cwd()) {
|
|
47
41
|
try {
|
|
48
42
|
return execSync('git rev-parse --abbrev-ref HEAD', {
|
|
49
|
-
cwd
|
|
43
|
+
cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: 1000,
|
|
50
44
|
}).toString().trim();
|
|
51
45
|
} catch {
|
|
52
46
|
return '';
|
|
53
47
|
}
|
|
54
48
|
}
|
|
55
49
|
|
|
56
|
-
// Approximate token count from text (~4 chars per token).
|
|
57
50
|
function approxTokens(messages) {
|
|
58
51
|
const total = messages.reduce((n, m) => n + (m.content?.length || 0), 0);
|
|
59
52
|
return Math.round(total / 4);
|
|
60
53
|
}
|
|
61
54
|
|
|
55
|
+
function pkgVersion() {
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version || 'unknown';
|
|
58
|
+
} catch {
|
|
59
|
+
return 'unknown';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function feedbackUrl() {
|
|
64
|
+
try {
|
|
65
|
+
const raw = execSync('git config --get remote.origin.url', {
|
|
66
|
+
cwd: process.cwd(), stdio: ['ignore', 'pipe', 'ignore'], timeout: 1000,
|
|
67
|
+
}).toString().trim();
|
|
68
|
+
if (!raw) return 'https://github.com/issues/new';
|
|
69
|
+
const gh = raw
|
|
70
|
+
.replace(/^git@github\.com:/, 'https://github.com/')
|
|
71
|
+
.replace(/\.git$/, '');
|
|
72
|
+
if (/^https:\/\/github\.com\//.test(gh)) return `${gh}/issues/new`;
|
|
73
|
+
} catch {}
|
|
74
|
+
return 'https://github.com/issues/new';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isTrustedDir(cfg, dir) {
|
|
78
|
+
const trusted = cfg?.tui?.trustedDirs || {};
|
|
79
|
+
let cur = resolve(dir);
|
|
80
|
+
while (true) {
|
|
81
|
+
if (trusted[cur]) return true;
|
|
82
|
+
const parent = dirname(cur);
|
|
83
|
+
if (parent === cur) break;
|
|
84
|
+
cur = parent;
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function itemPreview(it) {
|
|
90
|
+
if (!it) return '';
|
|
91
|
+
if (it.type === 'user' || it.type === 'assistant' || it.type === 'notice' || it.type === 'help') return String(it.text || it.type);
|
|
92
|
+
if (it.type === 'shell') return `$ ${it.command}\n${it.output || ''}`;
|
|
93
|
+
if (it.type === 'plan') return `Plan ${it.reply?.summary || ''}`;
|
|
94
|
+
if (it.type === 'connect') return `Connect ${it.reply?.device?.kind || ''}`;
|
|
95
|
+
if (it.type === 'activity') return (it.status || []).join('\n');
|
|
96
|
+
return JSON.stringify(it);
|
|
97
|
+
}
|
|
98
|
+
|
|
62
99
|
function useTerminalSize() {
|
|
63
100
|
const { stdout } = useStdout();
|
|
64
|
-
const [size, setSize] = useState({
|
|
65
|
-
cols: stdout?.columns || 80,
|
|
66
|
-
rows: stdout?.rows || 24,
|
|
67
|
-
});
|
|
101
|
+
const [size, setSize] = useState({ cols: stdout?.columns || 80, rows: stdout?.rows || 24 });
|
|
68
102
|
useEffect(() => {
|
|
69
103
|
if (!stdout) return;
|
|
70
104
|
const on = () => setSize({ cols: stdout.columns || 80, rows: stdout.rows || 24 });
|
|
@@ -76,85 +110,241 @@ function useTerminalSize() {
|
|
|
76
110
|
|
|
77
111
|
let itemId = 0;
|
|
78
112
|
const nextId = () => ++itemId;
|
|
79
|
-
|
|
80
|
-
// Mode cycle: ask → plan → autopilot → ask
|
|
81
113
|
const MODES = ['ask', 'plan', 'autopilot'];
|
|
82
114
|
|
|
83
|
-
export function App({ cfg, display = {}, onExternal, onExit, initialQuestion }) {
|
|
115
|
+
export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, showBanner = true, agentName, resumeMode }) {
|
|
84
116
|
const { exit } = useApp();
|
|
85
|
-
const { cols
|
|
117
|
+
const { cols } = useTerminalSize();
|
|
86
118
|
const colsRef = useRef(cols);
|
|
87
119
|
useEffect(() => { colsRef.current = cols; }, [cols]);
|
|
88
120
|
|
|
89
121
|
const [items, setItems] = useState([]);
|
|
90
|
-
const [live, setLive] = useState(null);
|
|
122
|
+
const [live, setLive] = useState(null);
|
|
91
123
|
const [busy, setBusy] = useState(false);
|
|
92
124
|
const [model, setModel] = useState(cfg._model);
|
|
93
|
-
const [showReasoning, setShowReasoning] = useState(false);
|
|
94
|
-
const [mode, setMode] = useState('ask');
|
|
125
|
+
const [showReasoning, setShowReasoning] = useState(false);
|
|
126
|
+
const [mode, setMode] = useState('ask');
|
|
95
127
|
const [activeTab, setActiveTab] = useState('session');
|
|
96
|
-
const [chips, setChips] = useState([]);
|
|
128
|
+
const [chips, setChips] = useState([]);
|
|
97
129
|
const [activeChip, setActiveChip] = useState(0);
|
|
98
130
|
const [hint, setHint] = useState('');
|
|
99
131
|
const [startedAt, setStartedAt] = useState(0);
|
|
100
|
-
const [choice, setChoice] = useState(null);
|
|
101
|
-
const [clearEpoch, setClearEpoch] = useState(0);
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
132
|
+
const [choice, setChoice] = useState(null);
|
|
133
|
+
const [clearEpoch, setClearEpoch] = useState(0);
|
|
134
|
+
const [composerClearToken, setComposerClearToken] = useState(0);
|
|
135
|
+
const [composerText, setComposerText] = useState('');
|
|
136
|
+
const [search, setSearch] = useState(null);
|
|
137
|
+
const [expandedItems, setExpandedItems] = useState(null);
|
|
138
|
+
const [allowAll, setAllowAll] = useState(Boolean(cfg?.tui?.allowAll));
|
|
139
|
+
const [streamerMode, setStreamerMode] = useState(Boolean(cfg?.tui?.streamerMode));
|
|
140
|
+
const [shellMode, setShellMode] = useState(false);
|
|
141
|
+
const [trusted, setTrusted] = useState(isTrustedDir(cfg, process.cwd()));
|
|
142
|
+
|
|
143
|
+
const convo = useRef([]);
|
|
144
|
+
const plain = useRef([]);
|
|
145
|
+
const abortRef = useRef(null);
|
|
146
|
+
const ctrlC = useRef(0);
|
|
147
|
+
const apRef = useRef(false);
|
|
148
|
+
const modeRef = useRef('ask');
|
|
149
|
+
const modelRef = useRef(cfg._model);
|
|
150
|
+
const lastAnswerRef = useRef('');
|
|
151
|
+
const choiceActionRef = useRef(null);
|
|
152
|
+
const branchRef = useRef(gitBranch());
|
|
153
|
+
const queuedRef = useRef(null);
|
|
154
|
+
const activeTurns = useRef(0);
|
|
155
|
+
const dismissTimers = useRef(new Map());
|
|
156
|
+
const dispatchSubmitRef = useRef(null);
|
|
157
|
+
|
|
117
158
|
useEffect(() => {
|
|
118
159
|
try { process.stdout.write('\x1b[?2004h'); } catch {}
|
|
119
160
|
return () => { try { process.stdout.write('\x1b[?2004l'); } catch {} };
|
|
120
161
|
}, []);
|
|
121
162
|
|
|
163
|
+
useEffect(() => () => {
|
|
164
|
+
for (const timer of dismissTimers.current.values()) clearTimeout(timer);
|
|
165
|
+
}, []);
|
|
166
|
+
|
|
167
|
+
const persistCfg = useCallback(() => {
|
|
168
|
+
try { saveConfig(cfg); } catch {}
|
|
169
|
+
}, [cfg]);
|
|
170
|
+
|
|
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
|
+
|
|
122
180
|
const setModeState = useCallback((next) => {
|
|
123
181
|
modeRef.current = next;
|
|
124
182
|
apRef.current = next === 'autopilot';
|
|
125
183
|
setMode(next);
|
|
126
184
|
}, []);
|
|
127
185
|
|
|
128
|
-
const push = useCallback((item) =>
|
|
186
|
+
const push = useCallback((item) => {
|
|
187
|
+
const next = { id: nextId(), cols: colsRef.current, ts: item.ts ?? Date.now(), ...item };
|
|
188
|
+
setItems((xs) => [...xs, next]);
|
|
189
|
+
return next;
|
|
190
|
+
}, []);
|
|
191
|
+
|
|
192
|
+
const removeItem = useCallback((id) => {
|
|
193
|
+
setItems((xs) => xs.filter((x) => x.id !== id));
|
|
194
|
+
const timer = dismissTimers.current.get(id);
|
|
195
|
+
if (timer) {
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
dismissTimers.current.delete(id);
|
|
198
|
+
}
|
|
199
|
+
}, []);
|
|
200
|
+
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
for (const item of items) {
|
|
203
|
+
if (item.type !== 'notice' || !item.autoDismiss || dismissTimers.current.has(item.id)) continue;
|
|
204
|
+
const timer = setTimeout(() => removeItem(item.id), 4000);
|
|
205
|
+
dismissTimers.current.set(item.id, timer);
|
|
206
|
+
}
|
|
207
|
+
}, [items, removeItem]);
|
|
208
|
+
|
|
209
|
+
const pushNotice = useCallback((text, color = C.gray, autoDismiss = false) => {
|
|
210
|
+
push({ type: 'notice', text, color, autoDismiss });
|
|
211
|
+
}, [push]);
|
|
212
|
+
|
|
129
213
|
const log = (line) => plain.current.push(line);
|
|
130
214
|
|
|
131
|
-
// Clear the visible transcript: wipe the screen + native scrollback and
|
|
132
|
-
// remount <Static> (via clearEpoch) so nothing is re-flushed above the input.
|
|
133
215
|
const clearAll = useCallback(() => {
|
|
134
216
|
try { process.stdout.write('\x1b[2J\x1b[3J\x1b[H'); } catch {}
|
|
135
217
|
setItems([]);
|
|
218
|
+
setExpandedItems(null);
|
|
219
|
+
setSearch(null);
|
|
136
220
|
setClearEpoch((n) => n + 1);
|
|
137
221
|
}, []);
|
|
138
222
|
|
|
223
|
+
const queueSubmission = useCallback((text) => {
|
|
224
|
+
const q = String(text || '').trim();
|
|
225
|
+
if (!q) { pushNotice('Nothing to queue.', C.muted, true); return; }
|
|
226
|
+
queuedRef.current = q;
|
|
227
|
+
pushNotice(`[queued] ${q}`, C.warning, true);
|
|
228
|
+
}, [pushNotice]);
|
|
229
|
+
|
|
230
|
+
const openTrustDialog = useCallback((dir = process.cwd()) => {
|
|
231
|
+
choiceActionRef.current = (value) => {
|
|
232
|
+
if (value === 'session') {
|
|
233
|
+
setTrusted(true);
|
|
234
|
+
pushNotice(`Trusted for this session: ${shortenPath(dir)}`, C.success, true);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (value === 'remember') {
|
|
238
|
+
cfg.tui = cfg.tui || {};
|
|
239
|
+
cfg.tui.trustedDirs = { ...(cfg.tui.trustedDirs || {}), [resolve(dir)]: true };
|
|
240
|
+
persistCfg();
|
|
241
|
+
setTrusted(true);
|
|
242
|
+
pushNotice(`Trusted and remembered: ${shortenPath(dir)}`, C.success, true);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
doExit();
|
|
246
|
+
};
|
|
247
|
+
setChoice({
|
|
248
|
+
title: 'Do you trust files in this folder?',
|
|
249
|
+
note: shortenPath(dir),
|
|
250
|
+
sel: 0,
|
|
251
|
+
text: '',
|
|
252
|
+
focusText: false,
|
|
253
|
+
allowText: false,
|
|
254
|
+
options: [
|
|
255
|
+
{ label: '1. Yes', value: 'session' },
|
|
256
|
+
{ label: '2. Yes, remember', value: 'remember' },
|
|
257
|
+
{ label: '3. No, exit', value: 'exit', danger: true },
|
|
258
|
+
],
|
|
259
|
+
});
|
|
260
|
+
}, [cfg, persistCfg]);
|
|
261
|
+
|
|
262
|
+
useEffect(() => {
|
|
263
|
+
if (!trusted && !choice) openTrustDialog(process.cwd());
|
|
264
|
+
}, [trusted, choice, openTrustDialog]);
|
|
265
|
+
|
|
266
|
+
const doExit = useCallback(() => { onExit?.(plain.current); exit(); }, [exit, onExit]);
|
|
267
|
+
|
|
268
|
+
const maybeProcessQueue = useCallback(() => {
|
|
269
|
+
if (activeTurns.current !== 0 || !queuedRef.current) return;
|
|
270
|
+
const next = queuedRef.current;
|
|
271
|
+
queuedRef.current = null;
|
|
272
|
+
setComposerClearToken((n) => n + 1);
|
|
273
|
+
setTimeout(() => dispatchSubmitRef.current?.(next, { queued: true }), 0);
|
|
274
|
+
}, []);
|
|
275
|
+
|
|
276
|
+
const doGist = useCallback(async (kind) => {
|
|
277
|
+
let content; let filename; let description;
|
|
278
|
+
if (kind === 'transcript') {
|
|
279
|
+
content = convo.current.map((m) => `### ${m.role}\n\n${m.content}`).join('\n\n---\n\n') || '(empty)';
|
|
280
|
+
filename = 'icli-transcript.md';
|
|
281
|
+
description = 'iCli conversation transcript';
|
|
282
|
+
} else {
|
|
283
|
+
content = lastAnswerRef.current || '(no answer)';
|
|
284
|
+
filename = 'icli-answer.md';
|
|
285
|
+
description = 'iCli answer';
|
|
286
|
+
}
|
|
287
|
+
pushNotice(`${glyphs.arrow} Creating secret gist…`, C.gray);
|
|
288
|
+
try {
|
|
289
|
+
const { url } = await createGist(cfg, { filename, content, description, isPublic: false });
|
|
290
|
+
const tool = await copyToClipboard(url);
|
|
291
|
+
pushNotice(`${glyphs.check} Gist created: ${url}${tool ? ' (URL copied)' : ''}`, C.green);
|
|
292
|
+
} catch (e) {
|
|
293
|
+
pushNotice(`${glyphs.cross} ${e.message}`, C.red);
|
|
294
|
+
}
|
|
295
|
+
}, [cfg, pushNotice]);
|
|
296
|
+
|
|
297
|
+
const runShellCommand = useCallback((rawCommand) => {
|
|
298
|
+
const command = String(rawCommand || '').trim();
|
|
299
|
+
if (!command) {
|
|
300
|
+
setShellMode(true);
|
|
301
|
+
pushNotice('Shell mode enabled. Press Esc to exit.', C.warning, true);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (shellMode && /^(exit|quit)$/i.test(command)) {
|
|
305
|
+
setShellMode(false);
|
|
306
|
+
pushNotice('Exited shell mode.', C.muted, true);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
push({ type: 'user', text: shellMode ? command : `!${command}` });
|
|
310
|
+
log(`\n$ ${command}\n`);
|
|
311
|
+
const res = spawnSync(command, { shell: true, encoding: 'utf8', cwd: process.cwd(), maxBuffer: 1024 * 1024 });
|
|
312
|
+
const output = [res.stdout || '', res.stderr || ''].join('').trim() || '(no output)';
|
|
313
|
+
push({ type: 'shell', command, output, code: res.status ?? 0 });
|
|
314
|
+
}, [log, push, pushNotice, shellMode]);
|
|
315
|
+
|
|
139
316
|
const runTurn = useCallback(async (question, opts = {}) => {
|
|
140
317
|
const { depth = 0, archive = null, label = '', synthetic = false } = opts;
|
|
318
|
+
activeTurns.current += 1;
|
|
141
319
|
if (!synthetic) {
|
|
142
320
|
push({ type: 'user', text: question });
|
|
143
321
|
log(`\n${glyphs.prompt} ${question}\n`);
|
|
144
322
|
}
|
|
145
323
|
convo.current.push({ role: 'user', content: question });
|
|
324
|
+
|
|
325
|
+
// Auto-compact at ~95% of context budget (approx 100k token limit)
|
|
326
|
+
const TOKEN_LIMIT = 100000;
|
|
327
|
+
if (approxTokens(convo.current) > TOKEN_LIMIT * 0.95) {
|
|
328
|
+
pushNotice(`${glyphs.arrow} Context at 95% — auto-compacting history…`, C.warning, true);
|
|
329
|
+
const snapshot = convo.current;
|
|
330
|
+
convo.current = [];
|
|
331
|
+
convo.current.push({ role: 'user', content: 'Summarise our conversation so far in a concise way, preserving key facts and decisions.' });
|
|
332
|
+
}
|
|
333
|
+
|
|
146
334
|
setBusy(true);
|
|
147
335
|
setHint('');
|
|
148
336
|
setChips([]);
|
|
337
|
+
setExpandedItems(null);
|
|
338
|
+
setSearch(null);
|
|
149
339
|
setStartedAt(Date.now());
|
|
150
340
|
const s = { status: [], reasoning: '', text: '', reply: null };
|
|
151
341
|
setLive({ ...s });
|
|
152
342
|
|
|
153
343
|
const abort = new AbortController();
|
|
154
344
|
abortRef.current = abort;
|
|
155
|
-
|
|
156
345
|
const context = {
|
|
157
346
|
autopilot: apRef.current,
|
|
347
|
+
allowAll,
|
|
158
348
|
client: 'icli',
|
|
159
349
|
ui: { tables: true, prompts: true, markdown: true },
|
|
160
350
|
};
|
|
@@ -176,55 +366,51 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
176
366
|
});
|
|
177
367
|
if (meta.model) setModel(meta.model);
|
|
178
368
|
const type = reply.type ?? 'message';
|
|
179
|
-
// Persist the tool / sub-agent activity trace inline (Copilot-CLI style)
|
|
180
|
-
// so it stays in history instead of vanishing with the live region.
|
|
181
369
|
if (s.status.length) push({ type: 'activity', status: s.status });
|
|
182
|
-
if (type === 'plan') {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
370
|
+
if (type === 'plan') {
|
|
371
|
+
push({ type: 'plan', reply });
|
|
372
|
+
log('[plan] ' + (reply.summary || ''));
|
|
373
|
+
} else if (type === 'connect') {
|
|
374
|
+
push({ type: 'connect', reply });
|
|
375
|
+
log('[connect] ' + (reply.device?.kind || ''));
|
|
188
376
|
if (depth < 2) {
|
|
189
377
|
const dev = reply.device || {};
|
|
190
378
|
const goal = convo.current.filter((m) => m.role === 'user').slice(-1)[0]?.content || '';
|
|
191
379
|
const sessionEvent = `[session-event] Terminal session opened on ${dev.kind || 'device'} id=${dev.id || '?'} proto=${dev.proto || 'ssh'}. vendor=auto. OPERATOR GOAL: ${goal}`;
|
|
192
|
-
setLive(null);
|
|
380
|
+
setLive(null);
|
|
193
381
|
return runTurn(sessionEvent, { depth: depth + 1, synthetic: true });
|
|
194
382
|
}
|
|
383
|
+
} else if (type === 'prompt' || type === 'choice') {
|
|
384
|
+
push({ type: 'assistant', text: text || reply.question || '' });
|
|
385
|
+
log(text || reply.question || '');
|
|
386
|
+
} else {
|
|
387
|
+
push({ type: 'assistant', text: text || '(no response)' });
|
|
388
|
+
log(text || '');
|
|
195
389
|
}
|
|
196
|
-
else if (type === 'prompt' || type === 'choice') { push({ type: 'assistant', text: text || reply.question || '' }); log(text || reply.question || ''); }
|
|
197
|
-
else { push({ type: 'assistant', text: text || '(no response)' }); log(text || ''); }
|
|
198
390
|
if (text) convo.current.push({ role: 'assistant', content: text });
|
|
199
391
|
lastAnswerRef.current = text || reply.summary || reply.question || lastAnswerRef.current;
|
|
200
392
|
|
|
201
|
-
// Populate followup chips from reply.suggestions (up to 4).
|
|
202
393
|
if (Array.isArray(reply.suggestions) && reply.suggestions.length) {
|
|
203
394
|
setChips(reply.suggestions.slice(0, 4));
|
|
204
395
|
setActiveChip(0);
|
|
205
396
|
}
|
|
206
397
|
|
|
207
|
-
// Archive a /backup answer to the per-user git repo.
|
|
208
398
|
if (archive === 'backup' && text) {
|
|
209
399
|
try {
|
|
210
400
|
const r = await commitBackup(cfg, label, text);
|
|
211
|
-
|
|
212
|
-
text: `${glyphs.check} Backup saved to git repo (${r.file})${r.committed ? '' : ' — nothing new to commit'}.`,
|
|
213
|
-
color: C.green });
|
|
401
|
+
pushNotice(`${glyphs.check} Backup saved to git repo (${r.file})${r.committed ? '' : ' — nothing new to commit'}.`, C.green);
|
|
214
402
|
} catch (e) {
|
|
215
|
-
|
|
403
|
+
pushNotice(`${glyphs.warn} Could not archive backup: ${e.message}`, C.warning);
|
|
216
404
|
}
|
|
217
405
|
}
|
|
218
406
|
|
|
219
|
-
// Autopilot: auto-confirm a proposed plan without waiting for the user.
|
|
220
407
|
if (type === 'plan' && apRef.current && depth < 3) {
|
|
221
|
-
|
|
222
|
-
setLive(null);
|
|
408
|
+
pushNotice(`${glyphs.bolt} autopilot ${dash()} applying fix…`, C.warning, true);
|
|
409
|
+
setLive(null);
|
|
223
410
|
return runTurn('apply fix', { depth: depth + 1 });
|
|
224
411
|
}
|
|
225
412
|
|
|
226
|
-
|
|
227
|
-
setLive(null); setBusy(false); abortRef.current = null;
|
|
413
|
+
setLive(null);
|
|
228
414
|
if (type === 'plan') {
|
|
229
415
|
const destructive = (reply.steps || []).some((st) => /destruct|high/.test(st.risk || ''));
|
|
230
416
|
setChoice({
|
|
@@ -249,77 +435,73 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
249
435
|
});
|
|
250
436
|
}
|
|
251
437
|
} catch (e) {
|
|
252
|
-
if (abort.signal.aborted)
|
|
253
|
-
else
|
|
254
|
-
setLive(null); setBusy(false); abortRef.current = null;
|
|
438
|
+
if (abort.signal.aborted) pushNotice(`${glyphs.cross} Cancelled.`, C.warning);
|
|
439
|
+
else pushNotice(`${glyphs.cross} ${e.message}`, C.red);
|
|
255
440
|
} finally {
|
|
256
441
|
abortRef.current = null;
|
|
257
442
|
setLive(null);
|
|
258
443
|
setBusy(false);
|
|
444
|
+
activeTurns.current = Math.max(0, activeTurns.current - 1);
|
|
445
|
+
maybeProcessQueue();
|
|
259
446
|
}
|
|
260
|
-
}, [cfg, push]);
|
|
261
|
-
|
|
262
|
-
const doExit = useCallback(() => { onExit?.(plain.current); exit(); }, [exit, onExit]);
|
|
447
|
+
}, [allowAll, cfg, log, maybeProcessQueue, push, pushNotice]);
|
|
263
448
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
if (kind === 'transcript') {
|
|
269
|
-
content = convo.current.map((m) => `### ${m.role}\n\n${m.content}`).join('\n\n---\n\n') || '(empty)';
|
|
270
|
-
filename = 'icli-transcript.md';
|
|
271
|
-
description = 'iCli conversation transcript';
|
|
272
|
-
} else {
|
|
273
|
-
content = lastAnswerRef.current || '(no answer)';
|
|
274
|
-
filename = 'icli-answer.md';
|
|
275
|
-
description = 'iCli answer';
|
|
276
|
-
}
|
|
277
|
-
push({ type: 'notice', text: `${glyphs.arrow} Creating secret gist…`, color: C.gray });
|
|
278
|
-
try {
|
|
279
|
-
const { url } = await createGist(cfg, { filename, content, description, isPublic: false });
|
|
280
|
-
const tool = await copyToClipboard(url);
|
|
281
|
-
push({ type: 'notice', text: `${glyphs.check} Gist created: ${url}${tool ? ' (URL copied)' : ''}`, color: C.green });
|
|
282
|
-
} catch (e) {
|
|
283
|
-
push({ type: 'notice', text: `${glyphs.cross} ${e.message}`, color: C.red });
|
|
449
|
+
const showExpanded = useCallback((kind, sourceItems) => {
|
|
450
|
+
if (!sourceItems.length) {
|
|
451
|
+
pushNotice('Nothing to expand yet.', C.muted, true);
|
|
452
|
+
return;
|
|
284
453
|
}
|
|
285
|
-
|
|
454
|
+
setExpandedItems({
|
|
455
|
+
kind,
|
|
456
|
+
items: sourceItems.map((it, idx) => ({ ...it, id: `expanded-${kind}-${idx}-${it.id}` })),
|
|
457
|
+
});
|
|
458
|
+
pushNotice(`Expanded ${kind}.`, C.muted, true);
|
|
459
|
+
}, [pushNotice]);
|
|
286
460
|
|
|
287
|
-
const
|
|
288
|
-
const q = raw.trim();
|
|
289
|
-
if (!q) return;
|
|
461
|
+
const dispatchSubmit = useCallback((raw, opts = {}) => {
|
|
462
|
+
const q = String(raw || '').trim();
|
|
463
|
+
if (!q || !trusted) return;
|
|
290
464
|
setHint('');
|
|
465
|
+
setExpandedItems(null);
|
|
466
|
+
setSearch(null);
|
|
467
|
+
|
|
468
|
+
if (shellMode) { runShellCommand(q); return; }
|
|
469
|
+
if (q === '!') { runShellCommand(''); return; }
|
|
470
|
+
if (q.startsWith('!')) { runShellCommand(q.slice(1)); return; }
|
|
471
|
+
if (q === '?') { push({ type: 'help', text: 'help' }); return; }
|
|
291
472
|
|
|
292
473
|
if (q === '/exit' || q === 'exit' || q === 'quit') { doExit(); return; }
|
|
293
474
|
if (q === '/clear') { clearAll(); return; }
|
|
294
475
|
if (q === '/new') {
|
|
295
476
|
convo.current = [];
|
|
296
477
|
clearAll();
|
|
297
|
-
|
|
478
|
+
pushNotice(`${glyphs.check} New conversation started.`, C.green);
|
|
298
479
|
return;
|
|
299
480
|
}
|
|
300
481
|
if (q === '/reasoning') {
|
|
301
|
-
setShowReasoning((v) => {
|
|
482
|
+
setShowReasoning((v) => { pushNotice(`Reasoning display ${!v ? 'expanded' : 'collapsed'}.`); return !v; });
|
|
302
483
|
return;
|
|
303
484
|
}
|
|
304
485
|
if (q === '/autopilot' || q === '/auto') {
|
|
305
486
|
const next = modeRef.current !== 'autopilot' ? 'autopilot' : 'ask';
|
|
306
487
|
setModeState(next);
|
|
307
|
-
|
|
308
|
-
|
|
488
|
+
pushNotice(
|
|
489
|
+
next === 'autopilot'
|
|
309
490
|
? `${glyphs.bolt} Autopilot ON ${dash()} proposed fixes will be applied automatically. Shift+Tab to cycle modes.`
|
|
310
491
|
: `Mode: ask ${dash()} fixes will wait for your confirmation.`,
|
|
311
|
-
|
|
492
|
+
next === 'autopilot' ? C.warning : C.muted,
|
|
493
|
+
);
|
|
312
494
|
return;
|
|
313
495
|
}
|
|
314
496
|
if (q === '/plan') {
|
|
315
497
|
const next = modeRef.current !== 'plan' ? 'plan' : 'ask';
|
|
316
498
|
setModeState(next);
|
|
317
|
-
|
|
499
|
+
pushNotice(`Mode: ${next} ${dash()} Shift+Tab to cycle.`, C.accent);
|
|
318
500
|
return;
|
|
319
501
|
}
|
|
320
502
|
if (q === '/context' || q === '/usage') {
|
|
321
503
|
const tokens = approxTokens(convo.current);
|
|
322
|
-
|
|
504
|
+
pushNotice(`Context: ~${tokens.toLocaleString()} tokens (${convo.current.length} messages). /compact to summarise.`);
|
|
323
505
|
return;
|
|
324
506
|
}
|
|
325
507
|
if (q === '/compact' || q.startsWith('/compact ')) {
|
|
@@ -328,30 +510,43 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
328
510
|
? `Summarise our conversation so far, focusing on: ${focus}`
|
|
329
511
|
: 'Summarise our conversation so far in a concise way, preserving key facts and decisions.';
|
|
330
512
|
convo.current = [];
|
|
331
|
-
|
|
513
|
+
pushNotice(`${glyphs.arrow} Compacting history…`, C.muted);
|
|
332
514
|
runTurn(prompt);
|
|
333
515
|
return;
|
|
334
516
|
}
|
|
335
517
|
if (q === '/theme') {
|
|
336
|
-
|
|
518
|
+
pushNotice('Theme: dark (default). Palette: purple accent, blue brand, green success, amber warning, red error.\nSet NO_COLOR=1 to disable colour or FORCE_COLOR=1 to force it.');
|
|
337
519
|
return;
|
|
338
520
|
}
|
|
339
521
|
if (q === '/session') {
|
|
340
522
|
const tokens = approxTokens(convo.current);
|
|
341
523
|
const branch = branchRef.current;
|
|
342
524
|
const cwd = shortenPath(process.cwd());
|
|
343
|
-
|
|
344
|
-
|
|
525
|
+
const name = cfg?.tui?.sessionNames?.[process.cwd()] || '(unnamed)';
|
|
526
|
+
pushNotice([
|
|
527
|
+
`Session ${dash()} ${name}`,
|
|
528
|
+
`Path ${dash()} ${cwd}${branch ? ' [' + branch + ']' : ''}`,
|
|
345
529
|
`Messages ${dash()} ${convo.current.length}`,
|
|
346
530
|
`Tokens ${dash()} ~${tokens.toLocaleString()} (approx)`,
|
|
347
531
|
`Model ${dash()} ${model || modelRef.current || 'server default'}`,
|
|
348
532
|
`Mode ${dash()} ${modeRef.current}`,
|
|
349
|
-
].join('\n')
|
|
533
|
+
].join('\n'));
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
if (q === '/resume' || q.startsWith('/resume ') || q === '/continue' || q.startsWith('/continue ')) {
|
|
537
|
+
const name = cfg?.tui?.sessionNames?.[process.cwd()] || '(unnamed)';
|
|
538
|
+
pushNotice([
|
|
539
|
+
`Resume (local stub)`,
|
|
540
|
+
`Name ${dash()} ${name}`,
|
|
541
|
+
`Path ${dash()} ${shortenPath(process.cwd())}`,
|
|
542
|
+
`Messages ${dash()} ${convo.current.length}`,
|
|
543
|
+
`Last AI ${dash()} ${(lastAnswerRef.current || '(none)').slice(0, 120)}`,
|
|
544
|
+
].join('\n'));
|
|
350
545
|
return;
|
|
351
546
|
}
|
|
352
547
|
if (q === '/share') {
|
|
353
548
|
if (!lastAnswerRef.current && convo.current.length === 0) {
|
|
354
|
-
|
|
549
|
+
pushNotice('Nothing to share yet — ask something first.', C.muted); return;
|
|
355
550
|
}
|
|
356
551
|
choiceActionRef.current = (v) => doGist(v);
|
|
357
552
|
setChoice({
|
|
@@ -368,80 +563,195 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
368
563
|
if (q === '/model' || q.startsWith('/model ')) {
|
|
369
564
|
const name = q.slice('/model'.length).trim();
|
|
370
565
|
if (!name) {
|
|
371
|
-
|
|
566
|
+
pushNotice(`Current model: ${model || modelRef.current || 'server default'}.\nUse /model <name> to request a different model.`);
|
|
372
567
|
} else {
|
|
373
568
|
modelRef.current = name;
|
|
374
569
|
setModel(name);
|
|
375
570
|
cfg._model = name;
|
|
376
|
-
|
|
377
|
-
|
|
571
|
+
persistCfg();
|
|
572
|
+
pushNotice(`${glyphs.check} Model set to ${name}. It applies to your next message.`, C.green);
|
|
573
|
+
}
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (q === '/cwd') {
|
|
577
|
+
pushNotice(process.cwd());
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (q === '/cd' || q.startsWith('/cd ')) {
|
|
581
|
+
const arg = q.slice('/cd'.length).trim();
|
|
582
|
+
if (!arg) { pushNotice(`cwd ${dash()} ${process.cwd()}`); return; }
|
|
583
|
+
const target = isAbsolute(arg) ? arg : resolve(process.cwd(), arg);
|
|
584
|
+
if (!existsSync(target)) { pushNotice(`Directory not found: ${target}`, C.red); return; }
|
|
585
|
+
try {
|
|
586
|
+
process.chdir(target);
|
|
587
|
+
branchRef.current = gitBranch(target);
|
|
588
|
+
const ok = isTrustedDir(cfg, target);
|
|
589
|
+
setTrusted(ok);
|
|
590
|
+
if (!ok) openTrustDialog(target);
|
|
591
|
+
pushNotice(`Changed directory to ${shortenPath(target)}`, C.green, true);
|
|
592
|
+
} catch (e) {
|
|
593
|
+
pushNotice(`Could not change directory: ${e.message}`, C.red);
|
|
594
|
+
}
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
if (q === '/allow-all' || q.startsWith('/allow-all ')) {
|
|
598
|
+
const arg = q.slice('/allow-all'.length).trim().toLowerCase();
|
|
599
|
+
const next = arg === 'show' ? allowAll : arg === 'on' ? true : arg === 'off' ? false : !allowAll;
|
|
600
|
+
if (arg !== 'show') {
|
|
601
|
+
cfg.tui = { ...(cfg.tui || {}), allowAll: next };
|
|
602
|
+
persistCfg();
|
|
603
|
+
setAllowAll(next);
|
|
604
|
+
}
|
|
605
|
+
pushNotice(`allow-all ${dash()} ${next ? 'ON' : 'OFF'}`, next ? C.warning : C.muted, arg !== 'show');
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
if (q === '/rename' || q.startsWith('/rename ')) {
|
|
609
|
+
const name = q.slice('/rename'.length).trim() || `session-${new Date().toISOString().slice(0, 16).replace(/[T:]/g, '-')}`;
|
|
610
|
+
cfg.tui = cfg.tui || {};
|
|
611
|
+
cfg.tui.sessionNames = { ...(cfg.tui.sessionNames || {}), [process.cwd()]: name };
|
|
612
|
+
persistCfg();
|
|
613
|
+
pushNotice(`${glyphs.check} Session renamed to ${name}.`, C.green);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (q === '/changelog' || q.startsWith('/changelog')) {
|
|
617
|
+
try {
|
|
618
|
+
const raw = execSync('npm view ispbills-icli version time --json', {
|
|
619
|
+
cwd: process.cwd(), stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000,
|
|
620
|
+
}).toString();
|
|
621
|
+
const data = JSON.parse(raw);
|
|
622
|
+
const versions = Object.entries(data.time || {})
|
|
623
|
+
.filter(([k]) => /^\d/.test(k))
|
|
624
|
+
.slice(-5)
|
|
625
|
+
.map(([k, v]) => ` ${k} ${dash()} ${String(v).slice(0, 10)}`)
|
|
626
|
+
.join('\n');
|
|
627
|
+
pushNotice(`ispbills-icli ${dash()} current ${pkgVersion()}\nRecent npm releases:\n${versions || ' unavailable'}`);
|
|
628
|
+
} catch {
|
|
629
|
+
pushNotice(`ispbills-icli ${dash()} current ${pkgVersion()}\nRecent changelog unavailable offline.`);
|
|
378
630
|
}
|
|
379
631
|
return;
|
|
380
632
|
}
|
|
633
|
+
if (q === '/feedback' || q === '/bug') {
|
|
634
|
+
pushNotice(`Feedback: ${feedbackUrl()}`);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
if (q === '/env') {
|
|
638
|
+
pushNotice([
|
|
639
|
+
`url ${dash()} ${cfg.url}`,
|
|
640
|
+
`user ${dash()} ${cfg.user?.name || cfg.user?.email || 'unknown'}`,
|
|
641
|
+
`model ${dash()} ${model || modelRef.current || 'server default'}`,
|
|
642
|
+
`mode ${dash()} ${shellMode ? 'shell' : modeRef.current}`,
|
|
643
|
+
`memory ${dash()} ${loadNotes(cfg).length} notes`,
|
|
644
|
+
`allowAll ${dash()} ${allowAll ? 'on' : 'off'}`,
|
|
645
|
+
`streamer ${dash()} ${streamerMode ? 'on' : 'off'}`,
|
|
646
|
+
].join('\n'));
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if (q === '/streamer-mode') {
|
|
650
|
+
const next = !streamerMode;
|
|
651
|
+
cfg.tui = { ...(cfg.tui || {}), streamerMode: next };
|
|
652
|
+
persistCfg();
|
|
653
|
+
setStreamerMode(next);
|
|
654
|
+
pushNotice(`streamer-mode ${dash()} ${next ? 'ON' : 'OFF'}`, next ? C.warning : C.muted, true);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
if (q === '/diff') {
|
|
658
|
+
gitCmd(cfg, ['diff', '--stat']).then((r) => {
|
|
659
|
+
const body = (r.stdout || '').trim() || (r.stderr || '').trim() || 'No recent diff in the backup repo.';
|
|
660
|
+
pushNotice(`backup repo diff\n${body.slice(0, 3500)}`, r.ok ? C.muted : C.red);
|
|
661
|
+
});
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
if (q === '/review' || q.startsWith('/review ')) {
|
|
665
|
+
if (!lastAnswerRef.current) { pushNotice('Nothing to review yet — ask something first.', C.muted); return; }
|
|
666
|
+
const focus = q.slice('/review'.length).trim();
|
|
667
|
+
const reviewPrompt = `${focus ? `${focus}. ` : ''}Review the following last answer for correctness, risks, and missing details in the ISP context:\n\n${lastAnswerRef.current}`;
|
|
668
|
+
runTurn(reviewPrompt, { synthetic: false });
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
381
671
|
if (q === '/help' || q === 'help') {
|
|
382
672
|
let group = null;
|
|
383
673
|
const lines = SLASH_COMMANDS.map((c) => {
|
|
384
674
|
const head = c.group !== group ? ((group = c.group), `\n ${c.group}\n`) : '';
|
|
385
|
-
return head + ` ${(c.cmd + (c.hint ? ' ' + c.hint : '')).padEnd(
|
|
675
|
+
return head + ` ${(c.cmd + (c.hint ? ' ' + c.hint : '')).padEnd(30)}${c.desc}`;
|
|
386
676
|
}).join('\n');
|
|
387
|
-
|
|
677
|
+
pushNotice(
|
|
678
|
+
'Commands:' + lines +
|
|
679
|
+
'\n\n Keyboard shortcuts:\n' +
|
|
680
|
+
' Shift+Tab cycle modes: ask → plan → autopilot\n' +
|
|
681
|
+
' Ctrl+T toggle reasoning display\n' +
|
|
682
|
+
' Ctrl+L clear screen\n' +
|
|
683
|
+
' Ctrl+R reverse history search\n' +
|
|
684
|
+
' Ctrl+F timeline search\n' +
|
|
685
|
+
' Ctrl+O expand recent items\n' +
|
|
686
|
+
' Ctrl+E expand all items\n' +
|
|
687
|
+
' Ctrl+G open $EDITOR\n' +
|
|
688
|
+
' Ctrl+X e open $EDITOR (alt)\n' +
|
|
689
|
+
' Ctrl+X / slash picker mid-prompt\n' +
|
|
690
|
+
' Ctrl+X b promote task to background\n' +
|
|
691
|
+
' Ctrl+X o open most recent link\n' +
|
|
692
|
+
' Ctrl+S run and preserve input\n' +
|
|
693
|
+
' Ctrl+N / Ctrl+P cycle followup chips\n' +
|
|
694
|
+
' Ctrl+Enter queue message while busy\n' +
|
|
695
|
+
' Ctrl+V paste attachment\n' +
|
|
696
|
+
' Ctrl+Z suspend (Unix)\n' +
|
|
697
|
+
' Shift+Enter insert newline\n' +
|
|
698
|
+
' Alt+← / Alt+→ jump word\n' +
|
|
699
|
+
' Ctrl+Home / End jump to start/end\n' +
|
|
700
|
+
' Ctrl+D shutdown\n' +
|
|
701
|
+
' Ctrl+C × 2 exit\n' +
|
|
702
|
+
' Esc cancel / exit mode\n'
|
|
703
|
+
);
|
|
388
704
|
return;
|
|
389
705
|
}
|
|
390
706
|
if (q === '/history') {
|
|
391
707
|
const us = convo.current.filter((m) => m.role === 'user');
|
|
392
|
-
|
|
708
|
+
pushNotice(us.length ? us.map((m, i) => ` ${i + 1}. ${m.content.slice(0, 80)}`).join('\n') : 'No history yet.');
|
|
393
709
|
return;
|
|
394
710
|
}
|
|
395
711
|
if (q === '/logout') { onExternal?.('logout'); doExit(); return; }
|
|
396
712
|
if (q === '/update') { onExternal?.('update'); doExit(); return; }
|
|
397
713
|
|
|
398
|
-
// ── Memory ──
|
|
399
714
|
if (q === '/remember' || q.startsWith('/remember ')) {
|
|
400
715
|
const note = q.slice('/remember'.length).trim();
|
|
401
|
-
if (!note) {
|
|
716
|
+
if (!note) { pushNotice('Usage: /remember <note>'); return; }
|
|
402
717
|
const notes = addNote(cfg, note);
|
|
403
|
-
|
|
718
|
+
pushNotice(`${glyphs.check} Remembered (${notes.length} note${notes.length > 1 ? 's' : ''}). I'll keep this in mind.`, C.green);
|
|
404
719
|
return;
|
|
405
720
|
}
|
|
406
721
|
if (q === '/memory') {
|
|
407
722
|
const notes = loadNotes(cfg);
|
|
408
|
-
|
|
723
|
+
pushNotice(notes.length
|
|
409
724
|
? 'Memory notes:\n' + notes.map((n, i) => ` ${i + 1}. ${n.text}`).join('\n') + '\n\n /forget <n|all> to remove.'
|
|
410
|
-
: 'No memory notes yet. Add one with /remember <note>.'
|
|
725
|
+
: 'No memory notes yet. Add one with /remember <note>.');
|
|
411
726
|
return;
|
|
412
727
|
}
|
|
413
728
|
if (q === '/forget' || q.startsWith('/forget ')) {
|
|
414
729
|
const which = q.slice('/forget'.length).trim();
|
|
415
|
-
if (!which) {
|
|
730
|
+
if (!which) { pushNotice('Usage: /forget <number | all | text>'); return; }
|
|
416
731
|
const { notes, removed } = forgetNote(cfg, which);
|
|
417
|
-
|
|
418
|
-
text: removed ? `${glyphs.check} Forgot ${removed} note(s). ${notes.length} remain.` : 'No matching note found.',
|
|
419
|
-
color: removed ? C.green : C.gray });
|
|
732
|
+
pushNotice(removed ? `${glyphs.check} Forgot ${removed} note(s). ${notes.length} remain.` : 'No matching note found.', removed ? C.green : C.gray);
|
|
420
733
|
return;
|
|
421
734
|
}
|
|
422
|
-
|
|
423
|
-
// ── Clipboard ──
|
|
424
735
|
if (q === '/copy') {
|
|
425
736
|
const ans = lastAnswerRef.current;
|
|
426
|
-
if (!ans) {
|
|
427
|
-
copyToClipboard(ans).then((tool) =>
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
737
|
+
if (!ans) { pushNotice('Nothing to copy yet — ask something first.', C.gray); return; }
|
|
738
|
+
copyToClipboard(ans).then((tool) => pushNotice(
|
|
739
|
+
tool ? `${glyphs.check} Copied last answer (${ans.length} chars) to the clipboard.`
|
|
740
|
+
: `${glyphs.cross} No clipboard tool found. Install xclip / wl-clipboard (Linux), or use macOS/Windows.`,
|
|
741
|
+
tool ? C.green : C.red,
|
|
742
|
+
));
|
|
431
743
|
return;
|
|
432
744
|
}
|
|
433
|
-
|
|
434
|
-
// ── GitHub gist ──
|
|
435
745
|
if (q === '/gist' || q.startsWith('/gist ')) {
|
|
436
746
|
const rest = q.slice('/gist'.length).trim();
|
|
437
747
|
if (rest.startsWith('token ')) {
|
|
438
748
|
cfg.github_token = rest.slice('token '.length).trim();
|
|
439
|
-
|
|
440
|
-
|
|
749
|
+
persistCfg();
|
|
750
|
+
pushNotice(`${glyphs.check} GitHub token saved. /gist will use it (needs the "gist" scope).`, C.green);
|
|
441
751
|
return;
|
|
442
752
|
}
|
|
443
753
|
if (!lastAnswerRef.current && convo.current.length === 0) {
|
|
444
|
-
|
|
754
|
+
pushNotice('Nothing to save yet — ask something first.', C.gray);
|
|
445
755
|
return;
|
|
446
756
|
}
|
|
447
757
|
choiceActionRef.current = (v) => doGist(v);
|
|
@@ -456,42 +766,383 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
456
766
|
});
|
|
457
767
|
return;
|
|
458
768
|
}
|
|
459
|
-
|
|
460
|
-
// ── Local git repo ──
|
|
461
769
|
if (q === '/git' || q.startsWith('/git ')) {
|
|
462
770
|
const args = q.slice('/git'.length).trim();
|
|
463
|
-
if (!args) {
|
|
771
|
+
if (!args) { pushNotice('Usage: /git <status | log | commit -m "msg" | push | diff | remote add origin <url>>'); return; }
|
|
464
772
|
gitCmd(cfg, splitArgs(args)).then((r) => {
|
|
465
773
|
const body = (r.stdout || '').trim() || (r.stderr || '').trim() || (r.ok ? '(done)' : '(no output)');
|
|
466
|
-
|
|
774
|
+
pushNotice(`$ git ${args}\n${body.slice(0, 3500)}`, r.ok ? C.muted : C.red);
|
|
467
775
|
});
|
|
468
776
|
return;
|
|
469
777
|
}
|
|
470
778
|
|
|
779
|
+
// ── Terminal setup ──────────────────────────────────────────────────────
|
|
780
|
+
if (q === '/terminal-setup') {
|
|
781
|
+
pushNotice([
|
|
782
|
+
'Terminal setup — enable Shift+Enter for multi-line input:',
|
|
783
|
+
'',
|
|
784
|
+
' iTerm2 (macOS): Preferences → Keys → Add: Shift+Enter → Send escape sequence: \\n',
|
|
785
|
+
' Kitty: kitty.conf → map shift+return send_text all \\n',
|
|
786
|
+
' WezTerm: Auto-supported.',
|
|
787
|
+
' Windows Terminal: Add JSON keybinding for shiftEnter → newline.',
|
|
788
|
+
' VS Code terminal: Already supported.',
|
|
789
|
+
' Other: Try Alt+Enter (sends \\n in most terminals).',
|
|
790
|
+
'',
|
|
791
|
+
'If your terminal supports bracketed paste, multi-line paste also works.',
|
|
792
|
+
].join('\n'));
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// ── Experimental ────────────────────────────────────────────────────────
|
|
797
|
+
if (q === '/experimental' || q.startsWith('/experimental ')) {
|
|
798
|
+
const arg = q.slice('/experimental'.length).trim().toLowerCase();
|
|
799
|
+
const current = Boolean(cfg?.tui?.experimental);
|
|
800
|
+
const next = arg === 'show' ? current : arg === 'on' ? true : arg === 'off' ? false : !current;
|
|
801
|
+
if (arg !== 'show') {
|
|
802
|
+
cfg.tui = { ...(cfg.tui || {}), experimental: next };
|
|
803
|
+
persistCfg();
|
|
804
|
+
}
|
|
805
|
+
pushNotice(`experimental features ${dash()} ${next ? 'ON' : 'OFF'}`, next ? C.warning : C.muted, arg !== 'show');
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// ── Permissions ──────────────────────────────────────────────────────────
|
|
810
|
+
if (q === '/permissions' || q.startsWith('/permissions ')) {
|
|
811
|
+
const arg = q.slice('/permissions'.length).trim().toLowerCase();
|
|
812
|
+
if (arg === 'reset') {
|
|
813
|
+
cfg.tui = { ...(cfg.tui || {}), allowedTools: [], allowAll: false };
|
|
814
|
+
persistCfg();
|
|
815
|
+
setAllowAll(false);
|
|
816
|
+
pushNotice(`${glyphs.check} All permissions reset.`, C.green);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
const tools = cfg?.tui?.allowedTools || [];
|
|
820
|
+
pushNotice([
|
|
821
|
+
`Permissions ${dash()} ${shortenPath(process.cwd())}`,
|
|
822
|
+
`allow-all ${dash()} ${allowAll ? 'ON' : 'off'}`,
|
|
823
|
+
`tools ${dash()} ${tools.length ? tools.join(', ') : 'none pre-approved'}`,
|
|
824
|
+
`dirs ${dash()} ${Object.keys(cfg?.tui?.trustedDirs || {}).map(shortenPath).join(', ') || 'none'}`,
|
|
825
|
+
'',
|
|
826
|
+
'Use /reset-allowed-tools to clear, /allow-all to toggle, /add-dir PATH to add.',
|
|
827
|
+
].join('\n'));
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
if (q === '/reset-allowed-tools') {
|
|
831
|
+
cfg.tui = { ...(cfg.tui || {}), allowedTools: [] };
|
|
832
|
+
persistCfg();
|
|
833
|
+
pushNotice(`${glyphs.check} In-session tool approvals cleared.`, C.green);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
if (q === '/allow-tool' || q.startsWith('/allow-tool ')) {
|
|
837
|
+
const tool = q.slice('/allow-tool'.length).trim();
|
|
838
|
+
if (!tool) { pushNotice('Usage: /allow-tool TOOL (e.g. shell(git))'); return; }
|
|
839
|
+
const tools = [...(cfg?.tui?.allowedTools || []), tool];
|
|
840
|
+
cfg.tui = { ...(cfg.tui || {}), allowedTools: tools };
|
|
841
|
+
persistCfg();
|
|
842
|
+
pushNotice(`${glyphs.check} Pre-approved: ${tool}`, C.green);
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
if (q === '/sandbox' || q.startsWith('/sandbox ')) {
|
|
846
|
+
const arg = q.slice('/sandbox'.length).trim().toLowerCase();
|
|
847
|
+
const current = Boolean(cfg?.tui?.sandbox);
|
|
848
|
+
const next = arg === 'enable' ? true : arg === 'disable' ? false : !current;
|
|
849
|
+
cfg.tui = { ...(cfg.tui || {}), sandbox: next };
|
|
850
|
+
persistCfg();
|
|
851
|
+
pushNotice(`sandbox mode ${dash()} ${next ? 'ENABLED — restricted filesystem/network access' : 'disabled'}`, next ? C.warning : C.muted, true);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
if (q === '/add-dir' || q.startsWith('/add-dir ')) {
|
|
855
|
+
const p = q.slice('/add-dir'.length).trim();
|
|
856
|
+
if (!p) { pushNotice('Usage: /add-dir PATH'); return; }
|
|
857
|
+
const target = isAbsolute(p) ? p : resolve(process.cwd(), p);
|
|
858
|
+
if (!existsSync(target)) { pushNotice(`Directory not found: ${target}`, C.red); return; }
|
|
859
|
+
cfg.tui = { ...(cfg.tui || {}), trustedDirs: { ...(cfg.tui?.trustedDirs || {}), [target]: true } };
|
|
860
|
+
persistCfg();
|
|
861
|
+
pushNotice(`${glyphs.check} Added to allowed directories: ${shortenPath(target)}`, C.green);
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
if (q === '/list-dirs') {
|
|
865
|
+
const dirs = Object.keys(cfg?.tui?.trustedDirs || {});
|
|
866
|
+
pushNotice(dirs.length
|
|
867
|
+
? 'Allowed directories:\n' + dirs.map((d, i) => ` ${i + 1}. ${shortenPath(d)}`).join('\n')
|
|
868
|
+
: 'No directories added yet. Use /add-dir PATH or trust a directory at launch.');
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
if (q === '/init') {
|
|
872
|
+
const fp = resolve(process.cwd(), '.github/copilot-instructions.md');
|
|
873
|
+
if (existsSync(fp)) {
|
|
874
|
+
pushNotice(`Copilot instructions already exist at ${shortenPath(fp)}. Edit it directly.`);
|
|
875
|
+
} else {
|
|
876
|
+
runTurn('Create a .github/copilot-instructions.md file for this repository with sensible defaults for an ISP NOC tool.');
|
|
877
|
+
}
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// ── Agents & tools ───────────────────────────────────────────────────────
|
|
882
|
+
if (q === '/agent') {
|
|
883
|
+
pushNotice([
|
|
884
|
+
'Built-in agents:',
|
|
885
|
+
' explore — Quick codebase analysis; questions without polluting main context',
|
|
886
|
+
' task — Runs builds, tests, linters with brief success/full failure output',
|
|
887
|
+
' general — Complex multi-step tasks with full toolset, separate context',
|
|
888
|
+
' code-review — Surfaces only genuine issues; minimal noise',
|
|
889
|
+
' research — Deep research across codebase, repos, web with citations',
|
|
890
|
+
' rubber-duck — Constructive critic; consulted automatically on non-trivial tasks',
|
|
891
|
+
'',
|
|
892
|
+
'Use: /rubber-duck [prompt] · /research TOPIC · /fleet [prompt]',
|
|
893
|
+
].join('\n'));
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
if (q === '/skills') {
|
|
897
|
+
pushNotice([
|
|
898
|
+
'Skills (ISP NOC context):',
|
|
899
|
+
' optical — Optical power analysis and ONU diagnostics (enabled)',
|
|
900
|
+
' fleet-check — Parallel multi-device scanning (enabled)',
|
|
901
|
+
' backup — Automated config backup via git (enabled)',
|
|
902
|
+
' customers — Customer lookup and ticket integration (enabled)',
|
|
903
|
+
' alarms — Real-time alarm monitoring (enabled)',
|
|
904
|
+
'',
|
|
905
|
+
'Use /experimental to unlock additional skills.',
|
|
906
|
+
].join('\n'));
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
if (q === '/tasks') {
|
|
910
|
+
const active = activeTurns.current;
|
|
911
|
+
const queued = queuedRef.current ? 1 : 0;
|
|
912
|
+
pushNotice([
|
|
913
|
+
`Background tasks ${dash()} ${active > 0 ? `${active} running` : 'idle'}`,
|
|
914
|
+
`Queued messages ${dash()} ${queued}`,
|
|
915
|
+
`Shell mode ${dash()} ${shellMode ? 'active' : 'off'}`,
|
|
916
|
+
'',
|
|
917
|
+
'Use Ctrl+Enter to queue a message while busy. Ctrl+X b promotes to background.',
|
|
918
|
+
].join('\n'));
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
if (q === '/fleet' || q.startsWith('/fleet ')) {
|
|
922
|
+
const prompt = q.slice('/fleet'.length).trim();
|
|
923
|
+
runTurn(prompt
|
|
924
|
+
? `Fleet mode: spawn parallel subagents to work on: ${prompt}. Report combined results.`
|
|
925
|
+
: 'Fleet mode: spawn parallel subagents to check status of all devices and report combined results.');
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
if (q === '/rubber-duck' || q.startsWith('/rubber-duck ')) {
|
|
929
|
+
const prompt = q.slice('/rubber-duck'.length).trim();
|
|
930
|
+
const context = lastAnswerRef.current ? `\nContext from last answer:\n${lastAnswerRef.current.slice(0, 1000)}` : '';
|
|
931
|
+
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.')}`);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
if (q === '/research' || q.startsWith('/research ')) {
|
|
935
|
+
const topic = q.slice('/research'.length).trim();
|
|
936
|
+
if (!topic) { pushNotice('Usage: /research TOPIC'); return; }
|
|
937
|
+
runTurn(`Run deep research on: ${topic}. Search GitHub repos, docs, and known ISP NOC resources. Produce a report with citations.`);
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
if (q === '/mcp' || q.startsWith('/mcp ')) {
|
|
941
|
+
const sub = q.slice('/mcp'.length).trim();
|
|
942
|
+
pushNotice([
|
|
943
|
+
`MCP servers ${dash()} ${sub || 'show'}`,
|
|
944
|
+
' GitHub MCP server is preconfigured (github.com resources: repos, issues, PRs).',
|
|
945
|
+
' Config: ~/.config/ispbills-icli/mcp-config.json',
|
|
946
|
+
'',
|
|
947
|
+
sub === 'show' || !sub
|
|
948
|
+
? ' No additional MCP servers configured. Use /mcp add to add one.'
|
|
949
|
+
: ` Subcommand '${sub}' — MCP management UI coming soon.`,
|
|
950
|
+
].join('\n'));
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
if (q === '/plugin' || q.startsWith('/plugin ')) {
|
|
954
|
+
pushNotice(`Plugin marketplace — coming soon. Use npm to install ispbills-icli-* plugins.`);
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
if (q === '/lsp' || q.startsWith('/lsp ')) {
|
|
958
|
+
pushNotice(`LSP integration — configure ~/.config/ispbills-icli/lsp-config.json\nSubcommands: show, test, reload, help.`);
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
if (q === '/ide') {
|
|
962
|
+
pushNotice(`IDE connection — connect via VS Code extension. Install "IspBills AI" from the VS Code marketplace.`);
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
if (q === '/pr' || q.startsWith('/pr ')) {
|
|
966
|
+
const sub = q.slice('/pr'.length).trim() || 'view';
|
|
967
|
+
runTurn(`GitHub PR ${sub}: check the current branch's pull request status, list any open PRs, and summarise review status.`);
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
if (q === '/delegate' || q.startsWith('/delegate ')) {
|
|
971
|
+
const prompt = q.slice('/delegate'.length).trim();
|
|
972
|
+
runTurn(prompt
|
|
973
|
+
? `Delegate the following changes to a remote repo with an AI-generated PR: ${prompt}`
|
|
974
|
+
: 'Summarise what changes could be delegated to a remote PR and ask which to proceed with.');
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
if (q === '/instructions') {
|
|
978
|
+
const paths = [
|
|
979
|
+
resolve(process.cwd(), '.github/copilot-instructions.md'),
|
|
980
|
+
resolve(process.cwd(), 'AGENTS.md'),
|
|
981
|
+
resolve(process.cwd(), 'CLAUDE.md'),
|
|
982
|
+
resolve(homedir(), '.copilot/copilot-instructions.md'),
|
|
983
|
+
];
|
|
984
|
+
const found = paths.filter(existsSync).map(shortenPath);
|
|
985
|
+
pushNotice(found.length
|
|
986
|
+
? 'Custom instruction files:\n' + found.map((p, i) => ` ${i + 1}. ${p}`).join('\n') + '\n\n Edit any to customise AI behaviour.'
|
|
987
|
+
: 'No custom instruction files found.\n Use /init to create .github/copilot-instructions.md.');
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
if (q === '/remote' || q.startsWith('/remote ')) {
|
|
991
|
+
pushNotice('Remote steering — allows steering this session from another device via a shared link. Coming soon.');
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
if (q === '/chronicle' || q.startsWith('/chronicle ')) {
|
|
995
|
+
const sub = q.slice('/chronicle'.length).trim() || 'standup';
|
|
996
|
+
const prompts = {
|
|
997
|
+
standup: 'Generate a concise standup summary from this session: what was worked on, what was completed, and any blockers.',
|
|
998
|
+
tips: 'Based on this session, what best practices or tips would improve my ISP NOC workflow?',
|
|
999
|
+
improve: 'Analyse this session and suggest concrete improvements to my approach or tooling.',
|
|
1000
|
+
reindex: 'Reindex and categorise all actions taken in this session.',
|
|
1001
|
+
};
|
|
1002
|
+
runTurn(prompts[sub] || `Chronicle ${sub}: summarise this session focusing on ${sub}.`);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
if (q === '/keep-alive' || q.startsWith('/keep-alive ')) {
|
|
1006
|
+
const arg = q.slice('/keep-alive'.length).trim().toLowerCase();
|
|
1007
|
+
pushNotice(`keep-alive ${dash()} ${arg || 'status'}. Note: keep-alive prevents machine sleep. Use /keep-alive on|off|busy.`, C.muted);
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
if (q === '/ask' || q.startsWith('/ask ')) {
|
|
1011
|
+
const question2 = q.slice('/ask'.length).trim();
|
|
1012
|
+
if (!question2) { pushNotice('Usage: /ask QUESTION — quick side-question without adding to history'); return; }
|
|
1013
|
+
pushNotice(`[ask] ${question2}`, C.muted, false);
|
|
1014
|
+
// Run as an isolated turn that doesn't add to main history
|
|
1015
|
+
const savedConvo = [...convo.current];
|
|
1016
|
+
convo.current = [];
|
|
1017
|
+
runTurn(question2).then(() => { convo.current = savedConvo; });
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
if (q === '/after' || q.startsWith('/after ')) {
|
|
1021
|
+
pushNotice('Scheduled prompts (/after, /every) are experimental. Feature coming in a future release.', C.muted);
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
if (q === '/every' || q.startsWith('/every ')) {
|
|
1025
|
+
pushNotice('Scheduled prompts (/every) are experimental. Feature coming in a future release.', C.muted);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// ── Misc system ──────────────────────────────────────────────────────────
|
|
1030
|
+
if (q === '/restart') {
|
|
1031
|
+
pushNotice(`${glyphs.arrow} Restarting iCli…`, C.muted, true);
|
|
1032
|
+
setTimeout(() => { try { process.exit(0); } catch {} }, 500);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
if (q === '/downgrade' || q.startsWith('/downgrade ')) {
|
|
1036
|
+
const ver = q.slice('/downgrade'.length).trim();
|
|
1037
|
+
if (!ver) { pushNotice('Usage: /downgrade VERSION (e.g. /downgrade 8.5.3)'); return; }
|
|
1038
|
+
pushNotice(`${glyphs.arrow} Rolling back to ispbills-icli@${ver}…`, C.warning, true);
|
|
1039
|
+
const res = spawnSync('npm', ['install', '-g', `ispbills-icli@${ver}`], { stdio: 'pipe', encoding: 'utf8' });
|
|
1040
|
+
pushNotice(res.status === 0
|
|
1041
|
+
? `${glyphs.check} Downgraded to ${ver}. Restart iCli.`
|
|
1042
|
+
: `${glyphs.cross} Downgrade failed: ${(res.stderr || '').slice(0, 200)}`, res.status === 0 ? C.green : C.red);
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
if (q === '/app') {
|
|
1046
|
+
pushNotice('Launching GitHub Copilot desktop app… (stub — install from github.com/github/copilot-for-desktop)', C.muted);
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
if (q === '/user' || q.startsWith('/user ')) {
|
|
1050
|
+
const u = cfg.user;
|
|
1051
|
+
pushNotice([
|
|
1052
|
+
`User ${dash()} ${u?.name || u?.email || 'unknown'}`,
|
|
1053
|
+
`Email ${dash()} ${u?.email || 'n/a'}`,
|
|
1054
|
+
`Role ${dash()} ${u?.role || 'operator'}`,
|
|
1055
|
+
`URL ${dash()} ${cfg.url || 'n/a'}`,
|
|
1056
|
+
].join('\n'));
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
if (q === '/login') {
|
|
1060
|
+
pushNotice('Run `icli login` from a new terminal to re-authenticate. /logout then restart to force re-login.', C.muted);
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
if (q === '/whoami') {
|
|
1064
|
+
const u = cfg.user;
|
|
1065
|
+
pushNotice([
|
|
1066
|
+
`${u?.name || u?.email || 'unknown'} ${dash()} ${u?.role || 'operator'}`,
|
|
1067
|
+
u?.email ? `email ${dash()} ${u.email}` : '',
|
|
1068
|
+
`url ${dash()} ${cfg.url || 'n/a'}`,
|
|
1069
|
+
].filter(Boolean).join('\n'));
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
if (q === '/clikit' || q.startsWith('/clikit ')) {
|
|
1073
|
+
pushNotice('clikit component preview — UI component library. Coming soon.', C.muted);
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
if (q === '/extensions' || q.startsWith('/extensions ')) {
|
|
1077
|
+
pushNotice('Extensions — manage CLI extensions at .github/extensions/. Coming soon.', C.muted);
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
if (q === '/reset') {
|
|
1081
|
+
convo.current = [];
|
|
1082
|
+
clearAll();
|
|
1083
|
+
pushNotice(`${glyphs.check} Conversation reset.`, C.green);
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
if (q === '/models' || q.startsWith('/models ')) {
|
|
1087
|
+
const name = q.slice('/models'.length).trim();
|
|
1088
|
+
if (!name) {
|
|
1089
|
+
pushNotice(`Current model: ${model || modelRef.current || 'server default'}.\nUse /model <name> or /models <name> to set.`);
|
|
1090
|
+
} else {
|
|
1091
|
+
modelRef.current = name;
|
|
1092
|
+
setModel(name);
|
|
1093
|
+
cfg._model = name;
|
|
1094
|
+
persistCfg();
|
|
1095
|
+
pushNotice(`${glyphs.check} Model set to ${name}.`, C.green);
|
|
1096
|
+
}
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
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
|
+
const turnOpts = {};
|
|
471
1113
|
let question = q;
|
|
472
|
-
const opts = {};
|
|
473
1114
|
if (q.startsWith('/')) {
|
|
474
1115
|
const mapped = slashToQuestion(q);
|
|
475
1116
|
if (mapped) {
|
|
476
1117
|
question = mapped;
|
|
477
1118
|
if (q === '/backup' || q.startsWith('/backup ')) {
|
|
478
|
-
|
|
479
|
-
|
|
1119
|
+
turnOpts.archive = 'backup';
|
|
1120
|
+
turnOpts.label = q.slice('/backup'.length).trim() || 'fleet';
|
|
480
1121
|
}
|
|
481
|
-
} else {
|
|
1122
|
+
} else {
|
|
1123
|
+
pushNotice(`Unknown command: ${q}. Type /help.`);
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
482
1126
|
}
|
|
483
|
-
runTurn(question,
|
|
484
|
-
}, [doExit,
|
|
1127
|
+
runTurn(question, turnOpts);
|
|
1128
|
+
}, [allowAll, cfg, clearAll, doExit, doGist, modeRef, model, onExternal, openTrustDialog, persistCfg, push, pushNotice, runShellCommand, runTurn, setModeState, shellMode, streamerMode, trusted]);
|
|
1129
|
+
|
|
1130
|
+
useEffect(() => {
|
|
1131
|
+
dispatchSubmitRef.current = dispatchSubmit;
|
|
1132
|
+
}, [dispatchSubmit]);
|
|
1133
|
+
|
|
1134
|
+
useEffect(() => {
|
|
1135
|
+
if (initialQuestion && trusted && !choice && convo.current.length === 0) dispatchSubmit(initialQuestion);
|
|
1136
|
+
}, [initialQuestion, trusted, choice, dispatchSubmit]);
|
|
485
1137
|
|
|
486
|
-
// Interactive prompt/choice handling (owns the keyboard while open).
|
|
487
1138
|
const pickChoice = useCallback((value) => {
|
|
488
1139
|
const act = choiceActionRef.current;
|
|
489
1140
|
choiceActionRef.current = null;
|
|
490
1141
|
setChoice(null);
|
|
491
|
-
if (act) {
|
|
492
|
-
if (value == null) {
|
|
1142
|
+
if (act) { act(value); return; }
|
|
1143
|
+
if (value == null) { pushNotice('Dismissed.', C.muted, true); return; }
|
|
493
1144
|
runTurn(String(value));
|
|
494
|
-
}, [
|
|
1145
|
+
}, [pushNotice, runTurn]);
|
|
495
1146
|
|
|
496
1147
|
useInput((input, key) => {
|
|
497
1148
|
if (!choice) return;
|
|
@@ -520,9 +1171,37 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
520
1171
|
}
|
|
521
1172
|
});
|
|
522
1173
|
|
|
523
|
-
|
|
1174
|
+
const openSearch = useCallback(() => {
|
|
1175
|
+
setSearch({ query: '', active: 0 });
|
|
1176
|
+
setHint('Timeline search');
|
|
1177
|
+
}, []);
|
|
1178
|
+
|
|
1179
|
+
const searchMatches = search
|
|
1180
|
+
? items
|
|
1181
|
+
.map((it) => ({ ...it, preview: itemPreview(it) }))
|
|
1182
|
+
.filter((it) => !search.query || it.preview.toLowerCase().includes(search.query.toLowerCase()))
|
|
1183
|
+
: [];
|
|
1184
|
+
|
|
1185
|
+
useInput((input, key) => {
|
|
1186
|
+
if (!search) return;
|
|
1187
|
+
if (key.escape || (key.ctrl && input === 'c')) { setSearch(null); setHint(''); return; }
|
|
1188
|
+
if (key.backspace || key.delete) { setSearch((s) => ({ ...s, query: s.query.slice(0, -1), active: 0 })); return; }
|
|
1189
|
+
if (key.upArrow) { setSearch((s) => ({ ...s, active: Math.max(0, s.active - 1) })); return; }
|
|
1190
|
+
if (key.downArrow || key.tab) { setSearch((s) => ({ ...s, active: Math.min(Math.max(0, searchMatches.length - 1), s.active + 1) })); return; }
|
|
1191
|
+
if (key.return) {
|
|
1192
|
+
if (searchMatches.length) showExpanded('search results', [searchMatches[search.active] || searchMatches[0]]);
|
|
1193
|
+
setSearch(null);
|
|
1194
|
+
setHint('');
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
if (input && !key.ctrl && !key.meta) {
|
|
1198
|
+
const printable = input.replace(/[\x00-\x1f\x7f]/g, '');
|
|
1199
|
+
if (printable) setSearch((s) => ({ ...s, query: s.query + printable, active: 0 }));
|
|
1200
|
+
}
|
|
1201
|
+
});
|
|
1202
|
+
|
|
524
1203
|
useInput((input, key) => {
|
|
525
|
-
if (choice) return;
|
|
1204
|
+
if (choice || search) return;
|
|
526
1205
|
if (key.ctrl && input === 'c') {
|
|
527
1206
|
if (busy) { abortRef.current?.abort(); setHint(''); return; }
|
|
528
1207
|
const now = Date.now();
|
|
@@ -531,11 +1210,12 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
531
1210
|
setHint('Press Ctrl+C again to exit');
|
|
532
1211
|
return;
|
|
533
1212
|
}
|
|
534
|
-
if (key.ctrl && input === 'd') { doExit(); return; }
|
|
1213
|
+
if (key.ctrl && input === 'd') { doExit(); return; }
|
|
535
1214
|
if (key.escape && busy) { abortRef.current?.abort(); return; }
|
|
536
|
-
if (key.escape &&
|
|
1215
|
+
if (key.escape && shellMode && !composerText.trim()) { setShellMode(false); pushNotice('Exited shell mode.', C.muted, true); return; }
|
|
1216
|
+
if (key.escape && expandedItems) { setExpandedItems(null); return; }
|
|
1217
|
+
if (key.escape && chips.length) { setChips([]); return; }
|
|
537
1218
|
if (key.tab && key.shift && !busy) {
|
|
538
|
-
// Cycle: ask → plan → autopilot → ask
|
|
539
1219
|
const cur = MODES.indexOf(modeRef.current);
|
|
540
1220
|
const next = MODES[(cur + 1) % MODES.length];
|
|
541
1221
|
setModeState(next);
|
|
@@ -544,75 +1224,101 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
544
1224
|
}
|
|
545
1225
|
if (key.ctrl && input === 't') { setShowReasoning((v) => !v); return; }
|
|
546
1226
|
if (key.ctrl && input === 'l' && !busy) { clearAll(); return; }
|
|
547
|
-
|
|
548
|
-
if (key.ctrl && input === '
|
|
549
|
-
|
|
550
|
-
}
|
|
551
|
-
if (key.ctrl && input === 'p' && chips.length) {
|
|
552
|
-
|
|
553
|
-
}
|
|
554
|
-
// Tab cycles the tab bar (when not in slash palette)
|
|
555
|
-
if (key.tab && !key.shift && !busy) {
|
|
1227
|
+
if (key.ctrl && input === 'f' && !busy) { openSearch(); return; }
|
|
1228
|
+
if (key.ctrl && input === 'o' && !busy && !composerText.trim()) { showExpanded('recent items', items.slice(-5)); return; }
|
|
1229
|
+
if (key.ctrl && input === 'e' && !busy && !composerText.trim()) { showExpanded('all items', items); return; }
|
|
1230
|
+
if (key.ctrl && input === 'n' && chips.length) { setActiveChip((i) => (i + 1) % chips.length); return; }
|
|
1231
|
+
if (key.ctrl && input === 'p' && chips.length) { setActiveChip((i) => (i - 1 + chips.length) % chips.length); return; }
|
|
1232
|
+
if (key.tab && !key.shift && !busy && !composerText.trim()) {
|
|
556
1233
|
setActiveTab((t) => {
|
|
557
1234
|
const tabs = ['session', 'devices', 'customers', 'alarms'];
|
|
558
1235
|
return tabs[(tabs.indexOf(t) + 1) % tabs.length];
|
|
559
1236
|
});
|
|
560
|
-
return;
|
|
561
1237
|
}
|
|
562
1238
|
});
|
|
563
1239
|
|
|
564
|
-
//
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
1240
|
+
// Tab-specific content panels (shown when not on Session tab)
|
|
1241
|
+
const TAB_CONTENT = {
|
|
1242
|
+
devices: {
|
|
1243
|
+
title: 'Devices',
|
|
1244
|
+
commands: [
|
|
1245
|
+
{ cmd: '/status', desc: 'Fleet health summary' },
|
|
1246
|
+
{ cmd: '/reachable', desc: 'Who is up / down' },
|
|
1247
|
+
{ cmd: '/alarms', desc: 'Active alarms' },
|
|
1248
|
+
{ cmd: '/check [device]', desc: 'Full device diagnostics' },
|
|
1249
|
+
{ cmd: '/uptime [device]', desc: 'Uptime and last reboot' },
|
|
1250
|
+
{ cmd: '/cpu [device]', desc: 'CPU / memory load' },
|
|
1251
|
+
{ cmd: '/offline', desc: 'All offline devices' },
|
|
1252
|
+
{ cmd: '/flapping [device]', desc: 'Flapping interfaces' },
|
|
1253
|
+
],
|
|
1254
|
+
hint: 'Type a command or press Enter on Session tab to interact',
|
|
1255
|
+
},
|
|
1256
|
+
customers: {
|
|
1257
|
+
title: 'Customers',
|
|
1258
|
+
commands: [
|
|
1259
|
+
{ cmd: '/customer [name]', desc: 'Look up a customer' },
|
|
1260
|
+
{ cmd: '/online', desc: 'Online PPPoE sessions' },
|
|
1261
|
+
{ cmd: '/pppoe [user]', desc: 'PPPoE session detail' },
|
|
1262
|
+
{ cmd: '/dhcp', desc: 'DHCP leases & pool usage' },
|
|
1263
|
+
{ cmd: '/onu [id]', desc: 'ONU signal and status' },
|
|
1264
|
+
{ cmd: '/bandwidth [device]', desc: 'Traffic / bandwidth' },
|
|
1265
|
+
],
|
|
1266
|
+
hint: 'Switch to Session tab (Tab) to send commands',
|
|
1267
|
+
},
|
|
1268
|
+
alarms: {
|
|
1269
|
+
title: 'Alarms',
|
|
1270
|
+
commands: [
|
|
1271
|
+
{ cmd: '/alarms', desc: 'Active alarms & recent faults' },
|
|
1272
|
+
{ cmd: '/offline', desc: 'All offline devices' },
|
|
1273
|
+
{ cmd: '/down [circuit]', desc: 'Down circuits / uplinks' },
|
|
1274
|
+
{ cmd: '/flapping [device]', desc: 'Flapping interfaces' },
|
|
1275
|
+
{ cmd: '/optical [device]', desc: 'Optical RX/TX (dBm)' },
|
|
1276
|
+
{ cmd: '/reachable', desc: 'Reachability sweep' },
|
|
1277
|
+
],
|
|
1278
|
+
hint: 'Switch to Session tab (Tab) and type a command',
|
|
1279
|
+
},
|
|
1280
|
+
};
|
|
1281
|
+
|
|
1282
|
+
const tabPanel = activeTab !== 'session' ? TAB_CONTENT[activeTab] : null;
|
|
569
1283
|
|
|
570
1284
|
const renderItem = (it) => {
|
|
571
1285
|
const c = it.cols || 80;
|
|
572
1286
|
switch (it.type) {
|
|
573
|
-
case 'user': return html`<${UserMessage} key=${it.id} text=${it.text} cols=${c} />`;
|
|
574
|
-
case 'assistant': return html`<${AssistantMessage} key=${it.id} text=${it.text} cols=${c} />`;
|
|
1287
|
+
case 'user': return html`<${UserMessage} key=${it.id} text=${it.text} cols=${c} ts=${it.ts} />`;
|
|
1288
|
+
case 'assistant': return html`<${AssistantMessage} key=${it.id} text=${it.text} cols=${c} ts=${it.ts} />`;
|
|
575
1289
|
case 'plan': return html`<${Plan} key=${it.id} reply=${it.reply} />`;
|
|
576
1290
|
case 'connect': return html`<${Connect} key=${it.id} reply=${it.reply} />`;
|
|
577
1291
|
case 'activity': return html`<${StatusLines} key=${it.id} lines=${it.status} busy=${false} />`;
|
|
578
1292
|
case 'notice': return html`<${Notice} key=${it.id} text=${it.text} color=${it.color} />`;
|
|
1293
|
+
case 'shell': return html`<${ShellOutput} key=${it.id} command=${it.command} output=${it.output} code=${it.code} cols=${c} ts=${it.ts} />`;
|
|
1294
|
+
case 'help': return html`<${HelpOverlay} key=${it.id} cols=${c} ts=${it.ts} />`;
|
|
579
1295
|
default: return null;
|
|
580
1296
|
}
|
|
581
1297
|
};
|
|
582
1298
|
|
|
583
|
-
// ── Footer ───────────────────────────────────────────────────────────────────
|
|
584
|
-
// Left: ~/cwd ⏎ branch mode-badge
|
|
585
|
-
// Right: model ~Nk ctx
|
|
586
1299
|
const cwd = shortenPath(process.cwd());
|
|
587
1300
|
const branch = branchRef.current;
|
|
588
1301
|
const tokenCount = approxTokens(convo.current);
|
|
589
|
-
const tokenLabel = tokenCount > 0 ? `~${Math.round(tokenCount / 1000)}k ctx` : '';
|
|
590
|
-
const modelLabel = shortModel(model || modelRef.current || '');
|
|
591
|
-
const
|
|
592
|
-
|
|
593
|
-
const footerLeft = [
|
|
594
|
-
cwd,
|
|
595
|
-
branch ? `${glyphs.gitBranch}${branch}` : null,
|
|
596
|
-
].filter(Boolean).join(' ');
|
|
597
|
-
|
|
1302
|
+
const tokenLabel = tokenCount > 0 ? `~${Math.max(1, Math.round(tokenCount / 1000))}k ctx` : '';
|
|
1303
|
+
const modelLabel = streamerMode ? '' : shortModel(model || modelRef.current || 'server default');
|
|
1304
|
+
const footerLeft = [cwd, branch ? `${glyphs.gitBranch}${branch}` : null].filter(Boolean).join(' ');
|
|
598
1305
|
const footerRight = hint
|
|
599
1306
|
? hint
|
|
600
|
-
:
|
|
601
|
-
|
|
1307
|
+
: streamerMode
|
|
1308
|
+
? ''
|
|
1309
|
+
: [modelLabel, tokenLabel].filter(Boolean).join(midDot());
|
|
602
1310
|
const compact = cols < 60;
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
// native scrollback intact so earlier conversation can be scrolled back to.
|
|
608
|
-
const staticItems = [{ type: 'banner', id: `banner-${clearEpoch}`, cols }, ...items];
|
|
1311
|
+
const composerMode = shellMode ? 'shell' : mode;
|
|
1312
|
+
const staticItems = showBanner
|
|
1313
|
+
? [{ type: 'banner', id: `banner-${clearEpoch}`, cols }, ...items]
|
|
1314
|
+
: items;
|
|
609
1315
|
|
|
610
1316
|
return html`
|
|
611
1317
|
<${Box} flexDirection="column" width=${cols}>
|
|
612
1318
|
<${Static} key=${clearEpoch} items=${staticItems}>
|
|
613
1319
|
${(it) => it.type === 'banner'
|
|
614
1320
|
? html`<${Box} key=${it.id} flexDirection="column" alignItems="center" marginBottom=${1}>
|
|
615
|
-
<${Banner} cfg=${cfg} model=${model} width=${cols} compact=${compact} />
|
|
1321
|
+
<${Banner} cfg=${cfg} model=${model} width=${cols} compact=${compact} streamerMode=${streamerMode} />
|
|
616
1322
|
<//>`
|
|
617
1323
|
: renderItem(it)}
|
|
618
1324
|
<//>
|
|
@@ -622,24 +1328,60 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
622
1328
|
<${StatusLines} lines=${live.status} busy=${busy} />
|
|
623
1329
|
${live.reasoning ? html`<${Reasoning} text=${live.reasoning} show=${showReasoning} />` : null}
|
|
624
1330
|
${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
|
|
625
|
-
${live.text ? html`<${AssistantMessage} text=${live.text} cols=${cols} />` : null}
|
|
1331
|
+
${live.text ? html`<${AssistantMessage} text=${live.text} cols=${cols} ts=${startedAt || Date.now()} />` : null}
|
|
626
1332
|
<${Thinking} label=${live.reply ? 'Planning' : live.text ? 'Responding' : 'Working'} startedAt=${startedAt} />
|
|
627
1333
|
<//>`
|
|
628
1334
|
: null}
|
|
629
1335
|
|
|
630
|
-
|
|
1336
|
+
${expandedItems
|
|
1337
|
+
? html`<${Box} flexDirection="column">
|
|
1338
|
+
<${Notice} text=${`${glyphs.expand} ${expandedItems.kind}`} color=${C.muted} />
|
|
1339
|
+
${expandedItems.items.map((it) => renderItem(it))}
|
|
1340
|
+
<//>`
|
|
1341
|
+
: null}
|
|
1342
|
+
|
|
1343
|
+
${search ? html`<${SearchOverlay} query=${search.query} matches=${searchMatches} active=${search.active} cols=${cols} />` : null}
|
|
1344
|
+
|
|
1345
|
+
<${TabBar} active=${activeTab} cols=${cols} mode=${composerMode} />
|
|
1346
|
+
|
|
1347
|
+
${tabPanel
|
|
1348
|
+
? html`<${Box} flexDirection="column" paddingX=${1} paddingY=${1}>
|
|
1349
|
+
<${Text} color=${C.brand} bold>${tabPanel.title} — Quick Commands<//>\n
|
|
1350
|
+
${tabPanel.commands.map((c, i) =>
|
|
1351
|
+
html`<${Box} key=${i}>
|
|
1352
|
+
<${Text} color=${C.slash}>${c.cmd.padEnd(24)}<//>
|
|
1353
|
+
<${Text} color=${C.muted}>${c.desc}<//>
|
|
1354
|
+
<//>`)}
|
|
1355
|
+
\n<${Text} color=${C.muted}>${tabPanel.hint}<//>
|
|
1356
|
+
<//>`
|
|
1357
|
+
: null}
|
|
631
1358
|
|
|
632
1359
|
${!busy && chips.length ? html`<${FollowupChips} chips=${chips} active=${activeChip} />` : null}
|
|
633
1360
|
|
|
634
1361
|
<${Box} flexDirection="column">
|
|
635
1362
|
${choice
|
|
636
1363
|
? html`<${Choice} ...${choice} />`
|
|
637
|
-
: html`<${Composer}
|
|
1364
|
+
: html`<${Composer}
|
|
1365
|
+
onSubmit=${dispatchSubmit}
|
|
1366
|
+
onSubmitKeep=${dispatchSubmit}
|
|
1367
|
+
onQueue=${queueSubmission}
|
|
1368
|
+
onSearch=${openSearch}
|
|
1369
|
+
onNotice=${(text, color = C.muted) => pushNotice(text, color, true)}
|
|
1370
|
+
onEmptyEsc=${() => { if (shellMode) setShellMode(false); else if (expandedItems) setExpandedItems(null); }}
|
|
1371
|
+
onValueChange=${setComposerText}
|
|
1372
|
+
onEmptySubmit=${() => {
|
|
1373
|
+
if (chips.length) dispatchSubmit(chips[activeChip]);
|
|
1374
|
+
}}
|
|
1375
|
+
busy=${busy}
|
|
1376
|
+
blocked=${Boolean(choice || search || !trusted)}
|
|
1377
|
+
width=${cols}
|
|
1378
|
+
mode=${composerMode}
|
|
1379
|
+
clearToken=${composerClearToken}
|
|
1380
|
+
/>`}
|
|
638
1381
|
|
|
639
1382
|
<${Box} paddingX=${1} width=${cols}>
|
|
640
1383
|
<${Box} flexGrow=${1}>
|
|
641
1384
|
<${Text} color=${C.muted} wrap="truncate-end">${footerLeft}<//>
|
|
642
|
-
${modeLabel ? html`<${Text} color=${mode === 'autopilot' ? C.warning : C.accent}>${modeLabel}<//>` : null}
|
|
643
1385
|
<//>
|
|
644
1386
|
<${Text} color=${hint ? C.warning : C.muted} wrap="truncate-end">${footerRight}<//>
|
|
645
1387
|
<//>
|