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 +1 -1
- package/src/tui/app.js +280 -115
- package/src/tui/components.js +3 -3
- package/src/tui/composer.js +2 -2
- package/src/tui/run.js +31 -2
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 });
|
|
@@ -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
|
-
|
|
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
|
-
'
|
|
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', '
|
|
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}
|
|
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
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
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
|
-
:
|
|
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}
|
|
2040
|
+
: null}
|
|
2041
|
+
${scrollIndicator}
|
|
2042
|
+
${visibleItems.map((it) => renderItem(it))}
|
|
2043
|
+
${liveArea}
|
|
2044
|
+
<//>`;
|
|
1847
2045
|
|
|
1848
|
-
|
|
2046
|
+
return html`
|
|
2047
|
+
<${Box} flexDirection="column" width=${cols} height=${rows}>
|
|
2048
|
+
<${TabBar} active=${activeTab} cols=${cols} mode=${composerMode} />
|
|
1849
2049
|
|
|
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} />
|
|
2050
|
+
<${Box} flexDirection="column" flexGrow=${1} overflow="hidden">
|
|
2051
|
+
${timelineContent}
|
|
1877
2052
|
<//>
|
|
1878
2053
|
|
|
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}
|
|
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
|
|
package/src/tui/components.js
CHANGED
|
@@ -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: '
|
|
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.
|
|
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}
|
|
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
|
<//>
|
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,9 +1,26 @@
|
|
|
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
|
+
|
|
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
|
-
|
|
140
|
+
try {
|
|
141
|
+
await instance.waitUntilExit();
|
|
142
|
+
} finally {
|
|
143
|
+
exitAltScreen();
|
|
144
|
+
}
|
|
116
145
|
return externalAction;
|
|
117
146
|
}
|