@xingjisoft/neutrino 0.1.0 → 0.1.2

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
@@ -5,6 +5,8 @@ A CLI coding agent that lives in your terminal. Neutrino gives you an interactiv
5
5
  ## Features
6
6
 
7
7
  - **Interactive terminal UI** — logo screen, chat, model select, history, and settings screens.
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).
8
10
  - **LLM providers** — ships with DeepSeek, OpenAI, and Anthropic presets; works with any OpenAI-compatible endpoint.
9
11
  - **Tool calling** — the agent can run shell commands, read files, write files, and list directories in your working directory.
10
12
  - **Streaming responses** — token-by-token output with pause/resume (`ESC ESC`).
@@ -52,9 +54,25 @@ Type a message on the start screen and press `Enter` to begin a new chat.
52
54
  | `Ctrl+B` | Back to menu (from chat) |
53
55
  | `Ctrl+L` | Clear conversation (from chat) |
54
56
  | `Esc Esc` | Pause / resume streaming |
55
- | `PgUp` / `PgDn`, scroll | Scroll chat history |
57
+ | `Esc` | Clear an active selection |
58
+ | `PgUp` / `PgDn` | Scroll chat history |
59
+ | `Ctrl+C` | Copy the selection, or quit if nothing is selected |
60
+ | `Ctrl+Q` | Quit |
61
+
62
+ ### Mouse
63
+
64
+ | Action | Effect |
65
+ | --- | --- |
66
+ | Hover over an item | Highlight / select it in lists and menus |
67
+ | Click an item | Select and confirm (open a screen, load a session, apply a setting…) |
68
+ | Click the input box | Focus it and place the cursor at the clicked position |
69
+ | Click and drag | Select text under the rectangle; releasing copies it to the clipboard |
70
+ | Click the header (left of `ⓘ`) | Open the model selector |
56
71
  | Click `ⓘ` (chat header) | Toggle token usage stats |
57
- | `Ctrl+C` / `Ctrl+Q` | Quit |
72
+ | Scroll wheel | Scroll the chat/log, or move through lists |
73
+ | Right-click | Go back (like `Esc`) |
74
+
75
+ 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.
58
76
 
59
77
  ## Configuration
60
78
 
@@ -89,8 +107,23 @@ The agent can call the following tools during a conversation:
89
107
 
90
108
  ```sh
91
109
  npm run build # compile TypeScript to dist/
110
+ npm test # build, then run every test below
111
+ ```
112
+
113
+ Individual tests:
114
+
115
+ ```sh
116
+ node scripts/test-mouse.mjs # verify mouse escape-sequence parsing
117
+ node scripts/test-cursor.mjs # verify click-to-position-cursor math
118
+ node scripts/test-selection.mjs # verify selection highlighting & text extraction
119
+ node scripts/test-render.mjs # fuzz the diff renderer + render the chat screen,
120
+ # asserting no stale/residual cells are left behind
92
121
  ```
93
122
 
123
+ ### Rendering model
124
+
125
+ 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.
126
+
94
127
  ## License
95
128
 
96
129
  Apache-2.0. See [LICENSE](./LICENSE).
package/dist/app.js CHANGED
@@ -4,12 +4,17 @@ 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 { copyToClipboard } from "./clipboard.js";
7
10
  import { ChatScreen } from "./screens/chat.js";
8
11
  import { HistoryScreen } from "./screens/history.js";
9
12
  import { AgentSelectScreen } from "./screens/agentSelect.js";
10
13
  import { ModelSelectScreen } from "./screens/modelSelect.js";
11
14
  import { SettingsScreen } from "./screens/settings.js";
12
15
  import { StartScreen } from "./screens/start.js";
16
+ const CURSOR_BLINK_MS = 530;
17
+ const TOAST_MS = 1600;
13
18
  export class App {
14
19
  state = defaultState();
15
20
  width;
@@ -19,6 +24,19 @@ export class App {
19
24
  input = new InputReader();
20
25
  running = false;
21
26
  sizeTimer = null;
27
+ blinkTimer = null;
28
+ cursorTimer = null;
29
+ toastTimer = null;
30
+ toast = "";
31
+ // Most recently rendered frame. Kept so a selection can be turned back into
32
+ // text without having to re-run the screen's render.
33
+ lastBuffer = null;
34
+ // Mouse selection bookkeeping. `pressed` is true between a left-button press
35
+ // and its release; `pressStart` is where the press landed, used as the anchor
36
+ // once the pointer starts moving (i.e. once it becomes a drag). A plain click
37
+ // never moves, so it is forwarded to the screen and leaves no selection.
38
+ pressed = false;
39
+ pressStart = null;
22
40
  resizeHandler = () => this.onResize();
23
41
  constructor() {
24
42
  this.width = process.stdout.columns || 80;
@@ -71,26 +89,98 @@ export class App {
71
89
  pushScreen(screen) {
72
90
  screen.app = this;
73
91
  this.screens.push(screen);
92
+ this.resetMouse();
93
+ this.state.selection = null;
74
94
  screen.onActivate();
75
95
  this.render();
76
96
  }
77
97
  popScreen() {
78
98
  const screen = this.screens.pop();
99
+ this.resetMouse();
100
+ this.state.selection = null;
79
101
  screen?.onDeactivate();
80
102
  this.render();
81
103
  }
82
104
  render() {
105
+ // Start/stop the "thinking" blink animation. While the agent is thinking,
106
+ // a timer toggles the blink phase and re-renders so the top status bar
107
+ // flashes to draw the user's attention.
108
+ if (this.state.isThinking && !this.blinkTimer) {
109
+ this.state.blinkOn = true;
110
+ this.blinkTimer = setInterval(() => {
111
+ this.state.blinkOn = !this.state.blinkOn;
112
+ this.render();
113
+ }, 500);
114
+ }
115
+ else if (!this.state.isThinking && this.blinkTimer) {
116
+ clearInterval(this.blinkTimer);
117
+ this.blinkTimer = null;
118
+ this.state.blinkOn = false;
119
+ }
120
+ // The text cursor blinks whenever the top screen shows one. The timer is
121
+ // only kept alive while needed so idle screens don't repaint needlessly.
122
+ this.updateCursorBlink();
83
123
  const buf = new Buffer(this.width, this.height);
84
124
  this.top?.render(buf);
125
+ this.lastBuffer = buf;
126
+ // The selection overlay is painted over the finished frame, so it works on
127
+ // every screen (chat log, input box, menus, …) without each having to know
128
+ // about selection at all.
129
+ if (this.state.selection)
130
+ applySelection(buf, this.state.selection);
131
+ this.drawToast(buf);
85
132
  this.renderer.render(buf);
86
133
  }
134
+ drawToast(buf) {
135
+ if (!this.toast)
136
+ return;
137
+ const y = buf.height - 1;
138
+ buf.fillRect(0, y, buf.width, 1, { bg: C.black });
139
+ buf.centerText(y, ` ${this.toast} `, { fg: C.black, bg: C.accent, bold: true });
140
+ }
141
+ updateCursorBlink() {
142
+ const want = this.top?.showsCursor() ?? false;
143
+ if (want && !this.cursorTimer) {
144
+ this.cursorTimer = setInterval(() => {
145
+ this.state.cursorOn = !this.state.cursorOn;
146
+ this.render();
147
+ }, CURSOR_BLINK_MS);
148
+ }
149
+ else if (!want && this.cursorTimer) {
150
+ clearInterval(this.cursorTimer);
151
+ this.cursorTimer = null;
152
+ this.state.cursorOn = true;
153
+ }
154
+ }
155
+ /** Reset the blink phase so the cursor is solid right after an interaction. */
156
+ wakeCursor() {
157
+ this.state.cursorOn = true;
158
+ }
87
159
  handleEvent(ev) {
88
160
  if (ev.type === "key") {
89
161
  const k = ev.key;
90
- if (k.ctrl && (k.name === "c" || k.name === "q")) {
162
+ // Any keystroke ends a pending mouse gesture (in case a release was lost).
163
+ this.resetMouse();
164
+ if (k.ctrl && k.name === "q") {
91
165
  this.quit();
92
166
  return;
93
167
  }
168
+ if (k.ctrl && k.name === "c") {
169
+ // Ctrl+C copies the selection when there is one, otherwise it quits —
170
+ // the usual terminal convention.
171
+ if (this.state.selection) {
172
+ this.copySelection();
173
+ return;
174
+ }
175
+ this.quit();
176
+ return;
177
+ }
178
+ if (k.name === "escape" && this.state.selection) {
179
+ this.state.selection = null;
180
+ this.render();
181
+ return;
182
+ }
183
+ this.wakeCursor();
94
184
  if (k.ctrl && (k.name === "m" || k.name === "e")) {
95
185
  this.openModelConfig();
96
186
  return;
@@ -98,9 +188,85 @@ export class App {
98
188
  this.top?.onKey(k);
99
189
  }
100
190
  else {
101
- this.top?.onMouse(ev.event);
191
+ this.handleMouse(ev.event);
192
+ }
193
+ }
194
+ handleMouse(ev) {
195
+ if (ev.type === "click") {
196
+ this.wakeCursor();
197
+ this.pressed = true;
198
+ this.pressStart = { x: ev.x, y: ev.y };
199
+ if (this.state.selection) {
200
+ this.state.selection = null;
201
+ this.render();
202
+ }
203
+ // Forward immediately so plain clicks (focus, tabs, header, …) keep
204
+ // working even in terminals that don't report button releases.
205
+ this.top?.onMouse(ev);
206
+ return;
207
+ }
208
+ if (ev.type === "move") {
209
+ if (this.pressed && this.pressStart) {
210
+ const s = this.pressStart;
211
+ if (ev.x !== s.x || ev.y !== s.y) {
212
+ // The pointer left the press cell, so this is a drag: grow/shrink the
213
+ // selection instead of forwarding a hover to the screen.
214
+ this.state.selection = { anchor: s, cursor: { x: ev.x, y: ev.y } };
215
+ this.render();
216
+ }
217
+ return;
218
+ }
219
+ this.top?.onMouse(ev);
220
+ return;
221
+ }
222
+ if (ev.type === "release") {
223
+ const sel = this.state.selection;
224
+ this.pressed = false;
225
+ this.pressStart = null;
226
+ if (sel) {
227
+ if (this.lastBuffer) {
228
+ const text = selectionText(this.lastBuffer, sel);
229
+ if (text.trim().length > 0) {
230
+ copyToClipboard(text);
231
+ this.showToast(`Copied ${text.length} char${text.length === 1 ? "" : "s"}`);
232
+ return;
233
+ }
234
+ }
235
+ this.render();
236
+ }
237
+ return;
238
+ }
239
+ if (ev.type === "wheel") {
240
+ if (this.state.selection)
241
+ this.state.selection = null;
242
+ this.top?.onMouse(ev);
102
243
  }
103
244
  }
245
+ resetMouse() {
246
+ this.pressed = false;
247
+ this.pressStart = null;
248
+ }
249
+ copySelection() {
250
+ const sel = this.state.selection;
251
+ if (!sel || !this.lastBuffer)
252
+ return;
253
+ const text = selectionText(this.lastBuffer, sel);
254
+ if (text.trim().length === 0)
255
+ return;
256
+ copyToClipboard(text);
257
+ this.showToast(`Copied ${text.length} char${text.length === 1 ? "" : "s"}`);
258
+ }
259
+ showToast(text) {
260
+ this.toast = text;
261
+ if (this.toastTimer)
262
+ clearTimeout(this.toastTimer);
263
+ this.toastTimer = setTimeout(() => {
264
+ this.toastTimer = null;
265
+ this.toast = "";
266
+ this.render();
267
+ }, TOAST_MS);
268
+ this.render();
269
+ }
104
270
  setModelConfig(providerName, model, intensity) {
105
271
  this.state.selectedProvider = providerName;
106
272
  this.state.selectedModelName = model.name;
@@ -162,6 +328,18 @@ export class App {
162
328
  this.running = true;
163
329
  if (this.sizeTimer)
164
330
  clearInterval(this.sizeTimer);
331
+ if (this.blinkTimer) {
332
+ clearInterval(this.blinkTimer);
333
+ this.blinkTimer = null;
334
+ }
335
+ if (this.cursorTimer) {
336
+ clearInterval(this.cursorTimer);
337
+ this.cursorTimer = null;
338
+ }
339
+ if (this.toastTimer) {
340
+ clearTimeout(this.toastTimer);
341
+ this.toastTimer = null;
342
+ }
165
343
  this.input.stop();
166
344
  this.renderer.dispose();
167
345
  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
@@ -10,5 +10,7 @@ export const C = {
10
10
  white: [226, 232, 240],
11
11
  green: [115, 218, 202],
12
12
  red: [247, 118, 142],
13
+ codeBg: [27, 35, 49], // dark blue-grey used behind code blocks
14
+ selection: [56, 92, 138], // muted blue used to highlight selected text
13
15
  };
14
16
  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/header.js CHANGED
@@ -2,14 +2,19 @@ import { C } from "./colors.js";
2
2
  import { truncateByWidth } from "./width.js";
3
3
  export function drawHeader(buf, state) {
4
4
  const w = buf.width;
5
- buf.fillRect(0, 0, w, 1, { bg: C.highlightBg });
6
- let text;
7
5
  if (state.isThinking) {
8
- text = "thinking";
9
- }
10
- else {
11
- text = `${state.selectedProvider}/${state.selectedModelName} | ${state.selectedIntensity}`;
6
+ // Flashing indicator: the whole bar alternates between highlighted and
7
+ // dark while the agent is working, so the user always notices it.
8
+ const on = state.blinkOn;
9
+ const bg = on ? C.highlightBg : C.black;
10
+ const fg = on ? C.accent : C.dim;
11
+ const text = "⚡ thinking";
12
+ buf.fillRect(0, 0, w, 1, { bg });
13
+ buf.centerText(0, text, { fg, bg, bold: true });
14
+ return;
12
15
  }
16
+ buf.fillRect(0, 0, w, 1, { bg: C.highlightBg });
17
+ let text = `${state.selectedProvider}/${state.selectedModelName} | ${state.selectedIntensity}`;
13
18
  text = truncateByWidth(text, w);
14
19
  buf.centerText(0, text, { fg: C.accent, bg: C.highlightBg, bold: true });
15
20
  }