@xingjisoft/neutrino 0.1.1 → 0.1.3
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 +31 -4
- package/dist/app.js +205 -2
- package/dist/clipboard.js +113 -0
- package/dist/colors.js +3 -0
- package/dist/config.js +19 -0
- package/dist/markdown.js +1 -1
- package/dist/screens/agentConversation.js +29 -2
- package/dist/screens/chat.js +47 -2
- package/dist/screens/start.js +69 -5
- package/dist/selection.js +75 -0
- package/dist/state.js +2 -0
- package/dist/terminal/buffer.js +63 -8
- package/dist/terminal/input.js +94 -16
- package/dist/terminal/renderer.js +9 -2
- package/dist/terminal/screen.js +19 -0
- package/dist/ui.js +129 -44
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -6,6 +6,10 @@ A CLI coding agent that lives in your terminal. Neutrino gives you an interactiv
|
|
|
6
6
|
|
|
7
7
|
- **Interactive terminal UI** — logo screen, chat, model select, history, and settings screens.
|
|
8
8
|
- **Full mouse support** — hover to highlight, click to select/open/position the cursor, wheel to scroll, right-click to go back (like OpenCode).
|
|
9
|
+
- **Select & copy** — drag the mouse over any text (agent output, the input box, menus, …) to select it; releasing the button copies the selection to the clipboard (via OSC 52, with a native fallback).
|
|
10
|
+
- **Paste-friendly input** — like OpenCode, a pasted block has its newlines turned into spaces so it stays one line, and only a manually typed `Enter` submits the message.
|
|
11
|
+
- **Faithful log rendering** — text in the chat log keeps its indentation, blank lines and column alignment; when a long tool-output line wraps, continuation lines stay indented instead of snapping back to the left margin.
|
|
12
|
+
- **Scrollable context log** — a vertical scrollbar is drawn on the right edge of the chat (and the agent-conversation viewer) whenever the conversation is taller than the view. Scroll with the wheel, `PgUp`/`PgDn` or `↑`/`↓`, or grab the scrollbar with the mouse and drag it like in a GUI.
|
|
9
13
|
- **LLM providers** — ships with DeepSeek, OpenAI, and Anthropic presets; works with any OpenAI-compatible endpoint.
|
|
10
14
|
- **Tool calling** — the agent can run shell commands, read files, write files, and list directories in your working directory.
|
|
11
15
|
- **Streaming responses** — token-by-token output with pause/resume (`ESC ESC`).
|
|
@@ -47,14 +51,16 @@ Type a message on the start screen and press `Enter` to begin a new chat.
|
|
|
47
51
|
|
|
48
52
|
| Keys | Action |
|
|
49
53
|
| --- | --- |
|
|
50
|
-
| `Enter` | Send message / confirm |
|
|
54
|
+
| `Enter` | Send message / confirm (only a manually typed Enter sends) |
|
|
51
55
|
| `←`/`→`, `↑`/`↓` | Move focus / navigate |
|
|
52
56
|
| `Ctrl+E` | Open model config |
|
|
53
57
|
| `Ctrl+B` | Back to menu (from chat) |
|
|
54
58
|
| `Ctrl+L` | Clear conversation (from chat) |
|
|
55
59
|
| `Esc Esc` | Pause / resume streaming |
|
|
60
|
+
| `Esc` | Clear an active selection |
|
|
56
61
|
| `PgUp` / `PgDn` | Scroll chat history |
|
|
57
|
-
| `Ctrl+C`
|
|
62
|
+
| `Ctrl+C` | Copy the selection, or quit if nothing is selected |
|
|
63
|
+
| `Ctrl+Q` | Quit |
|
|
58
64
|
|
|
59
65
|
### Mouse
|
|
60
66
|
|
|
@@ -63,11 +69,15 @@ Type a message on the start screen and press `Enter` to begin a new chat.
|
|
|
63
69
|
| Hover over an item | Highlight / select it in lists and menus |
|
|
64
70
|
| Click an item | Select and confirm (open a screen, load a session, apply a setting…) |
|
|
65
71
|
| Click the input box | Focus it and place the cursor at the clicked position |
|
|
72
|
+
| Click and drag | Select text under the rectangle; releasing copies it to the clipboard |
|
|
66
73
|
| Click the header (left of `ⓘ`) | Open the model selector |
|
|
67
74
|
| Click `ⓘ` (chat header) | Toggle token usage stats |
|
|
75
|
+
| Drag the scrollbar | Grab the thumb on the right edge of the chat/log and drag it to scroll |
|
|
68
76
|
| Scroll wheel | Scroll the chat/log, or move through lists |
|
|
69
77
|
| Right-click | Go back (like `Esc`) |
|
|
70
78
|
|
|
79
|
+
The selection highlight is drawn over whatever is on screen, so it works uniformly on the chat log, the input box and every menu. Selected text is copied with trailing whitespace trimmed per line (shell-style), so it can be pasted straight back into a terminal.
|
|
80
|
+
|
|
71
81
|
## Configuration
|
|
72
82
|
|
|
73
83
|
Settings are stored under `~/.neutrino/`:
|
|
@@ -101,10 +111,27 @@ The agent can call the following tools during a conversation:
|
|
|
101
111
|
|
|
102
112
|
```sh
|
|
103
113
|
npm run build # compile TypeScript to dist/
|
|
104
|
-
|
|
105
|
-
node scripts/test-cursor.mjs # verify click-to-position-cursor math
|
|
114
|
+
npm test # build, then run every test below
|
|
106
115
|
```
|
|
107
116
|
|
|
117
|
+
Individual tests:
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
node scripts/test-paste.mjs # verify pasted newlines are flattened to spaces
|
|
121
|
+
node scripts/test-mouse.mjs # verify mouse escape-sequence parsing
|
|
122
|
+
node scripts/test-cursor.mjs # verify click-to-position-cursor math
|
|
123
|
+
node scripts/test-selection.mjs # verify selection highlighting & text extraction
|
|
124
|
+
node scripts/test-scrollbar.mjs # verify the conversation scrollbar size/position and drag maths
|
|
125
|
+
node scripts/test-markdown.mjs # verify markdown/table rendering (incl. ragged rows)
|
|
126
|
+
node scripts/test-wrap.mjs # verify indentation & spacing are preserved when wrapping
|
|
127
|
+
node scripts/test-render.mjs # fuzz the diff renderer + render the chat screen,
|
|
128
|
+
# asserting no stale/residual cells are left behind
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Rendering model
|
|
132
|
+
|
|
133
|
+
The screen is drawn into a `Buffer` of cells which is diffed against the previous frame, so only changed rows are emitted. Rows are erased to the end of the line before being repainted, and the buffer keeps double-width glyphs consistent (writing over one half of a wide character blanks the other half). Together these guarantee that a repainted row can never leave a stale cell behind.
|
|
134
|
+
|
|
108
135
|
## License
|
|
109
136
|
|
|
110
137
|
Apache-2.0. See [LICENSE](./LICENSE).
|
package/dist/app.js
CHANGED
|
@@ -4,12 +4,18 @@ import { InputReader } from "./terminal/input.js";
|
|
|
4
4
|
import { Renderer } from "./terminal/renderer.js";
|
|
5
5
|
import { defaultState } from "./state.js";
|
|
6
6
|
import { loadConfig, saveConfig } from "./config.js";
|
|
7
|
+
import { C } from "./colors.js";
|
|
8
|
+
import { applySelection, selectionText } from "./selection.js";
|
|
9
|
+
import { offsetFromThumbTop, scrollbarGeometry } from "./ui.js";
|
|
10
|
+
import { copyToClipboard } from "./clipboard.js";
|
|
7
11
|
import { ChatScreen } from "./screens/chat.js";
|
|
8
12
|
import { HistoryScreen } from "./screens/history.js";
|
|
9
13
|
import { AgentSelectScreen } from "./screens/agentSelect.js";
|
|
10
14
|
import { ModelSelectScreen } from "./screens/modelSelect.js";
|
|
11
15
|
import { SettingsScreen } from "./screens/settings.js";
|
|
12
16
|
import { StartScreen } from "./screens/start.js";
|
|
17
|
+
const CURSOR_BLINK_MS = 530;
|
|
18
|
+
const TOAST_MS = 1600;
|
|
13
19
|
export class App {
|
|
14
20
|
state = defaultState();
|
|
15
21
|
width;
|
|
@@ -20,6 +26,21 @@ export class App {
|
|
|
20
26
|
running = false;
|
|
21
27
|
sizeTimer = null;
|
|
22
28
|
blinkTimer = null;
|
|
29
|
+
cursorTimer = null;
|
|
30
|
+
toastTimer = null;
|
|
31
|
+
toast = "";
|
|
32
|
+
// Most recently rendered frame. Kept so a selection can be turned back into
|
|
33
|
+
// text without having to re-run the screen's render.
|
|
34
|
+
lastBuffer = null;
|
|
35
|
+
// Mouse selection bookkeeping. `pressed` is true between a left-button press
|
|
36
|
+
// and its release; `pressStart` is where the press landed, used as the anchor
|
|
37
|
+
// once the pointer starts moving (i.e. once it becomes a drag). A plain click
|
|
38
|
+
// never moves, so it is forwarded to the screen and leaves no selection.
|
|
39
|
+
pressed = false;
|
|
40
|
+
pressStart = null;
|
|
41
|
+
// Active scrollbar drag: how many rows below the thumb's top the press
|
|
42
|
+
// landed, so the thumb stays under the pointer as it moves.
|
|
43
|
+
barDrag = null;
|
|
23
44
|
resizeHandler = () => this.onResize();
|
|
24
45
|
constructor() {
|
|
25
46
|
this.width = process.stdout.columns || 80;
|
|
@@ -72,11 +93,15 @@ export class App {
|
|
|
72
93
|
pushScreen(screen) {
|
|
73
94
|
screen.app = this;
|
|
74
95
|
this.screens.push(screen);
|
|
96
|
+
this.resetMouse();
|
|
97
|
+
this.state.selection = null;
|
|
75
98
|
screen.onActivate();
|
|
76
99
|
this.render();
|
|
77
100
|
}
|
|
78
101
|
popScreen() {
|
|
79
102
|
const screen = this.screens.pop();
|
|
103
|
+
this.resetMouse();
|
|
104
|
+
this.state.selection = null;
|
|
80
105
|
screen?.onDeactivate();
|
|
81
106
|
this.render();
|
|
82
107
|
}
|
|
@@ -96,17 +121,78 @@ export class App {
|
|
|
96
121
|
this.blinkTimer = null;
|
|
97
122
|
this.state.blinkOn = false;
|
|
98
123
|
}
|
|
124
|
+
// The text cursor blinks whenever the top screen shows one. The timer is
|
|
125
|
+
// only kept alive while needed so idle screens don't repaint needlessly.
|
|
126
|
+
this.updateCursorBlink();
|
|
99
127
|
const buf = new Buffer(this.width, this.height);
|
|
100
128
|
this.top?.render(buf);
|
|
129
|
+
this.lastBuffer = buf;
|
|
130
|
+
// The selection overlay is painted over the finished frame, so it works on
|
|
131
|
+
// every screen (chat log, input box, menus, …) without each having to know
|
|
132
|
+
// about selection at all.
|
|
133
|
+
if (this.state.selection)
|
|
134
|
+
applySelection(buf, this.state.selection);
|
|
135
|
+
this.drawToast(buf);
|
|
101
136
|
this.renderer.render(buf);
|
|
102
137
|
}
|
|
138
|
+
drawToast(buf) {
|
|
139
|
+
if (!this.toast)
|
|
140
|
+
return;
|
|
141
|
+
const y = buf.height - 1;
|
|
142
|
+
buf.fillRect(0, y, buf.width, 1, { bg: C.black });
|
|
143
|
+
buf.centerText(y, ` ${this.toast} `, { fg: C.black, bg: C.accent, bold: true });
|
|
144
|
+
}
|
|
145
|
+
updateCursorBlink() {
|
|
146
|
+
const want = this.top?.showsCursor() ?? false;
|
|
147
|
+
if (want && !this.cursorTimer) {
|
|
148
|
+
this.cursorTimer = setInterval(() => {
|
|
149
|
+
this.state.cursorOn = !this.state.cursorOn;
|
|
150
|
+
this.render();
|
|
151
|
+
}, CURSOR_BLINK_MS);
|
|
152
|
+
}
|
|
153
|
+
else if (!want && this.cursorTimer) {
|
|
154
|
+
clearInterval(this.cursorTimer);
|
|
155
|
+
this.cursorTimer = null;
|
|
156
|
+
this.state.cursorOn = true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** Reset the blink phase so the cursor is solid right after an interaction. */
|
|
160
|
+
wakeCursor() {
|
|
161
|
+
this.state.cursorOn = true;
|
|
162
|
+
}
|
|
103
163
|
handleEvent(ev) {
|
|
164
|
+
if (ev.type === "paste") {
|
|
165
|
+
// Pasted text arrives with its newlines already flattened to spaces, so
|
|
166
|
+
// it is inserted into the input instead of being submitted.
|
|
167
|
+
this.resetMouse();
|
|
168
|
+
this.wakeCursor();
|
|
169
|
+
this.top?.onPaste(ev.text);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
104
172
|
if (ev.type === "key") {
|
|
105
173
|
const k = ev.key;
|
|
106
|
-
|
|
174
|
+
// Any keystroke ends a pending mouse gesture (in case a release was lost).
|
|
175
|
+
this.resetMouse();
|
|
176
|
+
if (k.ctrl && k.name === "q") {
|
|
107
177
|
this.quit();
|
|
108
178
|
return;
|
|
109
179
|
}
|
|
180
|
+
if (k.ctrl && k.name === "c") {
|
|
181
|
+
// Ctrl+C copies the selection when there is one, otherwise it quits —
|
|
182
|
+
// the usual terminal convention.
|
|
183
|
+
if (this.state.selection) {
|
|
184
|
+
this.copySelection();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
this.quit();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (k.name === "escape" && this.state.selection) {
|
|
191
|
+
this.state.selection = null;
|
|
192
|
+
this.render();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
this.wakeCursor();
|
|
110
196
|
if (k.ctrl && (k.name === "m" || k.name === "e")) {
|
|
111
197
|
this.openModelConfig();
|
|
112
198
|
return;
|
|
@@ -114,9 +200,118 @@ export class App {
|
|
|
114
200
|
this.top?.onKey(k);
|
|
115
201
|
}
|
|
116
202
|
else {
|
|
117
|
-
this.
|
|
203
|
+
this.handleMouse(ev.event);
|
|
118
204
|
}
|
|
119
205
|
}
|
|
206
|
+
handleMouse(ev) {
|
|
207
|
+
if (ev.type === "click") {
|
|
208
|
+
this.wakeCursor();
|
|
209
|
+
// A left-press on the scrollbar starts a thumb drag rather than a text
|
|
210
|
+
// selection or a click on the screen behind it.
|
|
211
|
+
if (ev.button === 0) {
|
|
212
|
+
const bar = this.top?.scrollbar();
|
|
213
|
+
if (bar && ev.x >= bar.x && ev.y >= bar.top && ev.y <= bar.bottom) {
|
|
214
|
+
const g = scrollbarGeometry(bar.top, bar.bottom, bar.total, bar.viewport, bar.offset);
|
|
215
|
+
let grab = ev.y - bar.top - g.thumbTop;
|
|
216
|
+
if (grab < 0 || grab >= g.thumbH) {
|
|
217
|
+
// Pressed the groove: centre the thumb under the pointer.
|
|
218
|
+
grab = Math.floor(g.thumbH / 2);
|
|
219
|
+
const thumbTop = Math.max(0, Math.min(ev.y - bar.top - grab, g.maxThumbTop));
|
|
220
|
+
bar.scrollTo(offsetFromThumbTop(g, thumbTop));
|
|
221
|
+
}
|
|
222
|
+
this.barDrag = { grab };
|
|
223
|
+
this.pressed = false;
|
|
224
|
+
this.pressStart = null;
|
|
225
|
+
if (this.state.selection)
|
|
226
|
+
this.state.selection = null;
|
|
227
|
+
this.render();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
this.pressed = true;
|
|
232
|
+
this.pressStart = { x: ev.x, y: ev.y };
|
|
233
|
+
if (this.state.selection) {
|
|
234
|
+
this.state.selection = null;
|
|
235
|
+
this.render();
|
|
236
|
+
}
|
|
237
|
+
// Forward immediately so plain clicks (focus, tabs, header, …) keep
|
|
238
|
+
// working even in terminals that don't report button releases.
|
|
239
|
+
this.top?.onMouse(ev);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (ev.type === "move") {
|
|
243
|
+
if (this.barDrag) {
|
|
244
|
+
const bar = this.top?.scrollbar();
|
|
245
|
+
if (bar) {
|
|
246
|
+
const g = scrollbarGeometry(bar.top, bar.bottom, bar.total, bar.viewport, bar.offset);
|
|
247
|
+
const thumbTop = Math.max(0, Math.min(ev.y - bar.top - this.barDrag.grab, g.maxThumbTop));
|
|
248
|
+
bar.scrollTo(offsetFromThumbTop(g, thumbTop));
|
|
249
|
+
}
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (this.pressed && this.pressStart) {
|
|
253
|
+
const s = this.pressStart;
|
|
254
|
+
if (ev.x !== s.x || ev.y !== s.y) {
|
|
255
|
+
// The pointer left the press cell, so this is a drag: grow/shrink the
|
|
256
|
+
// selection instead of forwarding a hover to the screen.
|
|
257
|
+
this.state.selection = { anchor: s, cursor: { x: ev.x, y: ev.y } };
|
|
258
|
+
this.render();
|
|
259
|
+
}
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
this.top?.onMouse(ev);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (ev.type === "release") {
|
|
266
|
+
const sel = this.state.selection;
|
|
267
|
+
this.pressed = false;
|
|
268
|
+
this.pressStart = null;
|
|
269
|
+
this.barDrag = null;
|
|
270
|
+
if (sel) {
|
|
271
|
+
if (this.lastBuffer) {
|
|
272
|
+
const text = selectionText(this.lastBuffer, sel);
|
|
273
|
+
if (text.trim().length > 0) {
|
|
274
|
+
copyToClipboard(text);
|
|
275
|
+
this.showToast(`Copied ${text.length} char${text.length === 1 ? "" : "s"}`);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
this.render();
|
|
280
|
+
}
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (ev.type === "wheel") {
|
|
284
|
+
if (this.state.selection)
|
|
285
|
+
this.state.selection = null;
|
|
286
|
+
this.top?.onMouse(ev);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
resetMouse() {
|
|
290
|
+
this.pressed = false;
|
|
291
|
+
this.pressStart = null;
|
|
292
|
+
this.barDrag = null;
|
|
293
|
+
}
|
|
294
|
+
copySelection() {
|
|
295
|
+
const sel = this.state.selection;
|
|
296
|
+
if (!sel || !this.lastBuffer)
|
|
297
|
+
return;
|
|
298
|
+
const text = selectionText(this.lastBuffer, sel);
|
|
299
|
+
if (text.trim().length === 0)
|
|
300
|
+
return;
|
|
301
|
+
copyToClipboard(text);
|
|
302
|
+
this.showToast(`Copied ${text.length} char${text.length === 1 ? "" : "s"}`);
|
|
303
|
+
}
|
|
304
|
+
showToast(text) {
|
|
305
|
+
this.toast = text;
|
|
306
|
+
if (this.toastTimer)
|
|
307
|
+
clearTimeout(this.toastTimer);
|
|
308
|
+
this.toastTimer = setTimeout(() => {
|
|
309
|
+
this.toastTimer = null;
|
|
310
|
+
this.toast = "";
|
|
311
|
+
this.render();
|
|
312
|
+
}, TOAST_MS);
|
|
313
|
+
this.render();
|
|
314
|
+
}
|
|
120
315
|
setModelConfig(providerName, model, intensity) {
|
|
121
316
|
this.state.selectedProvider = providerName;
|
|
122
317
|
this.state.selectedModelName = model.name;
|
|
@@ -182,6 +377,14 @@ export class App {
|
|
|
182
377
|
clearInterval(this.blinkTimer);
|
|
183
378
|
this.blinkTimer = null;
|
|
184
379
|
}
|
|
380
|
+
if (this.cursorTimer) {
|
|
381
|
+
clearInterval(this.cursorTimer);
|
|
382
|
+
this.cursorTimer = null;
|
|
383
|
+
}
|
|
384
|
+
if (this.toastTimer) {
|
|
385
|
+
clearTimeout(this.toastTimer);
|
|
386
|
+
this.toastTimer = null;
|
|
387
|
+
}
|
|
185
388
|
this.input.stop();
|
|
186
389
|
this.renderer.dispose();
|
|
187
390
|
process.exit(0);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import process from "node:process";
|
|
7
|
+
/**
|
|
8
|
+
* Copy `text` to the system clipboard.
|
|
9
|
+
*
|
|
10
|
+
* Two independent, best-effort strategies are used so the copy works across as
|
|
11
|
+
* many terminals as possible:
|
|
12
|
+
*
|
|
13
|
+
* 1. OSC 52 — a terminal escape sequence that asks the host terminal to put
|
|
14
|
+
* the (base64-encoded) text on the clipboard. This is the only strategy
|
|
15
|
+
* that also works over SSH, and is supported by Windows Terminal, iTerm2,
|
|
16
|
+
* Kitty, WezTerm, Alacritty, …
|
|
17
|
+
* 2. A native helper process (`clip` / `pbcopy` / `wl-copy` / `xclip` / …),
|
|
18
|
+
* as a fallback for terminals that ignore OSC 52.
|
|
19
|
+
*
|
|
20
|
+
* Both are best-effort: any failure is silently ignored.
|
|
21
|
+
*/
|
|
22
|
+
export function copyToClipboard(text) {
|
|
23
|
+
if (!text)
|
|
24
|
+
return;
|
|
25
|
+
copyViaOsc52(text);
|
|
26
|
+
copyViaNative(text);
|
|
27
|
+
}
|
|
28
|
+
function copyViaOsc52(text) {
|
|
29
|
+
try {
|
|
30
|
+
const payload = Buffer.from(text, "utf8").toString("base64");
|
|
31
|
+
// OSC 52 ; <selection> ; <base64> BEL
|
|
32
|
+
process.stdout.write(`\x1b]52;c;${payload}\x07`);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
/* ignore */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function copyViaNative(text) {
|
|
39
|
+
if (process.platform === "win32") {
|
|
40
|
+
copyWindows(text);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const candidates = process.platform === "darwin"
|
|
44
|
+
? [["pbcopy", []]]
|
|
45
|
+
: process.env.WAYLAND_DISPLAY
|
|
46
|
+
? [
|
|
47
|
+
["wl-copy", []],
|
|
48
|
+
["xclip", ["-selection", "clipboard"]],
|
|
49
|
+
["xsel", ["--clipboard", "--input"]],
|
|
50
|
+
]
|
|
51
|
+
: [
|
|
52
|
+
["xclip", ["-selection", "clipboard"]],
|
|
53
|
+
["xsel", ["--clipboard", "--input"]],
|
|
54
|
+
["wl-copy", []],
|
|
55
|
+
];
|
|
56
|
+
pipeToFirstAvailable(candidates, text);
|
|
57
|
+
}
|
|
58
|
+
/** Spawn the first available helper and pipe the text into it. */
|
|
59
|
+
function pipeToFirstAvailable(candidates, text) {
|
|
60
|
+
let index = 0;
|
|
61
|
+
const tryNext = () => {
|
|
62
|
+
if (index >= candidates.length)
|
|
63
|
+
return;
|
|
64
|
+
const [cmd, args] = candidates[index++];
|
|
65
|
+
let child;
|
|
66
|
+
try {
|
|
67
|
+
child = spawn(cmd, args, { stdio: ["pipe", "ignore", "ignore"] });
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
tryNext();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
child.once("error", () => tryNext());
|
|
74
|
+
child.once("spawn", () => {
|
|
75
|
+
child.stdin?.end(text);
|
|
76
|
+
});
|
|
77
|
+
child.stdin?.on("error", () => {
|
|
78
|
+
/* the helper closed early — ignore */
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
tryNext();
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Windows: `clip.exe` decodes its input using the console code page, which
|
|
85
|
+
* mangles non-ASCII text. Instead we drop the text in a temp file and let
|
|
86
|
+
* PowerShell read it back as UTF-8 via the Unicode-aware Set-Clipboard cmdlet.
|
|
87
|
+
*/
|
|
88
|
+
function copyWindows(text) {
|
|
89
|
+
let tmp = null;
|
|
90
|
+
try {
|
|
91
|
+
tmp = path.join(os.tmpdir(), `neutrino-clip-${process.pid}-${Date.now()}.txt`);
|
|
92
|
+
fs.writeFileSync(tmp, text, "utf8");
|
|
93
|
+
const script = `Set-Clipboard -Value ([IO.File]::ReadAllText(${psQuote(tmp)}, [Text.Encoding]::UTF8)); ` +
|
|
94
|
+
`Remove-Item -LiteralPath ${psQuote(tmp)} -ErrorAction SilentlyContinue`;
|
|
95
|
+
const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { stdio: "ignore", windowsHide: true });
|
|
96
|
+
child.once("error", () => {
|
|
97
|
+
if (tmp) {
|
|
98
|
+
try {
|
|
99
|
+
fs.rmSync(tmp, { force: true });
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* ignore */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
/* ignore */
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function psQuote(value) {
|
|
112
|
+
return "'" + value.replace(/'/g, "''") + "'";
|
|
113
|
+
}
|
package/dist/colors.js
CHANGED
|
@@ -11,5 +11,8 @@ export const C = {
|
|
|
11
11
|
green: [115, 218, 202],
|
|
12
12
|
red: [247, 118, 142],
|
|
13
13
|
codeBg: [27, 35, 49], // dark blue-grey used behind code blocks
|
|
14
|
+
selection: [56, 92, 138], // muted blue used to highlight selected text
|
|
15
|
+
scrollTrack: [45, 58, 62], // dim groove behind the scrollbar thumb
|
|
16
|
+
scrollThumb: [130, 142, 190], // draggable scrollbar thumb
|
|
14
17
|
};
|
|
15
18
|
export const EMPTY_STYLE = {};
|
package/dist/config.js
CHANGED
|
@@ -194,3 +194,22 @@ export function loadPrompt() {
|
|
|
194
194
|
return (readAsset(path.join(PKG_ROOT, "assets", "prompt.txt"), path.join(PKG_ROOT, "prompt.txt")) ??
|
|
195
195
|
"You are Neutrino, a helpful AI coding assistant.");
|
|
196
196
|
}
|
|
197
|
+
let cachedVersion = null;
|
|
198
|
+
/**
|
|
199
|
+
* Read the CLI version straight from the package's `package.json` so the
|
|
200
|
+
* displayed version can never drift out of sync with the published package.
|
|
201
|
+
* The result is cached after the first successful read.
|
|
202
|
+
*/
|
|
203
|
+
export function loadVersion() {
|
|
204
|
+
if (cachedVersion !== null)
|
|
205
|
+
return cachedVersion;
|
|
206
|
+
try {
|
|
207
|
+
const raw = fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf-8");
|
|
208
|
+
const pkg = JSON.parse(raw);
|
|
209
|
+
cachedVersion = typeof pkg.version === "string" ? pkg.version : "";
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
cachedVersion = "";
|
|
213
|
+
}
|
|
214
|
+
return cachedVersion;
|
|
215
|
+
}
|
package/dist/markdown.js
CHANGED
|
@@ -566,7 +566,7 @@ function renderTable(t, width) {
|
|
|
566
566
|
for (let c = 0; c < nCols; c++) {
|
|
567
567
|
if (c === 0)
|
|
568
568
|
spans.push({ text: "│ ", style: borderStyle });
|
|
569
|
-
const cellLine = wrapped[c][li]?.spans ?? [];
|
|
569
|
+
const cellLine = wrapped[c]?.[li]?.spans ?? [];
|
|
570
570
|
for (const s of cellLine) {
|
|
571
571
|
spans.push(isHeader ? { ...s, style: { ...s.style, bold: true } } : s);
|
|
572
572
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Screen } from "../terminal/screen.js";
|
|
2
2
|
import { C } from "../colors.js";
|
|
3
3
|
import { drawHeader } from "../header.js";
|
|
4
|
-
import { drawHint } from "../ui.js";
|
|
4
|
+
import { drawHint, drawScrollbar } from "../ui.js";
|
|
5
5
|
import { drawSpans, renderMarkdownText } from "../markdown.js";
|
|
6
6
|
import { ellipsize } from "../width.js";
|
|
7
7
|
export class AgentConversationScreen extends Screen {
|
|
@@ -9,6 +9,7 @@ export class AgentConversationScreen extends Screen {
|
|
|
9
9
|
messages;
|
|
10
10
|
scrollTop = 0;
|
|
11
11
|
followBottom = true;
|
|
12
|
+
scrollbarGeom = null;
|
|
12
13
|
constructor(title, messages) {
|
|
13
14
|
super();
|
|
14
15
|
this.title = title;
|
|
@@ -52,6 +53,25 @@ export class AgentConversationScreen extends Screen {
|
|
|
52
53
|
this.app.popScreen();
|
|
53
54
|
}
|
|
54
55
|
}
|
|
56
|
+
scrollbar() {
|
|
57
|
+
const g = this.scrollbarGeom;
|
|
58
|
+
if (!g || g.total <= g.viewport)
|
|
59
|
+
return null;
|
|
60
|
+
return {
|
|
61
|
+
x: g.x,
|
|
62
|
+
top: g.top,
|
|
63
|
+
bottom: g.bottom,
|
|
64
|
+
total: g.total,
|
|
65
|
+
viewport: g.viewport,
|
|
66
|
+
offset: this.scrollTop,
|
|
67
|
+
scrollTo: (offset) => {
|
|
68
|
+
const maxScroll = Math.max(0, g.total - g.viewport);
|
|
69
|
+
this.scrollTop = Math.max(0, Math.min(offset, maxScroll));
|
|
70
|
+
this.followBottom = this.scrollTop >= maxScroll;
|
|
71
|
+
this.app.render();
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
55
75
|
buildLines(width) {
|
|
56
76
|
const lines = [];
|
|
57
77
|
for (const m of this.messages) {
|
|
@@ -85,12 +105,19 @@ export class AgentConversationScreen extends Screen {
|
|
|
85
105
|
}
|
|
86
106
|
if (this.scrollTop < 0)
|
|
87
107
|
this.scrollTop = 0;
|
|
108
|
+
// Expose the bar so the app can drag it (see the scrollbar() method).
|
|
109
|
+
this.scrollbarGeom =
|
|
110
|
+
lines.length > viewH
|
|
111
|
+
? { x: w - 1, top: logTop, bottom: logBottom, total: lines.length, viewport: viewH }
|
|
112
|
+
: null;
|
|
88
113
|
for (let y = logTop; y <= logBottom; y++) {
|
|
89
114
|
const li = this.scrollTop + (y - logTop);
|
|
90
115
|
if (li >= lines.length)
|
|
91
116
|
break;
|
|
92
117
|
drawSpans(buf, 2, y, lines[li].spans, Math.max(4, w - 4));
|
|
93
118
|
}
|
|
94
|
-
|
|
119
|
+
// Scrollbar on the right edge, visible only when the log overflows.
|
|
120
|
+
drawScrollbar(buf, w - 1, logTop, logBottom, lines.length, viewH, this.scrollTop);
|
|
121
|
+
drawHint(buf, h - 1, "↑/↓ scroll · wheel or drag the bar · right-click back · Esc back");
|
|
95
122
|
}
|
|
96
123
|
}
|
package/dist/screens/chat.js
CHANGED
|
@@ -2,7 +2,7 @@ import { Screen } from "../terminal/screen.js";
|
|
|
2
2
|
import { C } from "../colors.js";
|
|
3
3
|
import { loadPrompt, saveSession, } from "../config.js";
|
|
4
4
|
import { drawHeader } from "../header.js";
|
|
5
|
-
import { computeInputWindow, cursorFromColumn, drawInputBox, wrapText } from "../ui.js";
|
|
5
|
+
import { computeInputWindow, cursorFromColumn, drawInputBox, drawScrollbar, wrapText } from "../ui.js";
|
|
6
6
|
import { drawSpans, renderMarkdownText } from "../markdown.js";
|
|
7
7
|
import { ellipsize, displayWidth } from "../width.js";
|
|
8
8
|
import { LLMClient } from "../llm/client.js";
|
|
@@ -42,6 +42,7 @@ export class ChatScreen extends Screen {
|
|
|
42
42
|
cursor = 0;
|
|
43
43
|
scrollTop = 0;
|
|
44
44
|
followBottom = true;
|
|
45
|
+
scrollbarGeom = null;
|
|
45
46
|
lastEscTime = 0;
|
|
46
47
|
initialized = false;
|
|
47
48
|
constructor(initialMessage = "") {
|
|
@@ -78,6 +79,20 @@ export class ChatScreen extends Screen {
|
|
|
78
79
|
this.app.render();
|
|
79
80
|
}
|
|
80
81
|
}
|
|
82
|
+
showsCursor() {
|
|
83
|
+
// The chat input is always focused; a cursor is drawn when it holds text.
|
|
84
|
+
return this.inputValue.length > 0;
|
|
85
|
+
}
|
|
86
|
+
onPaste(text) {
|
|
87
|
+
if (!text)
|
|
88
|
+
return;
|
|
89
|
+
if (this.showInfo)
|
|
90
|
+
this.showInfo = false;
|
|
91
|
+
this.inputValue =
|
|
92
|
+
this.inputValue.slice(0, this.cursor) + text + this.inputValue.slice(this.cursor);
|
|
93
|
+
this.cursor += text.length;
|
|
94
|
+
this.app.render();
|
|
95
|
+
}
|
|
81
96
|
onKey(key) {
|
|
82
97
|
if (this.showInfo) {
|
|
83
98
|
this.showInfo = false;
|
|
@@ -233,6 +248,26 @@ export class ChatScreen extends Screen {
|
|
|
233
248
|
this.app.render();
|
|
234
249
|
}
|
|
235
250
|
}
|
|
251
|
+
scrollbar() {
|
|
252
|
+
const g = this.scrollbarGeom;
|
|
253
|
+
if (!g || g.total <= g.viewport)
|
|
254
|
+
return null;
|
|
255
|
+
return {
|
|
256
|
+
x: g.x,
|
|
257
|
+
top: g.top,
|
|
258
|
+
bottom: g.bottom,
|
|
259
|
+
total: g.total,
|
|
260
|
+
viewport: g.viewport,
|
|
261
|
+
offset: this.scrollTop,
|
|
262
|
+
scrollTo: (offset) => {
|
|
263
|
+
const maxScroll = Math.max(0, g.total - g.viewport);
|
|
264
|
+
this.scrollTop = Math.max(0, Math.min(offset, maxScroll));
|
|
265
|
+
// Dragging to the very bottom resumes auto-following new output.
|
|
266
|
+
this.followBottom = this.scrollTop >= maxScroll;
|
|
267
|
+
this.app.render();
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
236
271
|
togglePause() {
|
|
237
272
|
if (!this.busy && !this.queued)
|
|
238
273
|
return;
|
|
@@ -459,20 +494,30 @@ export class ChatScreen extends Screen {
|
|
|
459
494
|
}
|
|
460
495
|
if (this.scrollTop < 0)
|
|
461
496
|
this.scrollTop = 0;
|
|
497
|
+
// Remember the bar's geometry so a drag on the last column can scroll the
|
|
498
|
+
// log (see the scrollbar() method).
|
|
499
|
+
this.scrollbarGeom =
|
|
500
|
+
lines.length > viewH
|
|
501
|
+
? { x: w - 1, top: logTop, bottom: logBottom, total: lines.length, viewport: viewH }
|
|
502
|
+
: null;
|
|
462
503
|
for (let y = logTop; y <= logBottom; y++) {
|
|
463
504
|
const li = this.scrollTop + (y - logTop);
|
|
464
505
|
if (li >= lines.length)
|
|
465
506
|
break;
|
|
466
507
|
drawSpans(buf, 2, y, lines[li].spans, Math.max(4, w - 4));
|
|
467
508
|
}
|
|
509
|
+
// A scrollbar on the right edge of the log, shown only when the
|
|
510
|
+
// conversation overflows the visible area.
|
|
511
|
+
drawScrollbar(buf, w - 1, logTop, logBottom, lines.length, viewH, this.scrollTop);
|
|
468
512
|
const inW = Math.max(12, w - 2);
|
|
469
513
|
drawInputBox(buf, 1, inputY, inW, this.inputValue, this.cursor, {
|
|
470
514
|
active: true,
|
|
471
515
|
dimmed: this.busy,
|
|
516
|
+
cursorOn: this.app.state.cursorOn,
|
|
472
517
|
placeholder: "Type a message...",
|
|
473
518
|
});
|
|
474
519
|
const hintY = h - 1;
|
|
475
|
-
const hints = "Enter send
|
|
520
|
+
const hints = "Enter send · ESC ESC pause · Ctrl+B menu · Ctrl+L clear · drag to copy";
|
|
476
521
|
const hintW = displayWidth(hints);
|
|
477
522
|
const hintX = Math.max(0, w - 1 - hintW);
|
|
478
523
|
const pathAvail = Math.max(0, hintX - 2);
|