ispbills-icli 8.7.0 → 8.7.2
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/client.js +41 -0
- package/src/tui/app.js +58 -9
- package/src/tui/components.js +238 -0
- package/src/tui/mouse.js +6 -6
- package/src/tui/run.js +55 -1
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
import { refreshToken } from './auth.js';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Generic authenticated GET for any /api/v1/<path> endpoint.
|
|
5
|
+
* Uses the same Bearer token as the AI chat stream.
|
|
6
|
+
*/
|
|
7
|
+
export async function fetchApi(cfg, path) {
|
|
8
|
+
const doFetch = (token) =>
|
|
9
|
+
fetch(`${cfg.url}/api/v1/${path}`, {
|
|
10
|
+
headers: { Accept: 'application/json', Authorization: 'Bearer ' + token },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
let res = await doFetch(cfg.token);
|
|
14
|
+
if (res.status === 401 && cfg.refresh_token) {
|
|
15
|
+
const updated = await refreshToken(cfg);
|
|
16
|
+
if (!updated) throw new Error('Session expired – run `icli login`.');
|
|
17
|
+
Object.assign(cfg, updated);
|
|
18
|
+
res = await doFetch(cfg.token);
|
|
19
|
+
}
|
|
20
|
+
if (!res.ok) throw new Error(`API ${path} — HTTP ${res.status}`);
|
|
21
|
+
return res.json();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Fetch all device types in parallel for the Devices / Maps tabs.
|
|
26
|
+
* Uses Promise.allSettled so a missing endpoint never kills the whole load.
|
|
27
|
+
*/
|
|
28
|
+
export async function fetchDeviceData(cfg) {
|
|
29
|
+
const [routers, olts, ubiquiti, cambium] = await Promise.allSettled([
|
|
30
|
+
fetchApi(cfg, 'routers'),
|
|
31
|
+
fetchApi(cfg, 'olts'),
|
|
32
|
+
fetchApi(cfg, 'monitoring/ubiquiti'),
|
|
33
|
+
fetchApi(cfg, 'monitoring/cambium'),
|
|
34
|
+
]);
|
|
35
|
+
return {
|
|
36
|
+
routers: routers.status === 'fulfilled' ? (routers.value.data || []) : [],
|
|
37
|
+
olts: olts.status === 'fulfilled' ? (olts.value.data || []) : [],
|
|
38
|
+
ubiquiti: ubiquiti.status === 'fulfilled' ? (ubiquiti.value.data || []) : [],
|
|
39
|
+
cambium: cambium.status === 'fulfilled' ? (cambium.value.data || []) : [],
|
|
40
|
+
fetchedAt: Date.now(),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
3
44
|
/**
|
|
4
45
|
* Incrementally extract the answer text from the raw JSON the backend streams
|
|
5
46
|
* in `delta` events (the reply object is built up as a JSON string).
|
package/src/tui/app.js
CHANGED
|
@@ -6,10 +6,10 @@ import { useMouse } from './mouse.js';
|
|
|
6
6
|
import {
|
|
7
7
|
Banner, TabBar, FollowupChips, UserMessage, AssistantMessage, StatusLines, Reasoning,
|
|
8
8
|
Plan, Connect, Notice, Thinking, Choice, ShellOutput, SearchOverlay, HelpOverlay,
|
|
9
|
-
SessionPicker, DiffViewer, LabeledNotice, TABS, TAB_W,
|
|
9
|
+
SessionPicker, DiffViewer, LabeledNotice, DevicesPanel, MapsPanel, TABS, TAB_W,
|
|
10
10
|
} from './components.js';
|
|
11
11
|
import { Composer } from './composer.js';
|
|
12
|
-
import { streamChat } from '../client.js';
|
|
12
|
+
import { streamChat, fetchDeviceData } from '../client.js';
|
|
13
13
|
import { slashToQuestion, SLASH_COMMANDS } from '../commands.js';
|
|
14
14
|
import { saveConfig } from '../config.js';
|
|
15
15
|
import { shortModel } from '../ui.js';
|
|
@@ -432,9 +432,12 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
432
432
|
const [trusted, setTrusted] = useState(isTrustedDir(cfg, process.cwd()));
|
|
433
433
|
const [sessionPicker, setSessionPicker] = useState(null);
|
|
434
434
|
const [diffState, setDiffState] = useState(null);
|
|
435
|
+
const [deviceData, setDeviceData] = useState(null);
|
|
436
|
+
const [deviceLoading, setDeviceLoading] = useState(false);
|
|
437
|
+
const [deviceError, setDeviceError] = useState(null);
|
|
435
438
|
const chipsRows = (!busy && chips.length && !choice && !search && !sessionPicker && !diffState) ? 1 : 0;
|
|
436
439
|
const composerRows = 2;
|
|
437
|
-
const chromeRows = 2 + composerRows + 1 + chipsRows +
|
|
440
|
+
const chromeRows = 2 + composerRows + 1 + chipsRows + 2; // +2 for two footer rows
|
|
438
441
|
const viewportRows = Math.max(3, rows - chromeRows);
|
|
439
442
|
const scrollPage = Math.max(4, viewportRows - 2);
|
|
440
443
|
|
|
@@ -483,6 +486,31 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
483
486
|
for (const timer of dismissTimers.current.values()) clearTimeout(timer);
|
|
484
487
|
}, []);
|
|
485
488
|
|
|
489
|
+
// ── Device data fetch ───────────────────────────────────────────────────────
|
|
490
|
+
const deviceLoadingRef = useRef(false);
|
|
491
|
+
const loadDeviceData = useCallback(async () => {
|
|
492
|
+
if (deviceLoadingRef.current) return;
|
|
493
|
+
deviceLoadingRef.current = true;
|
|
494
|
+
setDeviceLoading(true);
|
|
495
|
+
setDeviceError(null);
|
|
496
|
+
try {
|
|
497
|
+
const data = await fetchDeviceData(cfg);
|
|
498
|
+
setDeviceData(data);
|
|
499
|
+
} catch (e) {
|
|
500
|
+
setDeviceError(String(e?.message || e));
|
|
501
|
+
} finally {
|
|
502
|
+
deviceLoadingRef.current = false;
|
|
503
|
+
setDeviceLoading(false);
|
|
504
|
+
}
|
|
505
|
+
}, [cfg]);
|
|
506
|
+
|
|
507
|
+
// Auto-fetch when the user first opens the Devices or Maps tab
|
|
508
|
+
useEffect(() => {
|
|
509
|
+
if ((activeTab === 'devices' || activeTab === 'maps') && !deviceData && !deviceLoadingRef.current) {
|
|
510
|
+
loadDeviceData();
|
|
511
|
+
}
|
|
512
|
+
}, [activeTab]);
|
|
513
|
+
|
|
486
514
|
const persistCfg = useCallback(() => {
|
|
487
515
|
try { saveConfig(cfg); } catch {}
|
|
488
516
|
}, [cfg]);
|
|
@@ -1997,6 +2025,12 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1997
2025
|
pushNotice(`Search ${activeTab} tab…`, C.muted, true);
|
|
1998
2026
|
return;
|
|
1999
2027
|
}
|
|
2028
|
+
// 'r' refreshes the device list when on Devices or Maps tab
|
|
2029
|
+
if ((activeTab === 'devices' || activeTab === 'maps') && input === 'r' && !key.ctrl) {
|
|
2030
|
+
loadDeviceData();
|
|
2031
|
+
setHint('Refreshing devices…');
|
|
2032
|
+
return;
|
|
2033
|
+
}
|
|
2000
2034
|
if (key.tab && key.shift && !busy) {
|
|
2001
2035
|
const cur = MODES.indexOf(modeRef.current);
|
|
2002
2036
|
const next = MODES[(cur + 1) % MODES.length];
|
|
@@ -2090,11 +2124,15 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
2090
2124
|
const cwdMaxLen = Math.max(12, cols - footerRightEstLen - (branch ? branch.length + 6 : 0) - 4);
|
|
2091
2125
|
const cwd = shortenPath(process.cwd(), cwdMaxLen);
|
|
2092
2126
|
const footerLeft = [cwd, branch ? `${glyphs.gitBranch}${branch}` : null].filter(Boolean).join(midDot());
|
|
2093
|
-
const
|
|
2127
|
+
const footerInfoRight = streamerMode ? '' : [modelLabel, tokenLabel].filter(Boolean).join(midDot());
|
|
2128
|
+
// Status row: working indicator when busy, key hints when idle, hint message when set
|
|
2129
|
+
const footerStatusLeft = busy
|
|
2130
|
+
? `${glyphs.spinner} Working\u2026`
|
|
2131
|
+
: hint
|
|
2094
2132
|
? hint
|
|
2095
|
-
:
|
|
2096
|
-
|
|
2097
|
-
|
|
2133
|
+
: `? help ${midDot()} / commands ${midDot()} \u2191\u2193 history ${midDot()} Tab mode`;
|
|
2134
|
+
const footerStatusColor = busy ? C.warning : hint ? C.warning : C.muted;
|
|
2135
|
+
const footerStatusRight = busy ? `Ctrl+C to cancel` : '';
|
|
2098
2136
|
const composerMode = shellMode ? 'shell' : mode;
|
|
2099
2137
|
|
|
2100
2138
|
// ── Viewport slicing ───────────────────────────────────────────────────────
|
|
@@ -2154,6 +2192,10 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
2154
2192
|
<${Notice} text=${`${glyphs.expand} ${expandedItems.kind}`} color=${C.muted} />
|
|
2155
2193
|
${expandedItems.items.map((it) => renderItem(it))}
|
|
2156
2194
|
<//>`
|
|
2195
|
+
: activeTab === 'devices'
|
|
2196
|
+
? html`<${DevicesPanel} data=${deviceData} loading=${deviceLoading} error=${deviceError} cols=${cols} />`
|
|
2197
|
+
: activeTab === 'maps'
|
|
2198
|
+
? html`<${MapsPanel} data=${deviceData} loading=${deviceLoading} error=${deviceError} cols=${cols} />`
|
|
2157
2199
|
: tabPanel
|
|
2158
2200
|
? html`<${TabView} panel=${tabPanel} cols=${cols} activeTab=${activeTab} />`
|
|
2159
2201
|
: html`<${Box} flexDirection="column">
|
|
@@ -2210,9 +2252,16 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
2210
2252
|
|
|
2211
2253
|
<${Box} paddingX=${1} width=${cols}>
|
|
2212
2254
|
<${Box} flexGrow=${1}>
|
|
2213
|
-
<${Text} color=${
|
|
2255
|
+
<${Text} color=${footerStatusColor} wrap="truncate-end">${footerStatusLeft}<//>
|
|
2256
|
+
<//>
|
|
2257
|
+
<${Text} color=${footerStatusColor} dimColor wrap="truncate-end">${footerStatusRight}<//>
|
|
2258
|
+
<//>
|
|
2259
|
+
|
|
2260
|
+
<${Box} paddingX=${1} width=${cols}>
|
|
2261
|
+
<${Box} flexGrow=${1}>
|
|
2262
|
+
<${Text} color=${C.muted} dimColor wrap="truncate-end">${footerLeft}<//>
|
|
2214
2263
|
<//>
|
|
2215
|
-
<${Text} color=${
|
|
2264
|
+
<${Text} color=${C.muted} dimColor wrap="truncate-end">${footerInfoRight}<//>
|
|
2216
2265
|
<//>
|
|
2217
2266
|
<//>
|
|
2218
2267
|
<//>`;
|
package/src/tui/components.js
CHANGED
|
@@ -591,3 +591,241 @@ export function Choice({ title, note, options = [], sel = 0, allowText = false,
|
|
|
591
591
|
<//>
|
|
592
592
|
<//>`;
|
|
593
593
|
}
|
|
594
|
+
|
|
595
|
+
// ── Shared device-status helpers ────────────────────────────────────────────
|
|
596
|
+
|
|
597
|
+
function devStatus(d) {
|
|
598
|
+
const s = String(d.status || '').toLowerCase();
|
|
599
|
+
if (d.is_online === true || s === 'online' || s === 'active' || s === 'ok') return 'online';
|
|
600
|
+
if (d.is_online === false || s === 'offline' || s === 'inactive' || s === 'down') return 'offline';
|
|
601
|
+
return 'unknown';
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function statusDot(d) {
|
|
605
|
+
const st = devStatus(d);
|
|
606
|
+
return st === 'online' ? { dot: '●', color: C.success }
|
|
607
|
+
: st === 'offline' ? { dot: '○', color: C.error }
|
|
608
|
+
: { dot: '◌', color: C.warning };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function statusCounts(list) {
|
|
612
|
+
const on = list.filter((d) => devStatus(d) === 'online').length;
|
|
613
|
+
const off = list.filter((d) => devStatus(d) === 'offline').length;
|
|
614
|
+
return { on, off, other: list.length - on - off };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ── DevicesPanel ─────────────────────────────────────────────────────────────
|
|
618
|
+
|
|
619
|
+
export function DevicesPanel({ data, loading, error, cols }) {
|
|
620
|
+
const bw = cols - 4;
|
|
621
|
+
const header = html`
|
|
622
|
+
<${Box} flexDirection="column" paddingX=${2}>
|
|
623
|
+
<${Box} marginBottom=${0}>
|
|
624
|
+
<${Text} color=${C.brand} bold>Devices<//><${Text} color=${C.muted}> ISP fleet — routers · OLTs · access points<//>
|
|
625
|
+
<//>
|
|
626
|
+
<${Text} color=${C.separator}>${'─'.repeat(Math.max(1, bw))}<//>
|
|
627
|
+
<//>`;
|
|
628
|
+
|
|
629
|
+
if (loading) return html`
|
|
630
|
+
<${Box} flexDirection="column">
|
|
631
|
+
${header}
|
|
632
|
+
<${Box} paddingX=${2} paddingY=${1} gap=${1}>
|
|
633
|
+
<${Spinner} type=${spinnerType} /><${Text} color=${C.muted}> Loading devices…<//>
|
|
634
|
+
<//>
|
|
635
|
+
<//>`;
|
|
636
|
+
|
|
637
|
+
if (error || !data) return html`
|
|
638
|
+
<${Box} flexDirection="column">
|
|
639
|
+
${header}
|
|
640
|
+
<${Box} paddingX=${2} paddingY=${1} flexDirection="column">
|
|
641
|
+
<${Text} color=${C.error}>${error || 'No data'}<//>
|
|
642
|
+
<${Text} color=${C.muted} dimColor>Press r to retry<//>
|
|
643
|
+
<//>
|
|
644
|
+
<//>`;
|
|
645
|
+
|
|
646
|
+
const groups = [
|
|
647
|
+
{ label: 'Routers', icon: '⬡', items: data.routers, typeTag: 'router' },
|
|
648
|
+
{ label: 'OLTs', icon: '◈', items: data.olts, typeTag: 'olt' },
|
|
649
|
+
{ label: 'Ubiquiti APs', icon: '◉', items: data.ubiquiti, typeTag: 'ubiquiti' },
|
|
650
|
+
{ label: 'Cambium APs', icon: '◉', items: data.cambium, typeTag: 'cambium' },
|
|
651
|
+
].filter((g) => g.items.length > 0);
|
|
652
|
+
|
|
653
|
+
if (groups.length === 0) return html`
|
|
654
|
+
<${Box} flexDirection="column">
|
|
655
|
+
${header}
|
|
656
|
+
<${Box} paddingX=${2} paddingY=${1}>
|
|
657
|
+
<${Text} color=${C.muted}>No devices found. Press r to refresh.<//>
|
|
658
|
+
<//>
|
|
659
|
+
<//>`;
|
|
660
|
+
|
|
661
|
+
const nameW = Math.max(20, Math.floor(cols * 0.35));
|
|
662
|
+
const ipW = 16;
|
|
663
|
+
const tagW = 10;
|
|
664
|
+
const maxRowsPerGroup = Math.max(5, Math.floor((cols > 100 ? 12 : 8)));
|
|
665
|
+
|
|
666
|
+
const ts = data.fetchedAt
|
|
667
|
+
? new Date(data.fetchedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
668
|
+
: '';
|
|
669
|
+
|
|
670
|
+
return html`
|
|
671
|
+
<${Box} flexDirection="column">
|
|
672
|
+
${header}
|
|
673
|
+
<${Box} flexDirection="column" paddingX=${2} paddingY=${0}>
|
|
674
|
+
${groups.map((g) => {
|
|
675
|
+
const { on, off, other } = statusCounts(g.items);
|
|
676
|
+
const sorted = [...g.items].sort((a, b) => {
|
|
677
|
+
const order = { online: 0, unknown: 1, offline: 2 };
|
|
678
|
+
return (order[devStatus(a)] ?? 1) - (order[devStatus(b)] ?? 1);
|
|
679
|
+
});
|
|
680
|
+
const shown = sorted.slice(0, maxRowsPerGroup);
|
|
681
|
+
const hidden = sorted.length - shown.length;
|
|
682
|
+
return html`
|
|
683
|
+
<${Box} key=${g.label} flexDirection="column" marginTop=${1}>
|
|
684
|
+
<${Box}>
|
|
685
|
+
<${Text} color=${C.accent} bold>${g.icon} ${g.label}<//>
|
|
686
|
+
<${Text} color=${C.muted}> <//>
|
|
687
|
+
<${Text} color=${C.success}>${on}↑<//>
|
|
688
|
+
<${Text} color=${C.muted}> <//>
|
|
689
|
+
<${Text} color=${C.error}>${off}↓<//>
|
|
690
|
+
${other > 0 ? html`<${Text} color=${C.warning}> ${other}?<//>` : null}
|
|
691
|
+
<//>
|
|
692
|
+
${shown.map((d, i) => {
|
|
693
|
+
const { dot, color } = statusDot(d);
|
|
694
|
+
const isLast = i === shown.length - 1 && hidden === 0;
|
|
695
|
+
const tree = isLast ? '└─' : '├─';
|
|
696
|
+
const name = (d.name || d.device_name || `#${d.id}`)
|
|
697
|
+
.slice(0, nameW - 1).padEnd(nameW);
|
|
698
|
+
const ip = (d.ip_address || '─').slice(0, ipW - 1).padEnd(ipW);
|
|
699
|
+
const extra = d.model ? d.model.slice(0, tagW) : '';
|
|
700
|
+
return html`
|
|
701
|
+
<${Box} key=${d.id}>
|
|
702
|
+
<${Text} color=${C.muted}>${tree}<//>
|
|
703
|
+
<${Text} color=${color}>${dot} <//>
|
|
704
|
+
<${Text}>${name}<//>
|
|
705
|
+
<${Text} color=${C.muted}>${ip}<//>
|
|
706
|
+
<${Text} color=${C.muted} dimColor>${extra}<//>
|
|
707
|
+
<//>`;
|
|
708
|
+
})}
|
|
709
|
+
${hidden > 0
|
|
710
|
+
? html`<${Box}><${Text} color=${C.muted} dimColor> └─ …and ${hidden} more<//> <//>`
|
|
711
|
+
: null}
|
|
712
|
+
<//>`;
|
|
713
|
+
})}
|
|
714
|
+
<//>
|
|
715
|
+
<${Box} paddingX=${2} marginTop=${1}>
|
|
716
|
+
<${Text} color=${C.separator}>${'─'.repeat(Math.max(1, bw))}<//>
|
|
717
|
+
<//>
|
|
718
|
+
<${Box} paddingX=${2}>
|
|
719
|
+
<${Text} color=${C.muted} dimColor>r refresh${ts ? ' ' + midDot() + ' updated ' + ts : ''} ${midDot()} Enter → Session<//>
|
|
720
|
+
<//>
|
|
721
|
+
<//>`;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// ── MapsPanel (static ASCII topology) ────────────────────────────────────────
|
|
725
|
+
|
|
726
|
+
export function MapsPanel({ data, loading, error, cols }) {
|
|
727
|
+
const bw = cols - 4;
|
|
728
|
+
const header = html`
|
|
729
|
+
<${Box} flexDirection="column" paddingX=${2}>
|
|
730
|
+
<${Box} marginBottom=${0}>
|
|
731
|
+
<${Text} color=${C.brand} bold>Network Map<//><${Text} color=${C.muted}> Static topology — routers · OLTs · access points<//>
|
|
732
|
+
<//>
|
|
733
|
+
<${Text} color=${C.separator}>${'─'.repeat(Math.max(1, bw))}<//>
|
|
734
|
+
<//>`;
|
|
735
|
+
|
|
736
|
+
if (loading) return html`
|
|
737
|
+
<${Box} flexDirection="column">
|
|
738
|
+
${header}
|
|
739
|
+
<${Box} paddingX=${2} paddingY=${1} gap=${1}>
|
|
740
|
+
<${Spinner} type=${spinnerType} /><${Text} color=${C.muted}> Building topology…<//>
|
|
741
|
+
<//>
|
|
742
|
+
<//>`;
|
|
743
|
+
|
|
744
|
+
if (error || !data) return html`
|
|
745
|
+
<${Box} flexDirection="column">
|
|
746
|
+
${header}
|
|
747
|
+
<${Box} paddingX=${2} paddingY=${1} flexDirection="column">
|
|
748
|
+
<${Text} color=${C.error}>${error || 'No data'}<//>
|
|
749
|
+
<${Text} color=${C.muted} dimColor>Press r to retry<//>
|
|
750
|
+
<//>
|
|
751
|
+
<//>`;
|
|
752
|
+
|
|
753
|
+
const tiers = [
|
|
754
|
+
{ label: 'Routers', items: data.routers, icon: '⬡', color: C.brand },
|
|
755
|
+
{ label: 'OLTs', items: data.olts, icon: '◈', color: C.accent },
|
|
756
|
+
{ label: 'Ubiquiti APs', items: data.ubiquiti, icon: '◉', color: C.success },
|
|
757
|
+
{ label: 'Cambium APs', items: data.cambium, icon: '◉', color: C.warning },
|
|
758
|
+
].filter((t) => t.items.length > 0);
|
|
759
|
+
|
|
760
|
+
const maxPerTier = Math.max(4, Math.floor((cols > 100 ? 10 : 6)));
|
|
761
|
+
const indent = ' ';
|
|
762
|
+
|
|
763
|
+
return html`
|
|
764
|
+
<${Box} flexDirection="column">
|
|
765
|
+
${header}
|
|
766
|
+
<${Box} flexDirection="column" paddingX=${2} paddingY=${0}>
|
|
767
|
+
|
|
768
|
+
<${Box} marginTop=${1} marginBottom=${0}>
|
|
769
|
+
<${Text} color=${C.muted}>${indent}┌─────────────┐<//>
|
|
770
|
+
<//>
|
|
771
|
+
<${Box}>
|
|
772
|
+
<${Text} color=${C.muted}>${indent}│<//>
|
|
773
|
+
<${Text} color=${C.brand} bold> Internet <//>
|
|
774
|
+
<${Text} color=${C.muted}>│<//>
|
|
775
|
+
<//>
|
|
776
|
+
<${Box} marginBottom=${0}>
|
|
777
|
+
<${Text} color=${C.muted}>${indent}└──────┬──────┘<//>
|
|
778
|
+
<//>
|
|
779
|
+
|
|
780
|
+
${tiers.map((tier, ti) => {
|
|
781
|
+
const { on, off } = statusCounts(tier.items);
|
|
782
|
+
const shown = tier.items.slice(0, maxPerTier);
|
|
783
|
+
const hidden = tier.items.length - shown.length;
|
|
784
|
+
const isLast = ti === tiers.length - 1;
|
|
785
|
+
const nameW = Math.max(18, Math.floor(cols * 0.3));
|
|
786
|
+
|
|
787
|
+
return html`
|
|
788
|
+
<${Box} key=${tier.label} flexDirection="column">
|
|
789
|
+
<${Box}>
|
|
790
|
+
<${Text} color=${C.muted}>${indent} │<//>
|
|
791
|
+
<//>
|
|
792
|
+
<${Box}>
|
|
793
|
+
<${Text} color=${C.muted}>${indent} ┌────┴<//>
|
|
794
|
+
<${Text} color=${tier.color} bold> ${tier.icon} ${tier.label} <//>
|
|
795
|
+
<${Text} color=${C.success}>${on}↑ <//>
|
|
796
|
+
<${Text} color=${C.error}>${off}↓<//>
|
|
797
|
+
<//>
|
|
798
|
+
${shown.map((d, i) => {
|
|
799
|
+
const { dot, color } = statusDot(d);
|
|
800
|
+
const isLastItem = i === shown.length - 1 && hidden === 0;
|
|
801
|
+
const tree = isLastItem ? ' └──' : ' ├──';
|
|
802
|
+
const name = (d.name || d.device_name || `#${d.id}`).slice(0, nameW);
|
|
803
|
+
const ip = d.ip_address ? ` ${d.ip_address}` : '';
|
|
804
|
+
const st = devStatus(d) === 'offline' ? ' [offline]' : '';
|
|
805
|
+
return html`
|
|
806
|
+
<${Box} key=${d.id}>
|
|
807
|
+
<${Text} color=${C.muted}>${indent}${tree} <//>
|
|
808
|
+
<${Text} color=${color}>${dot} <//>
|
|
809
|
+
<${Text} bold>${name}<//>
|
|
810
|
+
<${Text} color=${C.muted}>${ip}<//>
|
|
811
|
+
<${Text} color=${C.error} dimColor>${st}<//>
|
|
812
|
+
<//>`;
|
|
813
|
+
})}
|
|
814
|
+
${hidden > 0
|
|
815
|
+
? html`<${Box}><${Text} color=${C.muted} dimColor>${indent} └── …+${hidden} more<//><//>`
|
|
816
|
+
: null}
|
|
817
|
+
${!isLast
|
|
818
|
+
? null
|
|
819
|
+
: html`<${Box}><${Text} color=${C.muted}>${indent} │ (end)<//> <//>` }
|
|
820
|
+
<//>`;
|
|
821
|
+
})}
|
|
822
|
+
|
|
823
|
+
<//>
|
|
824
|
+
<${Box} paddingX=${2} marginTop=${1}>
|
|
825
|
+
<${Text} color=${C.separator}>${'─'.repeat(Math.max(1, bw))}<//>
|
|
826
|
+
<//>
|
|
827
|
+
<${Box} paddingX=${2}>
|
|
828
|
+
<${Text} color=${C.muted} dimColor>r refresh ${midDot()} /topology for AI-generated diagram ${midDot()} Enter → Session<//>
|
|
829
|
+
<//>
|
|
830
|
+
<//>`;
|
|
831
|
+
}
|
package/src/tui/mouse.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { useEffect, useRef } from './dom.js';
|
|
2
|
-
import { useStdin } from 'ink';
|
|
3
2
|
|
|
4
3
|
function parseMouseData(chunk, emit) {
|
|
5
4
|
let i = 0;
|
|
@@ -28,15 +27,16 @@ function parseMouseData(chunk, emit) {
|
|
|
28
27
|
return '';
|
|
29
28
|
}
|
|
30
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.
|
|
31
32
|
export function useMouse(handler, enabled = true) {
|
|
32
|
-
const { stdin } = useStdin();
|
|
33
33
|
const handlerRef = useRef(handler);
|
|
34
34
|
const bufferRef = useRef('');
|
|
35
35
|
|
|
36
36
|
handlerRef.current = handler;
|
|
37
37
|
|
|
38
38
|
useEffect(() => {
|
|
39
|
-
if (!enabled
|
|
39
|
+
if (!enabled) return;
|
|
40
40
|
|
|
41
41
|
try { process.stdout.write('\x1b[?1000h\x1b[?1006h'); } catch {}
|
|
42
42
|
|
|
@@ -47,11 +47,11 @@ export function useMouse(handler, enabled = true) {
|
|
|
47
47
|
});
|
|
48
48
|
};
|
|
49
49
|
|
|
50
|
-
stdin.on('data', onData);
|
|
50
|
+
process.stdin.on('data', onData);
|
|
51
51
|
return () => {
|
|
52
|
-
stdin.off('data', onData);
|
|
52
|
+
process.stdin.off('data', onData);
|
|
53
53
|
bufferRef.current = '';
|
|
54
54
|
try { process.stdout.write('\x1b[?1000l\x1b[?1006l'); } catch {}
|
|
55
55
|
};
|
|
56
|
-
}, [enabled
|
|
56
|
+
}, [enabled]);
|
|
57
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 {
|