ispbills-icli 8.6.9 → 8.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/auth.js +54 -14
- package/src/gitrepo.js +6 -3
- package/src/session.js +6 -2
- package/src/tui/app.js +163 -37
- package/src/tui/components.js +96 -108
- package/src/tui/composer.js +25 -23
- package/src/tui/mouse.js +57 -0
- package/src/tui/theme.js +1 -0
package/package.json
CHANGED
package/src/auth.js
CHANGED
|
@@ -2,6 +2,40 @@ import * as readline from 'readline/promises';
|
|
|
2
2
|
import { stdin as input, stdout as output } from 'process';
|
|
3
3
|
import { saveConfig } from './config.js';
|
|
4
4
|
|
|
5
|
+
/** Read a password from stdin without echoing characters. */
|
|
6
|
+
async function readPasswordHidden(prompt) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
output.write(prompt);
|
|
9
|
+
let pass = '';
|
|
10
|
+
const onData = (ch) => {
|
|
11
|
+
if (ch === '\r' || ch === '\n') {
|
|
12
|
+
input.setRawMode(false);
|
|
13
|
+
input.removeListener('data', onData);
|
|
14
|
+
output.write('\n');
|
|
15
|
+
resolve(pass);
|
|
16
|
+
} else if (ch === '\u0003') {
|
|
17
|
+
input.setRawMode(false);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
} else if (ch === '\u007f' || ch === '\b') {
|
|
20
|
+
if (pass.length > 0) pass = pass.slice(0, -1);
|
|
21
|
+
} else {
|
|
22
|
+
pass += ch;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
try {
|
|
26
|
+
input.setRawMode(true);
|
|
27
|
+
input.resume();
|
|
28
|
+
input.setEncoding('utf8');
|
|
29
|
+
input.on('data', onData);
|
|
30
|
+
} catch {
|
|
31
|
+
// fallback: readline without masking (non-TTY env)
|
|
32
|
+
input.removeListener('data', onData);
|
|
33
|
+
const rl2 = readline.createInterface({ input, output });
|
|
34
|
+
rl2.question(prompt, (ans) => { rl2.close(); resolve(ans); });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
5
39
|
export async function loginFlow(existingUrl = '') {
|
|
6
40
|
const rl = readline.createInterface({ input, output });
|
|
7
41
|
|
|
@@ -16,11 +50,11 @@ export async function loginFlow(existingUrl = '') {
|
|
|
16
50
|
url = url.replace(/\/$/, '');
|
|
17
51
|
if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
|
|
18
52
|
|
|
19
|
-
const email
|
|
20
|
-
const password = await rl.question('Password: ');
|
|
21
|
-
|
|
53
|
+
const email = (await rl.question('Email: ')).trim();
|
|
22
54
|
rl.close();
|
|
23
55
|
|
|
56
|
+
const password = await readPasswordHidden('Password: ');
|
|
57
|
+
|
|
24
58
|
const res = await fetch(`${url}/api/v1/auth/login`, {
|
|
25
59
|
method: 'POST',
|
|
26
60
|
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
@@ -44,15 +78,21 @@ export async function loginFlow(existingUrl = '') {
|
|
|
44
78
|
}
|
|
45
79
|
|
|
46
80
|
export async function refreshToken(cfg) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
81
|
+
try {
|
|
82
|
+
const res = await fetch(`${cfg.url}/api/v1/auth/refresh`, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
85
|
+
body: JSON.stringify({ refresh_token: cfg.refresh_token }),
|
|
86
|
+
});
|
|
87
|
+
if (!res.ok) return null;
|
|
88
|
+
const data = await res.json().catch(() => null);
|
|
89
|
+
if (!data?.token) return null;
|
|
90
|
+
cfg.token = data.token;
|
|
91
|
+
cfg.refresh_token = data.refresh_token;
|
|
92
|
+
saveConfig(cfg);
|
|
93
|
+
return cfg;
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
58
97
|
}
|
|
98
|
+
|
package/src/gitrepo.js
CHANGED
|
@@ -42,13 +42,16 @@ function run(cfg, args, { input } = {}) {
|
|
|
42
42
|
export async function ensureRepo(cfg) {
|
|
43
43
|
const dir = repoDir(cfg);
|
|
44
44
|
if (!existsSync(join(dir, '.git'))) {
|
|
45
|
-
await run(cfg, ['init', '-q']);
|
|
45
|
+
const init = await run(cfg, ['init', '-q']);
|
|
46
|
+
if (init.code !== 0) throw new Error(`git init failed: ${init.stderr.trim()}`);
|
|
46
47
|
await run(cfg, ['config', 'user.name', cfg?.user?.name || 'IspBills operator']);
|
|
47
48
|
await run(cfg, ['config', 'user.email', cfg?.user?.email || 'operator@ispbills.local']);
|
|
48
49
|
await run(cfg, ['config', 'commit.gpgsign', 'false']);
|
|
49
50
|
writeFileSync(join(dir, 'README.md'), '# IspBills device backups\n\nManaged by iCli (`/backup`, `/git`).\n');
|
|
50
|
-
await run(cfg, ['add', 'README.md']);
|
|
51
|
-
|
|
51
|
+
const add = await run(cfg, ['add', 'README.md']);
|
|
52
|
+
if (add.code !== 0) throw new Error(`git add failed: ${add.stderr.trim()}`);
|
|
53
|
+
const commit = await run(cfg, ['commit', '-q', '-m', 'Initialise backup repository']);
|
|
54
|
+
if (commit.code !== 0) throw new Error(`git commit failed: ${commit.stderr.trim()}`);
|
|
52
55
|
}
|
|
53
56
|
return dir;
|
|
54
57
|
}
|
package/src/session.js
CHANGED
|
@@ -134,8 +134,12 @@ export function listSessions() {
|
|
|
134
134
|
export function loadSession(sessionPathOrId) {
|
|
135
135
|
if (!sessionPathOrId) return [];
|
|
136
136
|
if (sessionPathOrId.endsWith?.('.json')) {
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
try {
|
|
138
|
+
const session = safeJson(readFileSync(sessionPathOrId, 'utf8'), null);
|
|
139
|
+
return normalizeSession(session || {}).messages;
|
|
140
|
+
} catch {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
139
143
|
}
|
|
140
144
|
return loadSessionById(sessionPathOrId)?.messages || [];
|
|
141
145
|
}
|
package/src/tui/app.js
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
import { html, useState, useEffect, useRef, useCallback } from './dom.js';
|
|
3
3
|
import { Box, Text, useStdout, useApp, useInput } from 'ink';
|
|
4
4
|
import { C, glyphs, midDot, dash } from './theme.js';
|
|
5
|
+
import { useMouse } from './mouse.js';
|
|
5
6
|
import {
|
|
6
7
|
Banner, TabBar, FollowupChips, UserMessage, AssistantMessage, StatusLines, Reasoning,
|
|
7
8
|
Plan, Connect, Notice, Thinking, Choice, ShellOutput, SearchOverlay, HelpOverlay,
|
|
8
|
-
SessionPicker, DiffViewer, LabeledNotice,
|
|
9
|
+
SessionPicker, DiffViewer, LabeledNotice, TABS, TAB_W,
|
|
9
10
|
} from './components.js';
|
|
10
11
|
import { Composer } from './composer.js';
|
|
11
12
|
import { streamChat } from '../client.js';
|
|
@@ -243,15 +244,15 @@ function itemHeight(it, cols = 80) {
|
|
|
243
244
|
const w = Math.max(1, cols - 4); // paddingX + body inset
|
|
244
245
|
switch (it.type) {
|
|
245
246
|
case 'user':
|
|
246
|
-
return
|
|
247
|
+
return 2 + wrappedLines(it.text, w); // marginTop + speaker + body
|
|
247
248
|
case 'assistant':
|
|
248
|
-
return
|
|
249
|
+
return 2 + wrappedLines(it.text, Math.min(w, 96));
|
|
249
250
|
case 'shell':
|
|
250
|
-
return
|
|
251
|
+
return 3 + wrappedLines(it.output || '(no output)', w) + 1;
|
|
251
252
|
case 'plan':
|
|
252
|
-
return
|
|
253
|
+
return 3 + (it.reply?.steps?.length || 0);
|
|
253
254
|
case 'connect':
|
|
254
|
-
return
|
|
255
|
+
return 4;
|
|
255
256
|
case 'activity':
|
|
256
257
|
return 1 + (it.status?.length || 0);
|
|
257
258
|
case 'notice':
|
|
@@ -295,7 +296,7 @@ function liveAreaHeight(live, cols = 80, showReasoning = false) {
|
|
|
295
296
|
if (!live) return 0;
|
|
296
297
|
const w = Math.max(1, cols - 4);
|
|
297
298
|
let h = 2; // thinking line + margin
|
|
298
|
-
if (live.text) h +=
|
|
299
|
+
if (live.text) h += 2 + wrappedLines(live.text, Math.min(w, 96));
|
|
299
300
|
if (live.reasoning) h += showReasoning ? 2 + wrappedLines(live.reasoning, w) : 1;
|
|
300
301
|
if (live.reply?.steps) h += 3 + live.reply.steps.length;
|
|
301
302
|
if (live.status?.length) h += 1 + live.status.length;
|
|
@@ -362,6 +363,35 @@ const TAB_CONTENT = {
|
|
|
362
363
|
},
|
|
363
364
|
};
|
|
364
365
|
|
|
366
|
+
function TabView({ panel, cols, activeTab }) {
|
|
367
|
+
const descriptions = {
|
|
368
|
+
devices: 'ISP device management — status, diagnostics, reachability',
|
|
369
|
+
maps: 'Network topology — VLAN, routing, IP planning, topology',
|
|
370
|
+
alarms: 'Real-time monitoring — alarms, offline devices, optical levels',
|
|
371
|
+
};
|
|
372
|
+
const desc = descriptions[activeTab] || '';
|
|
373
|
+
const bw = cols - 4;
|
|
374
|
+
return html`
|
|
375
|
+
<${Box} flexDirection="column" paddingX=${2} paddingY=${1}>
|
|
376
|
+
<${Box} marginBottom=${1}>
|
|
377
|
+
<${Text} color=${C.brand} bold>${panel.title}<//><${Text} color=${C.muted}> ${desc}<//>
|
|
378
|
+
<//>
|
|
379
|
+
<${Text} color=${C.separator}>${'─'.repeat(Math.max(1, bw))}<//>
|
|
380
|
+
<${Box} flexDirection="column" marginTop=${1}>
|
|
381
|
+
${panel.commands.map((c, i) => html`
|
|
382
|
+
<${Box} key=${i}>
|
|
383
|
+
<${Text} color=${C.accent} bold>${String(i + 1).padStart(2)}. <//>
|
|
384
|
+
<${Text} color=${C.slash}>${c.cmd.padEnd(26)}<//>
|
|
385
|
+
<${Text} color=${C.muted}>${c.desc.slice(0, Math.max(10, cols - 38))}<//>
|
|
386
|
+
<//>`)}
|
|
387
|
+
<//>
|
|
388
|
+
<${Text} color=${C.separator} dimColor>${'─'.repeat(Math.max(1, bw))}<//>
|
|
389
|
+
<${Box} marginTop=${1}>
|
|
390
|
+
<${Text} color=${C.muted} dimColor>${panel.hint}<//><${Text} color=${C.muted} dimColor> ${midDot()} o open in browser<//><${Text} color=${C.muted} dimColor> ${midDot()} c/Enter → Session<//>
|
|
391
|
+
<//>
|
|
392
|
+
<//>`;
|
|
393
|
+
}
|
|
394
|
+
|
|
365
395
|
export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, showBanner = true, agentName, resumeMode }) {
|
|
366
396
|
const { exit } = useApp();
|
|
367
397
|
const initialResumeRef = useRef(undefined);
|
|
@@ -377,11 +407,6 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
377
407
|
const { cols, rows } = useTerminalSize();
|
|
378
408
|
const colsRef = useRef(cols);
|
|
379
409
|
useEffect(() => { colsRef.current = cols; }, [cols]);
|
|
380
|
-
// Fixed-height viewport: total rows minus chrome (tabbar + spacing + chips +
|
|
381
|
-
// composer + footer). Everything else scrolls inside this region.
|
|
382
|
-
const viewportRows = Math.max(3, rows - 6);
|
|
383
|
-
const scrollPage = Math.max(4, viewportRows - 2);
|
|
384
|
-
|
|
385
410
|
const [items, setItems] = useState(() => renderConversationItems(initialMessages, cols));
|
|
386
411
|
const [live, setLive] = useState(null);
|
|
387
412
|
const [busy, setBusy] = useState(false);
|
|
@@ -407,6 +432,11 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
407
432
|
const [trusted, setTrusted] = useState(isTrustedDir(cfg, process.cwd()));
|
|
408
433
|
const [sessionPicker, setSessionPicker] = useState(null);
|
|
409
434
|
const [diffState, setDiffState] = useState(null);
|
|
435
|
+
const chipsRows = (!busy && chips.length && !choice && !search && !sessionPicker && !diffState) ? 1 : 0;
|
|
436
|
+
const composerRows = 2;
|
|
437
|
+
const chromeRows = 2 + composerRows + 1 + chipsRows + 1;
|
|
438
|
+
const viewportRows = Math.max(3, rows - chromeRows);
|
|
439
|
+
const scrollPage = Math.max(4, viewportRows - 2);
|
|
410
440
|
|
|
411
441
|
const convo = useRef(initialMessages);
|
|
412
442
|
const plain = useRef([]);
|
|
@@ -427,6 +457,23 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
427
457
|
const sessionCreatedAtRef = useRef(initialSession?.createdAt || new Date().toISOString());
|
|
428
458
|
const sessionNameRef = useRef(initialSession?.name || sessionAutoName(initialMessages));
|
|
429
459
|
|
|
460
|
+
useMouse(({ btn, x, y, press }) => {
|
|
461
|
+
if (!press) return;
|
|
462
|
+
if (y <= 1 && btn === 0) {
|
|
463
|
+
const idx = Math.floor(x / TAB_W);
|
|
464
|
+
if (idx >= 0 && idx < TABS.length) {
|
|
465
|
+
setActiveTab(TABS[idx].id);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (choice || search || sessionPicker || diffState) return;
|
|
470
|
+
if (btn === 64) {
|
|
471
|
+
setScrollOffset((n) => Math.min(maxScrollRef.current, n + 3));
|
|
472
|
+
} else if (btn === 65) {
|
|
473
|
+
setScrollOffset((n) => Math.max(0, n - 3));
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
430
477
|
useEffect(() => {
|
|
431
478
|
try { process.stdout.write('\x1b[?2004h'); } catch {}
|
|
432
479
|
return () => { try { process.stdout.write('\x1b[?2004l'); } catch {} };
|
|
@@ -1197,7 +1244,10 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1197
1244
|
' Ctrl+Home / End jump to start/end\n' +
|
|
1198
1245
|
' Ctrl+D shutdown\n' +
|
|
1199
1246
|
' Ctrl+C × 2 exit\n' +
|
|
1200
|
-
' Esc cancel / exit mode\n'
|
|
1247
|
+
' Esc cancel / exit mode\n' +
|
|
1248
|
+
'\n Web commands:\n' +
|
|
1249
|
+
' /search QUERY Web search (DuckDuckGo instant answers)\n' +
|
|
1250
|
+
' /fetch URL Fetch and display a web page\n'
|
|
1201
1251
|
);
|
|
1202
1252
|
return;
|
|
1203
1253
|
}
|
|
@@ -1387,7 +1437,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1387
1437
|
' research — Deep research across codebase, repos, web with citations',
|
|
1388
1438
|
' rubber-duck — Constructive critic; consulted automatically on non-trivial tasks',
|
|
1389
1439
|
'',
|
|
1390
|
-
'Use: /rubber-duck [prompt] · /research TOPIC · /fleet [prompt]',
|
|
1440
|
+
'Use: /search QUERY · /fetch URL · /rubber-duck [prompt] · /research TOPIC · /fleet [prompt]',
|
|
1391
1441
|
].join('\n'));
|
|
1392
1442
|
return;
|
|
1393
1443
|
}
|
|
@@ -1429,6 +1479,68 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1429
1479
|
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.')}`);
|
|
1430
1480
|
return;
|
|
1431
1481
|
}
|
|
1482
|
+
if (q === '/search' || q.startsWith('/search ')) {
|
|
1483
|
+
const query = q.slice('/search'.length).trim();
|
|
1484
|
+
if (!query) { pushNotice('Usage: /search QUERY', C.warning); return; }
|
|
1485
|
+
pushNotice(`Searching: ${query}…`, C.muted, true);
|
|
1486
|
+
(async () => {
|
|
1487
|
+
try {
|
|
1488
|
+
const url = `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json&no_redirect=1&no_html=1&skip_disambig=1`;
|
|
1489
|
+
const res = await fetch(url, { headers: { 'User-Agent': 'ispbills-icli/8.6.10' } });
|
|
1490
|
+
const data = await res.json();
|
|
1491
|
+
const lines = [];
|
|
1492
|
+
if (data.AbstractText) lines.push(`**${data.Heading}**\n\n${data.AbstractText}\n\nSource: ${data.AbstractURL}`);
|
|
1493
|
+
if (data.RelatedTopics?.length) {
|
|
1494
|
+
lines.push('\n**Related:**');
|
|
1495
|
+
data.RelatedTopics.slice(0, 5).forEach((t) => {
|
|
1496
|
+
if (t.Text) lines.push(`- ${t.Text}`);
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
if (!lines.length) lines.push(`No instant results. Try: /research ${query}`);
|
|
1500
|
+
push({ type: 'assistant', text: lines.join('\n') });
|
|
1501
|
+
} catch (err) {
|
|
1502
|
+
push({ type: 'error', text: `Search failed: ${err.message}` });
|
|
1503
|
+
}
|
|
1504
|
+
})();
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
if (q === '/fetch' || q.startsWith('/fetch ')) {
|
|
1508
|
+
const rawUrl = q.slice('/fetch'.length).trim();
|
|
1509
|
+
if (!rawUrl) { pushNotice('Usage: /fetch URL', C.warning); return; }
|
|
1510
|
+
const fetchUrl = rawUrl.startsWith('http') ? rawUrl : `https://${rawUrl}`;
|
|
1511
|
+
pushNotice(`Fetching ${fetchUrl}…`, C.muted, true);
|
|
1512
|
+
(async () => {
|
|
1513
|
+
try {
|
|
1514
|
+
const res = await fetch(fetchUrl, {
|
|
1515
|
+
headers: { 'User-Agent': 'ispbills-icli/8.6.10', 'Accept': 'text/html,text/plain,*/*' },
|
|
1516
|
+
redirect: 'follow',
|
|
1517
|
+
signal: AbortSignal.timeout(15000),
|
|
1518
|
+
});
|
|
1519
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
1520
|
+
const ct = res.headers.get('content-type') || '';
|
|
1521
|
+
const text = await res.text();
|
|
1522
|
+
let content;
|
|
1523
|
+
if (ct.includes('json')) {
|
|
1524
|
+
try { content = '```json\n' + JSON.stringify(JSON.parse(text), null, 2).slice(0, 4000) + '\n```'; }
|
|
1525
|
+
catch { content = text.slice(0, 4000); }
|
|
1526
|
+
} else {
|
|
1527
|
+
content = text
|
|
1528
|
+
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
1529
|
+
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
1530
|
+
.replace(/<[^>]+>/g, ' ')
|
|
1531
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/ /g, ' ').replace(/&#?\w+;/g, '')
|
|
1532
|
+
.replace(/[ \t]{2,}/g, ' ')
|
|
1533
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
1534
|
+
.trim()
|
|
1535
|
+
.slice(0, 5000);
|
|
1536
|
+
}
|
|
1537
|
+
push({ type: 'assistant', text: `**${fetchUrl}**\n\n${content}` });
|
|
1538
|
+
} catch (err) {
|
|
1539
|
+
push({ type: 'error', text: `Fetch failed: ${err.message}` });
|
|
1540
|
+
}
|
|
1541
|
+
})();
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1432
1544
|
if (q === '/research' || q.startsWith('/research ')) {
|
|
1433
1545
|
const topic = q.slice('/research'.length).trim();
|
|
1434
1546
|
if (!topic) { pushNotice('Usage: /research TOPIC'); return; }
|
|
@@ -1541,7 +1653,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1541
1653
|
return;
|
|
1542
1654
|
}
|
|
1543
1655
|
if (q === '/app') {
|
|
1544
|
-
pushNotice('
|
|
1656
|
+
pushNotice('iCopilot web app — open your IspBills instance in the browser with /o or by typing the URL.', C.muted);
|
|
1545
1657
|
return;
|
|
1546
1658
|
}
|
|
1547
1659
|
if (q === '/user' || q.startsWith('/user ')) {
|
|
@@ -1894,7 +2006,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1894
2006
|
}
|
|
1895
2007
|
if (key.ctrl && input === 't') { setShowReasoning((v) => !v); return; }
|
|
1896
2008
|
if (key.ctrl && input === 'l' && !busy) { clearAll(); return; }
|
|
1897
|
-
if (key.ctrl && input === 'f' && !busy) { openSearch(); return; }
|
|
2009
|
+
if (key.ctrl && input === 'f' && !busy && !composerText.trim()) { openSearch(); return; }
|
|
1898
2010
|
if (key.ctrl && input === 'o' && !busy && !composerText.trim()) { showExpanded('recent items', items.slice(-5)); return; }
|
|
1899
2011
|
if (key.ctrl && input === 'e' && !busy && !composerText.trim()) { showExpanded('all items', items); return; }
|
|
1900
2012
|
if (key.ctrl && input === 'n' && chips.length) { setActiveChip((i) => (i + 1) % chips.length); return; }
|
|
@@ -1904,6 +2016,25 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1904
2016
|
const tabs = ['session', 'devices', 'maps', 'alarms'];
|
|
1905
2017
|
return tabs[(tabs.indexOf(t) + 1) % tabs.length];
|
|
1906
2018
|
});
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
// ←/→ arrow tab switching when the composer is empty (no cursor to move).
|
|
2022
|
+
// The outer early-return already guards against choice/search/sessionPicker/diffState.
|
|
2023
|
+
if (!composerText.trim()) {
|
|
2024
|
+
if (key.leftArrow) {
|
|
2025
|
+
setActiveTab((t) => {
|
|
2026
|
+
const tabs = ['session', 'devices', 'maps', 'alarms'];
|
|
2027
|
+
return tabs[(tabs.indexOf(t) - 1 + tabs.length) % tabs.length];
|
|
2028
|
+
});
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
if (key.rightArrow) {
|
|
2032
|
+
setActiveTab((t) => {
|
|
2033
|
+
const tabs = ['session', 'devices', 'maps', 'alarms'];
|
|
2034
|
+
return tabs[(tabs.indexOf(t) + 1) % tabs.length];
|
|
2035
|
+
});
|
|
2036
|
+
return;
|
|
2037
|
+
}
|
|
1907
2038
|
}
|
|
1908
2039
|
// Alt+↑/↓ for viewport scrolling (alternative to PgUp/PgDn)
|
|
1909
2040
|
if (key.meta && key.upArrow && !choice && !search && !sessionPicker && !diffState) {
|
|
@@ -1936,7 +2067,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1936
2067
|
const c = it.cols || 80;
|
|
1937
2068
|
switch (it.type) {
|
|
1938
2069
|
case 'user': return html`<${UserMessage} key=${it.id} text=${it.text} cols=${c} ts=${it.ts} />`;
|
|
1939
|
-
case 'assistant': return html`<${AssistantMessage} key=${it.id} text=${it.text} cols=${c} />`;
|
|
2070
|
+
case 'assistant': return html`<${AssistantMessage} key=${it.id} text=${it.text} cols=${c} ts=${it.ts} />`;
|
|
1940
2071
|
case 'plan': return html`<${Plan} key=${it.id} reply=${it.reply} />`;
|
|
1941
2072
|
case 'connect': return html`<${Connect} key=${it.id} reply=${it.reply} />`;
|
|
1942
2073
|
case 'activity': return html`<${StatusLines} key=${it.id} lines=${it.status} busy=${false} />`;
|
|
@@ -1958,7 +2089,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1958
2089
|
const footerRightEstLen = (modelLabel.length || 0) + (tokenLabel.length || 0) + 8;
|
|
1959
2090
|
const cwdMaxLen = Math.max(12, cols - footerRightEstLen - (branch ? branch.length + 6 : 0) - 4);
|
|
1960
2091
|
const cwd = shortenPath(process.cwd(), cwdMaxLen);
|
|
1961
|
-
const footerLeft = [cwd, branch ? `${glyphs.gitBranch}${branch}` : null].filter(Boolean).join(
|
|
2092
|
+
const footerLeft = [cwd, branch ? `${glyphs.gitBranch}${branch}` : null].filter(Boolean).join(midDot());
|
|
1962
2093
|
const footerRight = hint
|
|
1963
2094
|
? hint
|
|
1964
2095
|
: streamerMode
|
|
@@ -1979,17 +2110,19 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1979
2110
|
|
|
1980
2111
|
const liveArea = live
|
|
1981
2112
|
? html`<${Box} flexDirection="column">
|
|
1982
|
-
|
|
2113
|
+
<${StatusLines} lines=${live.status} busy=${busy} />
|
|
1983
2114
|
${live.reasoning ? html`<${Reasoning} text=${live.reasoning} show=${showReasoning} />` : null}
|
|
2115
|
+
${live.text ? html`<${AssistantMessage} text=${live.text} cols=${cols} ts=${startedAt || Date.now()} streaming=${true} />` : null}
|
|
1984
2116
|
${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
|
|
1985
|
-
<${StatusLines} lines=${live.status} busy=${busy} />
|
|
1986
2117
|
<${Thinking} label=${live.reply ? 'Planning' : live.text ? 'Responding' : 'Working'} startedAt=${startedAt} />
|
|
1987
2118
|
<//>`
|
|
1988
2119
|
: null;
|
|
1989
2120
|
|
|
2121
|
+
const scrollPct = maxScrollRef.current > 0 ? Math.round((scrollOffset / maxScrollRef.current) * 100) : 100;
|
|
1990
2122
|
const scrollIndicator = hasAbove
|
|
1991
|
-
? html`<${Box} paddingX=${1}>
|
|
1992
|
-
<${Text} color=${C.muted} dimColor>${glyphs.up} ${aboveCount}
|
|
2123
|
+
? html`<${Box} paddingX=${1} justifyContent="space-between" width=${cols}>
|
|
2124
|
+
<${Text} color=${C.muted} dimColor>${glyphs.up} ${aboveCount} above ${midDot()} PgUp/PgDn ${midDot()} scroll<//>
|
|
2125
|
+
<${Text} color=${C.muted} dimColor>${scrollPct}%<//>
|
|
1993
2126
|
<//>`
|
|
1994
2127
|
: null;
|
|
1995
2128
|
|
|
@@ -2022,20 +2155,11 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
2022
2155
|
${expandedItems.items.map((it) => renderItem(it))}
|
|
2023
2156
|
<//>`
|
|
2024
2157
|
: tabPanel
|
|
2025
|
-
? html`<${
|
|
2026
|
-
<${Text} color=${C.brand} bold>${tabPanel.title} — Quick Commands<//>\n
|
|
2027
|
-
${tabPanel.commands.map((c, i) =>
|
|
2028
|
-
html`<${Box} key=${i}>
|
|
2029
|
-
<${Text} color=${C.accent} bold>${String(i + 1)}. <//>
|
|
2030
|
-
<${Text} color=${C.slash}>${c.cmd.padEnd(22)}<//>
|
|
2031
|
-
<${Text} color=${C.muted}>${c.desc}<//>
|
|
2032
|
-
<//>`)}
|
|
2033
|
-
\n<${Text} color=${C.muted}>${tabPanel.hint}<//>
|
|
2034
|
-
<//>`
|
|
2158
|
+
? html`<${TabView} panel=${tabPanel} cols=${cols} activeTab=${activeTab} />`
|
|
2035
2159
|
: html`<${Box} flexDirection="column">
|
|
2036
2160
|
${showEmptyBanner
|
|
2037
2161
|
? html`<${Box} flexDirection="column" alignItems="center" width=${cols}>
|
|
2038
|
-
<${Banner} cfg=${cfg} model=${model} width=${cols} compact=${
|
|
2162
|
+
<${Banner} cfg=${cfg} model=${model} width=${cols} compact=${!showBanner} streamerMode=${streamerMode} />
|
|
2039
2163
|
<//>`
|
|
2040
2164
|
: null}
|
|
2041
2165
|
${scrollIndicator}
|
|
@@ -2045,17 +2169,19 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
2045
2169
|
|
|
2046
2170
|
return html`
|
|
2047
2171
|
<${Box} flexDirection="column" width=${cols} height=${rows}>
|
|
2048
|
-
<${TabBar} active=${activeTab} cols=${cols}
|
|
2172
|
+
<${TabBar} active=${activeTab} cols=${cols} />
|
|
2049
2173
|
|
|
2050
2174
|
<${Box} flexDirection="column" flexGrow=${1} overflow="hidden">
|
|
2051
2175
|
${timelineContent}
|
|
2052
2176
|
<//>
|
|
2053
2177
|
|
|
2054
|
-
|
|
2178
|
+
<${Box} flexDirection="column">
|
|
2179
|
+
${search ? html`<${SearchOverlay} query=${search.query} matches=${searchMatches} active=${search.active} cols=${cols} />` : null}
|
|
2055
2180
|
|
|
2056
|
-
|
|
2181
|
+
${!busy && chips.length && !choice && !search && !sessionPicker && !diffState
|
|
2182
|
+
? html`<${FollowupChips} chips=${chips} active=${activeChip} />`
|
|
2183
|
+
: null}
|
|
2057
2184
|
|
|
2058
|
-
<${Box} flexDirection="column">
|
|
2059
2185
|
${choice
|
|
2060
2186
|
? html`<${Choice} ...${choice} cols=${cols} />`
|
|
2061
2187
|
: sessionPicker
|
package/src/tui/components.js
CHANGED
|
@@ -106,45 +106,33 @@ function SpeakerLine({ label, color, glyphColor, cols = 80, ts, rightLabel = ''
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
// ── Tab Bar ─────────────────────────────────────────────────────────────────
|
|
109
|
-
const TABS = [
|
|
110
|
-
{ id: 'session',
|
|
111
|
-
{ id: 'devices',
|
|
112
|
-
{ id: 'maps',
|
|
113
|
-
{ id: 'alarms',
|
|
109
|
+
export const TABS = [
|
|
110
|
+
{ id: 'session', label: 'Session' },
|
|
111
|
+
{ id: 'devices', label: 'Devices' },
|
|
112
|
+
{ id: 'maps', label: 'Maps' },
|
|
113
|
+
{ id: 'alarms', label: 'Alarms' },
|
|
114
114
|
];
|
|
115
115
|
|
|
116
|
-
export
|
|
117
|
-
const modeBadge = mode !== 'ask'
|
|
118
|
-
? ` ${mode === 'autopilot' ? '⚡ AUTO' : mode === 'shell' ? '⌘ SHELL' : '📋 PLAN'}`
|
|
119
|
-
: '';
|
|
120
|
-
const badgeLen = modeBadge ? modeBadge.length + 2 : 0;
|
|
121
|
-
const available = cols - 2 - badgeLen; // -2 for paddingX={1}
|
|
122
|
-
|
|
123
|
-
// §11: clip tab labels when terminal is narrow (~50 cols)
|
|
124
|
-
// Full: [Session] Devices Customers Alarms = ~42 chars
|
|
125
|
-
// Short: [Ses] Dev Cus Alm = ~26 chars
|
|
126
|
-
const fullWidth = TABS.reduce((sum, t, i) => {
|
|
127
|
-
const w = t.id === active ? t.label.length + 2 : t.label.length;
|
|
128
|
-
return sum + (i > 0 ? 3 : 0) + w;
|
|
129
|
-
}, 0);
|
|
130
|
-
const abbreviated = fullWidth > available;
|
|
116
|
+
export const TAB_W = 14;
|
|
131
117
|
|
|
118
|
+
export function TabBar({ active = 'session', cols = 80 }) {
|
|
132
119
|
return html`
|
|
133
|
-
<${Box} width=${cols}
|
|
134
|
-
<${Box}
|
|
135
|
-
${TABS.map((
|
|
136
|
-
const on =
|
|
137
|
-
const
|
|
120
|
+
<${Box} flexDirection="column" width=${cols}>
|
|
121
|
+
<${Box} flexDirection="row" width=${cols}>
|
|
122
|
+
${TABS.map((tab, i) => {
|
|
123
|
+
const on = tab.id === active;
|
|
124
|
+
const label = ` ${i + 1} ${tab.label} `.padEnd(TAB_W);
|
|
138
125
|
return html`
|
|
139
|
-
<${Box} key=${
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
: html`<${Text} color="white" dimColor backgroundColor="blue">${lbl}<//>`}
|
|
126
|
+
<${Box} key=${tab.id} width=${TAB_W}>
|
|
127
|
+
<${Text} bold=${on} color=${on ? 'cyan' : 'white'} dimColor=${!on} inverse=${on}>
|
|
128
|
+
${label}
|
|
129
|
+
<//>
|
|
144
130
|
<//>`;
|
|
145
131
|
})}
|
|
132
|
+
<${Box} flexGrow=${1} />
|
|
133
|
+
<${Text} color=${C.muted} dimColor>click ${midDot()} ←/→<//>
|
|
146
134
|
<//>
|
|
147
|
-
|
|
135
|
+
<${Text} color=${C.separator}>${'─'.repeat(Math.max(1, cols))}<//>
|
|
148
136
|
<//>`;
|
|
149
137
|
}
|
|
150
138
|
|
|
@@ -152,73 +140,62 @@ export function FollowupChips({ chips = [], active = 0 }) {
|
|
|
152
140
|
if (!chips.length) return null;
|
|
153
141
|
return html`
|
|
154
142
|
<${Box} paddingX=${1} flexWrap="wrap">
|
|
155
|
-
<${Text} color=${C.muted}>Next
|
|
156
|
-
${chips.map((chip, i) => {
|
|
143
|
+
<${Text} color=${C.muted}>Next <//>
|
|
144
|
+
${chips.slice(0, 4).map((chip, i) => {
|
|
157
145
|
const on = i === active;
|
|
158
146
|
return html`
|
|
159
|
-
<${Box} key=${'ch' + i}>
|
|
160
|
-
|
|
161
|
-
<${Text} color=${on ? C.success : C.muted} bold=${on}>[${chip}]<//>
|
|
147
|
+
<${Box} key=${'ch' + i} marginRight=${1}>
|
|
148
|
+
<${Text} color=${on ? C.success : 'white'} inverse=${on}> ${chip} <//>
|
|
162
149
|
<//>`;
|
|
163
150
|
})}
|
|
164
|
-
<${Text} color=${C.muted}> ↵ run Ctrl+N/P cycle Esc dismiss<//>
|
|
165
151
|
<//>`;
|
|
166
152
|
}
|
|
167
153
|
|
|
168
154
|
export function Banner({ cfg = {}, model, width = 80, compact = false, streamerMode = false }) {
|
|
155
|
+
const [frame, setFrame] = useState(0);
|
|
156
|
+
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
const iv = setInterval(() => setFrame((f) => (f + 1) % 4), 120);
|
|
159
|
+
return () => clearInterval(iv);
|
|
160
|
+
}, []);
|
|
161
|
+
|
|
162
|
+
const colors = ['cyan', 'blue', C.success, C.accent];
|
|
163
|
+
const logoColor = colors[frame] ?? 'cyan';
|
|
164
|
+
|
|
169
165
|
const m = shortModel(model || cfg._model || 'IspBills AI');
|
|
170
|
-
const wide = !compact && width >= 60;
|
|
171
|
-
const meta = [
|
|
172
|
-
['model', streamerMode ? 'hidden' : m], ['user', cfg.user?.name], ['role', cfg.user?.role],
|
|
173
|
-
].filter(([, v]) => v);
|
|
174
|
-
// §3: show /login instruction when unauthenticated
|
|
175
166
|
const isAuth = Boolean(cfg.user?.name || cfg.user?.email || cfg.apiKey || cfg._token);
|
|
176
167
|
|
|
177
168
|
if (compact) {
|
|
178
169
|
return html`
|
|
179
170
|
<${Box} marginBottom=${1}>
|
|
180
|
-
<${Text} color=${C.accent} bold
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
<//>`)}
|
|
171
|
+
<${Text} color=${C.accent} bold>${glyphs.ring} iCopilot <//>
|
|
172
|
+
<${Text} color=${C.muted}>${midDot()}<//>
|
|
173
|
+
<${Text} color=${C.muted}>${m}<//>
|
|
174
|
+
${cfg.user?.name ? html`<${Text} color=${C.muted}>${midDot()}<//>` : null}
|
|
175
|
+
${cfg.user?.name ? html`<${Text} color=${C.brand}>${cfg.user.name}<//>` : null}
|
|
186
176
|
<//>`;
|
|
187
177
|
}
|
|
188
178
|
|
|
189
179
|
return html`
|
|
190
180
|
<${Box} flexDirection="column" marginBottom=${1} alignItems="center" width=${width}>
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
<//>`
|
|
195
|
-
: html`<${Text} color=${C.accent} bold>◈ iCopilot ${html`<${Text} color=${C.muted}>${dash()} IspBills AI terminal<//>`}<//>` }
|
|
196
|
-
|
|
197
|
-
${wide ? html`<${Text} color=${C.separator}>${'─'.repeat(Math.max(1, width))}<//>\n` : null}
|
|
181
|
+
<${Box} flexDirection="column" alignItems="center" width=${width}>
|
|
182
|
+
${LOGO.map((l, i) => html`<${Text} key=${'l' + i} color=${logoColor} bold>${l}<//>`)}
|
|
183
|
+
<//>
|
|
198
184
|
|
|
199
|
-
<${Box} marginTop=${
|
|
200
|
-
<${Text} color=${C.accent} bold
|
|
185
|
+
<${Box} marginTop=${1} justifyContent="center">
|
|
186
|
+
<${Text} color=${C.accent} bold>${glyphs.ring} iCopilot<//>
|
|
201
187
|
<${Text} color=${C.muted}> ${dash()} IspBills network engineer in your terminal<//>
|
|
202
188
|
<//>
|
|
203
189
|
|
|
204
|
-
${
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
<//>` : null}
|
|
214
|
-
|
|
215
|
-
${cfg.url ? html`<${Box} justifyContent="center"><${Text} color=${C.muted}>${cfg.url}<//><//>` : null}
|
|
216
|
-
|
|
217
|
-
<${Box} flexDirection="column" marginTop=${1} alignItems="center" width=${width}>
|
|
218
|
-
<${Box} paddingX=${2} flexDirection="column">
|
|
219
|
-
<${Text} color=${C.muted} dimColor> ${' @ '}${html`<${Text} color=${C.accent}>@<//>`} mention files · ${html`<${Text} color=${C.accent}>/<//>`} commands · ${html`<${Text} color=${C.accent}>?<//>`} help · ${html`<${Text} color=${C.accent}>!<//>` } shell<//>
|
|
220
|
-
<//>
|
|
221
|
-
<//>
|
|
190
|
+
${model || cfg._model ? html`
|
|
191
|
+
<${Box} marginTop=${1} justifyContent="center">
|
|
192
|
+
<${Text} color=${C.muted}>model <//>
|
|
193
|
+
<${Text} color=${C.accent}>${m}<//>
|
|
194
|
+
${cfg.user?.name ? html`<${Text} color=${C.muted}>${midDot()}<//>` : null}
|
|
195
|
+
${cfg.user?.name ? html`<${Text} color=${C.muted}>user <//>` : null}
|
|
196
|
+
${cfg.user?.name ? html`<${Text} color=${C.brand}>${cfg.user.name}<//>` : null}
|
|
197
|
+
${cfg.url ? html`<${Text} color=${C.muted}>${midDot()}${cfg.url}<//>` : null}
|
|
198
|
+
<//>` : null}
|
|
222
199
|
|
|
223
200
|
${!isAuth
|
|
224
201
|
? html`<${Box} marginTop=${1} justifyContent="center">
|
|
@@ -226,6 +203,10 @@ export function Banner({ cfg = {}, model, width = 80, compact = false, streamerM
|
|
|
226
203
|
<${Text} color=${C.slash} bold>/login<//>
|
|
227
204
|
<${Text} color=${C.warning}> to connect.<//><//>`
|
|
228
205
|
: null}
|
|
206
|
+
|
|
207
|
+
<${Box} marginTop=${1} justifyContent="center">
|
|
208
|
+
<${Text} color=${C.muted} dimColor>@ mention / commands ? help ! shell Shift+Tab cycle mode<//>
|
|
209
|
+
<//>
|
|
229
210
|
<//>`;
|
|
230
211
|
}
|
|
231
212
|
|
|
@@ -245,12 +226,15 @@ function Sep({ cols = 80 }) {
|
|
|
245
226
|
}
|
|
246
227
|
|
|
247
228
|
export function UserMessage({ text, cols = 80, ts }) {
|
|
229
|
+
const stamp = ts ? new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '';
|
|
248
230
|
return html`
|
|
249
|
-
<${Box} flexDirection="column"
|
|
250
|
-
<${
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
231
|
+
<${Box} flexDirection="column" marginBottom=${1}>
|
|
232
|
+
<${Box} flexDirection="row" justifyContent="space-between" width=${cols} paddingX=${1}>
|
|
233
|
+
<${Text} bold color="cyan">You<//>
|
|
234
|
+
<${Text} color=${C.muted}>${stamp}<//>
|
|
235
|
+
<//>
|
|
236
|
+
<${Box} paddingX=${1} marginLeft=${2} flexWrap="wrap">
|
|
237
|
+
${withMentions(text)}
|
|
254
238
|
<//>
|
|
255
239
|
<//>`;
|
|
256
240
|
}
|
|
@@ -278,14 +262,17 @@ function StreamingText({ text = '' }) {
|
|
|
278
262
|
return rendered;
|
|
279
263
|
}
|
|
280
264
|
|
|
281
|
-
export function AssistantMessage({ text, cols = 80, ts, label
|
|
282
|
-
|
|
265
|
+
export function AssistantMessage({ text, cols = 80, ts, label, streaming = false }) {
|
|
266
|
+
const stamp = ts ? new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '';
|
|
267
|
+
const speakerLabel = streaming ? 'iCopilot ●' : 'iCopilot';
|
|
283
268
|
const bodyWidth = Math.min(cols, 100);
|
|
284
269
|
return html`
|
|
285
|
-
<${Box} flexDirection="column"
|
|
286
|
-
<${
|
|
287
|
-
|
|
288
|
-
|
|
270
|
+
<${Box} flexDirection="column" marginBottom=${1}>
|
|
271
|
+
<${Box} flexDirection="row" justifyContent="space-between" width=${cols} paddingX=${1}>
|
|
272
|
+
<${Text} bold color=${C.success}>${speakerLabel}<//>
|
|
273
|
+
<${Text} color=${C.muted}>${stamp}<//>
|
|
274
|
+
<//>
|
|
275
|
+
<${Box} paddingX=${1} marginLeft=${2} flexDirection="column" width=${bodyWidth}>
|
|
289
276
|
${streaming ? html`<${StreamingText} text=${text} />` : html`<${Markdown} content=${text} />`}
|
|
290
277
|
<//>
|
|
291
278
|
<//>`;
|
|
@@ -294,9 +281,8 @@ export function AssistantMessage({ text, cols = 80, ts, label = `${glyphs.ring}
|
|
|
294
281
|
export function ShellOutput({ command, output, cols = 80, ts, code = 0 }) {
|
|
295
282
|
return html`
|
|
296
283
|
<${Box} flexDirection="column" marginTop=${1}>
|
|
297
|
-
<${Sep} cols=${cols} />
|
|
298
284
|
<${SpeakerLine}
|
|
299
|
-
label
|
|
285
|
+
label=${"$ Shell"}
|
|
300
286
|
color=${code === 0 ? C.warning : C.error}
|
|
301
287
|
cols=${cols}
|
|
302
288
|
ts=${ts}
|
|
@@ -314,44 +300,49 @@ const LEAD_EMOJI = /^\s*([\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\
|
|
|
314
300
|
|
|
315
301
|
export function StatusLines({ lines = [], busy = false }) {
|
|
316
302
|
if (!lines.length) return null;
|
|
317
|
-
const subLines = lines.filter((l) => SUBAGENT.test(l));
|
|
318
303
|
const lastIdx = lines.length - 1;
|
|
319
304
|
return html`
|
|
320
305
|
<${Box} flexDirection="column" marginTop=${1}>
|
|
321
306
|
${lines.map((line, i) => {
|
|
322
|
-
const
|
|
307
|
+
const lineStr = String(line);
|
|
308
|
+
const sm = lineStr.match(SUBAGENT);
|
|
323
309
|
if (sm) {
|
|
324
310
|
const [, idx, total, host, result] = sm;
|
|
311
|
+
// A subagent line is still running when busy and hasn't got a ✓/✗ prefix yet
|
|
312
|
+
const isCompleted = /^\s*[✓✗x]/.test(lineStr);
|
|
313
|
+
const running = busy && !isCompleted;
|
|
325
314
|
const good = !/fail|error|down|offline|unreachable|timeout|deny/i.test(result);
|
|
326
|
-
const isLast = subLines[subLines.length - 1] === line;
|
|
327
|
-
const running = busy && isLast && Number(idx) < Number(total);
|
|
328
315
|
const branch = Number(idx) === Number(total) ? glyphs.branchEnd : glyphs.branchMid;
|
|
316
|
+
const hostTrunc = host.length > 24 ? host.slice(0, 22) + '…' : host;
|
|
317
|
+
const resultTrunc = result.length > 32 ? result.slice(0, 30) + '…' : result;
|
|
329
318
|
return html`
|
|
330
319
|
<${Box} key=${'s' + i}>
|
|
331
320
|
<${Text} color=${C.gray}> <//>
|
|
332
|
-
<${Text} color=${C.
|
|
333
|
-
<${Text} color=${C.
|
|
334
|
-
<${Text} color=${C.white} bold>${
|
|
335
|
-
<${Text} color=${C.
|
|
321
|
+
<${Text} color=${C.accent}>${branch} <//>
|
|
322
|
+
<${Text} color=${C.muted}>${idx}/${total} <//>
|
|
323
|
+
<${Text} color=${C.white} bold>${hostTrunc}<//>
|
|
324
|
+
<${Text} color=${C.muted}> ${dash()} <//>
|
|
336
325
|
${running
|
|
337
|
-
? html`<${Text} color=${C.
|
|
338
|
-
: html`<${Text} color=${good ? C.
|
|
326
|
+
? html`<${Text} color=${C.warning}><${Spinner} type=${spinnerType()} /><//> <${Text} color=${C.muted}>running<//>`
|
|
327
|
+
: html`<${Text} color=${good ? C.success : C.error} bold>${good ? glyphs.check : glyphs.cross} ${resultTrunc}<//>`}
|
|
339
328
|
<//>`;
|
|
340
329
|
}
|
|
341
330
|
|
|
342
331
|
const running = busy && i === lastIdx;
|
|
343
|
-
const em =
|
|
344
|
-
const body = em ?
|
|
332
|
+
const em = lineStr.match(LEAD_EMOJI);
|
|
333
|
+
const body = em ? lineStr.slice(em[0].length) : lineStr.replace(/^[\s◈◆●○◎⇢→>*✓✗×!⚠·-]+/u, '');
|
|
345
334
|
const ci = body.indexOf(':');
|
|
346
335
|
const action = ci > -1 ? body.slice(0, ci).trim() : body.trim();
|
|
347
336
|
const detail = ci > -1 ? body.slice(ci + 1).trim() : '';
|
|
348
|
-
|
|
349
|
-
|
|
337
|
+
const chipLabel = detail
|
|
338
|
+
? `${em ? em[1] : ''}${action}: ${detail.slice(0, 40)}`
|
|
339
|
+
: `${em ? em[1] : ''}${action}`;
|
|
350
340
|
return html`
|
|
351
341
|
<${Box} key=${'s' + i} marginTop=${0}>
|
|
352
342
|
<${Text} color=${C.muted}> <//>
|
|
353
343
|
<${Text} color=${running ? C.warning : C.muted}>[<//>
|
|
354
|
-
<${Text} color=${running ? C.white : C.muted} bold=${running}>${chipLabel.trim()}<//><${Text} color=${running ? C.warning : C.muted}>]
|
|
344
|
+
<${Text} color=${running ? C.white : C.muted} bold=${running}>${chipLabel.trim().slice(0, 60)}<//><${Text} color=${running ? C.warning : C.muted}>]<//>
|
|
345
|
+
<${Text}> <//>
|
|
355
346
|
${running
|
|
356
347
|
? html`<${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /><//>`
|
|
357
348
|
: html`<${Text} color=${C.success}>${glyphs.check}<//>`}
|
|
@@ -429,7 +420,6 @@ export function Notice({ text, color = C.gray }) {
|
|
|
429
420
|
export function LabeledNotice({ label, text, color = C.gray, cols = 80, ts, bodyColor = C.white }) {
|
|
430
421
|
return html`
|
|
431
422
|
<${Box} flexDirection="column" marginTop=${1}>
|
|
432
|
-
<${Sep} cols=${cols} />
|
|
433
423
|
<${SpeakerLine} label=${label} color=${color} cols=${cols} ts=${ts} />
|
|
434
424
|
<${Box} paddingX=${1}>
|
|
435
425
|
<${Text} color=${bodyColor}>${text}<//>
|
|
@@ -534,7 +524,7 @@ export function Thinking({ label = 'Working', startedAt }) {
|
|
|
534
524
|
<${Box} marginTop=${1} paddingX=${1}>
|
|
535
525
|
<${Text} color=${C.warning}><${Spinner} type=${spinnerType()} /><//>
|
|
536
526
|
<${Text} color=${C.warning} bold> ${label}<//>
|
|
537
|
-
<${Text} color=${C.muted}>… (${secs}s ${midDot()}esc to
|
|
527
|
+
<${Text} color=${C.muted}>… (${secs}s ${midDot()} esc to cancel)<//>
|
|
538
528
|
<//>`;
|
|
539
529
|
}
|
|
540
530
|
|
|
@@ -543,11 +533,9 @@ export function Choice({ title, note, options = [], sel = 0, allowText = false,
|
|
|
543
533
|
if (isToolApproval) {
|
|
544
534
|
return html`
|
|
545
535
|
<${Box} flexDirection="column" marginTop=${1}>
|
|
546
|
-
<${Sep} cols=${cols} />
|
|
547
536
|
<${Box} paddingX=${2} marginTop=${1}>
|
|
548
|
-
<${Text} color=${C.accent} bold
|
|
537
|
+
<${Text} color=${C.accent} bold>${glyphs.bolt} Tool request<//>
|
|
549
538
|
<//>
|
|
550
|
-
<${Sep} cols=${cols} />
|
|
551
539
|
${note ? html`<${Box} paddingX=${2} marginTop=${1}><${Text} color=${C.muted}>${note}<//><//>` : null}
|
|
552
540
|
<${Box} flexDirection="column" marginTop=${1} paddingX=${2}>
|
|
553
541
|
${options.map((o, i) => {
|
package/src/tui/composer.js
CHANGED
|
@@ -466,13 +466,13 @@ export function Composer({
|
|
|
466
466
|
const promptGlyph = glyphs.prompt;
|
|
467
467
|
const promptColor = busy ? C.warning : C.success;
|
|
468
468
|
const ghostHint = busy ? 'Working…' : 'Enter @ to mention files or / for commands…';
|
|
469
|
-
const
|
|
470
|
-
? '[plan]
|
|
469
|
+
const modeLabel = mode === 'plan'
|
|
470
|
+
? '[plan]'
|
|
471
471
|
: mode === 'autopilot'
|
|
472
|
-
? '[auto]
|
|
472
|
+
? '[auto]'
|
|
473
473
|
: mode === 'shell'
|
|
474
|
-
? '[shell]
|
|
475
|
-
: '';
|
|
474
|
+
? '[shell]'
|
|
475
|
+
: '[ask]';
|
|
476
476
|
|
|
477
477
|
const paletteRows = (() => {
|
|
478
478
|
if (!menuOpen) return null;
|
|
@@ -483,12 +483,14 @@ export function Composer({
|
|
|
483
483
|
return window.map((s, wi) => {
|
|
484
484
|
const i = start + wi;
|
|
485
485
|
const on = i === sel;
|
|
486
|
-
const kk = slashMode ? (s.cmd + (s.hint ? ' ' + s.hint : '')) : s.tag;
|
|
486
|
+
const kk = (slashMode ? (s.cmd + (s.hint ? ' ' + s.hint : '')) : s.tag).slice(0, 22);
|
|
487
487
|
const label = kk.padEnd(22);
|
|
488
|
+
const descWidth = Math.max(8, width - 28);
|
|
489
|
+
const desc = s.desc ? s.desc.slice(0, descWidth) : '';
|
|
488
490
|
return html`<${Box} key=${'m' + i}>
|
|
489
491
|
<${Text} color=${on ? C.success : C.muted}>${on ? glyphs.prompt + ' ' : ' '}<//>
|
|
490
492
|
<${Text} color=${on ? C.success : slashMode ? C.slash : C.accent} bold=${on}>${label}<//>
|
|
491
|
-
<${Text} color=${C.muted}>${
|
|
493
|
+
<${Text} color=${C.muted}>${desc}<//>
|
|
492
494
|
<//>`;
|
|
493
495
|
});
|
|
494
496
|
})();
|
|
@@ -505,20 +507,22 @@ export function Composer({
|
|
|
505
507
|
<//>`
|
|
506
508
|
: null}
|
|
507
509
|
|
|
508
|
-
<${Text} color=${C.
|
|
509
|
-
|
|
510
|
-
<${Box} width=${width} paddingX=${1}>
|
|
511
|
-
<${
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
510
|
+
<${Text} color=${C.muted}>${'═'.repeat(Math.max(1, width))}<//>
|
|
511
|
+
|
|
512
|
+
<${Box} width=${width} paddingX=${1} justifyContent="space-between">
|
|
513
|
+
<${Box} flexGrow=${1}>
|
|
514
|
+
<${Text} color=${promptColor} bold>${promptGlyph} <//>
|
|
515
|
+
${busy ? html`<${Text} color=${C.muted}>(working…) <//>` : null}
|
|
516
|
+
${empty
|
|
517
|
+
? html`<${Box} flexGrow=${1}>
|
|
518
|
+
<${Text} color="white" inverse> <//>
|
|
519
|
+
<${Text} color=${C.muted} italic> ${ghostHint}<//>
|
|
520
|
+
<//>`
|
|
521
|
+
: html`<${Box} flexGrow=${1}>
|
|
522
|
+
<${Text}>${before}<//><${Text} inverse>${at}<//><${Text}>${after}<//><${Text} color=${C.muted} italic>${ghost}<//>
|
|
523
|
+
<//>`}
|
|
524
|
+
<//>
|
|
525
|
+
<${Text} color=${C.muted}>${modeLabel}<//>
|
|
522
526
|
<//>
|
|
523
527
|
|
|
524
528
|
${menuOpen
|
|
@@ -533,7 +537,5 @@ export function Composer({
|
|
|
533
537
|
${paletteRows}
|
|
534
538
|
<//>`
|
|
535
539
|
: null}
|
|
536
|
-
|
|
537
|
-
<${Text} color=${C.separator}>${(asciiSafe() ? '-' : '─').repeat(Math.max(1, width))}<//>
|
|
538
540
|
<//>`;
|
|
539
541
|
}
|
package/src/tui/mouse.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useEffect, useRef } from './dom.js';
|
|
2
|
+
import { useStdin } from 'ink';
|
|
3
|
+
|
|
4
|
+
function parseMouseData(chunk, emit) {
|
|
5
|
+
let i = 0;
|
|
6
|
+
while (i < chunk.length) {
|
|
7
|
+
if (chunk.startsWith('\x1b[<', i)) {
|
|
8
|
+
const match = chunk.slice(i).match(/^\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
|
|
9
|
+
if (!match) return chunk.slice(i);
|
|
10
|
+
const btn = Number(match[1]);
|
|
11
|
+
const x = Math.max(0, Number(match[2]) - 1);
|
|
12
|
+
const y = Math.max(0, Number(match[3]) - 1);
|
|
13
|
+
emit({ btn, x, y, press: match[4] === 'M' });
|
|
14
|
+
i += match[0].length;
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (chunk.startsWith('\x1b[M', i)) {
|
|
18
|
+
if (i + 6 > chunk.length) return chunk.slice(i);
|
|
19
|
+
const btn = chunk.charCodeAt(i + 3) - 32;
|
|
20
|
+
const x = Math.max(0, chunk.charCodeAt(i + 4) - 33);
|
|
21
|
+
const y = Math.max(0, chunk.charCodeAt(i + 5) - 33);
|
|
22
|
+
emit({ btn, x, y, press: btn !== 3 });
|
|
23
|
+
i += 6;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
i += 1;
|
|
27
|
+
}
|
|
28
|
+
return '';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function useMouse(handler, enabled = true) {
|
|
32
|
+
const { stdin } = useStdin();
|
|
33
|
+
const handlerRef = useRef(handler);
|
|
34
|
+
const bufferRef = useRef('');
|
|
35
|
+
|
|
36
|
+
handlerRef.current = handler;
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!enabled || !stdin) return;
|
|
40
|
+
|
|
41
|
+
try { process.stdout.write('\x1b[?1000h\x1b[?1006h'); } catch {}
|
|
42
|
+
|
|
43
|
+
const onData = (chunk) => {
|
|
44
|
+
const text = Buffer.isBuffer(chunk) ? chunk.toString('latin1') : String(chunk);
|
|
45
|
+
bufferRef.current = parseMouseData(bufferRef.current + text, (event) => {
|
|
46
|
+
handlerRef.current?.(event);
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
stdin.on('data', onData);
|
|
51
|
+
return () => {
|
|
52
|
+
stdin.off('data', onData);
|
|
53
|
+
bufferRef.current = '';
|
|
54
|
+
try { process.stdout.write('\x1b[?1000l\x1b[?1006l'); } catch {}
|
|
55
|
+
};
|
|
56
|
+
}, [enabled, stdin]);
|
|
57
|
+
}
|
package/src/tui/theme.js
CHANGED