ispbills-icli 8.6.10 → 8.7.1
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 +174 -36
- package/src/tui/components.js +96 -102
- package/src/tui/composer.js +25 -21
- package/src/tui/mouse.js +57 -0
- package/src/tui/run.js +55 -1
- 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';
|
|
@@ -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,10 +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=1, composer=2, footer=1, buffer=3).
|
|
381
|
-
const viewportRows = Math.max(3, rows - 7);
|
|
382
|
-
const scrollPage = Math.max(4, viewportRows - 2);
|
|
383
|
-
|
|
384
410
|
const [items, setItems] = useState(() => renderConversationItems(initialMessages, cols));
|
|
385
411
|
const [live, setLive] = useState(null);
|
|
386
412
|
const [busy, setBusy] = useState(false);
|
|
@@ -406,6 +432,11 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
406
432
|
const [trusted, setTrusted] = useState(isTrustedDir(cfg, process.cwd()));
|
|
407
433
|
const [sessionPicker, setSessionPicker] = useState(null);
|
|
408
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 + 2; // +2 for two footer rows
|
|
438
|
+
const viewportRows = Math.max(3, rows - chromeRows);
|
|
439
|
+
const scrollPage = Math.max(4, viewportRows - 2);
|
|
409
440
|
|
|
410
441
|
const convo = useRef(initialMessages);
|
|
411
442
|
const plain = useRef([]);
|
|
@@ -426,6 +457,23 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
426
457
|
const sessionCreatedAtRef = useRef(initialSession?.createdAt || new Date().toISOString());
|
|
427
458
|
const sessionNameRef = useRef(initialSession?.name || sessionAutoName(initialMessages));
|
|
428
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
|
+
|
|
429
477
|
useEffect(() => {
|
|
430
478
|
try { process.stdout.write('\x1b[?2004h'); } catch {}
|
|
431
479
|
return () => { try { process.stdout.write('\x1b[?2004l'); } catch {} };
|
|
@@ -1196,7 +1244,10 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1196
1244
|
' Ctrl+Home / End jump to start/end\n' +
|
|
1197
1245
|
' Ctrl+D shutdown\n' +
|
|
1198
1246
|
' Ctrl+C × 2 exit\n' +
|
|
1199
|
-
' 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'
|
|
1200
1251
|
);
|
|
1201
1252
|
return;
|
|
1202
1253
|
}
|
|
@@ -1386,7 +1437,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1386
1437
|
' research — Deep research across codebase, repos, web with citations',
|
|
1387
1438
|
' rubber-duck — Constructive critic; consulted automatically on non-trivial tasks',
|
|
1388
1439
|
'',
|
|
1389
|
-
'Use: /rubber-duck [prompt] · /research TOPIC · /fleet [prompt]',
|
|
1440
|
+
'Use: /search QUERY · /fetch URL · /rubber-duck [prompt] · /research TOPIC · /fleet [prompt]',
|
|
1390
1441
|
].join('\n'));
|
|
1391
1442
|
return;
|
|
1392
1443
|
}
|
|
@@ -1428,6 +1479,68 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1428
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.')}`);
|
|
1429
1480
|
return;
|
|
1430
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
|
+
}
|
|
1431
1544
|
if (q === '/research' || q.startsWith('/research ')) {
|
|
1432
1545
|
const topic = q.slice('/research'.length).trim();
|
|
1433
1546
|
if (!topic) { pushNotice('Usage: /research TOPIC'); return; }
|
|
@@ -1540,7 +1653,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1540
1653
|
return;
|
|
1541
1654
|
}
|
|
1542
1655
|
if (q === '/app') {
|
|
1543
|
-
pushNotice('
|
|
1656
|
+
pushNotice('iCopilot web app — open your IspBills instance in the browser with /o or by typing the URL.', C.muted);
|
|
1544
1657
|
return;
|
|
1545
1658
|
}
|
|
1546
1659
|
if (q === '/user' || q.startsWith('/user ')) {
|
|
@@ -1893,7 +2006,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1893
2006
|
}
|
|
1894
2007
|
if (key.ctrl && input === 't') { setShowReasoning((v) => !v); return; }
|
|
1895
2008
|
if (key.ctrl && input === 'l' && !busy) { clearAll(); return; }
|
|
1896
|
-
if (key.ctrl && input === 'f' && !busy) { openSearch(); return; }
|
|
2009
|
+
if (key.ctrl && input === 'f' && !busy && !composerText.trim()) { openSearch(); return; }
|
|
1897
2010
|
if (key.ctrl && input === 'o' && !busy && !composerText.trim()) { showExpanded('recent items', items.slice(-5)); return; }
|
|
1898
2011
|
if (key.ctrl && input === 'e' && !busy && !composerText.trim()) { showExpanded('all items', items); return; }
|
|
1899
2012
|
if (key.ctrl && input === 'n' && chips.length) { setActiveChip((i) => (i + 1) % chips.length); return; }
|
|
@@ -1903,6 +2016,25 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1903
2016
|
const tabs = ['session', 'devices', 'maps', 'alarms'];
|
|
1904
2017
|
return tabs[(tabs.indexOf(t) + 1) % tabs.length];
|
|
1905
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
|
+
}
|
|
1906
2038
|
}
|
|
1907
2039
|
// Alt+↑/↓ for viewport scrolling (alternative to PgUp/PgDn)
|
|
1908
2040
|
if (key.meta && key.upArrow && !choice && !search && !sessionPicker && !diffState) {
|
|
@@ -1935,7 +2067,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1935
2067
|
const c = it.cols || 80;
|
|
1936
2068
|
switch (it.type) {
|
|
1937
2069
|
case 'user': return html`<${UserMessage} key=${it.id} text=${it.text} cols=${c} ts=${it.ts} />`;
|
|
1938
|
-
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} />`;
|
|
1939
2071
|
case 'plan': return html`<${Plan} key=${it.id} reply=${it.reply} />`;
|
|
1940
2072
|
case 'connect': return html`<${Connect} key=${it.id} reply=${it.reply} />`;
|
|
1941
2073
|
case 'activity': return html`<${StatusLines} key=${it.id} lines=${it.status} busy=${false} />`;
|
|
@@ -1957,12 +2089,16 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1957
2089
|
const footerRightEstLen = (modelLabel.length || 0) + (tokenLabel.length || 0) + 8;
|
|
1958
2090
|
const cwdMaxLen = Math.max(12, cols - footerRightEstLen - (branch ? branch.length + 6 : 0) - 4);
|
|
1959
2091
|
const cwd = shortenPath(process.cwd(), cwdMaxLen);
|
|
1960
|
-
const footerLeft = [cwd, branch ? `${glyphs.gitBranch}${branch}` : null].filter(Boolean).join(
|
|
1961
|
-
const
|
|
2092
|
+
const footerLeft = [cwd, branch ? `${glyphs.gitBranch}${branch}` : null].filter(Boolean).join(midDot());
|
|
2093
|
+
const footerInfoRight = streamerMode ? '' : [modelLabel, tokenLabel].filter(Boolean).join(midDot());
|
|
2094
|
+
// Status row: working indicator when busy, key hints when idle, hint message when set
|
|
2095
|
+
const footerStatusLeft = busy
|
|
2096
|
+
? `${glyphs.spinner} Working\u2026`
|
|
2097
|
+
: hint
|
|
1962
2098
|
? hint
|
|
1963
|
-
:
|
|
1964
|
-
|
|
1965
|
-
|
|
2099
|
+
: `? help ${midDot()} / commands ${midDot()} \u2191\u2193 history ${midDot()} Tab mode`;
|
|
2100
|
+
const footerStatusColor = busy ? C.warning : hint ? C.warning : C.muted;
|
|
2101
|
+
const footerStatusRight = busy ? `Ctrl+C to cancel` : '';
|
|
1966
2102
|
const composerMode = shellMode ? 'shell' : mode;
|
|
1967
2103
|
|
|
1968
2104
|
// ── Viewport slicing ───────────────────────────────────────────────────────
|
|
@@ -1978,17 +2114,19 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1978
2114
|
|
|
1979
2115
|
const liveArea = live
|
|
1980
2116
|
? html`<${Box} flexDirection="column">
|
|
1981
|
-
|
|
2117
|
+
<${StatusLines} lines=${live.status} busy=${busy} />
|
|
1982
2118
|
${live.reasoning ? html`<${Reasoning} text=${live.reasoning} show=${showReasoning} />` : null}
|
|
2119
|
+
${live.text ? html`<${AssistantMessage} text=${live.text} cols=${cols} ts=${startedAt || Date.now()} streaming=${true} />` : null}
|
|
1983
2120
|
${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
|
|
1984
|
-
<${StatusLines} lines=${live.status} busy=${busy} />
|
|
1985
2121
|
<${Thinking} label=${live.reply ? 'Planning' : live.text ? 'Responding' : 'Working'} startedAt=${startedAt} />
|
|
1986
2122
|
<//>`
|
|
1987
2123
|
: null;
|
|
1988
2124
|
|
|
2125
|
+
const scrollPct = maxScrollRef.current > 0 ? Math.round((scrollOffset / maxScrollRef.current) * 100) : 100;
|
|
1989
2126
|
const scrollIndicator = hasAbove
|
|
1990
|
-
? html`<${Box} paddingX=${1}>
|
|
1991
|
-
<${Text} color=${C.muted} dimColor>${glyphs.up} ${aboveCount}
|
|
2127
|
+
? html`<${Box} paddingX=${1} justifyContent="space-between" width=${cols}>
|
|
2128
|
+
<${Text} color=${C.muted} dimColor>${glyphs.up} ${aboveCount} above ${midDot()} PgUp/PgDn ${midDot()} scroll<//>
|
|
2129
|
+
<${Text} color=${C.muted} dimColor>${scrollPct}%<//>
|
|
1992
2130
|
<//>`
|
|
1993
2131
|
: null;
|
|
1994
2132
|
|
|
@@ -2021,20 +2159,11 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
2021
2159
|
${expandedItems.items.map((it) => renderItem(it))}
|
|
2022
2160
|
<//>`
|
|
2023
2161
|
: tabPanel
|
|
2024
|
-
? html`<${
|
|
2025
|
-
<${Text} color=${C.brand} bold>${tabPanel.title} — Quick Commands<//>\n
|
|
2026
|
-
${tabPanel.commands.map((c, i) =>
|
|
2027
|
-
html`<${Box} key=${i}>
|
|
2028
|
-
<${Text} color=${C.accent} bold>${String(i + 1)}. <//>
|
|
2029
|
-
<${Text} color=${C.slash}>${c.cmd.padEnd(22)}<//>
|
|
2030
|
-
<${Text} color=${C.muted}>${c.desc}<//>
|
|
2031
|
-
<//>`)}
|
|
2032
|
-
\n<${Text} color=${C.muted}>${tabPanel.hint}<//>
|
|
2033
|
-
<//>`
|
|
2162
|
+
? html`<${TabView} panel=${tabPanel} cols=${cols} activeTab=${activeTab} />`
|
|
2034
2163
|
: html`<${Box} flexDirection="column">
|
|
2035
2164
|
${showEmptyBanner
|
|
2036
2165
|
? html`<${Box} flexDirection="column" alignItems="center" width=${cols}>
|
|
2037
|
-
<${Banner} cfg=${cfg} model=${model} width=${cols} compact=${
|
|
2166
|
+
<${Banner} cfg=${cfg} model=${model} width=${cols} compact=${!showBanner} streamerMode=${streamerMode} />
|
|
2038
2167
|
<//>`
|
|
2039
2168
|
: null}
|
|
2040
2169
|
${scrollIndicator}
|
|
@@ -2044,17 +2173,19 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
2044
2173
|
|
|
2045
2174
|
return html`
|
|
2046
2175
|
<${Box} flexDirection="column" width=${cols} height=${rows}>
|
|
2047
|
-
<${TabBar} active=${activeTab} cols=${cols}
|
|
2176
|
+
<${TabBar} active=${activeTab} cols=${cols} />
|
|
2048
2177
|
|
|
2049
2178
|
<${Box} flexDirection="column" flexGrow=${1} overflow="hidden">
|
|
2050
2179
|
${timelineContent}
|
|
2051
2180
|
<//>
|
|
2052
2181
|
|
|
2053
|
-
|
|
2182
|
+
<${Box} flexDirection="column">
|
|
2183
|
+
${search ? html`<${SearchOverlay} query=${search.query} matches=${searchMatches} active=${search.active} cols=${cols} />` : null}
|
|
2054
2184
|
|
|
2055
|
-
|
|
2185
|
+
${!busy && chips.length && !choice && !search && !sessionPicker && !diffState
|
|
2186
|
+
? html`<${FollowupChips} chips=${chips} active=${activeChip} />`
|
|
2187
|
+
: null}
|
|
2056
2188
|
|
|
2057
|
-
<${Box} flexDirection="column">
|
|
2058
2189
|
${choice
|
|
2059
2190
|
? html`<${Choice} ...${choice} cols=${cols} />`
|
|
2060
2191
|
: sessionPicker
|
|
@@ -2083,9 +2214,16 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
2083
2214
|
|
|
2084
2215
|
<${Box} paddingX=${1} width=${cols}>
|
|
2085
2216
|
<${Box} flexGrow=${1}>
|
|
2086
|
-
<${Text} color=${
|
|
2217
|
+
<${Text} color=${footerStatusColor} wrap="truncate-end">${footerStatusLeft}<//>
|
|
2218
|
+
<//>
|
|
2219
|
+
<${Text} color=${footerStatusColor} dimColor wrap="truncate-end">${footerStatusRight}<//>
|
|
2220
|
+
<//>
|
|
2221
|
+
|
|
2222
|
+
<${Box} paddingX=${1} width=${cols}>
|
|
2223
|
+
<${Box} flexGrow=${1}>
|
|
2224
|
+
<${Text} color=${C.muted} dimColor wrap="truncate-end">${footerLeft}<//>
|
|
2087
2225
|
<//>
|
|
2088
|
-
<${Text} color=${
|
|
2226
|
+
<${Text} color=${C.muted} dimColor wrap="truncate-end">${footerInfoRight}<//>
|
|
2089
2227
|
<//>
|
|
2090
2228
|
<//>
|
|
2091
2229
|
<//>`;
|
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,11 +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
|
-
<${Text}>${
|
|
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)}
|
|
253
238
|
<//>
|
|
254
239
|
<//>`;
|
|
255
240
|
}
|
|
@@ -277,13 +262,17 @@ function StreamingText({ text = '' }) {
|
|
|
277
262
|
return rendered;
|
|
278
263
|
}
|
|
279
264
|
|
|
280
|
-
export function AssistantMessage({ text, cols = 80, ts, label
|
|
281
|
-
|
|
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';
|
|
282
268
|
const bodyWidth = Math.min(cols, 100);
|
|
283
269
|
return html`
|
|
284
|
-
<${Box} flexDirection="column"
|
|
285
|
-
<${
|
|
286
|
-
|
|
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}>
|
|
287
276
|
${streaming ? html`<${StreamingText} text=${text} />` : html`<${Markdown} content=${text} />`}
|
|
288
277
|
<//>
|
|
289
278
|
<//>`;
|
|
@@ -293,7 +282,7 @@ export function ShellOutput({ command, output, cols = 80, ts, code = 0 }) {
|
|
|
293
282
|
return html`
|
|
294
283
|
<${Box} flexDirection="column" marginTop=${1}>
|
|
295
284
|
<${SpeakerLine}
|
|
296
|
-
label
|
|
285
|
+
label=${"$ Shell"}
|
|
297
286
|
color=${code === 0 ? C.warning : C.error}
|
|
298
287
|
cols=${cols}
|
|
299
288
|
ts=${ts}
|
|
@@ -311,44 +300,49 @@ const LEAD_EMOJI = /^\s*([\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\
|
|
|
311
300
|
|
|
312
301
|
export function StatusLines({ lines = [], busy = false }) {
|
|
313
302
|
if (!lines.length) return null;
|
|
314
|
-
const subLines = lines.filter((l) => SUBAGENT.test(l));
|
|
315
303
|
const lastIdx = lines.length - 1;
|
|
316
304
|
return html`
|
|
317
305
|
<${Box} flexDirection="column" marginTop=${1}>
|
|
318
306
|
${lines.map((line, i) => {
|
|
319
|
-
const
|
|
307
|
+
const lineStr = String(line);
|
|
308
|
+
const sm = lineStr.match(SUBAGENT);
|
|
320
309
|
if (sm) {
|
|
321
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;
|
|
322
314
|
const good = !/fail|error|down|offline|unreachable|timeout|deny/i.test(result);
|
|
323
|
-
const isLast = subLines[subLines.length - 1] === line;
|
|
324
|
-
const running = busy && isLast && Number(idx) < Number(total);
|
|
325
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;
|
|
326
318
|
return html`
|
|
327
319
|
<${Box} key=${'s' + i}>
|
|
328
320
|
<${Text} color=${C.gray}> <//>
|
|
329
|
-
<${Text} color=${C.
|
|
330
|
-
<${Text} color=${C.
|
|
331
|
-
<${Text} color=${C.white} bold>${
|
|
332
|
-
<${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()} <//>
|
|
333
325
|
${running
|
|
334
|
-
? html`<${Text} color=${C.
|
|
335
|
-
: 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}<//>`}
|
|
336
328
|
<//>`;
|
|
337
329
|
}
|
|
338
330
|
|
|
339
331
|
const running = busy && i === lastIdx;
|
|
340
|
-
const em =
|
|
341
|
-
const body = em ?
|
|
332
|
+
const em = lineStr.match(LEAD_EMOJI);
|
|
333
|
+
const body = em ? lineStr.slice(em[0].length) : lineStr.replace(/^[\s◈◆●○◎⇢→>*✓✗×!⚠·-]+/u, '');
|
|
342
334
|
const ci = body.indexOf(':');
|
|
343
335
|
const action = ci > -1 ? body.slice(0, ci).trim() : body.trim();
|
|
344
336
|
const detail = ci > -1 ? body.slice(ci + 1).trim() : '';
|
|
345
|
-
|
|
346
|
-
|
|
337
|
+
const chipLabel = detail
|
|
338
|
+
? `${em ? em[1] : ''}${action}: ${detail.slice(0, 40)}`
|
|
339
|
+
: `${em ? em[1] : ''}${action}`;
|
|
347
340
|
return html`
|
|
348
341
|
<${Box} key=${'s' + i} marginTop=${0}>
|
|
349
342
|
<${Text} color=${C.muted}> <//>
|
|
350
343
|
<${Text} color=${running ? C.warning : C.muted}>[<//>
|
|
351
|
-
<${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}> <//>
|
|
352
346
|
${running
|
|
353
347
|
? html`<${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /><//>`
|
|
354
348
|
: html`<${Text} color=${C.success}>${glyphs.check}<//>`}
|
|
@@ -530,7 +524,7 @@ export function Thinking({ label = 'Working', startedAt }) {
|
|
|
530
524
|
<${Box} marginTop=${1} paddingX=${1}>
|
|
531
525
|
<${Text} color=${C.warning}><${Spinner} type=${spinnerType()} /><//>
|
|
532
526
|
<${Text} color=${C.warning} bold> ${label}<//>
|
|
533
|
-
<${Text} color=${C.muted}>… (${secs}s ${midDot()}esc to
|
|
527
|
+
<${Text} color=${C.muted}>… (${secs}s ${midDot()} esc to cancel)<//>
|
|
534
528
|
<//>`;
|
|
535
529
|
}
|
|
536
530
|
|
|
@@ -540,7 +534,7 @@ export function Choice({ title, note, options = [], sel = 0, allowText = false,
|
|
|
540
534
|
return html`
|
|
541
535
|
<${Box} flexDirection="column" marginTop=${1}>
|
|
542
536
|
<${Box} paddingX=${2} marginTop=${1}>
|
|
543
|
-
<${Text} color=${C.accent} bold
|
|
537
|
+
<${Text} color=${C.accent} bold>${glyphs.bolt} Tool request<//>
|
|
544
538
|
<//>
|
|
545
539
|
${note ? html`<${Box} paddingX=${2} marginTop=${1}><${Text} color=${C.muted}>${note}<//><//>` : null}
|
|
546
540
|
<${Box} flexDirection="column" marginTop=${1} paddingX=${2}>
|
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
|
package/src/tui/mouse.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useEffect, useRef } from './dom.js';
|
|
2
|
+
|
|
3
|
+
function parseMouseData(chunk, emit) {
|
|
4
|
+
let i = 0;
|
|
5
|
+
while (i < chunk.length) {
|
|
6
|
+
if (chunk.startsWith('\x1b[<', i)) {
|
|
7
|
+
const match = chunk.slice(i).match(/^\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
|
|
8
|
+
if (!match) return chunk.slice(i);
|
|
9
|
+
const btn = Number(match[1]);
|
|
10
|
+
const x = Math.max(0, Number(match[2]) - 1);
|
|
11
|
+
const y = Math.max(0, Number(match[3]) - 1);
|
|
12
|
+
emit({ btn, x, y, press: match[4] === 'M' });
|
|
13
|
+
i += match[0].length;
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
if (chunk.startsWith('\x1b[M', i)) {
|
|
17
|
+
if (i + 6 > chunk.length) return chunk.slice(i);
|
|
18
|
+
const btn = chunk.charCodeAt(i + 3) - 32;
|
|
19
|
+
const x = Math.max(0, chunk.charCodeAt(i + 4) - 33);
|
|
20
|
+
const y = Math.max(0, chunk.charCodeAt(i + 5) - 33);
|
|
21
|
+
emit({ btn, x, y, press: btn !== 3 });
|
|
22
|
+
i += 6;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
i += 1;
|
|
26
|
+
}
|
|
27
|
+
return '';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Read directly from process.stdin (before Ink's filtered stream) so we see
|
|
31
|
+
// the raw mouse escape sequences that the filter strips out in run.js.
|
|
32
|
+
export function useMouse(handler, enabled = true) {
|
|
33
|
+
const handlerRef = useRef(handler);
|
|
34
|
+
const bufferRef = useRef('');
|
|
35
|
+
|
|
36
|
+
handlerRef.current = handler;
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!enabled) 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
|
+
process.stdin.on('data', onData);
|
|
51
|
+
return () => {
|
|
52
|
+
process.stdin.off('data', onData);
|
|
53
|
+
bufferRef.current = '';
|
|
54
|
+
try { process.stdout.write('\x1b[?1000l\x1b[?1006l'); } catch {}
|
|
55
|
+
};
|
|
56
|
+
}, [enabled]);
|
|
57
|
+
}
|
package/src/tui/run.js
CHANGED
|
@@ -1,9 +1,58 @@
|
|
|
1
1
|
// Ink render entrypoint. Runs the TUI in a full-screen ALT-SCREEN viewport.
|
|
2
2
|
import { html } from './dom.js';
|
|
3
3
|
import { render } from 'ink';
|
|
4
|
+
import { Transform } from 'stream';
|
|
4
5
|
import { App } from './app.js';
|
|
5
6
|
import { saveConfig } from '../config.js';
|
|
6
7
|
|
|
8
|
+
// ── Mouse sequence filter ────────────────────────────────────────────────────
|
|
9
|
+
// Strips SGR (\x1b[<N;N;N[Mm]) and X10 (\x1b[M + 3 bytes) mouse escape
|
|
10
|
+
// sequences from the stdin stream before Ink sees it. Without this, unrecognised
|
|
11
|
+
// CSI sequences leak into the composer as raw characters when clicking/scrolling.
|
|
12
|
+
// mouse.js reads from process.stdin directly (before this filter) to get events.
|
|
13
|
+
class MouseFilterTransform extends Transform {
|
|
14
|
+
constructor() {
|
|
15
|
+
super();
|
|
16
|
+
this.isTTY = process.stdin.isTTY;
|
|
17
|
+
this.setRawMode = process.stdin.setRawMode
|
|
18
|
+
? (mode) => process.stdin.setRawMode(mode)
|
|
19
|
+
: undefined;
|
|
20
|
+
this._buf = '';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
_transform(chunk, _enc, cb) {
|
|
24
|
+
const text = Buffer.isBuffer(chunk) ? chunk.toString('latin1') : String(chunk);
|
|
25
|
+
this._buf += text;
|
|
26
|
+
let out = '';
|
|
27
|
+
let i = 0;
|
|
28
|
+
while (i < this._buf.length) {
|
|
29
|
+
// SGR mouse: \x1b[<N;N;N[Mm]
|
|
30
|
+
if (this._buf[i] === '\x1b' && this._buf[i + 1] === '[' && this._buf[i + 2] === '<') {
|
|
31
|
+
const rest = this._buf.slice(i);
|
|
32
|
+
const m = rest.match(/^\x1b\[<\d+;\d+;\d+[Mm]/);
|
|
33
|
+
if (m) { i += m[0].length; continue; }
|
|
34
|
+
if (rest.length < 32) break; // incomplete – wait for more bytes
|
|
35
|
+
out += this._buf[i++]; // give up, pass through
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
// X10 mouse: \x1b[M + 3 bytes
|
|
39
|
+
if (this._buf[i] === '\x1b' && this._buf[i + 1] === '[' && this._buf[i + 2] === 'M') {
|
|
40
|
+
if (i + 6 <= this._buf.length) { i += 6; continue; }
|
|
41
|
+
break; // incomplete
|
|
42
|
+
}
|
|
43
|
+
out += this._buf[i++];
|
|
44
|
+
}
|
|
45
|
+
this._buf = this._buf.slice(i);
|
|
46
|
+
if (out) this.push(out);
|
|
47
|
+
cb();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_flush(cb) {
|
|
51
|
+
if (this._buf) { this.push(this._buf); this._buf = ''; }
|
|
52
|
+
cb();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
7
56
|
// ── Alt-screen management ────────────────────────────────────────────────────
|
|
8
57
|
// Switch to the alternate terminal buffer so the TUI owns the full viewport and
|
|
9
58
|
// nothing leaks into the user's native scrollback. Must run BEFORE Ink renders.
|
|
@@ -124,6 +173,11 @@ export async function runTui(cfg, opts = {}) {
|
|
|
124
173
|
try { saveConfig(cfg); } catch {}
|
|
125
174
|
}
|
|
126
175
|
|
|
176
|
+
// Pipe raw stdin through the mouse filter so Ink never sees mouse escape
|
|
177
|
+
// sequences. mouse.js reads from process.stdin directly to capture events.
|
|
178
|
+
const mouseFilter = new MouseFilterTransform();
|
|
179
|
+
process.stdin.pipe(mouseFilter);
|
|
180
|
+
|
|
127
181
|
const instance = render(
|
|
128
182
|
html`<${App}
|
|
129
183
|
cfg=${cfg}
|
|
@@ -134,7 +188,7 @@ export async function runTui(cfg, opts = {}) {
|
|
|
134
188
|
resumeMode=${resumeMode}
|
|
135
189
|
onExternal=${(a) => { externalAction = a; onExternal?.(a); }}
|
|
136
190
|
/>`,
|
|
137
|
-
{ exitOnCtrlC: false },
|
|
191
|
+
{ exitOnCtrlC: false, stdin: mouseFilter },
|
|
138
192
|
);
|
|
139
193
|
|
|
140
194
|
try {
|
package/src/tui/theme.js
CHANGED