ispbills-icli 8.6.7 → 8.6.9

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ispbills-icli",
3
- "version": "8.6.7",
3
+ "version": "8.6.9",
4
4
  "description": "iCli — IspBills AI network engineer in your terminal",
5
5
  "keywords": [
6
6
  "ispbills",
package/src/tui/app.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Root Ink application. Renders an INLINE chat UI (GitHub Copilot CLI style).
2
2
  import { html, useState, useEffect, useRef, useCallback } from './dom.js';
3
- import { Box, Text, Static, useStdout, useApp, useInput } from 'ink';
3
+ import { Box, Text, useStdout, useApp, useInput } from 'ink';
4
4
  import { C, glyphs, midDot, dash } from './theme.js';
5
5
  import {
6
6
  Banner, TabBar, FollowupChips, UserMessage, AssistantMessage, StatusLines, Reasoning,
@@ -230,6 +230,78 @@ function itemPreview(it) {
230
230
  return JSON.stringify(it);
231
231
  }
232
232
 
233
+ // Approximate the number of terminal rows an item occupies. Used purely for
234
+ // viewport slicing — Ink clips any overflow, so this only needs to be close.
235
+ function wrappedLines(text, width) {
236
+ const w = Math.max(1, width);
237
+ return String(text || '')
238
+ .split('\n')
239
+ .reduce((n, line) => n + Math.max(1, Math.ceil(line.length / w)), 0);
240
+ }
241
+
242
+ function itemHeight(it, cols = 80) {
243
+ const w = Math.max(1, cols - 4); // paddingX + body inset
244
+ switch (it.type) {
245
+ case 'user':
246
+ return 3 + wrappedLines(it.text, w); // sep + speaker + margin + body
247
+ case 'assistant':
248
+ return 3 + wrappedLines(it.text, Math.min(w, 96));
249
+ case 'shell':
250
+ return 4 + wrappedLines(it.output || '(no output)', w) + 1;
251
+ case 'plan':
252
+ return 4 + (it.reply?.steps?.length || 0);
253
+ case 'connect':
254
+ return 5;
255
+ case 'activity':
256
+ return 1 + (it.status?.length || 0);
257
+ case 'notice':
258
+ return 1 + wrappedLines(it.text, w);
259
+ case 'info':
260
+ case 'error':
261
+ case 'system':
262
+ return 2 + wrappedLines(it.text, w);
263
+ case 'help':
264
+ return 44;
265
+ default:
266
+ return 2 + wrappedLines(it.text, w);
267
+ }
268
+ }
269
+
270
+ // Given the full items array, choose the subset visible in a viewport that is
271
+ // `avail` rows tall, scrolled up from the bottom by `scrollOffset` rows.
272
+ // scrollOffset === 0 pins to the newest content at the bottom.
273
+ function selectViewport(items, cols, avail, scrollOffset) {
274
+ const heights = items.map((it) => itemHeight(it, cols));
275
+ const total = heights.reduce((a, b) => a + b, 0);
276
+ const maxOffset = Math.max(0, total - avail);
277
+ const clamped = Math.max(0, Math.min(scrollOffset, maxOffset));
278
+ const windowBottom = total - clamped; // exclusive row from top
279
+ const windowTop = windowBottom - avail; // inclusive row from top
280
+ const visible = [];
281
+ let aboveCount = 0;
282
+ let y = 0;
283
+ for (let i = 0; i < items.length; i++) {
284
+ const h = heights[i];
285
+ const top = y;
286
+ const bottom = y + h;
287
+ if (bottom <= windowTop) aboveCount++;
288
+ else if (top < windowBottom) visible.push(items[i]);
289
+ y = bottom;
290
+ }
291
+ return { visible, aboveCount, hasAbove: windowTop > 0, maxOffset, clamped };
292
+ }
293
+
294
+ function liveAreaHeight(live, cols = 80, showReasoning = false) {
295
+ if (!live) return 0;
296
+ const w = Math.max(1, cols - 4);
297
+ let h = 2; // thinking line + margin
298
+ if (live.text) h += 3 + wrappedLines(live.text, Math.min(w, 96));
299
+ if (live.reasoning) h += showReasoning ? 2 + wrappedLines(live.reasoning, w) : 1;
300
+ if (live.reply?.steps) h += 3 + live.reply.steps.length;
301
+ if (live.status?.length) h += 1 + live.status.length;
302
+ return h;
303
+ }
304
+
233
305
  function useTerminalSize() {
234
306
  const { stdout } = useStdout();
235
307
  const [size, setSize] = useState({ cols: stdout?.columns || 80, rows: stdout?.rows || 24 });
@@ -246,6 +318,50 @@ let itemId = 0;
246
318
  const nextId = () => ++itemId;
247
319
  const MODES = ['ask', 'plan', 'autopilot'];
248
320
 
321
+ // Tab-specific quick-command panels. Static — does not depend on component state.
322
+ const TAB_CONTENT = {
323
+ devices: {
324
+ title: 'Devices',
325
+ commands: [
326
+ { cmd: '/status', desc: 'Fleet health summary' },
327
+ { cmd: '/reachable', desc: 'Who is up / down' },
328
+ { cmd: '/alarms', desc: 'Active alarms' },
329
+ { cmd: '/check [device]', desc: 'Full device diagnostics' },
330
+ { cmd: '/uptime [device]', desc: 'Uptime and last reboot' },
331
+ { cmd: '/cpu [device]', desc: 'CPU / memory load' },
332
+ { cmd: '/offline', desc: 'All offline devices' },
333
+ { cmd: '/flapping [device]', desc: 'Flapping interfaces' },
334
+ ],
335
+ hint: 'Press a number (1–8) to insert command · Enter to return to Session',
336
+ },
337
+ maps: {
338
+ title: 'Network Maps',
339
+ commands: [
340
+ { cmd: '/vlan [id]', desc: 'VLAN topology & members' },
341
+ { cmd: '/route [device]', desc: 'Routing table & next-hops' },
342
+ { cmd: '/overlaps', desc: 'IP subnet overlap check' },
343
+ { cmd: '/mac [addr]', desc: 'MAC address lookup' },
344
+ { cmd: '/arp [device]', desc: 'ARP table & IP-MAC map' },
345
+ { cmd: '/trace [host]', desc: 'Traceroute to host' },
346
+ { cmd: '/topology', desc: 'Network topology diagram' },
347
+ { cmd: '/segments', desc: 'Network segments & subnets' },
348
+ ],
349
+ hint: 'Press a number (1–8) to insert command · Enter to return to Session',
350
+ },
351
+ alarms: {
352
+ title: 'Alarms',
353
+ commands: [
354
+ { cmd: '/alarms', desc: 'Active alarms & recent faults' },
355
+ { cmd: '/offline', desc: 'All offline devices' },
356
+ { cmd: '/down [circuit]', desc: 'Down circuits / uplinks' },
357
+ { cmd: '/flapping [device]', desc: 'Flapping interfaces' },
358
+ { cmd: '/optical [device]', desc: 'Optical RX/TX (dBm)' },
359
+ { cmd: '/reachable', desc: 'Reachability sweep' },
360
+ ],
361
+ hint: 'Press a number (1–6) to insert command · Enter to return to Session',
362
+ },
363
+ };
364
+
249
365
  export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, showBanner = true, agentName, resumeMode }) {
250
366
  const { exit } = useApp();
251
367
  const initialResumeRef = useRef(undefined);
@@ -261,6 +377,10 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
261
377
  const { cols, rows } = useTerminalSize();
262
378
  const colsRef = useRef(cols);
263
379
  useEffect(() => { colsRef.current = cols; }, [cols]);
380
+ // Fixed-height viewport: total rows minus chrome (tabbar + spacing + chips +
381
+ // composer + footer). Everything else scrolls inside this region.
382
+ const viewportRows = Math.max(3, rows - 6);
383
+ const scrollPage = Math.max(4, viewportRows - 2);
264
384
 
265
385
  const [items, setItems] = useState(() => renderConversationItems(initialMessages, cols));
266
386
  const [live, setLive] = useState(null);
@@ -275,6 +395,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
275
395
  const [startedAt, setStartedAt] = useState(0);
276
396
  const [choice, setChoice] = useState(null);
277
397
  const [clearEpoch, setClearEpoch] = useState(0);
398
+ const [scrollOffset, setScrollOffset] = useState(0);
278
399
  const [composerClearToken, setComposerClearToken] = useState(0);
279
400
  const [composerText, setComposerText] = useState('');
280
401
  const [composerPreset, setComposerPreset] = useState({ token: 0, text: '' });
@@ -301,6 +422,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
301
422
  const activeTurns = useRef(0);
302
423
  const dismissTimers = useRef(new Map());
303
424
  const dispatchSubmitRef = useRef(null);
425
+ const maxScrollRef = useRef(0);
304
426
  const sessionIdRef = useRef(initialSession?.id || Date.now().toString(36));
305
427
  const sessionCreatedAtRef = useRef(initialSession?.createdAt || new Date().toISOString());
306
428
  const sessionNameRef = useRef(initialSession?.name || sessionAutoName(initialMessages));
@@ -327,6 +449,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
327
449
  const push = useCallback((item) => {
328
450
  const next = { id: nextId(), cols: colsRef.current, ts: item.ts ?? Date.now(), ...item };
329
451
  setItems((xs) => [...xs, next]);
452
+ setScrollOffset(0); // auto-pin to bottom on new content
330
453
  return next;
331
454
  }, []);
332
455
 
@@ -347,6 +470,9 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
347
470
  }
348
471
  }, [items, removeItem]);
349
472
 
473
+ // Pin the viewport to the newest content whenever a turn begins streaming.
474
+ useEffect(() => { if (startedAt) setScrollOffset(0); }, [startedAt]);
475
+
350
476
  const pushNotice = useCallback((text, color = C.gray, autoDismiss = false, type = null) => {
351
477
  const resolvedType = type || (color === C.red || color === C.error ? 'error' : 'info');
352
478
  push({ type: resolvedType, text, color, autoDismiss });
@@ -359,6 +485,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
359
485
  setItems([]);
360
486
  setExpandedItems(null);
361
487
  setSearch(null);
488
+ setScrollOffset(0);
362
489
  setClearEpoch((n) => n + 1);
363
490
  }, []);
364
491
 
@@ -367,6 +494,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
367
494
  setItems(renderConversationItems(messages, colsRef.current));
368
495
  setExpandedItems(null);
369
496
  setSearch(null);
497
+ setScrollOffset(0);
370
498
  setClearEpoch((n) => n + 1);
371
499
  }, []);
372
500
 
@@ -900,7 +1028,42 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
900
1028
  if (q === '/model' || q.startsWith('/model ')) {
901
1029
  const name = q.slice('/model'.length).trim();
902
1030
  if (!name) {
903
- pushNotice(`Current model: ${model || modelRef.current || 'server default'}.\nUse /model <name> to request a different model.`);
1031
+ // Show interactive model picker
1032
+ const cur = model || modelRef.current || '';
1033
+ const MODELS = [
1034
+ { id: 'gpt-4o-mini', desc: 'Fast & affordable (default)' },
1035
+ { id: 'openai/gpt-4o', desc: 'OpenAI GPT-4o — balanced' },
1036
+ { id: 'openai/gpt-4.1', desc: 'OpenAI GPT-4.1 — latest' },
1037
+ { id: 'openai/o3', desc: 'OpenAI o3 — reasoning' },
1038
+ { id: 'openai/o3-mini', desc: 'OpenAI o3-mini — fast reasoning' },
1039
+ { id: 'anthropic/claude-3-5-sonnet', desc: 'Claude 3.5 Sonnet — strong' },
1040
+ { id: 'anthropic/claude-3-opus', desc: 'Claude 3 Opus — advanced' },
1041
+ { id: 'anthropic/claude-3-haiku', desc: 'Claude 3 Haiku — fast' },
1042
+ { id: 'meta/llama-3.3-70b-instruct', desc: 'Llama 3.3 70B (open)' },
1043
+ { id: 'mistral/mistral-large', desc: 'Mistral Large' },
1044
+ ];
1045
+ choiceActionRef.current = (val) => {
1046
+ if (val) {
1047
+ modelRef.current = val;
1048
+ setModel(val);
1049
+ cfg._model = val;
1050
+ persistCfg();
1051
+ saveCurrentSession();
1052
+ pushNotice(`${glyphs.check} Model set to ${val}.`, C.green);
1053
+ }
1054
+ };
1055
+ setChoice({
1056
+ title: `Select AI Model (current: ${cur || 'server default'})`,
1057
+ note: 'Use ↑/↓ to move, Enter to confirm, Esc to cancel',
1058
+ sel: Math.max(0, MODELS.findIndex((m) => m.id === cur)),
1059
+ text: '',
1060
+ focusText: false,
1061
+ allowText: false,
1062
+ options: MODELS.map((m, i) => ({
1063
+ label: `${i + 1}. ${m.id.padEnd(36)} ${m.desc}`,
1064
+ value: m.id,
1065
+ })),
1066
+ });
904
1067
  } else {
905
1068
  modelRef.current = name;
906
1069
  setModel(name);
@@ -1234,7 +1397,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1234
1397
  ' optical — Optical power analysis and ONU diagnostics (enabled)',
1235
1398
  ' fleet-check — Parallel multi-device scanning (enabled)',
1236
1399
  ' backup — Automated config backup via git (enabled)',
1237
- ' customers Customer lookup and ticket integration (enabled)',
1400
+ ' maps Network topology maps and IP planning (enabled)',
1238
1401
  ' alarms — Real-time alarm monitoring (enabled)',
1239
1402
  '',
1240
1403
  'Use /experimental to unlock additional skills.',
@@ -1685,6 +1848,14 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1685
1848
  return;
1686
1849
  }
1687
1850
  if (key.ctrl && input === 'd') { doExit(); return; }
1851
+ if (key.pageUp && !choice && !search && !sessionPicker && !diffState) {
1852
+ setScrollOffset((n) => Math.min(maxScrollRef.current, n + scrollPage));
1853
+ return;
1854
+ }
1855
+ if (key.pageDown && !choice && !search && !sessionPicker && !diffState) {
1856
+ setScrollOffset((n) => Math.max(0, n - scrollPage));
1857
+ return;
1858
+ }
1688
1859
  if (key.escape && busy) { abortRef.current?.abort(); return; }
1689
1860
  if (key.escape && shellMode && !composerText.trim()) { setShellMode(false); pushNotice('Exited shell mode.', C.muted, true); return; }
1690
1861
  if (key.escape && expandedItems) { setExpandedItems(null); return; }
@@ -1730,61 +1901,42 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1730
1901
  if (key.ctrl && input === 'p' && chips.length) { setActiveChip((i) => (i - 1 + chips.length) % chips.length); return; }
1731
1902
  if (key.tab && !key.shift && !busy && !composerText.trim()) {
1732
1903
  setActiveTab((t) => {
1733
- const tabs = ['session', 'devices', 'customers', 'alarms'];
1904
+ const tabs = ['session', 'devices', 'maps', 'alarms'];
1734
1905
  return tabs[(tabs.indexOf(t) + 1) % tabs.length];
1735
1906
  });
1736
1907
  }
1908
+ // Alt+↑/↓ for viewport scrolling (alternative to PgUp/PgDn)
1909
+ if (key.meta && key.upArrow && !choice && !search && !sessionPicker && !diffState) {
1910
+ setScrollOffset((n) => Math.min(maxScrollRef.current, n + Math.max(3, Math.floor(scrollPage / 4))));
1911
+ return;
1912
+ }
1913
+ if (key.meta && key.downArrow && !choice && !search && !sessionPicker && !diffState) {
1914
+ setScrollOffset((n) => Math.max(0, n - Math.max(3, Math.floor(scrollPage / 4))));
1915
+ return;
1916
+ }
1917
+ // Number keys on non-session tabs: 1-9 insert the panel's Nth command into composer
1918
+ if (activeTab !== 'session' && !choice && !search) {
1919
+ const n = parseInt(input, 10);
1920
+ if (!isNaN(n) && n >= 1) {
1921
+ const panel = TAB_CONTENT[activeTab];
1922
+ const cmd = panel?.commands[n - 1]?.cmd;
1923
+ if (cmd) {
1924
+ const insertCmd = cmd.replace(/\[.*?\]/g, '').trim();
1925
+ setActiveTab('session');
1926
+ setComposerPreset({ token: Date.now(), text: insertCmd });
1927
+ }
1928
+ return;
1929
+ }
1930
+ }
1737
1931
  });
1738
1932
 
1739
- // Tab-specific content panels (shown when not on Session tab)
1740
- const TAB_CONTENT = {
1741
- devices: {
1742
- title: 'Devices',
1743
- commands: [
1744
- { cmd: '/status', desc: 'Fleet health summary' },
1745
- { cmd: '/reachable', desc: 'Who is up / down' },
1746
- { cmd: '/alarms', desc: 'Active alarms' },
1747
- { cmd: '/check [device]', desc: 'Full device diagnostics' },
1748
- { cmd: '/uptime [device]', desc: 'Uptime and last reboot' },
1749
- { cmd: '/cpu [device]', desc: 'CPU / memory load' },
1750
- { cmd: '/offline', desc: 'All offline devices' },
1751
- { cmd: '/flapping [device]', desc: 'Flapping interfaces' },
1752
- ],
1753
- hint: 'Press Enter to switch back to Session and use the composer',
1754
- },
1755
- customers: {
1756
- title: 'Customers',
1757
- commands: [
1758
- { cmd: '/customer [name]', desc: 'Look up a customer' },
1759
- { cmd: '/online', desc: 'Online PPPoE sessions' },
1760
- { cmd: '/pppoe [user]', desc: 'PPPoE session detail' },
1761
- { cmd: '/dhcp', desc: 'DHCP leases & pool usage' },
1762
- { cmd: '/onu [id]', desc: 'ONU signal and status' },
1763
- { cmd: '/bandwidth [device]', desc: 'Traffic / bandwidth' },
1764
- ],
1765
- hint: 'Press Enter to switch back to Session and send commands',
1766
- },
1767
- alarms: {
1768
- title: 'Alarms',
1769
- commands: [
1770
- { cmd: '/alarms', desc: 'Active alarms & recent faults' },
1771
- { cmd: '/offline', desc: 'All offline devices' },
1772
- { cmd: '/down [circuit]', desc: 'Down circuits / uplinks' },
1773
- { cmd: '/flapping [device]', desc: 'Flapping interfaces' },
1774
- { cmd: '/optical [device]', desc: 'Optical RX/TX (dBm)' },
1775
- { cmd: '/reachable', desc: 'Reachability sweep' },
1776
- ],
1777
- hint: 'Press Enter to switch back to Session and type a command',
1778
- },
1779
- };
1780
-
1781
1933
  const tabPanel = activeTab !== 'session' ? TAB_CONTENT[activeTab] : null;
1782
1934
 
1783
1935
  const renderItem = (it) => {
1784
1936
  const c = it.cols || 80;
1785
1937
  switch (it.type) {
1786
1938
  case 'user': return html`<${UserMessage} key=${it.id} text=${it.text} cols=${c} ts=${it.ts} />`;
1787
- case 'assistant': return html`<${AssistantMessage} key=${it.id} text=${it.text} cols=${c} ts=${it.ts} />`;
1939
+ case 'assistant': return html`<${AssistantMessage} key=${it.id} text=${it.text} cols=${c} />`;
1788
1940
  case 'plan': return html`<${Plan} key=${it.id} reply=${it.reply} />`;
1789
1941
  case 'connect': return html`<${Connect} key=${it.id} reply=${it.reply} />`;
1790
1942
  case 'activity': return html`<${StatusLines} key=${it.id} lines=${it.status} busy=${false} />`;
@@ -1812,81 +1964,94 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1812
1964
  : streamerMode
1813
1965
  ? ''
1814
1966
  : [modelLabel, tokenLabel].filter(Boolean).join(midDot());
1815
- const compact = cols < 60;
1816
1967
  const composerMode = shellMode ? 'shell' : mode;
1817
- const staticItems = showBanner
1818
- ? [{ type: 'banner', id: `banner-${clearEpoch}`, cols }, ...items]
1819
- : items;
1820
1968
 
1821
- return html`
1822
- <${Box} flexDirection="column" width=${cols}>
1823
- <${Static} key=${clearEpoch} items=${staticItems}>
1824
- ${(it) => it.type === 'banner'
1825
- ? html`<${Box} key=${it.id} flexDirection="column" alignItems="center" width=${cols}>
1826
- <${Banner} cfg=${cfg} model=${model} width=${cols} compact=${compact} streamerMode=${streamerMode} />
1969
+ // ── Viewport slicing ───────────────────────────────────────────────────────
1970
+ // Reserve room for the live streaming area, then pick the items that fit in
1971
+ // the remaining rows, scrolled up from the bottom by scrollOffset.
1972
+ const liveH = liveAreaHeight(live, cols, showReasoning);
1973
+ const availRows = Math.max(1, viewportRows - liveH);
1974
+ const { visible: visibleItems, aboveCount, hasAbove } = selectViewport(items, cols, availRows, scrollOffset);
1975
+ const totalRows = items.reduce((n, it) => n + itemHeight(it, cols), 0);
1976
+ maxScrollRef.current = Math.max(0, totalRows - availRows);
1977
+ const showEmptyBanner = items.length === 0 && !live;
1978
+ const diffHeight = Math.max(8, viewportRows - 1);
1979
+
1980
+ const liveArea = live
1981
+ ? html`<${Box} flexDirection="column">
1982
+ ${live.text ? html`<${AssistantMessage} text=${live.text} cols=${cols} ts=${startedAt || Date.now()} streaming=${true} />` : null}
1983
+ ${live.reasoning ? html`<${Reasoning} text=${live.reasoning} show=${showReasoning} />` : null}
1984
+ ${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
1985
+ <${StatusLines} lines=${live.status} busy=${busy} />
1986
+ <${Thinking} label=${live.reply ? 'Planning' : live.text ? 'Responding' : 'Working'} startedAt=${startedAt} />
1987
+ <//>`
1988
+ : null;
1989
+
1990
+ const scrollIndicator = hasAbove
1991
+ ? html`<${Box} paddingX=${1}>
1992
+ <${Text} color=${C.muted} dimColor>${glyphs.up} ${aboveCount} message${aboveCount === 1 ? '' : 's'} above ${midDot()} PgUp/PgDn or Alt+↑/↓ to scroll<//>
1993
+ <//>`
1994
+ : null;
1995
+
1996
+ const timelineContent = diffState
1997
+ ? html`<${Box} flexDirection="column">
1998
+ <${DiffViewer}
1999
+ diff=${diffState.lines}
2000
+ sel=${diffState.sel}
2001
+ staged=${diffState.staged}
2002
+ whitespace=${diffState.whitespace}
2003
+ cols=${cols}
2004
+ height=${diffHeight}
2005
+ commentCount=${Object.keys(diffState.comments || {}).length}
2006
+ />
2007
+ ${diffState.commentInput
2008
+ ? html`<${Choice}
2009
+ title=${`Tell Copilot what stands out about ${diffState.commentInput.file}:${diffState.commentInput.lineNumber || '?'}`}
2010
+ note=${diffState.commentInput.content}
2011
+ sel=${0}
2012
+ allowText=${true}
2013
+ text=${diffState.commentInput.text}
2014
+ focusText=${true}
2015
+ options=${[]}
2016
+ />`
2017
+ : null}
2018
+ <//>`
2019
+ : expandedItems
2020
+ ? html`<${Box} flexDirection="column">
2021
+ <${Notice} text=${`${glyphs.expand} ${expandedItems.kind}`} color=${C.muted} />
2022
+ ${expandedItems.items.map((it) => renderItem(it))}
2023
+ <//>`
2024
+ : tabPanel
2025
+ ? html`<${Box} flexDirection="column" paddingX=${1} paddingY=${1}>
2026
+ <${Text} color=${C.brand} bold>${tabPanel.title} — Quick Commands<//>\n
2027
+ ${tabPanel.commands.map((c, i) =>
2028
+ html`<${Box} key=${i}>
2029
+ <${Text} color=${C.accent} bold>${String(i + 1)}. <//>
2030
+ <${Text} color=${C.slash}>${c.cmd.padEnd(22)}<//>
2031
+ <${Text} color=${C.muted}>${c.desc}<//>
2032
+ <//>`)}
2033
+ \n<${Text} color=${C.muted}>${tabPanel.hint}<//>
2034
+ <//>`
2035
+ : html`<${Box} flexDirection="column">
2036
+ ${showEmptyBanner
2037
+ ? html`<${Box} flexDirection="column" alignItems="center" width=${cols}>
2038
+ <${Banner} cfg=${cfg} model=${model} width=${cols} compact=${true} streamerMode=${streamerMode} />
1827
2039
  <//>`
1828
- : renderItem(it)}
1829
- <//>
1830
-
1831
- ${live
1832
- ? html`<${Box} flexDirection="column">
1833
- ${live.text ? html`<${AssistantMessage} text=${live.text} cols=${cols} ts=${startedAt || Date.now()} streaming=${true} />` : null}
1834
- ${live.reasoning ? html`<${Reasoning} text=${live.reasoning} show=${showReasoning} />` : null}
1835
- ${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
1836
- <${StatusLines} lines=${live.status} busy=${busy} />
1837
- <${Thinking} label=${live.reply ? 'Planning' : live.text ? 'Responding' : 'Working'} startedAt=${startedAt} />
1838
- <//>`
1839
- : null}
1840
-
1841
- ${expandedItems
1842
- ? html`<${Box} flexDirection="column">
1843
- <${Notice} text=${`${glyphs.expand} ${expandedItems.kind}`} color=${C.muted} />
1844
- ${expandedItems.items.map((it) => renderItem(it))}
1845
- <//>`
1846
- : null}
2040
+ : null}
2041
+ ${scrollIndicator}
2042
+ ${visibleItems.map((it) => renderItem(it))}
2043
+ ${liveArea}
2044
+ <//>`;
1847
2045
 
1848
- ${search ? html`<${SearchOverlay} query=${search.query} matches=${searchMatches} active=${search.active} cols=${cols} />` : null}
2046
+ return html`
2047
+ <${Box} flexDirection="column" width=${cols} height=${rows}>
2048
+ <${TabBar} active=${activeTab} cols=${cols} mode=${composerMode} />
1849
2049
 
1850
- ${diffState
1851
- ? html`<${Box} flexDirection="column">
1852
- <${DiffViewer}
1853
- diff=${diffState.lines}
1854
- sel=${diffState.sel}
1855
- staged=${diffState.staged}
1856
- whitespace=${diffState.whitespace}
1857
- cols=${cols}
1858
- height=${Math.max(8, rows - 14)}
1859
- commentCount=${Object.keys(diffState.comments || {}).length}
1860
- />
1861
- ${diffState.commentInput
1862
- ? html`<${Choice}
1863
- title=${`Tell Copilot what stands out about ${diffState.commentInput.file}:${diffState.commentInput.lineNumber || '?'}`}
1864
- note=${diffState.commentInput.content}
1865
- sel=${0}
1866
- allowText=${true}
1867
- text=${diffState.commentInput.text}
1868
- focusText=${true}
1869
- options=${[]}
1870
- />`
1871
- : null}
1872
- <//>`
1873
- : null}
1874
-
1875
- <${Box} marginTop=${1}>
1876
- <${TabBar} active=${activeTab} cols=${cols} mode=${composerMode} />
2050
+ <${Box} flexDirection="column" flexGrow=${1} overflow="hidden">
2051
+ ${timelineContent}
1877
2052
  <//>
1878
2053
 
1879
- ${tabPanel && !diffState
1880
- ? html`<${Box} flexDirection="column" paddingX=${1} paddingY=${1}>
1881
- <${Text} color=${C.brand} bold>${tabPanel.title} — Quick Commands<//>\n
1882
- ${tabPanel.commands.map((c, i) =>
1883
- html`<${Box} key=${i}>
1884
- <${Text} color=${C.slash}>${c.cmd.padEnd(24)}<//>
1885
- <${Text} color=${C.muted}>${c.desc}<//>
1886
- <//>`)}
1887
- \n<${Text} color=${C.muted}>${tabPanel.hint} · Press Enter to return to Session · Press c to start a #reference<//>
1888
- <//>`
1889
- : null}
2054
+ ${search ? html`<${SearchOverlay} query=${search.query} matches=${searchMatches} active=${search.active} cols=${cols} />` : null}
1890
2055
 
1891
2056
  ${!busy && chips.length ? html`<${FollowupChips} chips=${chips} active=${activeChip} />` : null}
1892
2057
 
@@ -109,7 +109,7 @@ function SpeakerLine({ label, color, glyphColor, cols = 80, ts, rightLabel = ''
109
109
  const TABS = [
110
110
  { id: 'session', label: 'Session' },
111
111
  { id: 'devices', label: 'Devices' },
112
- { id: 'customers', label: 'Customers' },
112
+ { id: 'maps', label: 'Network Maps' },
113
113
  { id: 'alarms', label: 'Alarms' },
114
114
  ];
115
115
 
@@ -278,13 +278,13 @@ function StreamingText({ text = '' }) {
278
278
  return rendered;
279
279
  }
280
280
 
281
- export function AssistantMessage({ text, cols = 80, ts, label = `${glyphs.copilotDot} iCopilot`, streaming = false }) {
281
+ export function AssistantMessage({ text, cols = 80, ts, label = `${glyphs.ring} iCopilot`, streaming = false }) {
282
282
  // §11: response content box capped at 100 columns; separators stay full-width (§6).
283
283
  const bodyWidth = Math.min(cols, 100);
284
284
  return html`
285
285
  <${Box} flexDirection="column" marginTop=${1}>
286
286
  <${Sep} cols=${cols} />
287
- <${SpeakerLine} label=${label} color="white" glyphColor=${C.accent} cols=${cols} ts=${ts} />
287
+ <${SpeakerLine} label=${label} color="white" glyphColor=${C.accent} cols=${cols} />
288
288
  <${Box} paddingX=${1} flexDirection="column" width=${bodyWidth}>
289
289
  ${streaming ? html`<${StreamingText} text=${text} />` : html`<${Markdown} content=${text} />`}
290
290
  <//>
@@ -347,7 +347,7 @@ export function Composer({
347
347
  return;
348
348
  }
349
349
  if (key.pageUp || key.pageDown) {
350
- onNotice?.('Use terminal scrollback to view history.', C.muted);
350
+ // Viewport scrolling is handled by the app-level input handler.
351
351
  return;
352
352
  }
353
353
  if (key.ctrl && input === 'r') {
@@ -517,7 +517,7 @@ export function Composer({
517
517
  <${Text} color=${C.muted} italic> ${ghostHint}<//>
518
518
  <//>`
519
519
  : html`<${Box} flexGrow=${1}>
520
- <${Text}>${before}<//><${Text} inverse>${at}<//><${Text}>${after}<//><${Text} color=${C.muted}>${ghost}<//>
520
+ <${Text}>${before}<//><${Text} inverse>${at}<//><${Text}>${after}<//><${Text} color=${C.muted} italic>${ghost}<//>
521
521
  <//>`}
522
522
  <//>
523
523
 
package/src/tui/run.js CHANGED
@@ -1,9 +1,26 @@
1
- // Ink render entrypoint. Runs the TUI INLINE in the normal terminal buffer.
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
4
  import { App } from './app.js';
5
5
  import { saveConfig } from '../config.js';
6
6
 
7
+ // ── Alt-screen management ────────────────────────────────────────────────────
8
+ // Switch to the alternate terminal buffer so the TUI owns the full viewport and
9
+ // nothing leaks into the user's native scrollback. Must run BEFORE Ink renders.
10
+ let altScreenActive = false;
11
+
12
+ function enterAltScreen() {
13
+ if (altScreenActive || !process.stdout.isTTY) return;
14
+ process.stdout.write('\x1b[?1049h\x1b[2J\x1b[H'); // enter alt-screen, clear, home
15
+ altScreenActive = true;
16
+ }
17
+
18
+ function exitAltScreen() {
19
+ if (!altScreenActive) return;
20
+ try { process.stdout.write('\x1b[?1049l'); } catch {}
21
+ altScreenActive = false;
22
+ }
23
+
7
24
  // iCopilot wordmark in block letters
8
25
  const LOGO = [
9
26
  ' ██╗ ██████╗ ██████╗ ██████╗ ██╗██╗ ██████╗ ████████╗',
@@ -93,6 +110,14 @@ export async function runTui(cfg, opts = {}) {
93
110
  const seen = Boolean(cfg?.tui?.bannerSeen);
94
111
  const showAnimation = banner || !seen;
95
112
 
113
+ // Enter the alternate screen buffer before anything renders, and make sure we
114
+ // always restore the primary buffer no matter how the process ends.
115
+ enterAltScreen();
116
+ process.on('exit', exitAltScreen);
117
+ const onSignal = (sig) => { exitAltScreen(); process.exit(sig === 'SIGINT' ? 130 : 0); };
118
+ process.on('SIGINT', () => onSignal('SIGINT'));
119
+ process.on('SIGTERM', () => onSignal('SIGTERM'));
120
+
96
121
  if (showAnimation) {
97
122
  await animateSplash(cfg);
98
123
  cfg.tui = { ...(cfg.tui || {}), bannerSeen: true };
@@ -112,6 +137,10 @@ export async function runTui(cfg, opts = {}) {
112
137
  { exitOnCtrlC: false },
113
138
  );
114
139
 
115
- await instance.waitUntilExit();
140
+ try {
141
+ await instance.waitUntilExit();
142
+ } finally {
143
+ exitAltScreen();
144
+ }
116
145
  return externalAction;
117
146
  }