@xingjisoft/neutrino 0.1.1 → 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
@@ -6,6 +6,7 @@ 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).
9
10
  - **LLM providers** — ships with DeepSeek, OpenAI, and Anthropic presets; works with any OpenAI-compatible endpoint.
10
11
  - **Tool calling** — the agent can run shell commands, read files, write files, and list directories in your working directory.
11
12
  - **Streaming responses** — token-by-token output with pause/resume (`ESC ESC`).
@@ -53,8 +54,10 @@ Type a message on the start screen and press `Enter` to begin a new chat.
53
54
  | `Ctrl+B` | Back to menu (from chat) |
54
55
  | `Ctrl+L` | Clear conversation (from chat) |
55
56
  | `Esc Esc` | Pause / resume streaming |
57
+ | `Esc` | Clear an active selection |
56
58
  | `PgUp` / `PgDn` | Scroll chat history |
57
- | `Ctrl+C` / `Ctrl+Q` | Quit |
59
+ | `Ctrl+C` | Copy the selection, or quit if nothing is selected |
60
+ | `Ctrl+Q` | Quit |
58
61
 
59
62
  ### Mouse
60
63
 
@@ -63,11 +66,14 @@ Type a message on the start screen and press `Enter` to begin a new chat.
63
66
  | Hover over an item | Highlight / select it in lists and menus |
64
67
  | Click an item | Select and confirm (open a screen, load a session, apply a setting…) |
65
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 |
66
70
  | Click the header (left of `ⓘ`) | Open the model selector |
67
71
  | Click `ⓘ` (chat header) | Toggle token usage stats |
68
72
  | Scroll wheel | Scroll the chat/log, or move through lists |
69
73
  | Right-click | Go back (like `Esc`) |
70
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.
76
+
71
77
  ## Configuration
72
78
 
73
79
  Settings are stored under `~/.neutrino/`:
@@ -101,10 +107,23 @@ The agent can call the following tools during a conversation:
101
107
 
102
108
  ```sh
103
109
  npm run build # compile TypeScript to dist/
104
- node scripts/test-mouse.mjs # verify mouse escape-sequence parsing
105
- node scripts/test-cursor.mjs # verify click-to-position-cursor math
110
+ npm test # build, then run every test below
106
111
  ```
107
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
121
+ ```
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
+
108
127
  ## License
109
128
 
110
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;
@@ -20,6 +25,18 @@ export class App {
20
25
  running = false;
21
26
  sizeTimer = null;
22
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;
23
40
  resizeHandler = () => this.onResize();
24
41
  constructor() {
25
42
  this.width = process.stdout.columns || 80;
@@ -72,11 +89,15 @@ export class App {
72
89
  pushScreen(screen) {
73
90
  screen.app = this;
74
91
  this.screens.push(screen);
92
+ this.resetMouse();
93
+ this.state.selection = null;
75
94
  screen.onActivate();
76
95
  this.render();
77
96
  }
78
97
  popScreen() {
79
98
  const screen = this.screens.pop();
99
+ this.resetMouse();
100
+ this.state.selection = null;
80
101
  screen?.onDeactivate();
81
102
  this.render();
82
103
  }
@@ -96,17 +117,70 @@ export class App {
96
117
  this.blinkTimer = null;
97
118
  this.state.blinkOn = false;
98
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();
99
123
  const buf = new Buffer(this.width, this.height);
100
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);
101
132
  this.renderer.render(buf);
102
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
+ }
103
159
  handleEvent(ev) {
104
160
  if (ev.type === "key") {
105
161
  const k = ev.key;
106
- 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") {
165
+ this.quit();
166
+ return;
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
+ }
107
175
  this.quit();
108
176
  return;
109
177
  }
178
+ if (k.name === "escape" && this.state.selection) {
179
+ this.state.selection = null;
180
+ this.render();
181
+ return;
182
+ }
183
+ this.wakeCursor();
110
184
  if (k.ctrl && (k.name === "m" || k.name === "e")) {
111
185
  this.openModelConfig();
112
186
  return;
@@ -114,9 +188,85 @@ export class App {
114
188
  this.top?.onKey(k);
115
189
  }
116
190
  else {
117
- 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);
118
243
  }
119
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
+ }
120
270
  setModelConfig(providerName, model, intensity) {
121
271
  this.state.selectedProvider = providerName;
122
272
  this.state.selectedModelName = model.name;
@@ -182,6 +332,14 @@ export class App {
182
332
  clearInterval(this.blinkTimer);
183
333
  this.blinkTimer = null;
184
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
+ }
185
343
  this.input.stop();
186
344
  this.renderer.dispose();
187
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
@@ -11,5 +11,6 @@ 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
14
15
  };
15
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
+ }
@@ -78,6 +78,10 @@ export class ChatScreen extends Screen {
78
78
  this.app.render();
79
79
  }
80
80
  }
81
+ showsCursor() {
82
+ // The chat input is always focused; a cursor is drawn when it holds text.
83
+ return this.inputValue.length > 0;
84
+ }
81
85
  onKey(key) {
82
86
  if (this.showInfo) {
83
87
  this.showInfo = false;
@@ -469,10 +473,11 @@ export class ChatScreen extends Screen {
469
473
  drawInputBox(buf, 1, inputY, inW, this.inputValue, this.cursor, {
470
474
  active: true,
471
475
  dimmed: this.busy,
476
+ cursorOn: this.app.state.cursorOn,
472
477
  placeholder: "Type a message...",
473
478
  });
474
479
  const hintY = h - 1;
475
- const hints = "Enter send · ESC ESC pause · Ctrl+B menu · Ctrl+L clear";
480
+ const hints = "Enter send · ESC ESC pause · Ctrl+B menu · Ctrl+L clear · drag to copy";
476
481
  const hintW = displayWidth(hints);
477
482
  const hintX = Math.max(0, w - 1 - hintW);
478
483
  const pathAvail = Math.max(0, hintX - 2);
@@ -1,6 +1,6 @@
1
1
  import { Screen } from "../terminal/screen.js";
2
2
  import { C } from "../colors.js";
3
- import { loadLogo } from "../config.js";
3
+ import { loadLogo, loadVersion } from "../config.js";
4
4
  import { drawHeader } from "../header.js";
5
5
  import { centerVertically, computeInputWindow, cursorFromColumn, drawHint, drawInputBox, drawTabButton, inRect, } from "../ui.js";
6
6
  const TABS = ["Model", "History", "Settings"];
@@ -8,6 +8,9 @@ const LOGO = loadLogo();
8
8
  const FOCUS_COUNT = TABS.length + 1; // input box + 3 tabs
9
9
  const INPUT_H = 3;
10
10
  const TAB_H = 3;
11
+ function isLowSurrogate(code) {
12
+ return code >= 0xdc00 && code <= 0xdfff;
13
+ }
11
14
  export class StartScreen extends Screen {
12
15
  inputValue = "";
13
16
  cursor = 0;
@@ -15,12 +18,29 @@ export class StartScreen extends Screen {
15
18
  hoverIdx = -1; // -1 = nothing, 0 = input box, 1..3 = tabs
16
19
  lastHoverX = -1;
17
20
  lastHoverY = -1;
21
+ showsCursor() {
22
+ // The input cursor is only drawn for the focused, non-empty input box.
23
+ return this.focusIdx === 0 && this.inputValue.length > 0;
24
+ }
18
25
  onKey(key) {
19
- if (key.name === "left" || key.name === "up") {
26
+ // When the input box is focused and holds text, left/right move the text
27
+ // cursor instead of switching between the input box and the tabs. When the
28
+ // input box is empty (or another control is focused), left/right switch.
29
+ // Up/down always switch.
30
+ if (key.name === "left" || key.name === "right") {
31
+ if (this.focusIdx === 0 && this.inputValue.length > 0) {
32
+ this.moveCursor(key.name === "left" ? -1 : 1);
33
+ }
34
+ else {
35
+ this.move(key.name === "left" ? -1 : 1);
36
+ }
37
+ return;
38
+ }
39
+ if (key.name === "up") {
20
40
  this.move(-1);
21
41
  return;
22
42
  }
23
- if (key.name === "right" || key.name === "down") {
43
+ if (key.name === "down") {
24
44
  this.move(1);
25
45
  return;
26
46
  }
@@ -165,6 +185,24 @@ export class StartScreen extends Screen {
165
185
  this.hoverIdx = -1;
166
186
  this.app.render();
167
187
  }
188
+ /** Move the text cursor by one character, staying within bounds and on
189
+ * surrogate-pair boundaries. */
190
+ moveCursor(delta) {
191
+ const len = this.inputValue.length;
192
+ let next = Math.max(0, Math.min(len, this.cursor + delta));
193
+ if (delta === 1) {
194
+ if (next < len && isLowSurrogate(this.inputValue.charCodeAt(next))) {
195
+ next = Math.min(len, next + 1);
196
+ }
197
+ }
198
+ else if (next > 0 && isLowSurrogate(this.inputValue.charCodeAt(next))) {
199
+ next = Math.max(0, next - 1);
200
+ }
201
+ if (next === this.cursor)
202
+ return;
203
+ this.cursor = next;
204
+ this.app.render();
205
+ }
168
206
  enterTab() {
169
207
  if (this.focusIdx === 1)
170
208
  this.app.openModelConfig();
@@ -193,6 +231,7 @@ export class StartScreen extends Screen {
193
231
  y += 1;
194
232
  drawInputBox(buf, inputX, y, inputW, this.inputValue, this.cursor, {
195
233
  active: this.focusIdx === 0,
234
+ cursorOn: this.app.state.cursorOn,
196
235
  placeholder: "Type a message to start a new chat...",
197
236
  });
198
237
  y += INPUT_H + gap;
@@ -207,9 +246,23 @@ export class StartScreen extends Screen {
207
246
  drawTabButton(buf, tx, y, tw, TABS[i], selected);
208
247
  tx += tw + tabGap;
209
248
  }
249
+ // The version label is pinned one row above the very bottom of the screen,
250
+ // leaving the last row blank. It is read from package.json at runtime so it
251
+ // can never drift out of sync with the published package version.
252
+ const version = loadVersion();
253
+ const versionY = h - 2;
254
+ const showVersion = version.length > 0 && versionY >= 1;
255
+ // The hint sits right below the tabs, but is dropped when it would land on
256
+ // or below the reserved version row so the two never collide.
210
257
  const hintY = y + TAB_H + 1;
211
- if (hintY < h) {
212
- drawHint(buf, hintY, "←/→ / ↑/↓ switch · Enter confirm · click to choose · Ctrl+E model · Ctrl+Q quit");
258
+ if (hintY < h && (!showVersion || hintY < versionY)) {
259
+ const nav = this.focusIdx === 0 && this.inputValue.length > 0
260
+ ? "←/→ move cursor · ↑/↓ switch"
261
+ : "←/→ / ↑/↓ switch";
262
+ drawHint(buf, hintY, `${nav} · Enter confirm · drag to copy · Ctrl+E model · Ctrl+Q quit`);
263
+ }
264
+ if (showVersion) {
265
+ buf.centerText(versionY, `v${version}`, { fg: C.dim });
213
266
  }
214
267
  }
215
268
  }
@@ -0,0 +1,75 @@
1
+ import { C } from "./colors.js";
2
+ /** Order the two ends of a selection into reading order (top-left first). */
3
+ export function normalizeSelection(sel) {
4
+ const { anchor: a, cursor: b } = sel;
5
+ if (a.y < b.y || (a.y === b.y && a.x <= b.x))
6
+ return { start: a, end: b };
7
+ return { start: b, end: a };
8
+ }
9
+ /** Index of the last non-blank cell on a row, or -1 when the row is empty. */
10
+ function lastContentCol(buf, y) {
11
+ const row = buf.cells[y];
12
+ for (let x = row.length - 1; x >= 0; x--) {
13
+ if (row[x].ch !== " ")
14
+ return x;
15
+ }
16
+ return -1;
17
+ }
18
+ /**
19
+ * Paint the selection highlight directly onto the rendered buffer. Working on
20
+ * the buffer (rather than on each screen) means the highlight automatically
21
+ * follows whatever is on screen — chat output, the input box, menus, …
22
+ *
23
+ * Intermediate lines stop at the last non-blank cell so a multi-line selection
24
+ * doesn't paint a block out to the right edge of the terminal.
25
+ */
26
+ export function applySelection(buf, sel) {
27
+ const { start, end } = normalizeSelection(sel);
28
+ const y0 = Math.max(0, start.y);
29
+ const y1 = Math.min(buf.height - 1, end.y);
30
+ for (let y = y0; y <= y1; y++) {
31
+ const x0 = y === start.y ? Math.max(0, start.x) : 0;
32
+ const x1 = y === end.y ? Math.min(buf.width - 1, end.x) : lastContentCol(buf, y);
33
+ if (x1 < x0)
34
+ continue;
35
+ for (let x = x0; x <= x1; x++) {
36
+ const cell = buf.cells[y][x];
37
+ if (!cell)
38
+ continue;
39
+ cell.style = {
40
+ fg: C.white,
41
+ bg: C.selection,
42
+ bold: cell.style.bold,
43
+ italic: cell.style.italic,
44
+ };
45
+ }
46
+ }
47
+ }
48
+ /**
49
+ * Extract the plain text covered by a selection. Trailing whitespace is trimmed
50
+ * per line (shell-style) and wide-character continuation cells are skipped, so
51
+ * the result can be pasted straight back into a terminal.
52
+ */
53
+ export function selectionText(buf, sel) {
54
+ const { start, end } = normalizeSelection(sel);
55
+ const y0 = Math.max(0, start.y);
56
+ const y1 = Math.min(buf.height - 1, end.y);
57
+ const lines = [];
58
+ for (let y = y0; y <= y1; y++) {
59
+ const x0 = y === start.y ? Math.max(0, start.x) : 0;
60
+ let x1 = y === end.y ? end.x : buf.width - 1;
61
+ let line = "";
62
+ for (let x = x0; x <= Math.min(buf.width - 1, x1); x++) {
63
+ const cell = buf.cells[y][x];
64
+ if (!cell || cell.ch === "")
65
+ continue; // "" is the tail of a wide glyph
66
+ line += cell.ch;
67
+ }
68
+ lines.push(line.replace(/[ \t]+$/, ""));
69
+ }
70
+ while (lines.length > 0 && lines[0] === "")
71
+ lines.shift();
72
+ while (lines.length > 0 && lines[lines.length - 1] === "")
73
+ lines.pop();
74
+ return lines.join("\n");
75
+ }
package/dist/state.js CHANGED
@@ -8,5 +8,7 @@ export function defaultState() {
8
8
  selectedIntensity: "High",
9
9
  isThinking: false,
10
10
  blinkOn: false,
11
+ cursorOn: true,
12
+ selection: null,
11
13
  };
12
14
  }
@@ -4,6 +4,9 @@ const emptyCell = () => ({ ch: " ", style: { bg: C.black } });
4
4
  function normalize(style) {
5
5
  return style.bg ? style : { ...style, bg: C.black };
6
6
  }
7
+ function cellWidth(ch) {
8
+ return ch ? wcwidth(ch.codePointAt(0) ?? 0) : 0;
9
+ }
7
10
  export class Buffer {
8
11
  width;
9
12
  height;
@@ -13,12 +16,65 @@ export class Buffer {
13
16
  this.height = height;
14
17
  this.cells = Array.from({ length: height }, () => Array.from({ length: width }, emptyCell));
15
18
  }
19
+ /**
20
+ * Write a single cell, keeping the wide-glyph invariant intact. The renderer
21
+ * (and the selection logic) rely on the rule that a `""` cell is *always* the
22
+ * right half of a double-width glyph at `x - 1`. If the invariant is broken an
23
+ * orphan tail is left behind: the renderer skips it, so that column is never
24
+ * repainted and a stale glyph from the previous frame stays on screen (the
25
+ * "residual line" bug). To preserve it we blank the *other* half whenever we
26
+ * overwrite one half of a wide glyph.
27
+ */
28
+ overwriteCell(x, y, ch, style) {
29
+ if (x < 0 || x >= this.width || y < 0 || y >= this.height)
30
+ return;
31
+ const row = this.cells[y];
32
+ const prev = row[x];
33
+ if (prev.ch === "" && x > 0) {
34
+ // We're overwriting a wide glyph's tail: blank its head.
35
+ row[x - 1] = { ch: " ", style: row[x - 1].style };
36
+ }
37
+ else if (cellWidth(prev.ch) === 2 && x + 1 < this.width) {
38
+ // We're overwriting a wide glyph's head: blank its tail.
39
+ row[x + 1] = { ch: " ", style: row[x + 1].style };
40
+ }
41
+ row[x] = { ch, style: normalize(style) };
42
+ }
43
+ /**
44
+ * Mark `x` as the tail (`""`) of the wide glyph at `x - 1`. Unlike
45
+ * `overwriteCell` this never blanks the cell to the left, because that cell is
46
+ * the glyph we are currently writing.
47
+ */
48
+ writeTail(x, y, style) {
49
+ if (x < 0 || x >= this.width || y < 0 || y >= this.height)
50
+ return;
51
+ const row = this.cells[y];
52
+ const prev = row[x];
53
+ if (cellWidth(prev.ch) === 2 && x + 1 < this.width) {
54
+ row[x + 1] = { ch: " ", style: row[x + 1].style };
55
+ }
56
+ row[x] = { ch: "", style: normalize(style) };
57
+ }
58
+ writeChar(x, y, ch, style) {
59
+ if (cellWidth(ch) === 2) {
60
+ if (x + 1 >= this.width) {
61
+ // No room for the tail: degrade to a space so the line never overflows
62
+ // (which would make the terminal wrap and scroll).
63
+ this.overwriteCell(x, y, " ", style);
64
+ return;
65
+ }
66
+ this.overwriteCell(x, y, ch, style);
67
+ this.writeTail(x + 1, y, style);
68
+ return;
69
+ }
70
+ this.overwriteCell(x, y, ch, style);
71
+ }
16
72
  set(x, y, ch, style = EMPTY_STYLE) {
17
73
  if (ch === "\r" || ch === "\n")
18
74
  return;
19
- if (y < 0 || y >= this.height || x < 0 || x >= this.width)
75
+ if (x < 0 || x >= this.width || y < 0 || y >= this.height)
20
76
  return;
21
- this.cells[y][x] = { ch, style: normalize(style) };
77
+ this.writeChar(x, y, ch, style);
22
78
  }
23
79
  writeText(x, y, text, style = EMPTY_STYLE) {
24
80
  if (y < 0 || y >= this.height)
@@ -27,8 +83,10 @@ export class Buffer {
27
83
  for (const ch of text) {
28
84
  const w = wcwidth(ch.codePointAt(0) ?? 0);
29
85
  if (w === 0) {
30
- if (col - 1 >= x && col - 1 >= 0 && col - 1 < this.width) {
31
- this.cells[y][col - 1].ch += ch;
86
+ // Combining mark: attach it to the glyph immediately to the left.
87
+ const px = col - 1;
88
+ if (px >= 0 && px < this.width && this.cells[y][px].ch !== "") {
89
+ this.cells[y][px].ch += ch;
32
90
  }
33
91
  continue;
34
92
  }
@@ -38,10 +96,7 @@ export class Buffer {
38
96
  col += w;
39
97
  continue;
40
98
  }
41
- this.cells[y][col] = { ch, style: normalize(style) };
42
- if (w === 2 && col + 1 < this.width) {
43
- this.cells[y][col + 1] = { ch: "", style: normalize(style) };
44
- }
99
+ this.writeChar(col, y, ch, style);
45
100
  col += w;
46
101
  }
47
102
  }
@@ -158,12 +158,11 @@ export class InputReader {
158
158
  /**
159
159
  * Parse SGR (extended) mouse sequences: ESC [ < b ; x ; y M/m
160
160
  *
161
- * Button codes (SGR mode, any-event tracking ?1003):
162
- * 0/1/2 left/middle/right press
163
- * 3 release (ignored)
164
- * 32 motion with no buttons held (hover)
165
- * 35 motion with left button held (drag)
166
- * 64/65 wheel up / wheel down
161
+ * `b` is a bit field: the low two bits are the button (0 left, 1 middle,
162
+ * 2 right, 3 none/release), bit 5 (32) marks a motion event and bit 6 (64)
163
+ * marks a wheel event. A trailing `M` is a press/motion while `m` is a
164
+ * release. So motion with the left button held (a drag) is `b = 32`, whereas
165
+ * plain hover is `b = 35` (32 | 3).
167
166
  */
168
167
  handleMouse(s, final) {
169
168
  const parts = s.slice(1).split(";");
@@ -174,18 +173,23 @@ export class InputReader {
174
173
  const y = parseInt(parts[2], 10) - 1;
175
174
  if (Number.isNaN(b) || Number.isNaN(x) || Number.isNaN(y))
176
175
  return;
177
- if (b === 64) {
178
- this.emitMouse({ type: "wheel", dir: -1, x, y });
176
+ const button = b & 3;
177
+ const motion = (b & 32) !== 0;
178
+ const wheel = (b & 64) !== 0;
179
+ if (wheel) {
180
+ this.emitMouse({ type: "wheel", dir: button === 0 ? -1 : 1, x, y });
181
+ return;
179
182
  }
180
- else if (b === 65) {
181
- this.emitMouse({ type: "wheel", dir: 1, x, y });
183
+ if (final === "m") {
184
+ this.emitMouse({ type: "release", x, y, button });
185
+ return;
182
186
  }
183
- else if (final === "M" && (b === 32 || b === 35)) {
184
- // hover / drag motion
185
- this.emitMouse({ type: "move", x, y });
187
+ if (motion) {
188
+ this.emitMouse({ type: "move", x, y, drag: button === 0 });
189
+ return;
186
190
  }
187
- else if (final === "M" && (b === 0 || b === 1 || b === 2)) {
188
- this.emitMouse({ type: "click", x, y, button: b });
191
+ if (button !== 3) {
192
+ this.emitMouse({ type: "click", x, y, button });
189
193
  }
190
194
  }
191
195
  emitKey(name, ctrl, meta, shift, char) {
@@ -78,12 +78,17 @@ export class Renderer {
78
78
  this.write(out);
79
79
  }
80
80
  renderRow(y, row) {
81
- let s = `\x1b[${y + 1};1H`;
81
+ // Move to the row, reset attributes and erase the whole line first. The
82
+ // erase is belt-and-braces: every cell is written below anyway, but this
83
+ // guarantees no stale glyph can survive even if a row somehow ends up
84
+ // shorter than what was on screen before (this is what produced the
85
+ // "residual line above the input box" artefact).
86
+ let s = `\x1b[${y + 1};1H\x1b[0m\x1b[K`;
82
87
  let lastKey = "";
83
88
  for (let x = 0; x < row.length; x++) {
84
89
  const cell = row[x];
85
90
  if (cell.ch === "")
86
- continue;
91
+ continue; // tail of the wide glyph to the left
87
92
  const sk = styleKey(cell.style);
88
93
  if (sk !== lastKey) {
89
94
  s += sgr(cell.style);
@@ -1,6 +1,11 @@
1
1
  export class Screen {
2
2
  app;
3
3
  onMouse(_ev) { }
4
+ /** Whether this screen currently shows a blinking text cursor. Used by the
5
+ * app to run (or stop) the cursor blink timer. */
6
+ showsCursor() {
7
+ return false;
8
+ }
4
9
  onActivate() { }
5
10
  onDeactivate() { }
6
11
  }
package/dist/ui.js CHANGED
@@ -141,6 +141,7 @@ export function drawInputBox(buf, x, y, w, value, cursorPos, opts = {}) {
141
141
  const active = opts.active ?? true;
142
142
  const dimmed = opts.dimmed ?? false;
143
143
  const placeholder = opts.placeholder ?? "";
144
+ const cursorOn = opts.cursorOn ?? true;
144
145
  const h = 3;
145
146
  const innerX = x + 2;
146
147
  const innerW = w - 4;
@@ -158,22 +159,42 @@ export function drawInputBox(buf, x, y, w, value, cursorPos, opts = {}) {
158
159
  }
159
160
  const win = computeInputWindow(value, cursorPos, innerW);
160
161
  const chars = Array.from(value);
162
+ // Codepoint index of the cursor (cursorPos is a UTF-16 offset).
163
+ const cursorCp = Math.min(Array.from(value.slice(0, cursorPos)).length, chars.length);
161
164
  const visible = chars.slice(win.startCp, win.startCp + win.visibleCount).join("");
162
165
  buf.writeText(innerX, innerY, visible, textStyle);
163
- const cursorScreenCol = cursorColAt(value, cursorPos, win);
164
- if (active && cursorScreenCol >= 0 && cursorScreenCol < innerW) {
165
- buf.set(innerX + cursorScreenCol, innerY, " ", { fg: C.black, bg: C.accent });
166
+ // Blinking block cursor. While it is "on" the character underneath is drawn
167
+ // inside the block in the opposite colour so the text stays legible instead
168
+ // of being hidden by the cursor.
169
+ const cursorScreenCol = cursorColAt(chars, cursorCp, win);
170
+ if (active && cursorOn && cursorScreenCol >= 0 && cursorScreenCol < innerW) {
171
+ drawBlockCursor(buf, innerX + cursorScreenCol, innerY, chars, cursorCp, cursorScreenCol, innerW);
166
172
  }
167
173
  }
168
- function cursorColAt(value, cursorPos, win) {
169
- const chars = Array.from(value);
170
- const cursorCp = Math.min(Array.from(value.slice(0, cursorPos)).length, chars.length);
174
+ function cursorColAt(chars, cursorCp, win) {
171
175
  let col = win.prefixW;
172
176
  for (let i = win.startCp; i < cursorCp; i++) {
173
177
  col += wcwidth(chars[i].codePointAt(0) ?? 0);
174
178
  }
175
179
  return col - win.prefixW;
176
180
  }
181
+ /**
182
+ * Draw a single block cursor cell. The glyph under the cursor (the character
183
+ * immediately to its right) is rendered in the opposite colour — a dark glyph
184
+ * on the accent block — so it remains readable. Past the end of the text, or
185
+ * when the glyph would not fit, a solid block is drawn instead.
186
+ */
187
+ function drawBlockCursor(buf, x, y, chars, cursorCp, col, innerW) {
188
+ const cursorStyle = { fg: C.black, bg: C.accent };
189
+ const ch = cursorCp < chars.length ? chars[cursorCp] : "";
190
+ const cw = ch ? wcwidth(ch.codePointAt(0) ?? 0) : 0;
191
+ if (ch && ch !== " " && cw >= 1 && col + cw <= innerW) {
192
+ buf.writeText(x, y, ch, cursorStyle);
193
+ }
194
+ else {
195
+ buf.set(x, y, " ", cursorStyle);
196
+ }
197
+ }
177
198
  function textStylePlaceholder(active) {
178
199
  return active ? { fg: C.dim, bg: C.highlightBg } : { fg: C.dim };
179
200
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingjisoft/neutrino",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "XINGJI Neutrino - a CLI coding agent in the terminal.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -17,6 +17,7 @@
17
17
  },
18
18
  "scripts": {
19
19
  "build": "tsc -p tsconfig.json",
20
+ "test": "npm run build && node scripts/test-mouse.mjs && node scripts/test-cursor.mjs && node scripts/test-selection.mjs && node scripts/test-render.mjs",
20
21
  "prepare": "npm run build"
21
22
  },
22
23
  "keywords": [