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 CHANGED
@@ -59,11 +59,12 @@ $ icli
59
59
 
60
60
  ❯ is vlan 82 free on all devices?
61
61
 
62
- working
63
- Assigning 3 sub-agents…
64
- [1/3] nas#1: FREE
65
- [2/3] nas#2: FREE
66
- [3/3] olt#1: FREE
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 runs as a full-height app in the terminal's alternate screen: the banner
205
- stays pinned at the top, the transcript fills the middle, and the input box is
206
- pinned to the bottom the same layout as GitHub Copilot CLI. On exit, the session
207
- transcript is reprinted to your normal scrollback.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ispbills-icli",
3
- "version": "8.3.1",
3
+ "version": "8.4.0",
4
4
  "description": "iCli — IspBills AI network engineer in your terminal",
5
5
  "keywords": [
6
6
  "ispbills",
package/src/tui/app.js CHANGED
@@ -1,9 +1,10 @@
1
- // Root Ink application. Renders a full-height, alt-screen chat UI (Copilot-CLI
2
- // style): banner pinned to the top, transcript filling the middle, input
3
- // pinned to the bottom. Manages conversation state and streams from the
4
- // IspBills backend.
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') { setItems([]); return; }
231
+ if (q === '/clear') { clearAll(); return; }
250
232
  if (q === '/new') {
251
233
  convo.current = [];
252
- setItems([]);
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) { setItems([]); return; }
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
- // Tail-slice the transcript to what fits the middle region so nothing is
469
- // clipped mid-line on small terminals.
470
- const compact = rows < 30 || cols < 60;
471
- const budget = Math.max(
472
- 3,
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} height=${rows}>
488
- <${Box} flexShrink=${0} flexDirection="column" alignItems="center">
489
- <${Banner} cfg=${cfg} model=${model} width=${cols} compact=${compact} />
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
- : null}
465
+ : renderItem(it)}
504
466
  <//>
505
467
 
506
- <${Box} flexShrink=${0} flexDirection="column">
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} />`}
@@ -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
- /** Live server activity tool calls and sub-agents happening backend-side. */
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
- const { icon, color } = classify(line);
125
- const clean = String(line).replace(/^[\s◈◆●○◎⇢→>*✓✗×!⚠·-]+/u, '').trimStart() || String(line);
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
- <${Text} color=${color}>${icon}<//>
129
- <${Text} color=${C.gray}> ${clean}<//>
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 as a full-height app inside the
2
- // alternate screen buffer (like GitHub Copilot CLI): the banner stays pinned
3
- // at the top, the transcript fills the middle, and the input is pinned to the
4
- // bottom of the terminal. On exit we leave the alt screen and, optionally,
5
- // reprint the transcript into the normal scrollback.
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
- try {
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
  }