ispbills-icli 8.3.1 → 8.4.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/README.md +13 -9
- package/package.json +1 -1
- package/src/tui/app.js +45 -73
- package/src/tui/components.js +27 -17
- package/src/tui/run.js +6 -23
package/README.md
CHANGED
|
@@ -59,11 +59,12 @@ $ icli
|
|
|
59
59
|
|
|
60
60
|
❯ is vlan 82 free on all devices?
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
✓ Connecting to OLT epon-olt-3…
|
|
63
|
+
✓ 🧭 No single device named — delegating across the fleet. Assigning one
|
|
64
|
+
sub-agent per device to: check whether VLAN 82 is in use
|
|
65
|
+
├─ 1/3 nas#1 — ✓ FREE
|
|
66
|
+
├─ 2/3 nas#2 — ✓ FREE
|
|
67
|
+
└─ 3/3 olt#1 — ✓ FREE
|
|
67
68
|
|
|
68
69
|
VLAN 82 is free across all 3 reachable devices in your fleet.
|
|
69
70
|
|
|
@@ -201,10 +202,13 @@ suggestion (accept with **Tab** or **→**), and runs the highlighted command on
|
|
|
201
202
|
plan is applied automatically without waiting for you to type `apply fix`.
|
|
202
203
|
Toggle it with **Shift+Tab** or `/autopilot`.
|
|
203
204
|
|
|
204
|
-
The REPL
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
205
|
+
The REPL renders inline in your terminal's normal buffer — exactly like GitHub
|
|
206
|
+
Copilot CLI. The banner prints once at the top and scrolls away into your native
|
|
207
|
+
scrollback as the conversation grows, so you can scroll back through the entire
|
|
208
|
+
session with your terminal's own scrollbar/wheel. The input box stays at the
|
|
209
|
+
bottom while a request is in flight, and each answer (including the sub-agent /
|
|
210
|
+
tool-call trace that produced it) is committed to scrollback so it stays visible
|
|
211
|
+
in your history.
|
|
208
212
|
|
|
209
213
|
---
|
|
210
214
|
|
package/package.json
CHANGED
package/src/tui/app.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
// Root Ink application. Renders
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Root Ink application. Renders an INLINE chat UI (GitHub Copilot CLI style):
|
|
2
|
+
// the banner prints once at the top and scrolls away, completed transcript
|
|
3
|
+
// items are flushed to the terminal's native scrollback via Ink <Static> (so
|
|
4
|
+
// you can scroll back through history), and only the live/streaming area plus
|
|
5
|
+
// the input composer are re-rendered in place at the bottom.
|
|
5
6
|
import { html, useState, useEffect, useRef, useCallback } from './dom.js';
|
|
6
|
-
import { Box, Text, useStdout, useApp, useInput } from 'ink';
|
|
7
|
+
import { Box, Text, Static, useStdout, useApp, useInput } from 'ink';
|
|
7
8
|
import { C, glyphs, midDot, dash } from './theme.js';
|
|
8
9
|
import {
|
|
9
10
|
Banner, UserMessage, AssistantMessage, StatusLines, Reasoning,
|
|
@@ -46,37 +47,6 @@ function useTerminalSize() {
|
|
|
46
47
|
let itemId = 0;
|
|
47
48
|
const nextId = () => ++itemId;
|
|
48
49
|
|
|
49
|
-
// Rough rendered-height estimates so we can tail-slice the transcript to fit
|
|
50
|
-
// the available middle region (biased high → under-fill, never overflow/clip).
|
|
51
|
-
const wrapLines = (text, cols) =>
|
|
52
|
-
String(text || '').split('\n').reduce((n, l) => n + Math.max(1, Math.ceil(l.length / Math.max(1, cols))), 0);
|
|
53
|
-
|
|
54
|
-
function itemHeight(it, cols) {
|
|
55
|
-
switch (it.type) {
|
|
56
|
-
case 'user': return 1 + wrapLines(it.text, cols);
|
|
57
|
-
case 'assistant': return 1 + wrapLines(it.text, cols);
|
|
58
|
-
case 'notice': return 1 + wrapLines(it.text, cols);
|
|
59
|
-
case 'plan': { const s = it.reply?.steps || []; return 2 + (it.reply?.summary ? 1 : 0) + s.length * 2 + 2; }
|
|
60
|
-
case 'connect': return 3 + (it.reply?.note ? 1 : 0);
|
|
61
|
-
default: return 1;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function bannerHeight(cols, compact) {
|
|
66
|
-
if (compact) return 2;
|
|
67
|
-
return cols >= 60 ? 11 : 5;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function liveHeight(live, cols, showReasoning) {
|
|
71
|
-
if (!live) return 0;
|
|
72
|
-
let h = 2; // thinking line + margin
|
|
73
|
-
if (live.status?.length) h += 1 + live.status.length;
|
|
74
|
-
if (showReasoning && live.reasoning) h += 1 + wrapLines(live.reasoning, cols);
|
|
75
|
-
if (live.reply?.steps) h += 3 + live.reply.steps.length * 2 + (live.reply.summary ? 1 : 0);
|
|
76
|
-
if (live.text) h += 1 + wrapLines(live.text, cols);
|
|
77
|
-
return h;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
50
|
export function App({ cfg, display = {}, onExternal, onExit, initialQuestion }) {
|
|
81
51
|
const { exit } = useApp();
|
|
82
52
|
const { cols, rows } = useTerminalSize();
|
|
@@ -90,6 +60,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
90
60
|
const [hint, setHint] = useState('');
|
|
91
61
|
const [startedAt, setStartedAt] = useState(0);
|
|
92
62
|
const [choice, setChoice] = useState(null); // { title, note, options, allowText, sel, text, focusText, onPick }
|
|
63
|
+
const [clearEpoch, setClearEpoch] = useState(0); // bump to remount <Static> on /clear /new
|
|
93
64
|
|
|
94
65
|
const convo = useRef([]); // [{role, content}] for the backend
|
|
95
66
|
const plain = useRef([]); // plain-text transcript for exit reprint
|
|
@@ -116,6 +87,14 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
116
87
|
const push = useCallback((item) => setItems((xs) => [...xs, { id: nextId(), ...item }]), []);
|
|
117
88
|
const log = (line) => plain.current.push(line);
|
|
118
89
|
|
|
90
|
+
// Clear the visible transcript: wipe the screen + native scrollback and
|
|
91
|
+
// remount <Static> (via clearEpoch) so nothing is re-flushed above the input.
|
|
92
|
+
const clearAll = useCallback(() => {
|
|
93
|
+
try { process.stdout.write('\x1b[2J\x1b[3J\x1b[H'); } catch {}
|
|
94
|
+
setItems([]);
|
|
95
|
+
setClearEpoch((n) => n + 1);
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
119
98
|
const runTurn = useCallback(async (question, opts = {}) => {
|
|
120
99
|
const { depth = 0, archive = null, label = '' } = opts;
|
|
121
100
|
push({ type: 'user', text: question });
|
|
@@ -153,6 +132,9 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
153
132
|
});
|
|
154
133
|
if (meta.model) setModel(meta.model);
|
|
155
134
|
const type = reply.type ?? 'message';
|
|
135
|
+
// Persist the tool / sub-agent activity trace inline (Copilot-CLI style)
|
|
136
|
+
// so it stays in history instead of vanishing with the live region.
|
|
137
|
+
if (s.status.length) push({ type: 'activity', status: s.status });
|
|
156
138
|
if (type === 'plan') { push({ type: 'plan', reply }); log('[plan] ' + (reply.summary || '')); }
|
|
157
139
|
else if (type === 'connect') { push({ type: 'connect', reply }); log('[connect] ' + (reply.device?.kind || '')); }
|
|
158
140
|
else if (type === 'prompt' || type === 'choice') { push({ type: 'assistant', text: text || reply.question || '' }); log(text || reply.question || ''); }
|
|
@@ -246,10 +228,10 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
246
228
|
setHint('');
|
|
247
229
|
|
|
248
230
|
if (q === '/exit' || q === 'exit' || q === 'quit') { doExit(); return; }
|
|
249
|
-
if (q === '/clear') {
|
|
231
|
+
if (q === '/clear') { clearAll(); return; }
|
|
250
232
|
if (q === '/new') {
|
|
251
233
|
convo.current = [];
|
|
252
|
-
|
|
234
|
+
clearAll();
|
|
253
235
|
push({ type: 'notice', text: `${glyphs.check} New conversation started.`, color: C.green });
|
|
254
236
|
return;
|
|
255
237
|
}
|
|
@@ -441,7 +423,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
441
423
|
return;
|
|
442
424
|
}
|
|
443
425
|
if (key.ctrl && input === 't') { setShowReasoning((v) => !v); return; }
|
|
444
|
-
if (key.ctrl && input === 'l' && !busy) {
|
|
426
|
+
if (key.ctrl && input === 'l' && !busy) { clearAll(); return; }
|
|
445
427
|
});
|
|
446
428
|
|
|
447
429
|
// Auto-run a first question if launched with one.
|
|
@@ -456,6 +438,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
456
438
|
case 'assistant': return html`<${AssistantMessage} key=${it.id} text=${it.text} />`;
|
|
457
439
|
case 'plan': return html`<${Plan} key=${it.id} reply=${it.reply} />`;
|
|
458
440
|
case 'connect': return html`<${Connect} key=${it.id} reply=${it.reply} />`;
|
|
441
|
+
case 'activity': return html`<${StatusLines} key=${it.id} lines=${it.status} busy=${false} />`;
|
|
459
442
|
case 'notice': return html`<${Notice} key=${it.id} text=${it.text} color=${it.color} />`;
|
|
460
443
|
default: return null;
|
|
461
444
|
}
|
|
@@ -464,46 +447,35 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
464
447
|
const ctx = [model ? shortModel(model) : null, cfg.user?.name, cfg.user?.role].filter(Boolean).join(midDot());
|
|
465
448
|
const modeTag = autopilot ? `${glyphs.bolt} autopilot ${midDot()} ` : '';
|
|
466
449
|
const footerRight = hint || `${modeTag}/help ${midDot()} Shift+Tab autopilot ${midDot()} Ctrl+C exit`;
|
|
450
|
+
const compact = cols < 60;
|
|
467
451
|
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
rows - bannerHeight(cols, compact) - 3 /*composer*/ - 1 /*footer*/ - 1 /*safety*/
|
|
474
|
-
- liveHeight(live, cols, showReasoning) - 1 /*earlier indicator*/,
|
|
475
|
-
);
|
|
476
|
-
const chosen = [];
|
|
477
|
-
let used = 0;
|
|
478
|
-
for (let i = items.length - 1; i >= 0; i--) {
|
|
479
|
-
const h = itemHeight(items[i], cols);
|
|
480
|
-
if (chosen.length && used + h > budget) break;
|
|
481
|
-
chosen.unshift(items[i]);
|
|
482
|
-
used += h;
|
|
483
|
-
}
|
|
484
|
-
const hidden = items.length - chosen.length;
|
|
452
|
+
// The banner is the first <Static> item so it prints once at the very top and
|
|
453
|
+
// scrolls away with history; completed transcript items follow it. Ink flushes
|
|
454
|
+
// each <Static> item to the terminal's normal buffer exactly once, leaving the
|
|
455
|
+
// native scrollback intact so earlier conversation can be scrolled back to.
|
|
456
|
+
const staticItems = [{ type: 'banner', id: `banner-${clearEpoch}` }, ...items];
|
|
485
457
|
|
|
486
458
|
return html`
|
|
487
|
-
<${Box} flexDirection="column" width=${cols}
|
|
488
|
-
<${
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
<${Box} flexGrow=${1} flexDirection="column" justifyContent="flex-end" overflow="hidden">
|
|
493
|
-
${hidden > 0 ? html`<${Text} color=${C.gray}>${glyphs.up} ${hidden} earlier message${hidden > 1 ? 's' : ''}<//>` : null}
|
|
494
|
-
${chosen.map(renderItem)}
|
|
495
|
-
${live
|
|
496
|
-
? html`<${Box} flexDirection="column">
|
|
497
|
-
<${StatusLines} lines=${live.status} busy=${busy} />
|
|
498
|
-
${showReasoning && live.reasoning ? html`<${Reasoning} text=${live.reasoning} />` : null}
|
|
499
|
-
${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
|
|
500
|
-
${live.text ? html`<${Box} marginTop=${1}><${AssistantMessage} text=${live.text} /><//>` : null}
|
|
501
|
-
<${Thinking} label=${live.reply ? 'Planning' : live.text ? 'Responding' : 'Working'} startedAt=${startedAt} />
|
|
459
|
+
<${Box} flexDirection="column" width=${cols}>
|
|
460
|
+
<${Static} key=${clearEpoch} items=${staticItems}>
|
|
461
|
+
${(it) => it.type === 'banner'
|
|
462
|
+
? html`<${Box} key=${it.id} flexDirection="column" alignItems="center" marginBottom=${1}>
|
|
463
|
+
<${Banner} cfg=${cfg} model=${model} width=${cols} compact=${compact} />
|
|
502
464
|
<//>`
|
|
503
|
-
:
|
|
465
|
+
: renderItem(it)}
|
|
504
466
|
<//>
|
|
505
467
|
|
|
506
|
-
|
|
468
|
+
${live
|
|
469
|
+
? html`<${Box} flexDirection="column">
|
|
470
|
+
<${StatusLines} lines=${live.status} busy=${busy} />
|
|
471
|
+
${showReasoning && live.reasoning ? html`<${Reasoning} text=${live.reasoning} />` : null}
|
|
472
|
+
${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
|
|
473
|
+
${live.text ? html`<${Box} marginTop=${1}><${AssistantMessage} text=${live.text} /><//>` : null}
|
|
474
|
+
<${Thinking} label=${live.reply ? 'Planning' : live.text ? 'Responding' : 'Working'} startedAt=${startedAt} />
|
|
475
|
+
<//>`
|
|
476
|
+
: null}
|
|
477
|
+
|
|
478
|
+
<${Box} flexDirection="column" marginTop=${1}>
|
|
507
479
|
${choice
|
|
508
480
|
? html`<${Choice} ...${choice} />`
|
|
509
481
|
: html`<${Composer} onSubmit=${onSubmit} busy=${busy} width=${cols} />`}
|
package/src/tui/components.js
CHANGED
|
@@ -82,24 +82,22 @@ export function AssistantMessage({ text }) {
|
|
|
82
82
|
<//>`;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
/** Classify a server progress/status line into an icon + tone. */
|
|
86
|
-
function classify(line) {
|
|
87
|
-
const l = String(line).toLowerCase();
|
|
88
|
-
if (/unreachable|skip|fail|error|denied|timeout|down\b|offline/.test(l)) return { icon: glyphs.cross, color: C.red };
|
|
89
|
-
if (/done|success|found|complet|online|ready|free\b|ok\b|\bup\b/.test(l)) return { icon: glyphs.check, color: C.green };
|
|
90
|
-
if (/assign|sub.?agent|fleet|parallel|dispatch|spawn/.test(l)) return { icon: glyphs.ring, color: C.magenta };
|
|
91
|
-
if (/probe|reachab|ping/.test(l)) return { icon: glyphs.target, color: C.yellow };
|
|
92
|
-
if (/connect|ssh|telnet|api|session|running|exec|command/.test(l)) return { icon: glyphs.arrow, color: C.accent };
|
|
93
|
-
return { icon: glyphs.bullet, color: C.gray };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
85
|
// A sub-agent progress line, e.g. "✓ [1/3] nas#1: FREE" or "[2/3] olt#1: down".
|
|
97
86
|
const SUBAGENT = /^\s*[✓✗x]?\s*\[(\d+)\/(\d+)\]\s*(.+?)\s*[::]\s*(.+?)\s*$/;
|
|
98
87
|
|
|
99
|
-
|
|
88
|
+
// A leading emoji/pictograph on a tool status label (from statusLabel()).
|
|
89
|
+
const LEAD_EMOJI = /^\s*([\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{2190}-\u{21FF}\u{FE0F}\u{2049}\u{203C}]+)\s*/u;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Live server activity — tool calls and sub-agents, rendered Copilot-CLI style:
|
|
93
|
+
* each activity gets a status marker (spinner while it's the in-flight step,
|
|
94
|
+
* green check once done), the tool's purpose label is kept, and sub-agent
|
|
95
|
+
* progress is nested underneath its parent as a tree.
|
|
96
|
+
*/
|
|
100
97
|
export function StatusLines({ lines = [], busy = false }) {
|
|
101
98
|
if (!lines.length) return null;
|
|
102
99
|
const subLines = lines.filter((l) => SUBAGENT.test(l));
|
|
100
|
+
const lastIdx = lines.length - 1;
|
|
103
101
|
return html`
|
|
104
102
|
<${Box} flexDirection="column" marginTop=${1}>
|
|
105
103
|
${lines.map((line, i) => {
|
|
@@ -112,21 +110,33 @@ export function StatusLines({ lines = [], busy = false }) {
|
|
|
112
110
|
const branch = Number(idx) === Number(total) ? glyphs.branchEnd : glyphs.branchMid;
|
|
113
111
|
return html`
|
|
114
112
|
<${Box} key=${'s' + i}>
|
|
113
|
+
<${Text} color=${C.gray}> <//>
|
|
115
114
|
<${Text} color=${C.magenta}>${branch} <//>
|
|
116
115
|
<${Text} color=${C.gray}>${idx}/${total} <//>
|
|
117
116
|
<${Text} color=${C.white} bold>${host}<//>
|
|
118
117
|
<${Text} color=${C.gray}> ${dash()} <//>
|
|
119
118
|
${running
|
|
120
|
-
? html`<${Text} color=${C.accent}><${Spinner} type=${spinnerType()}
|
|
119
|
+
? html`<${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /> working<//>`
|
|
121
120
|
: html`<${Text} color=${good ? C.green : C.red} bold>${good ? glyphs.check : glyphs.cross} ${result}<//>`}
|
|
122
121
|
<//>`;
|
|
123
122
|
}
|
|
124
|
-
|
|
125
|
-
|
|
123
|
+
|
|
124
|
+
// Tool / activity line. The last line while busy is the in-flight step.
|
|
125
|
+
const running = busy && i === lastIdx;
|
|
126
|
+
const em = String(line).match(LEAD_EMOJI);
|
|
127
|
+
const body = em ? String(line).slice(em[0].length) : String(line).replace(/^[\s◈◆●○◎⇢→>*✓✗×!⚠·-]+/u, '');
|
|
128
|
+
const ci = body.indexOf(':');
|
|
129
|
+
const action = ci > -1 ? body.slice(0, ci) : body;
|
|
130
|
+
const detail = ci > -1 ? body.slice(ci + 1).trim() : '';
|
|
126
131
|
return html`
|
|
127
132
|
<${Box} key=${'s' + i}>
|
|
128
|
-
|
|
129
|
-
|
|
133
|
+
${running
|
|
134
|
+
? html`<${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /><//>`
|
|
135
|
+
: html`<${Text} color=${C.green}>${glyphs.check}<//>`}
|
|
136
|
+
<${Text}> <//>
|
|
137
|
+
${em ? html`<${Text}>${em[1]} <//>` : null}
|
|
138
|
+
<${Text} color=${running ? C.white : C.gray} bold=${running}>${action}<//>
|
|
139
|
+
${detail ? html`<${Text} color=${C.gray}>: ${detail}<//>` : null}
|
|
130
140
|
<//>`;
|
|
131
141
|
})}
|
|
132
142
|
<//>`;
|
package/src/tui/run.js
CHANGED
|
@@ -1,22 +1,15 @@
|
|
|
1
|
-
// Ink render entrypoint. Runs the TUI
|
|
2
|
-
//
|
|
3
|
-
// at the top
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// Ink render entrypoint. Runs the TUI INLINE in the normal terminal buffer
|
|
2
|
+
// (like GitHub Copilot CLI) — NOT the alternate screen — so the banner prints
|
|
3
|
+
// once at the top and scrolls away, and the terminal's native scrollback keeps
|
|
4
|
+
// the full conversation history. Completed transcript items are flushed to the
|
|
5
|
+
// scrollback by Ink's <Static>; only the live area + input re-render in place.
|
|
6
6
|
import { html } from './dom.js';
|
|
7
7
|
import { render } from 'ink';
|
|
8
8
|
import { App } from './app.js';
|
|
9
9
|
|
|
10
|
-
const ALT_ENTER = '\x1b[?1049h\x1b[2J\x1b[H';
|
|
11
|
-
const ALT_LEAVE = '\x1b[?1049l';
|
|
12
|
-
|
|
13
10
|
export async function runTui(cfg, opts = {}) {
|
|
14
11
|
const { display = {}, onExternal, initialQuestion } = opts;
|
|
15
12
|
let externalAction;
|
|
16
|
-
let transcript = null;
|
|
17
|
-
|
|
18
|
-
const isTTY = process.stdout.isTTY;
|
|
19
|
-
if (isTTY) process.stdout.write(ALT_ENTER);
|
|
20
13
|
|
|
21
14
|
const instance = render(
|
|
22
15
|
html`<${App}
|
|
@@ -24,21 +17,11 @@ export async function runTui(cfg, opts = {}) {
|
|
|
24
17
|
display=${display}
|
|
25
18
|
initialQuestion=${initialQuestion}
|
|
26
19
|
onExternal=${(a) => { externalAction = a; onExternal?.(a); }}
|
|
27
|
-
onExit=${(t) => { transcript = t; }}
|
|
28
20
|
/>`,
|
|
29
21
|
{ exitOnCtrlC: false },
|
|
30
22
|
);
|
|
31
23
|
|
|
32
|
-
|
|
33
|
-
await instance.waitUntilExit();
|
|
34
|
-
} finally {
|
|
35
|
-
if (isTTY) process.stdout.write(ALT_LEAVE);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Reprint the conversation into the normal buffer so it survives exit.
|
|
39
|
-
if (transcript && transcript.length) {
|
|
40
|
-
process.stdout.write('\n' + transcript.join('\n') + '\n');
|
|
41
|
-
}
|
|
24
|
+
await instance.waitUntilExit();
|
|
42
25
|
|
|
43
26
|
return externalAction;
|
|
44
27
|
}
|