@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.
@@ -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,40 @@ 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
+ }
25
+ onPaste(text) {
26
+ if (!text)
27
+ return;
28
+ // A paste always targets the message box, so focus it first.
29
+ this.focusIdx = 0;
30
+ this.hoverIdx = -1;
31
+ this.inputValue =
32
+ this.inputValue.slice(0, this.cursor) + text + this.inputValue.slice(this.cursor);
33
+ this.cursor += text.length;
34
+ this.app.render();
35
+ }
18
36
  onKey(key) {
19
- if (key.name === "left" || key.name === "up") {
37
+ // When the input box is focused and holds text, left/right move the text
38
+ // cursor instead of switching between the input box and the tabs. When the
39
+ // input box is empty (or another control is focused), left/right switch.
40
+ // Up/down always switch.
41
+ if (key.name === "left" || key.name === "right") {
42
+ if (this.focusIdx === 0 && this.inputValue.length > 0) {
43
+ this.moveCursor(key.name === "left" ? -1 : 1);
44
+ }
45
+ else {
46
+ this.move(key.name === "left" ? -1 : 1);
47
+ }
48
+ return;
49
+ }
50
+ if (key.name === "up") {
20
51
  this.move(-1);
21
52
  return;
22
53
  }
23
- if (key.name === "right" || key.name === "down") {
54
+ if (key.name === "down") {
24
55
  this.move(1);
25
56
  return;
26
57
  }
@@ -165,6 +196,24 @@ export class StartScreen extends Screen {
165
196
  this.hoverIdx = -1;
166
197
  this.app.render();
167
198
  }
199
+ /** Move the text cursor by one character, staying within bounds and on
200
+ * surrogate-pair boundaries. */
201
+ moveCursor(delta) {
202
+ const len = this.inputValue.length;
203
+ let next = Math.max(0, Math.min(len, this.cursor + delta));
204
+ if (delta === 1) {
205
+ if (next < len && isLowSurrogate(this.inputValue.charCodeAt(next))) {
206
+ next = Math.min(len, next + 1);
207
+ }
208
+ }
209
+ else if (next > 0 && isLowSurrogate(this.inputValue.charCodeAt(next))) {
210
+ next = Math.max(0, next - 1);
211
+ }
212
+ if (next === this.cursor)
213
+ return;
214
+ this.cursor = next;
215
+ this.app.render();
216
+ }
168
217
  enterTab() {
169
218
  if (this.focusIdx === 1)
170
219
  this.app.openModelConfig();
@@ -193,6 +242,7 @@ export class StartScreen extends Screen {
193
242
  y += 1;
194
243
  drawInputBox(buf, inputX, y, inputW, this.inputValue, this.cursor, {
195
244
  active: this.focusIdx === 0,
245
+ cursorOn: this.app.state.cursorOn,
196
246
  placeholder: "Type a message to start a new chat...",
197
247
  });
198
248
  y += INPUT_H + gap;
@@ -207,9 +257,23 @@ export class StartScreen extends Screen {
207
257
  drawTabButton(buf, tx, y, tw, TABS[i], selected);
208
258
  tx += tw + tabGap;
209
259
  }
260
+ // The version label is pinned one row above the very bottom of the screen,
261
+ // leaving the last row blank. It is read from package.json at runtime so it
262
+ // can never drift out of sync with the published package version.
263
+ const version = loadVersion();
264
+ const versionY = h - 2;
265
+ const showVersion = version.length > 0 && versionY >= 1;
266
+ // The hint sits right below the tabs, but is dropped when it would land on
267
+ // or below the reserved version row so the two never collide.
210
268
  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");
269
+ if (hintY < h && (!showVersion || hintY < versionY)) {
270
+ const nav = this.focusIdx === 0 && this.inputValue.length > 0
271
+ ? "←/→ move cursor · ↑/↓ switch"
272
+ : "←/→ / ↑/↓ switch";
273
+ drawHint(buf, hintY, `${nav} · Enter confirm · drag to copy · Ctrl+E model · Ctrl+Q quit`);
274
+ }
275
+ if (showVersion) {
276
+ buf.centerText(versionY, `v${version}`, { fg: C.dim });
213
277
  }
214
278
  }
215
279
  }
@@ -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
  }
@@ -1,11 +1,44 @@
1
1
  import process from "node:process";
2
2
  const ESC_TIMEOUT = 30;
3
+ // Bracketed paste: once enabled, the terminal wraps pasted text in
4
+ // ESC [ 2 0 0 ~ ... ESC [ 2 0 1 ~ so a paste can be told apart from typing.
5
+ const PASTE_START = "\x1b[200~";
6
+ const PASTE_END = "\x1b[201~";
7
+ /** Flatten a pasted blob so its line breaks become ordinary spaces. */
8
+ function flattenNewlines(s) {
9
+ return s.replace(/\r\n|\r|\n/g, " ");
10
+ }
11
+ /**
12
+ * Heuristic used when a terminal does not speak bracketed paste: a chunk of raw
13
+ * input counts as a paste when it carries a line break that is not merely the
14
+ * chunk's trailing one. A single line break at the very end is a manual Enter,
15
+ * and an all-whitespace chunk is just repeated Enter/space presses.
16
+ */
17
+ function looksLikePaste(s) {
18
+ const body = s.replace(/(\r\n|\r|\n)$/, "");
19
+ if (!/\r|\n/.test(body))
20
+ return false;
21
+ if (/^\s*$/.test(s))
22
+ return false;
23
+ return true;
24
+ }
25
+ /** Length of the longest suffix of `s` that prefixes `marker` (0 if none). */
26
+ function partialMarkerLength(s, marker) {
27
+ const max = Math.min(s.length, marker.length - 1);
28
+ for (let len = max; len > 0; len--) {
29
+ if (s.slice(s.length - len) === marker.slice(0, len))
30
+ return len;
31
+ }
32
+ return 0;
33
+ }
3
34
  export class InputReader {
4
35
  decoder = new TextDecoder("utf-8");
5
36
  pending = "";
6
37
  state = "normal";
7
38
  csiBuf = "";
8
39
  escTimer = null;
40
+ inPaste = false;
41
+ pasteBuf = "";
9
42
  handler = null;
10
43
  dataHandler = (chunk) => this.onData(chunk);
11
44
  start(handler) {
@@ -31,8 +64,34 @@ export class InputReader {
31
64
  }
32
65
  process() {
33
66
  const s = this.pending;
67
+ // An unwrapped multi-line paste (terminals without bracketed-paste support
68
+ // deliver the raw text). Flatten it so the whole paste stays on one line
69
+ // and nothing gets submitted — only a manual Enter submits.
70
+ if (!this.inPaste && this.state === "normal" && !s.includes(PASTE_START) && looksLikePaste(s)) {
71
+ this.pending = "";
72
+ this.emitPaste(flattenNewlines(s));
73
+ return;
74
+ }
34
75
  let i = 0;
35
76
  while (i < s.length) {
77
+ if (this.inPaste) {
78
+ const end = s.indexOf(PASTE_END, i);
79
+ if (end === -1) {
80
+ // No terminator yet: buffer what we have, holding back a possible
81
+ // partial terminator so it can be matched with the next chunk.
82
+ const rest = s.slice(i);
83
+ const keep = partialMarkerLength(rest, PASTE_END);
84
+ this.pasteBuf += rest.slice(0, rest.length - keep);
85
+ this.pending = rest.slice(rest.length - keep);
86
+ return;
87
+ }
88
+ this.pasteBuf += s.slice(i, end);
89
+ this.inPaste = false;
90
+ this.emitPaste(flattenNewlines(this.pasteBuf));
91
+ this.pasteBuf = "";
92
+ i = end + PASTE_END.length;
93
+ continue;
94
+ }
36
95
  const c = s[i];
37
96
  if (this.state === "normal") {
38
97
  if (c === "\x1b") {
@@ -44,7 +103,8 @@ export class InputReader {
44
103
  const code = c.charCodeAt(0);
45
104
  if (c === "\r" || c === "\n") {
46
105
  this.emitKey("return", false, false, false, c);
47
- i++;
106
+ // A CRLF pair is one Return, not two.
107
+ i += c === "\r" && s[i + 1] === "\n" ? 2 : 1;
48
108
  continue;
49
109
  }
50
110
  if (c === "\t") {
@@ -153,17 +213,25 @@ export class InputReader {
153
213
  this.emitKey("home", false, false, false, "\x1b[1~");
154
214
  else if (param === "4" || param === "8")
155
215
  this.emitKey("end", false, false, false, "\x1b[4~");
216
+ else if (param === "200") {
217
+ // Start of a bracketed paste.
218
+ this.inPaste = true;
219
+ this.pasteBuf = "";
220
+ }
221
+ else if (param === "201") {
222
+ // End marker without a preceding start: ignore.
223
+ this.inPaste = false;
224
+ }
156
225
  }
157
226
  }
158
227
  /**
159
228
  * Parse SGR (extended) mouse sequences: ESC [ < b ; x ; y M/m
160
229
  *
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
230
+ * `b` is a bit field: the low two bits are the button (0 left, 1 middle,
231
+ * 2 right, 3 none/release), bit 5 (32) marks a motion event and bit 6 (64)
232
+ * marks a wheel event. A trailing `M` is a press/motion while `m` is a
233
+ * release. So motion with the left button held (a drag) is `b = 32`, whereas
234
+ * plain hover is `b = 35` (32 | 3).
167
235
  */
168
236
  handleMouse(s, final) {
169
237
  const parts = s.slice(1).split(";");
@@ -174,18 +242,23 @@ export class InputReader {
174
242
  const y = parseInt(parts[2], 10) - 1;
175
243
  if (Number.isNaN(b) || Number.isNaN(x) || Number.isNaN(y))
176
244
  return;
177
- if (b === 64) {
178
- this.emitMouse({ type: "wheel", dir: -1, x, y });
245
+ const button = b & 3;
246
+ const motion = (b & 32) !== 0;
247
+ const wheel = (b & 64) !== 0;
248
+ if (wheel) {
249
+ this.emitMouse({ type: "wheel", dir: button === 0 ? -1 : 1, x, y });
250
+ return;
179
251
  }
180
- else if (b === 65) {
181
- this.emitMouse({ type: "wheel", dir: 1, x, y });
252
+ if (final === "m") {
253
+ this.emitMouse({ type: "release", x, y, button });
254
+ return;
182
255
  }
183
- else if (final === "M" && (b === 32 || b === 35)) {
184
- // hover / drag motion
185
- this.emitMouse({ type: "move", x, y });
256
+ if (motion) {
257
+ this.emitMouse({ type: "move", x, y, drag: button === 0 });
258
+ return;
186
259
  }
187
- else if (final === "M" && (b === 0 || b === 1 || b === 2)) {
188
- this.emitMouse({ type: "click", x, y, button: b });
260
+ if (button !== 3) {
261
+ this.emitMouse({ type: "click", x, y, button });
189
262
  }
190
263
  }
191
264
  emitKey(name, ctrl, meta, shift, char) {
@@ -203,6 +276,11 @@ export class InputReader {
203
276
  },
204
277
  });
205
278
  }
279
+ emitPaste(text) {
280
+ if (!this.handler || !text)
281
+ return;
282
+ this.handler({ type: "paste", text });
283
+ }
206
284
  emitMouse(event) {
207
285
  if (!this.handler)
208
286
  return;
@@ -50,8 +50,10 @@ export class Renderer {
50
50
  this.write("\x1b[?1049h"); // alternate screen
51
51
  this.write("\x1b[?25l"); // hide cursor
52
52
  this.write("\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h\x1b[?1015h"); // mouse tracking (incl. any-event for hover)
53
+ this.write("\x1b[?2004h"); // bracketed paste (so pastes can be told apart from typing)
53
54
  }
54
55
  dispose() {
56
+ this.write("\x1b[?2004l");
55
57
  this.write("\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l");
56
58
  this.write("\x1b[?25h");
57
59
  this.write("\x1b[?1049l");
@@ -78,12 +80,17 @@ export class Renderer {
78
80
  this.write(out);
79
81
  }
80
82
  renderRow(y, row) {
81
- let s = `\x1b[${y + 1};1H`;
83
+ // Move to the row, reset attributes and erase the whole line first. The
84
+ // erase is belt-and-braces: every cell is written below anyway, but this
85
+ // guarantees no stale glyph can survive even if a row somehow ends up
86
+ // shorter than what was on screen before (this is what produced the
87
+ // "residual line above the input box" artefact).
88
+ let s = `\x1b[${y + 1};1H\x1b[0m\x1b[K`;
82
89
  let lastKey = "";
83
90
  for (let x = 0; x < row.length; x++) {
84
91
  const cell = row[x];
85
92
  if (cell.ch === "")
86
- continue;
93
+ continue; // tail of the wide glyph to the left
87
94
  const sk = styleKey(cell.style);
88
95
  if (sk !== lastKey) {
89
96
  s += sgr(cell.style);
@@ -1,6 +1,25 @@
1
1
  export class Screen {
2
2
  app;
3
3
  onMouse(_ev) { }
4
+ /**
5
+ * Describe the on-screen scrollbar so the app can drag it, or null when this
6
+ * screen has none / its content fits. Geometry is only valid right after a
7
+ * render, which is exactly when the app needs it.
8
+ */
9
+ scrollbar() {
10
+ return null;
11
+ }
12
+ /**
13
+ * Handle a bracketed (or detected) paste. `text` already has its newlines
14
+ * flattened to spaces, so it can be inserted as a single line. Screens that
15
+ * have no text input can leave this as a no-op.
16
+ */
17
+ onPaste(_text) { }
18
+ /** Whether this screen currently shows a blinking text cursor. Used by the
19
+ * app to run (or stop) the cursor blink timer. */
20
+ showsCursor() {
21
+ return false;
22
+ }
4
23
  onActivate() { }
5
24
  onDeactivate() { }
6
25
  }