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 +1 -1
- package/src/tui/app.js +173 -69
- package/src/tui/components.js +22 -13
- package/src/tui/composer.js +2 -2
- package/src/tui/run.js +102 -27
package/package.json
CHANGED
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,
|
|
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
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
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
|
-
:
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
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
|
-
|
|
1985
|
+
return html`
|
|
1986
|
+
<${Box} flexDirection="column" width=${cols} height=${rows}>
|
|
1987
|
+
<${TabBar} active=${activeTab} cols=${cols} mode=${composerMode} />
|
|
1849
1988
|
|
|
1850
|
-
|
|
1851
|
-
|
|
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
|
-
${
|
|
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
|
|
package/src/tui/components.js
CHANGED
|
@@ -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
|
|
197
|
-
|
|
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
|
-
|
|
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.<//><//>`
|
package/src/tui/composer.js
CHANGED
|
@@ -347,7 +347,7 @@ export function Composer({
|
|
|
347
347
|
return;
|
|
348
348
|
}
|
|
349
349
|
if (key.pageUp || key.pageDown) {
|
|
350
|
-
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
140
|
+
try {
|
|
141
|
+
await instance.waitUntilExit();
|
|
142
|
+
} finally {
|
|
143
|
+
exitAltScreen();
|
|
144
|
+
}
|
|
70
145
|
return externalAction;
|
|
71
146
|
}
|