ispbills-icli 8.6.6 → 8.6.8

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.6",
3
+ "version": "8.6.8",
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 });
@@ -261,6 +333,10 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
261
333
  const { cols, rows } = useTerminalSize();
262
334
  const colsRef = useRef(cols);
263
335
  useEffect(() => { colsRef.current = cols; }, [cols]);
336
+ // Fixed-height viewport: total rows minus chrome (tabbar + spacing + chips +
337
+ // composer + footer). Everything else scrolls inside this region.
338
+ const viewportRows = Math.max(3, rows - 6);
339
+ const scrollPage = Math.max(4, viewportRows - 2);
264
340
 
265
341
  const [items, setItems] = useState(() => renderConversationItems(initialMessages, cols));
266
342
  const [live, setLive] = useState(null);
@@ -275,6 +351,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
275
351
  const [startedAt, setStartedAt] = useState(0);
276
352
  const [choice, setChoice] = useState(null);
277
353
  const [clearEpoch, setClearEpoch] = useState(0);
354
+ const [scrollOffset, setScrollOffset] = useState(0);
278
355
  const [composerClearToken, setComposerClearToken] = useState(0);
279
356
  const [composerText, setComposerText] = useState('');
280
357
  const [composerPreset, setComposerPreset] = useState({ token: 0, text: '' });
@@ -301,6 +378,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
301
378
  const activeTurns = useRef(0);
302
379
  const dismissTimers = useRef(new Map());
303
380
  const dispatchSubmitRef = useRef(null);
381
+ const maxScrollRef = useRef(0);
304
382
  const sessionIdRef = useRef(initialSession?.id || Date.now().toString(36));
305
383
  const sessionCreatedAtRef = useRef(initialSession?.createdAt || new Date().toISOString());
306
384
  const sessionNameRef = useRef(initialSession?.name || sessionAutoName(initialMessages));
@@ -327,6 +405,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
327
405
  const push = useCallback((item) => {
328
406
  const next = { id: nextId(), cols: colsRef.current, ts: item.ts ?? Date.now(), ...item };
329
407
  setItems((xs) => [...xs, next]);
408
+ setScrollOffset(0); // auto-pin to bottom on new content
330
409
  return next;
331
410
  }, []);
332
411
 
@@ -347,6 +426,9 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
347
426
  }
348
427
  }, [items, removeItem]);
349
428
 
429
+ // Pin the viewport to the newest content whenever a turn begins streaming.
430
+ useEffect(() => { if (startedAt) setScrollOffset(0); }, [startedAt]);
431
+
350
432
  const pushNotice = useCallback((text, color = C.gray, autoDismiss = false, type = null) => {
351
433
  const resolvedType = type || (color === C.red || color === C.error ? 'error' : 'info');
352
434
  push({ type: resolvedType, text, color, autoDismiss });
@@ -359,6 +441,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
359
441
  setItems([]);
360
442
  setExpandedItems(null);
361
443
  setSearch(null);
444
+ setScrollOffset(0);
362
445
  setClearEpoch((n) => n + 1);
363
446
  }, []);
364
447
 
@@ -367,6 +450,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
367
450
  setItems(renderConversationItems(messages, colsRef.current));
368
451
  setExpandedItems(null);
369
452
  setSearch(null);
453
+ setScrollOffset(0);
370
454
  setClearEpoch((n) => n + 1);
371
455
  }, []);
372
456
 
@@ -1685,6 +1769,14 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1685
1769
  return;
1686
1770
  }
1687
1771
  if (key.ctrl && input === 'd') { doExit(); return; }
1772
+ if (key.pageUp && !choice && !search && !sessionPicker && !diffState) {
1773
+ setScrollOffset((n) => Math.min(maxScrollRef.current, n + scrollPage));
1774
+ return;
1775
+ }
1776
+ if (key.pageDown && !choice && !search && !sessionPicker && !diffState) {
1777
+ setScrollOffset((n) => Math.max(0, n - scrollPage));
1778
+ return;
1779
+ }
1688
1780
  if (key.escape && busy) { abortRef.current?.abort(); return; }
1689
1781
  if (key.escape && shellMode && !composerText.trim()) { setShellMode(false); pushNotice('Exited shell mode.', C.muted, true); return; }
1690
1782
  if (key.escape && expandedItems) { setExpandedItems(null); return; }
@@ -1812,81 +1904,93 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
1812
1904
  : streamerMode
1813
1905
  ? ''
1814
1906
  : [modelLabel, tokenLabel].filter(Boolean).join(midDot());
1815
- const compact = cols < 60;
1816
1907
  const composerMode = shellMode ? 'shell' : mode;
1817
- const staticItems = showBanner
1818
- ? [{ type: 'banner', id: `banner-${clearEpoch}`, cols }, ...items]
1819
- : items;
1820
1908
 
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} />
1909
+ // ── Viewport slicing ───────────────────────────────────────────────────────
1910
+ // Reserve room for the live streaming area, then pick the items that fit in
1911
+ // the remaining rows, scrolled up from the bottom by scrollOffset.
1912
+ const liveH = liveAreaHeight(live, cols, showReasoning);
1913
+ const availRows = Math.max(1, viewportRows - liveH);
1914
+ const { visible: visibleItems, aboveCount, hasAbove } = selectViewport(items, cols, availRows, scrollOffset);
1915
+ const totalRows = items.reduce((n, it) => n + itemHeight(it, cols), 0);
1916
+ maxScrollRef.current = Math.max(0, totalRows - availRows);
1917
+ const showEmptyBanner = items.length === 0 && !live;
1918
+ const diffHeight = Math.max(8, viewportRows - 1);
1919
+
1920
+ const liveArea = live
1921
+ ? html`<${Box} flexDirection="column">
1922
+ ${live.text ? html`<${AssistantMessage} text=${live.text} cols=${cols} ts=${startedAt || Date.now()} streaming=${true} />` : null}
1923
+ ${live.reasoning ? html`<${Reasoning} text=${live.reasoning} show=${showReasoning} />` : null}
1924
+ ${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
1925
+ <${StatusLines} lines=${live.status} busy=${busy} />
1926
+ <${Thinking} label=${live.reply ? 'Planning' : live.text ? 'Responding' : 'Working'} startedAt=${startedAt} />
1927
+ <//>`
1928
+ : null;
1929
+
1930
+ const scrollIndicator = hasAbove
1931
+ ? html`<${Box} paddingX=${1}>
1932
+ <${Text} color=${C.muted} dimColor>${glyphs.up} ${aboveCount} message${aboveCount === 1 ? '' : 's'} above ${midDot()} PgUp/PgDn to scroll<//>
1933
+ <//>`
1934
+ : null;
1935
+
1936
+ const timelineContent = diffState
1937
+ ? html`<${Box} flexDirection="column">
1938
+ <${DiffViewer}
1939
+ diff=${diffState.lines}
1940
+ sel=${diffState.sel}
1941
+ staged=${diffState.staged}
1942
+ whitespace=${diffState.whitespace}
1943
+ cols=${cols}
1944
+ height=${diffHeight}
1945
+ commentCount=${Object.keys(diffState.comments || {}).length}
1946
+ />
1947
+ ${diffState.commentInput
1948
+ ? html`<${Choice}
1949
+ title=${`Tell Copilot what stands out about ${diffState.commentInput.file}:${diffState.commentInput.lineNumber || '?'}`}
1950
+ note=${diffState.commentInput.content}
1951
+ sel=${0}
1952
+ allowText=${true}
1953
+ text=${diffState.commentInput.text}
1954
+ focusText=${true}
1955
+ options=${[]}
1956
+ />`
1957
+ : null}
1958
+ <//>`
1959
+ : expandedItems
1960
+ ? html`<${Box} flexDirection="column">
1961
+ <${Notice} text=${`${glyphs.expand} ${expandedItems.kind}`} color=${C.muted} />
1962
+ ${expandedItems.items.map((it) => renderItem(it))}
1963
+ <//>`
1964
+ : tabPanel
1965
+ ? html`<${Box} flexDirection="column" paddingX=${1} paddingY=${1}>
1966
+ <${Text} color=${C.brand} bold>${tabPanel.title} — Quick Commands<//>\n
1967
+ ${tabPanel.commands.map((c, i) =>
1968
+ html`<${Box} key=${i}>
1969
+ <${Text} color=${C.slash}>${c.cmd.padEnd(24)}<//>
1970
+ <${Text} color=${C.muted}>${c.desc}<//>
1971
+ <//>`)}
1972
+ \n<${Text} color=${C.muted}>${tabPanel.hint} · Press Enter to return to Session · Press c to start a #reference<//>
1973
+ <//>`
1974
+ : html`<${Box} flexDirection="column">
1975
+ ${showEmptyBanner
1976
+ ? html`<${Box} flexDirection="column" alignItems="center" width=${cols}>
1977
+ <${Banner} cfg=${cfg} model=${model} width=${cols} compact=${true} streamerMode=${streamerMode} />
1827
1978
  <//>`
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}
1979
+ : null}
1980
+ ${scrollIndicator}
1981
+ ${visibleItems.map((it) => renderItem(it))}
1982
+ ${liveArea}
1983
+ <//>`;
1847
1984
 
1848
- ${search ? html`<${SearchOverlay} query=${search.query} matches=${searchMatches} active=${search.active} cols=${cols} />` : null}
1985
+ return html`
1986
+ <${Box} flexDirection="column" width=${cols} height=${rows}>
1987
+ <${TabBar} active=${activeTab} cols=${cols} mode=${composerMode} />
1849
1988
 
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} />
1989
+ <${Box} flexDirection="column" flexGrow=${1} overflow="hidden">
1990
+ ${timelineContent}
1877
1991
  <//>
1878
1992
 
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}
1993
+ ${search ? html`<${SearchOverlay} query=${search.query} matches=${searchMatches} active=${search.active} cols=${cols} />` : null}
1890
1994
 
1891
1995
  ${!busy && chips.length ? html`<${FollowupChips} chips=${chips} active=${activeChip} />` : null}
1892
1996
 
@@ -191,28 +191,37 @@ export function Banner({ cfg = {}, model, width = 80, compact = false, streamerM
191
191
  ${wide
192
192
  ? html`<${Box} flexDirection="column" alignItems="center" width=${width}>
193
193
  ${LOGO.map((l, i) => html`<${Text} key=${'l' + i} color=${C.accent} bold>${l}<//>`)}
194
- <${Text} color=${C.muted}>iCopilot ${dash()} IspBills network engineer in your terminal<//>
195
194
  <//>`
196
- : html`<${Text} color=${C.accent} bold>iCopilot ${html`<${Text} color=${C.muted}>${dash()} IspBills AI terminal<//>`}<//>`}
197
- <${Box} marginTop=${1}>
195
+ : html`<${Text} color=${C.accent} bold>◈ iCopilot ${html`<${Text} color=${C.muted}>${dash()} IspBills AI terminal<//>`}<//>` }
196
+
197
+ ${wide ? html`<${Text} color=${C.separator}>${'─'.repeat(Math.max(1, width))}<//>\n` : null}
198
+
199
+ <${Box} marginTop=${wide ? 0 : 1} justifyContent="center">
200
+ <${Text} color=${C.accent} bold>◈ iCopilot<//>
201
+ <${Text} color=${C.muted}> ${dash()} IspBills network engineer in your terminal<//>
202
+ <//>
203
+
204
+ ${wide ? html`<${Text} color=${C.separator}>${'─'.repeat(Math.max(1, width))}<//>\n` : null}
205
+
206
+ ${meta.length ? html`<${Box} marginTop=${1} justifyContent="center">
198
207
  ${meta.map(([label, val], i) => html`
199
208
  <${Box} key=${'m' + i}>
200
209
  ${i > 0 ? html`<${Text} color=${C.muted}>${midDot()}<//>` : null}
201
210
  <${Text} color=${C.muted}>${label} <//>
202
211
  <${Text} color=${label === 'model' ? C.accent : C.brand}>${val}<//>
203
212
  <//>`)}
213
+ <//>` : null}
214
+
215
+ ${cfg.url ? html`<${Box} justifyContent="center"><${Text} color=${C.muted}>${cfg.url}<//><//>` : null}
216
+
217
+ <${Box} flexDirection="column" marginTop=${1} alignItems="center" width=${width}>
218
+ <${Box} paddingX=${2} flexDirection="column">
219
+ <${Text} color=${C.muted} dimColor> ${' @ '}${html`<${Text} color=${C.accent}>@<//>`} mention files · ${html`<${Text} color=${C.accent}>/<//>`} commands · ${html`<${Text} color=${C.accent}>?<//>`} help · ${html`<${Text} color=${C.accent}>!<//>` } shell<//>
220
+ <//>
204
221
  <//>
205
- ${cfg.url ? html`<${Text} color=${C.muted}>${cfg.url}<//>` : null}
206
-
207
- <${Box} flexDirection="column" marginTop=${1} paddingX=${2} alignSelf="flex-start">
208
- <${Text} color=${C.muted} bold>Quick start:<//>\n
209
- <${Text} color=${C.muted}> <${Text} color=${C.accent}>@<//> mention files or devices<//>
210
- <${Text} color=${C.muted}> <${Text} color=${C.accent}>/<//> slash commands<//>
211
- <${Text} color=${C.muted}> <${Text} color=${C.accent}>?<//> quick help overlay<//>
212
- <${Text} color=${C.muted}> <${Text} color=${C.accent}>!<//> run shell command<//>
213
- <//>
222
+
214
223
  ${!isAuth
215
- ? html`<${Box} marginTop=${1}>
224
+ ? html`<${Box} marginTop=${1} justifyContent="center">
216
225
  <${Text} color=${C.warning}>⚠ Not authenticated — run <//>
217
226
  <${Text} color=${C.slash} bold>/login<//>
218
227
  <${Text} color=${C.warning}> to connect.<//><//>`
@@ -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,42 +1,105 @@
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
+
24
+ // iCopilot wordmark in block letters
7
25
  const LOGO = [
8
- '██╗ ██████╗ ██████╗ ██████╗ ██╗██╗ ██████╗ ████████╗',
9
- '██║██╔════╝██╔═══██╗██╔══██╗██║██║ ██╔═══██╗╚══██╔══╝',
10
- '██║██║ ██║ ██║██████╔╝██║██║ ██║ ██║ ██║ ',
11
- '██║██║ ██║ ██║██╔═══╝ ██║██║ ██║ ██║ ██║ ',
12
- '██║╚██████╗╚██████╔╝██║ ██║███████╗╚██████╔╝ ██║ ',
13
- '╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝ ',
26
+ ' ██╗ ██████╗ ██████╗ ██████╗ ██╗██╗ ██████╗ ████████╗',
27
+ ' ██║██╔════╝██╔═══██╗██╔══██╗██║██║ ██╔═══██╗╚══██╔══╝',
28
+ ' ██║██║ ██║ ██║██████╔╝██║██║ ██║ ██║ ██║ ',
29
+ ' ██║██║ ██║ ██║██╔═══╝ ██║██║ ██║ ██║ ██║ ',
30
+ ' ██║╚██████╗╚██████╔╝██║ ██║███████╗╚██████╔╝ ██║ ',
31
+ ' ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝ ',
32
+ ];
33
+
34
+ // ANSI gradient: dim purple → bright purple → bright blue → bright cyan
35
+ const FRAMES = [
36
+ LOGO.map((l) => `\x1b[2;35m${l}\x1b[0m`), // dim magenta
37
+ LOGO.map((l) => `\x1b[1;35m${l}\x1b[0m`), // bold magenta
38
+ LOGO.map((l, i) => i < 3 ? `\x1b[1;35m${l}\x1b[0m` : `\x1b[1;34m${l}\x1b[0m`), // split
39
+ LOGO.map((l) => `\x1b[1;34m${l}\x1b[0m`), // bold blue
40
+ LOGO.map((l, i) => i < 3 ? `\x1b[1;34m${l}\x1b[0m` : `\x1b[1;36m${l}\x1b[0m`), // split
41
+ LOGO.map((l) => `\x1b[1;36m${l}\x1b[0m`), // bold cyan
42
+ LOGO.map((l) => `\x1b[1;35m${l}\x1b[0m`), // back to bold magenta
14
43
  ];
15
- const COLORS = ['35', '95', '94', '96', '97'];
16
44
 
17
45
  async function animateSplash(cfg = {}) {
18
46
  if (!process.stdout.isTTY) return;
19
- for (let frame = 0; frame < COLORS.length; frame++) {
20
- const color = COLORS[frame];
21
- const lines = LOGO.map((line, idx) => `\x1b[${Math.min(frame + idx + 1, 5) > 3 ? '1' : '2'};${color}m${line}\x1b[0m`).join('\n');
22
- process.stdout.write(lines + '\n');
23
- await new Promise((resolve) => setTimeout(resolve, 110));
24
- if (frame !== COLORS.length - 1) process.stdout.write(`\x1b[${LOGO.length}A`);
47
+ const cols = process.stdout.columns || 80;
48
+ const logoW = LOGO[0].length;
49
+ const pad = ' '.repeat(Math.max(0, Math.floor((cols - logoW) / 2)));
50
+
51
+ // Phase 1: reveal lines one-by-one dim
52
+ for (let i = 0; i < LOGO.length; i++) {
53
+ process.stdout.write(`${pad}\x1b[2;35m${LOGO[i]}\x1b[0m\n`);
54
+ await new Promise((r) => setTimeout(r, 55));
55
+ }
56
+
57
+ // Phase 2: colour-cycle animation
58
+ for (let fi = 1; fi < FRAMES.length; fi++) {
59
+ process.stdout.write(`\x1b[${LOGO.length}A`);
60
+ for (const line of FRAMES[fi]) {
61
+ process.stdout.write(`${pad}${line}\n`);
62
+ }
63
+ await new Promise((r) => setTimeout(r, 90));
25
64
  }
26
- // §3: welcome text, quick-start tips and a visible /login instruction.
27
- const DIM = '\x1b[2m';
28
- const ACCENT = '\x1b[38;5;141m';
29
- const WARN = '\x1b[38;5;179m';
30
- const R = '\x1b[0m';
31
- process.stdout.write(`\n ${ACCENT}iCopilot${R} ${DIM}— IspBills network engineer in your terminal${R}\n\n`);
32
- process.stdout.write(` ${DIM}Quick start:${R}\n`);
33
- process.stdout.write(` ${ACCENT}@${R}${DIM} mention files or devices${R}\n`);
34
- process.stdout.write(` ${ACCENT}/${R}${DIM} slash commands${R}\n`);
35
- process.stdout.write(` ${ACCENT}?${R}${DIM} quick help overlay${R}\n`);
36
- process.stdout.write(` ${ACCENT}!${R}${DIM} run a shell command${R}\n`);
65
+
66
+ // Welcome section — centered
67
+ const DIM = '\x1b[2m';
68
+ const BOLD = '\x1b[1m';
69
+ const ACC = '\x1b[38;5;141m'; // #A371F7 purple
70
+ const BLU = '\x1b[38;5;75m'; // #58A6FF blue
71
+ const WARN = '\x1b[38;5;179m'; // #D29922 amber
72
+ const SEP = '\x1b[38;5;237m'; // #30363D separator
73
+ const R = '\x1b[0m';
74
+
75
+ const hr = `${SEP}${'─'.repeat(cols)}${R}`;
76
+ const subtitle = `${ACC}◈ iCopilot${R} ${DIM}IspBills network engineer in your terminal${R}`;
77
+ const subtitleLen = 10 + 2 + 46; // approx visual length
78
+ const subPad = ' '.repeat(Math.max(0, Math.floor((cols - subtitleLen) / 2)));
79
+
80
+ process.stdout.write(`\n${hr}\n`);
81
+ process.stdout.write(`${subPad}${subtitle}\n`);
82
+ process.stdout.write(`${hr}\n\n`);
83
+
84
+ // Quick-start tips in a two-column grid, centered
85
+ const tips = [
86
+ [`${ACC}@${R}`, 'mention files, devices, ONUs'],
87
+ [`${ACC}/${R}`, 'slash commands & autocomplete'],
88
+ [`${ACC}?${R}`, 'quick help overlay'],
89
+ [`${ACC}!${R}`, 'run shell command directly'],
90
+ [`${BLU}Shift+Tab${R}`, 'cycle Ask → Plan → Autopilot'],
91
+ [`${BLU}Ctrl+T${R}`, 'toggle reasoning display'],
92
+ ];
93
+ const tipW = 42;
94
+ const tipPad = ' '.repeat(Math.max(0, Math.floor((cols - tipW) / 2)));
95
+ process.stdout.write(`${tipPad}${DIM}Quick start:${R}\n`);
96
+ for (const [key, desc] of tips) {
97
+ process.stdout.write(`${tipPad} ${BOLD}${key}${R} ${DIM}${desc}${R}\n`);
98
+ }
99
+
37
100
  const isAuth = Boolean(cfg?.user?.name || cfg?.user?.email || cfg?.apiKey || cfg?._token);
38
101
  if (!isAuth) {
39
- process.stdout.write(`\n ${WARN}⚠ Not authenticatedrun ${R}${ACCENT}/login${R}${WARN} to connect.${R}\n`);
102
+ process.stdout.write(`\n${tipPad}${WARN}⚠ Not authenticated${R} ${DIM}run${R} ${ACC}/login${R} ${DIM}to connect${R}\n`);
40
103
  }
41
104
  process.stdout.write('\n');
42
105
  }
@@ -47,6 +110,14 @@ export async function runTui(cfg, opts = {}) {
47
110
  const seen = Boolean(cfg?.tui?.bannerSeen);
48
111
  const showAnimation = banner || !seen;
49
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
+
50
121
  if (showAnimation) {
51
122
  await animateSplash(cfg);
52
123
  cfg.tui = { ...(cfg.tui || {}), bannerSeen: true };
@@ -66,6 +137,10 @@ export async function runTui(cfg, opts = {}) {
66
137
  { exitOnCtrlC: false },
67
138
  );
68
139
 
69
- await instance.waitUntilExit();
140
+ try {
141
+ await instance.waitUntilExit();
142
+ } finally {
143
+ exitAltScreen();
144
+ }
70
145
  return externalAction;
71
146
  }