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 CHANGED
@@ -59,11 +59,12 @@ $ icli
59
59
 
60
60
  ❯ is vlan 82 free on all devices?
61
61
 
62
- working
63
- Assigning 3 sub-agents…
64
- [1/3] nas#1: FREE
65
- [2/3] nas#2: FREE
66
- [3/3] olt#1: FREE
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 are inserted as one line (never auto-submitted) |
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 runs as a full-height app in the terminal's alternate screen: the banner
205
- stays pinned at the top, the transcript fills the middle, and the input box is
206
- pinned to the bottom the same layout as GitHub Copilot CLI. On exit, the session
207
- transcript is reprinted to your normal scrollback.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ispbills-icli",
3
- "version": "8.3.0",
3
+ "version": "8.4.0",
4
4
  "description": "iCli — IspBills AI network engineer in your terminal",
5
5
  "keywords": [
6
6
  "ispbills",
package/src/tui/app.js CHANGED
@@ -1,9 +1,10 @@
1
- // Root Ink application. Renders a full-height, alt-screen chat UI (Copilot-CLI
2
- // style): banner pinned to the top, transcript filling the middle, input
3
- // pinned to the bottom. Manages conversation state and streams from the
4
- // IspBills backend.
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 arrive as one chunk
104
- // (the Composer flattens them) instead of submitting on the first newline.
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') { setItems([]); return; }
231
+ if (q === '/clear') { clearAll(); return; }
249
232
  if (q === '/new') {
250
233
  convo.current = [];
251
- setItems([]);
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) { setItems([]); return; }
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
- // Tail-slice the transcript to what fits the middle region so nothing is
468
- // clipped mid-line on small terminals.
469
- const compact = rows < 30 || cols < 60;
470
- const budget = Math.max(
471
- 3,
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} height=${rows}>
487
- <${Box} flexShrink=${0} flexDirection="column" alignItems="center">
488
- <${Banner} cfg=${cfg} model=${model} width=${cols} compact=${compact} />
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
- : null}
465
+ : renderItem(it)}
503
466
  <//>
504
467
 
505
- <${Box} flexShrink=${0} flexDirection="column">
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} />`}
@@ -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
- /** Live server activity tool calls and sub-agents happening backend-side. */
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
- const { icon, color } = classify(line);
125
- const clean = String(line).replace(/^[\s◈◆●○◎⇢→>*✓✗×!⚠·-]+/u, '').trimStart() || String(line);
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
- <${Text} color=${color}>${icon}<//>
129
- <${Text} color=${C.gray}> ${clean}<//>
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
  <//>`;
@@ -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
- // Strip bracketed-paste markers, then flatten newlines/tabs from a paste.
159
- let text = input.replace(/\x1b\[20[01]~/g, '');
160
- const isPaste = text.length > 1 || /[\r\n\t]/.test(text);
161
- text = text.replace(/[\r\n\t]+/g, ' ').replace(/[\x00-\x1f\x7f]/g, '');
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(isPaste ? 'paste' : 'type');
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 at = value[cursor] ?? ' ';
171
- const after = value.slice(cursor + 1);
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 as a full-height app inside the
2
- // alternate screen buffer (like GitHub Copilot CLI): the banner stays pinned
3
- // at the top, the transcript fills the middle, and the input is pinned to the
4
- // bottom of the terminal. On exit we leave the alt screen and, optionally,
5
- // reprint the transcript into the normal scrollback.
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
- try {
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
  }