ispbills-icli 8.3.0 → 8.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -11
- package/package.json +1 -1
- package/src/tui/app.js +48 -75
- package/src/tui/components.js +27 -17
- package/src/tui/composer.js +49 -7
- package/src/tui/run.js +6 -23
package/README.md
CHANGED
|
@@ -59,11 +59,12 @@ $ icli
|
|
|
59
59
|
|
|
60
60
|
❯ is vlan 82 free on all devices?
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
✓ Connecting to OLT epon-olt-3…
|
|
63
|
+
✓ 🧭 No single device named — delegating across the fleet. Assigning one
|
|
64
|
+
sub-agent per device to: check whether VLAN 82 is in use
|
|
65
|
+
├─ 1/3 nas#1 — ✓ FREE
|
|
66
|
+
├─ 2/3 nas#2 — ✓ FREE
|
|
67
|
+
└─ 3/3 olt#1 — ✓ FREE
|
|
67
68
|
|
|
68
69
|
VLAN 82 is free across all 3 reachable devices in your fleet.
|
|
69
70
|
|
|
@@ -188,7 +189,7 @@ suggestion (accept with **Tab** or **→**), and runs the highlighted command on
|
|
|
188
189
|
| **@** | Mention a device/entity (e.g. `@olt:`, `@onu:`); **Tab** / **→** completes the tag |
|
|
189
190
|
| **↑/↓** (empty input) | Navigate input history |
|
|
190
191
|
| **Ctrl+Z** / **Ctrl+Y** | Undo / redo the input line |
|
|
191
|
-
| **Paste** | Multi-line pastes
|
|
192
|
+
| **Paste** | Multi-line pastes (incl. mouse right-click) keep their line breaks and are never auto-submitted — press **Enter** to send |
|
|
192
193
|
| **Shift+Tab** | Toggle autopilot mode |
|
|
193
194
|
| **Ctrl+T** | Toggle reasoning display |
|
|
194
195
|
| **Ctrl+L** | Clear the transcript |
|
|
@@ -201,10 +202,13 @@ suggestion (accept with **Tab** or **→**), and runs the highlighted command on
|
|
|
201
202
|
plan is applied automatically without waiting for you to type `apply fix`.
|
|
202
203
|
Toggle it with **Shift+Tab** or `/autopilot`.
|
|
203
204
|
|
|
204
|
-
The REPL
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
205
|
+
The REPL renders inline in your terminal's normal buffer — exactly like GitHub
|
|
206
|
+
Copilot CLI. The banner prints once at the top and scrolls away into your native
|
|
207
|
+
scrollback as the conversation grows, so you can scroll back through the entire
|
|
208
|
+
session with your terminal's own scrollbar/wheel. The input box stays at the
|
|
209
|
+
bottom while a request is in flight, and each answer (including the sub-agent /
|
|
210
|
+
tool-call trace that produced it) is committed to scrollback so it stays visible
|
|
211
|
+
in your history.
|
|
208
212
|
|
|
209
213
|
---
|
|
210
214
|
|
|
@@ -225,7 +229,7 @@ iCli connects to the same AI engine as the in-browser terminal:
|
|
|
225
229
|
- **Bot memory** — `/remember` saves standing operator notes that are injected into every answer (`/memory`, `/forget` to manage)
|
|
226
230
|
- **Backups in git** — `/backup` archives config to a per-user git repo; run `/git status|log|commit|push` from the prompt
|
|
227
231
|
- **Share as a gist** — `/gist` publishes the last answer or transcript to a secret GitHub gist
|
|
228
|
-
- **Copy & paste** — `/copy` puts the last answer on the clipboard; multi-line pastes and **Ctrl+Z / Ctrl+Y** undo/redo work in the input
|
|
232
|
+
- **Copy & paste** — `/copy` puts the last answer on the clipboard; multi-line pastes (mouse right-click or Cmd/Ctrl+V) keep their line breaks, and **Ctrl+Z / Ctrl+Y** undo/redo work in the input
|
|
229
233
|
- **Full-screen terminal UI** — a full-height alternate-screen TUI (built on [Ink](https://github.com/vadimdemedes/ink)) with the banner pinned to the top, a bottom-pinned bordered input, a selectable slash-command menu, input history, and a bottom-anchored transcript — the same layout as GitHub Copilot CLI / Gemini CLI. Piped/non-interactive use falls back to plain streaming output
|
|
230
234
|
- **Terminal-aware rendering** — the layout reflows to your terminal width and resizes live; on classic Windows consoles (`cmd.exe` / legacy PowerShell) it auto-switches to ASCII-safe glyphs and borders so nothing shows as broken boxes
|
|
231
235
|
|
package/package.json
CHANGED
package/src/tui/app.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
// Root Ink application. Renders
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Root Ink application. Renders an INLINE chat UI (GitHub Copilot CLI style):
|
|
2
|
+
// the banner prints once at the top and scrolls away, completed transcript
|
|
3
|
+
// items are flushed to the terminal's native scrollback via Ink <Static> (so
|
|
4
|
+
// you can scroll back through history), and only the live/streaming area plus
|
|
5
|
+
// the input composer are re-rendered in place at the bottom.
|
|
5
6
|
import { html, useState, useEffect, useRef, useCallback } from './dom.js';
|
|
6
|
-
import { Box, Text, useStdout, useApp, useInput } from 'ink';
|
|
7
|
+
import { Box, Text, Static, useStdout, useApp, useInput } from 'ink';
|
|
7
8
|
import { C, glyphs, midDot, dash } from './theme.js';
|
|
8
9
|
import {
|
|
9
10
|
Banner, UserMessage, AssistantMessage, StatusLines, Reasoning,
|
|
@@ -46,37 +47,6 @@ function useTerminalSize() {
|
|
|
46
47
|
let itemId = 0;
|
|
47
48
|
const nextId = () => ++itemId;
|
|
48
49
|
|
|
49
|
-
// Rough rendered-height estimates so we can tail-slice the transcript to fit
|
|
50
|
-
// the available middle region (biased high → under-fill, never overflow/clip).
|
|
51
|
-
const wrapLines = (text, cols) =>
|
|
52
|
-
String(text || '').split('\n').reduce((n, l) => n + Math.max(1, Math.ceil(l.length / Math.max(1, cols))), 0);
|
|
53
|
-
|
|
54
|
-
function itemHeight(it, cols) {
|
|
55
|
-
switch (it.type) {
|
|
56
|
-
case 'user': return 1 + wrapLines(it.text, cols);
|
|
57
|
-
case 'assistant': return 1 + wrapLines(it.text, cols);
|
|
58
|
-
case 'notice': return 1 + wrapLines(it.text, cols);
|
|
59
|
-
case 'plan': { const s = it.reply?.steps || []; return 2 + (it.reply?.summary ? 1 : 0) + s.length * 2 + 2; }
|
|
60
|
-
case 'connect': return 3 + (it.reply?.note ? 1 : 0);
|
|
61
|
-
default: return 1;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function bannerHeight(cols, compact) {
|
|
66
|
-
if (compact) return 2;
|
|
67
|
-
return cols >= 60 ? 11 : 5;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function liveHeight(live, cols, showReasoning) {
|
|
71
|
-
if (!live) return 0;
|
|
72
|
-
let h = 2; // thinking line + margin
|
|
73
|
-
if (live.status?.length) h += 1 + live.status.length;
|
|
74
|
-
if (showReasoning && live.reasoning) h += 1 + wrapLines(live.reasoning, cols);
|
|
75
|
-
if (live.reply?.steps) h += 3 + live.reply.steps.length * 2 + (live.reply.summary ? 1 : 0);
|
|
76
|
-
if (live.text) h += 1 + wrapLines(live.text, cols);
|
|
77
|
-
return h;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
50
|
export function App({ cfg, display = {}, onExternal, onExit, initialQuestion }) {
|
|
81
51
|
const { exit } = useApp();
|
|
82
52
|
const { cols, rows } = useTerminalSize();
|
|
@@ -90,6 +60,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
90
60
|
const [hint, setHint] = useState('');
|
|
91
61
|
const [startedAt, setStartedAt] = useState(0);
|
|
92
62
|
const [choice, setChoice] = useState(null); // { title, note, options, allowText, sel, text, focusText, onPick }
|
|
63
|
+
const [clearEpoch, setClearEpoch] = useState(0); // bump to remount <Static> on /clear /new
|
|
93
64
|
|
|
94
65
|
const convo = useRef([]); // [{role, content}] for the backend
|
|
95
66
|
const plain = useRef([]); // plain-text transcript for exit reprint
|
|
@@ -100,8 +71,9 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
100
71
|
const lastAnswerRef = useRef(''); // most recent assistant answer (for /copy, /gist)
|
|
101
72
|
const choiceActionRef = useRef(null); // action to run when a non-AI Choice is picked
|
|
102
73
|
|
|
103
|
-
// Enable terminal bracketed-paste so multi-line pastes
|
|
104
|
-
//
|
|
74
|
+
// Enable terminal bracketed-paste so multi-line pastes (mouse right-click or
|
|
75
|
+
// Cmd/Ctrl+V) arrive wrapped in markers as one block — the Composer inserts
|
|
76
|
+
// them with newlines preserved instead of submitting on the first newline.
|
|
105
77
|
useEffect(() => {
|
|
106
78
|
try { process.stdout.write('\x1b[?2004h'); } catch {}
|
|
107
79
|
return () => { try { process.stdout.write('\x1b[?2004l'); } catch {} };
|
|
@@ -115,6 +87,14 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
115
87
|
const push = useCallback((item) => setItems((xs) => [...xs, { id: nextId(), ...item }]), []);
|
|
116
88
|
const log = (line) => plain.current.push(line);
|
|
117
89
|
|
|
90
|
+
// Clear the visible transcript: wipe the screen + native scrollback and
|
|
91
|
+
// remount <Static> (via clearEpoch) so nothing is re-flushed above the input.
|
|
92
|
+
const clearAll = useCallback(() => {
|
|
93
|
+
try { process.stdout.write('\x1b[2J\x1b[3J\x1b[H'); } catch {}
|
|
94
|
+
setItems([]);
|
|
95
|
+
setClearEpoch((n) => n + 1);
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
118
98
|
const runTurn = useCallback(async (question, opts = {}) => {
|
|
119
99
|
const { depth = 0, archive = null, label = '' } = opts;
|
|
120
100
|
push({ type: 'user', text: question });
|
|
@@ -152,6 +132,9 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
152
132
|
});
|
|
153
133
|
if (meta.model) setModel(meta.model);
|
|
154
134
|
const type = reply.type ?? 'message';
|
|
135
|
+
// Persist the tool / sub-agent activity trace inline (Copilot-CLI style)
|
|
136
|
+
// so it stays in history instead of vanishing with the live region.
|
|
137
|
+
if (s.status.length) push({ type: 'activity', status: s.status });
|
|
155
138
|
if (type === 'plan') { push({ type: 'plan', reply }); log('[plan] ' + (reply.summary || '')); }
|
|
156
139
|
else if (type === 'connect') { push({ type: 'connect', reply }); log('[connect] ' + (reply.device?.kind || '')); }
|
|
157
140
|
else if (type === 'prompt' || type === 'choice') { push({ type: 'assistant', text: text || reply.question || '' }); log(text || reply.question || ''); }
|
|
@@ -245,10 +228,10 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
245
228
|
setHint('');
|
|
246
229
|
|
|
247
230
|
if (q === '/exit' || q === 'exit' || q === 'quit') { doExit(); return; }
|
|
248
|
-
if (q === '/clear') {
|
|
231
|
+
if (q === '/clear') { clearAll(); return; }
|
|
249
232
|
if (q === '/new') {
|
|
250
233
|
convo.current = [];
|
|
251
|
-
|
|
234
|
+
clearAll();
|
|
252
235
|
push({ type: 'notice', text: `${glyphs.check} New conversation started.`, color: C.green });
|
|
253
236
|
return;
|
|
254
237
|
}
|
|
@@ -440,7 +423,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
440
423
|
return;
|
|
441
424
|
}
|
|
442
425
|
if (key.ctrl && input === 't') { setShowReasoning((v) => !v); return; }
|
|
443
|
-
if (key.ctrl && input === 'l' && !busy) {
|
|
426
|
+
if (key.ctrl && input === 'l' && !busy) { clearAll(); return; }
|
|
444
427
|
});
|
|
445
428
|
|
|
446
429
|
// Auto-run a first question if launched with one.
|
|
@@ -455,6 +438,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
455
438
|
case 'assistant': return html`<${AssistantMessage} key=${it.id} text=${it.text} />`;
|
|
456
439
|
case 'plan': return html`<${Plan} key=${it.id} reply=${it.reply} />`;
|
|
457
440
|
case 'connect': return html`<${Connect} key=${it.id} reply=${it.reply} />`;
|
|
441
|
+
case 'activity': return html`<${StatusLines} key=${it.id} lines=${it.status} busy=${false} />`;
|
|
458
442
|
case 'notice': return html`<${Notice} key=${it.id} text=${it.text} color=${it.color} />`;
|
|
459
443
|
default: return null;
|
|
460
444
|
}
|
|
@@ -463,46 +447,35 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
|
|
|
463
447
|
const ctx = [model ? shortModel(model) : null, cfg.user?.name, cfg.user?.role].filter(Boolean).join(midDot());
|
|
464
448
|
const modeTag = autopilot ? `${glyphs.bolt} autopilot ${midDot()} ` : '';
|
|
465
449
|
const footerRight = hint || `${modeTag}/help ${midDot()} Shift+Tab autopilot ${midDot()} Ctrl+C exit`;
|
|
450
|
+
const compact = cols < 60;
|
|
466
451
|
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
rows - bannerHeight(cols, compact) - 3 /*composer*/ - 1 /*footer*/ - 1 /*safety*/
|
|
473
|
-
- liveHeight(live, cols, showReasoning) - 1 /*earlier indicator*/,
|
|
474
|
-
);
|
|
475
|
-
const chosen = [];
|
|
476
|
-
let used = 0;
|
|
477
|
-
for (let i = items.length - 1; i >= 0; i--) {
|
|
478
|
-
const h = itemHeight(items[i], cols);
|
|
479
|
-
if (chosen.length && used + h > budget) break;
|
|
480
|
-
chosen.unshift(items[i]);
|
|
481
|
-
used += h;
|
|
482
|
-
}
|
|
483
|
-
const hidden = items.length - chosen.length;
|
|
452
|
+
// The banner is the first <Static> item so it prints once at the very top and
|
|
453
|
+
// scrolls away with history; completed transcript items follow it. Ink flushes
|
|
454
|
+
// each <Static> item to the terminal's normal buffer exactly once, leaving the
|
|
455
|
+
// native scrollback intact so earlier conversation can be scrolled back to.
|
|
456
|
+
const staticItems = [{ type: 'banner', id: `banner-${clearEpoch}` }, ...items];
|
|
484
457
|
|
|
485
458
|
return html`
|
|
486
|
-
<${Box} flexDirection="column" width=${cols}
|
|
487
|
-
<${
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
<${Box} flexGrow=${1} flexDirection="column" justifyContent="flex-end" overflow="hidden">
|
|
492
|
-
${hidden > 0 ? html`<${Text} color=${C.gray}>${glyphs.up} ${hidden} earlier message${hidden > 1 ? 's' : ''}<//>` : null}
|
|
493
|
-
${chosen.map(renderItem)}
|
|
494
|
-
${live
|
|
495
|
-
? html`<${Box} flexDirection="column">
|
|
496
|
-
<${StatusLines} lines=${live.status} busy=${busy} />
|
|
497
|
-
${showReasoning && live.reasoning ? html`<${Reasoning} text=${live.reasoning} />` : null}
|
|
498
|
-
${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
|
|
499
|
-
${live.text ? html`<${Box} marginTop=${1}><${AssistantMessage} text=${live.text} /><//>` : null}
|
|
500
|
-
<${Thinking} label=${live.reply ? 'Planning' : live.text ? 'Responding' : 'Working'} startedAt=${startedAt} />
|
|
459
|
+
<${Box} flexDirection="column" width=${cols}>
|
|
460
|
+
<${Static} key=${clearEpoch} items=${staticItems}>
|
|
461
|
+
${(it) => it.type === 'banner'
|
|
462
|
+
? html`<${Box} key=${it.id} flexDirection="column" alignItems="center" marginBottom=${1}>
|
|
463
|
+
<${Banner} cfg=${cfg} model=${model} width=${cols} compact=${compact} />
|
|
501
464
|
<//>`
|
|
502
|
-
:
|
|
465
|
+
: renderItem(it)}
|
|
503
466
|
<//>
|
|
504
467
|
|
|
505
|
-
|
|
468
|
+
${live
|
|
469
|
+
? html`<${Box} flexDirection="column">
|
|
470
|
+
<${StatusLines} lines=${live.status} busy=${busy} />
|
|
471
|
+
${showReasoning && live.reasoning ? html`<${Reasoning} text=${live.reasoning} />` : null}
|
|
472
|
+
${live.reply?.steps ? html`<${Plan} reply=${live.reply} live=${true} />` : null}
|
|
473
|
+
${live.text ? html`<${Box} marginTop=${1}><${AssistantMessage} text=${live.text} /><//>` : null}
|
|
474
|
+
<${Thinking} label=${live.reply ? 'Planning' : live.text ? 'Responding' : 'Working'} startedAt=${startedAt} />
|
|
475
|
+
<//>`
|
|
476
|
+
: null}
|
|
477
|
+
|
|
478
|
+
<${Box} flexDirection="column" marginTop=${1}>
|
|
506
479
|
${choice
|
|
507
480
|
? html`<${Choice} ...${choice} />`
|
|
508
481
|
: html`<${Composer} onSubmit=${onSubmit} busy=${busy} width=${cols} />`}
|
package/src/tui/components.js
CHANGED
|
@@ -82,24 +82,22 @@ export function AssistantMessage({ text }) {
|
|
|
82
82
|
<//>`;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
/** Classify a server progress/status line into an icon + tone. */
|
|
86
|
-
function classify(line) {
|
|
87
|
-
const l = String(line).toLowerCase();
|
|
88
|
-
if (/unreachable|skip|fail|error|denied|timeout|down\b|offline/.test(l)) return { icon: glyphs.cross, color: C.red };
|
|
89
|
-
if (/done|success|found|complet|online|ready|free\b|ok\b|\bup\b/.test(l)) return { icon: glyphs.check, color: C.green };
|
|
90
|
-
if (/assign|sub.?agent|fleet|parallel|dispatch|spawn/.test(l)) return { icon: glyphs.ring, color: C.magenta };
|
|
91
|
-
if (/probe|reachab|ping/.test(l)) return { icon: glyphs.target, color: C.yellow };
|
|
92
|
-
if (/connect|ssh|telnet|api|session|running|exec|command/.test(l)) return { icon: glyphs.arrow, color: C.accent };
|
|
93
|
-
return { icon: glyphs.bullet, color: C.gray };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
85
|
// A sub-agent progress line, e.g. "✓ [1/3] nas#1: FREE" or "[2/3] olt#1: down".
|
|
97
86
|
const SUBAGENT = /^\s*[✓✗x]?\s*\[(\d+)\/(\d+)\]\s*(.+?)\s*[::]\s*(.+?)\s*$/;
|
|
98
87
|
|
|
99
|
-
|
|
88
|
+
// A leading emoji/pictograph on a tool status label (from statusLabel()).
|
|
89
|
+
const LEAD_EMOJI = /^\s*([\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{2190}-\u{21FF}\u{FE0F}\u{2049}\u{203C}]+)\s*/u;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Live server activity — tool calls and sub-agents, rendered Copilot-CLI style:
|
|
93
|
+
* each activity gets a status marker (spinner while it's the in-flight step,
|
|
94
|
+
* green check once done), the tool's purpose label is kept, and sub-agent
|
|
95
|
+
* progress is nested underneath its parent as a tree.
|
|
96
|
+
*/
|
|
100
97
|
export function StatusLines({ lines = [], busy = false }) {
|
|
101
98
|
if (!lines.length) return null;
|
|
102
99
|
const subLines = lines.filter((l) => SUBAGENT.test(l));
|
|
100
|
+
const lastIdx = lines.length - 1;
|
|
103
101
|
return html`
|
|
104
102
|
<${Box} flexDirection="column" marginTop=${1}>
|
|
105
103
|
${lines.map((line, i) => {
|
|
@@ -112,21 +110,33 @@ export function StatusLines({ lines = [], busy = false }) {
|
|
|
112
110
|
const branch = Number(idx) === Number(total) ? glyphs.branchEnd : glyphs.branchMid;
|
|
113
111
|
return html`
|
|
114
112
|
<${Box} key=${'s' + i}>
|
|
113
|
+
<${Text} color=${C.gray}> <//>
|
|
115
114
|
<${Text} color=${C.magenta}>${branch} <//>
|
|
116
115
|
<${Text} color=${C.gray}>${idx}/${total} <//>
|
|
117
116
|
<${Text} color=${C.white} bold>${host}<//>
|
|
118
117
|
<${Text} color=${C.gray}> ${dash()} <//>
|
|
119
118
|
${running
|
|
120
|
-
? html`<${Text} color=${C.accent}><${Spinner} type=${spinnerType()}
|
|
119
|
+
? html`<${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /> working<//>`
|
|
121
120
|
: html`<${Text} color=${good ? C.green : C.red} bold>${good ? glyphs.check : glyphs.cross} ${result}<//>`}
|
|
122
121
|
<//>`;
|
|
123
122
|
}
|
|
124
|
-
|
|
125
|
-
|
|
123
|
+
|
|
124
|
+
// Tool / activity line. The last line while busy is the in-flight step.
|
|
125
|
+
const running = busy && i === lastIdx;
|
|
126
|
+
const em = String(line).match(LEAD_EMOJI);
|
|
127
|
+
const body = em ? String(line).slice(em[0].length) : String(line).replace(/^[\s◈◆●○◎⇢→>*✓✗×!⚠·-]+/u, '');
|
|
128
|
+
const ci = body.indexOf(':');
|
|
129
|
+
const action = ci > -1 ? body.slice(0, ci) : body;
|
|
130
|
+
const detail = ci > -1 ? body.slice(ci + 1).trim() : '';
|
|
126
131
|
return html`
|
|
127
132
|
<${Box} key=${'s' + i}>
|
|
128
|
-
|
|
129
|
-
|
|
133
|
+
${running
|
|
134
|
+
? html`<${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /><//>`
|
|
135
|
+
: html`<${Text} color=${C.green}>${glyphs.check}<//>`}
|
|
136
|
+
<${Text}> <//>
|
|
137
|
+
${em ? html`<${Text}>${em[1]} <//>` : null}
|
|
138
|
+
<${Text} color=${running ? C.white : C.gray} bold=${running}>${action}<//>
|
|
139
|
+
${detail ? html`<${Text} color=${C.gray}>: ${detail}<//>` : null}
|
|
130
140
|
<//>`;
|
|
131
141
|
})}
|
|
132
142
|
<//>`;
|
package/src/tui/composer.js
CHANGED
|
@@ -40,6 +40,26 @@ export function Composer({ onSubmit, busy, width = 80 }) {
|
|
|
40
40
|
lastMut.current = kind;
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
+
// Bracketed-paste assembly. A right-click / Cmd+V paste is wrapped by the
|
|
44
|
+
// terminal in \x1b[200~ … \x1b[201~ and can arrive split across several
|
|
45
|
+
// reads, so we buffer until the end marker before inserting.
|
|
46
|
+
const pasteRef = useRef({ active: false, buf: '' });
|
|
47
|
+
|
|
48
|
+
// Insert pasted text at the cursor, PRESERVING newlines so multi-line pastes
|
|
49
|
+
// (configs, logs) keep their structure. Tabs → 2 spaces; other control chars
|
|
50
|
+
// and the bracketed-paste markers are dropped.
|
|
51
|
+
const insertPaste = (raw) => {
|
|
52
|
+
const text = raw
|
|
53
|
+
.replace(/\x1b?\[20[01]~/g, '')
|
|
54
|
+
.replace(/\r\n?/g, '\n')
|
|
55
|
+
.replace(/\t/g, ' ')
|
|
56
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
|
|
57
|
+
if (!text) return;
|
|
58
|
+
snapshot('paste');
|
|
59
|
+
setValue((v) => v.slice(0, cursor) + text + v.slice(cursor));
|
|
60
|
+
setCursor((c) => c + text.length);
|
|
61
|
+
};
|
|
62
|
+
|
|
43
63
|
// Determine the token being typed at the cursor to drive the palette.
|
|
44
64
|
const upto = value.slice(0, cursor);
|
|
45
65
|
const tokStart = Math.max(upto.lastIndexOf(' '), upto.lastIndexOf('\n')) + 1;
|
|
@@ -79,6 +99,25 @@ export function Composer({ onSubmit, busy, width = 80 }) {
|
|
|
79
99
|
useInput((input, key) => {
|
|
80
100
|
if (busy) return; // ignore edits while a turn streams (App handles Ctrl+C/Esc)
|
|
81
101
|
|
|
102
|
+
// ── Bracketed paste (mouse right-click / Cmd+V), possibly split reads ──
|
|
103
|
+
// Ink strips the leading ESC, so the terminal's \x1b[200~ … \x1b[201~
|
|
104
|
+
// wrappers arrive as the marker text "[200~" / "[201~" (sometimes as their
|
|
105
|
+
// own useInput calls). Buffer everything between them so embedded newlines
|
|
106
|
+
// are never treated as Enter, then insert the whole block at once.
|
|
107
|
+
const START = '[200~';
|
|
108
|
+
const END = '[201~';
|
|
109
|
+
if (pasteRef.current.active || input.includes(START)) {
|
|
110
|
+
pasteRef.current.active = true;
|
|
111
|
+
pasteRef.current.buf += input;
|
|
112
|
+
const e = pasteRef.current.buf.indexOf(END);
|
|
113
|
+
if (e === -1) return; // still receiving the paste
|
|
114
|
+
const s = pasteRef.current.buf.indexOf(START);
|
|
115
|
+
const raw = pasteRef.current.buf.slice(s + START.length, e);
|
|
116
|
+
pasteRef.current = { active: false, buf: '' };
|
|
117
|
+
insertPaste(raw);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
82
121
|
// ── Submit / complete ──
|
|
83
122
|
// Some terminals/PTYs deliver Enter as input="\r" without key.return, so
|
|
84
123
|
// treat a lone CR/LF as Enter too (a paste is a longer chunk, never "\r").
|
|
@@ -155,20 +194,23 @@ export function Composer({ onSubmit, busy, width = 80 }) {
|
|
|
155
194
|
|
|
156
195
|
// ── Printable input & paste ──
|
|
157
196
|
if (input && !key.ctrl && !key.meta) {
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
text =
|
|
197
|
+
// A multi-char chunk or one containing newlines/tabs is a non-bracketed
|
|
198
|
+
// paste (terminal without bracketed-paste support): keep its newlines.
|
|
199
|
+
if (input.length > 1 || /[\r\n\t]/.test(input)) { insertPaste(input); return; }
|
|
200
|
+
const text = input.replace(/[\x00-\x1f\x7f]/g, '');
|
|
162
201
|
if (!text) return;
|
|
163
|
-
snapshot(
|
|
202
|
+
snapshot('type');
|
|
164
203
|
setValue((v) => v.slice(0, cursor) + text + v.slice(cursor));
|
|
165
204
|
setCursor((c) => c + text.length);
|
|
166
205
|
}
|
|
167
206
|
});
|
|
168
207
|
|
|
169
208
|
const before = value.slice(0, cursor);
|
|
170
|
-
const
|
|
171
|
-
|
|
209
|
+
const rawAt = value[cursor];
|
|
210
|
+
// When the cursor sits on a newline, show an inverse space and keep the line
|
|
211
|
+
// break in `after` so the buffer still renders across multiple lines.
|
|
212
|
+
const at = (rawAt === undefined || rawAt === '\n') ? ' ' : rawAt;
|
|
213
|
+
const after = rawAt === '\n' ? '\n' + value.slice(cursor + 1) : value.slice(cursor + 1);
|
|
172
214
|
const empty = value.length === 0;
|
|
173
215
|
const sugTok = menuOpen && matches[sel] ? (slashMode ? matches[sel].cmd : matches[sel].tag) : '';
|
|
174
216
|
const ghost = sugTok && sugTok.startsWith(token) && cursor >= value.length ? sugTok.slice(token.length) : '';
|
package/src/tui/run.js
CHANGED
|
@@ -1,22 +1,15 @@
|
|
|
1
|
-
// Ink render entrypoint. Runs the TUI
|
|
2
|
-
//
|
|
3
|
-
// at the top
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// Ink render entrypoint. Runs the TUI INLINE in the normal terminal buffer
|
|
2
|
+
// (like GitHub Copilot CLI) — NOT the alternate screen — so the banner prints
|
|
3
|
+
// once at the top and scrolls away, and the terminal's native scrollback keeps
|
|
4
|
+
// the full conversation history. Completed transcript items are flushed to the
|
|
5
|
+
// scrollback by Ink's <Static>; only the live area + input re-render in place.
|
|
6
6
|
import { html } from './dom.js';
|
|
7
7
|
import { render } from 'ink';
|
|
8
8
|
import { App } from './app.js';
|
|
9
9
|
|
|
10
|
-
const ALT_ENTER = '\x1b[?1049h\x1b[2J\x1b[H';
|
|
11
|
-
const ALT_LEAVE = '\x1b[?1049l';
|
|
12
|
-
|
|
13
10
|
export async function runTui(cfg, opts = {}) {
|
|
14
11
|
const { display = {}, onExternal, initialQuestion } = opts;
|
|
15
12
|
let externalAction;
|
|
16
|
-
let transcript = null;
|
|
17
|
-
|
|
18
|
-
const isTTY = process.stdout.isTTY;
|
|
19
|
-
if (isTTY) process.stdout.write(ALT_ENTER);
|
|
20
13
|
|
|
21
14
|
const instance = render(
|
|
22
15
|
html`<${App}
|
|
@@ -24,21 +17,11 @@ export async function runTui(cfg, opts = {}) {
|
|
|
24
17
|
display=${display}
|
|
25
18
|
initialQuestion=${initialQuestion}
|
|
26
19
|
onExternal=${(a) => { externalAction = a; onExternal?.(a); }}
|
|
27
|
-
onExit=${(t) => { transcript = t; }}
|
|
28
20
|
/>`,
|
|
29
21
|
{ exitOnCtrlC: false },
|
|
30
22
|
);
|
|
31
23
|
|
|
32
|
-
|
|
33
|
-
await instance.waitUntilExit();
|
|
34
|
-
} finally {
|
|
35
|
-
if (isTTY) process.stdout.write(ALT_LEAVE);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Reprint the conversation into the normal buffer so it survives exit.
|
|
39
|
-
if (transcript && transcript.length) {
|
|
40
|
-
process.stdout.write('\n' + transcript.join('\n') + '\n');
|
|
41
|
-
}
|
|
24
|
+
await instance.waitUntilExit();
|
|
42
25
|
|
|
43
26
|
return externalAction;
|
|
44
27
|
}
|