@xingjisoft/neutrino 0.1.2 → 0.1.4

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.
@@ -22,6 +22,17 @@ export class StartScreen extends Screen {
22
22
  // The input cursor is only drawn for the focused, non-empty input box.
23
23
  return this.focusIdx === 0 && this.inputValue.length > 0;
24
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
+ }
25
36
  onKey(key) {
26
37
  // When the input box is focused and holds text, left/right move the text
27
38
  // cursor instead of switching between the input box and the tabs. When the
@@ -157,6 +168,20 @@ export class StartScreen extends Screen {
157
168
  return;
158
169
  }
159
170
  }
171
+ /**
172
+ * The message box, the tabs below it and the hint row are persistent chrome;
173
+ * repainting them every frame keeps them self-healing against terminal-side
174
+ * drift (see Screen.alwaysRepaintRows).
175
+ */
176
+ alwaysRepaintRows() {
177
+ const { inputY, tabY } = this.layout();
178
+ const h = this.app.height;
179
+ const rows = [];
180
+ for (let y = inputY; y <= tabY + TAB_H; y++)
181
+ if (y >= 0 && y < h)
182
+ rows.push(y);
183
+ return rows;
184
+ }
160
185
  layout() {
161
186
  const w = this.app.width;
162
187
  const h = this.app.height;
package/dist/selection.js CHANGED
@@ -6,11 +6,16 @@ export function normalizeSelection(sel) {
6
6
  return { start: a, end: b };
7
7
  return { start: b, end: a };
8
8
  }
9
- /** Index of the last non-blank cell on a row, or -1 when the row is empty. */
9
+ /**
10
+ * Index of the last cell on a row that holds real content, or -1 when the row
11
+ * has none. UI chrome (a scrollbar) and blank cells are ignored, so a highlight
12
+ * never spills over the scrollbar column.
13
+ */
10
14
  function lastContentCol(buf, y) {
11
15
  const row = buf.cells[y];
12
16
  for (let x = row.length - 1; x >= 0; x--) {
13
- if (row[x].ch !== " ")
17
+ const cell = row[x];
18
+ if (!cell.chrome && cell.ch !== " ")
14
19
  return x;
15
20
  }
16
21
  return -1;
@@ -34,8 +39,8 @@ export function applySelection(buf, sel) {
34
39
  continue;
35
40
  for (let x = x0; x <= x1; x++) {
36
41
  const cell = buf.cells[y][x];
37
- if (!cell)
38
- continue;
42
+ if (!cell || cell.chrome)
43
+ continue; // never highlight the scrollbar
39
44
  cell.style = {
40
45
  fg: C.white,
41
46
  bg: C.selection,
@@ -46,9 +51,18 @@ export function applySelection(buf, sel) {
46
51
  }
47
52
  }
48
53
  /**
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.
54
+ * Extract the plain text covered by a selection.
55
+ *
56
+ * Three kinds of cell are skipped so the result is clean, pasteable text:
57
+ * - UI chrome (the scrollbar), which is never content;
58
+ * - wide-character continuation cells (`""`), which would duplicate a CJK
59
+ * glyph;
60
+ * - trailing blanks, trimmed per line (shell-style).
61
+ *
62
+ * Intermediate lines are copied out to the buffer's full width and only then
63
+ * trimmed, which keeps indentation intact while dropping the padding on the
64
+ * right. Skipping the scrollbar *before* trimming is what lets that trim
65
+ * actually reach the end of the text instead of stopping at the bar.
52
66
  */
53
67
  export function selectionText(buf, sel) {
54
68
  const { start, end } = normalizeSelection(sel);
@@ -57,12 +71,12 @@ export function selectionText(buf, sel) {
57
71
  const lines = [];
58
72
  for (let y = y0; y <= y1; y++) {
59
73
  const x0 = y === start.y ? Math.max(0, start.x) : 0;
60
- let x1 = y === end.y ? end.x : buf.width - 1;
74
+ const x1 = y === end.y ? Math.min(buf.width - 1, end.x) : buf.width - 1;
61
75
  let line = "";
62
- for (let x = x0; x <= Math.min(buf.width - 1, x1); x++) {
76
+ for (let x = x0; x <= x1; x++) {
63
77
  const cell = buf.cells[y][x];
64
- if (!cell || cell.ch === "")
65
- continue; // "" is the tail of a wide glyph
78
+ if (!cell || cell.chrome || cell.ch === "")
79
+ continue;
66
80
  line += cell.ch;
67
81
  }
68
82
  lines.push(line.replace(/[ \t]+$/, ""));
@@ -1,5 +1,6 @@
1
1
  import { C, EMPTY_STYLE } from "../colors.js";
2
2
  import { displayWidth, wcwidth } from "../width.js";
3
+ import { isControlChar } from "../sanitize.js";
3
4
  const emptyCell = () => ({ ch: " ", style: { bg: C.black } });
4
5
  function normalize(style) {
5
6
  return style.bg ? style : { ...style, bg: C.black };
@@ -56,6 +57,12 @@ export class Buffer {
56
57
  row[x] = { ch: "", style: normalize(style) };
57
58
  }
58
59
  writeChar(x, y, ch, style) {
60
+ // A control character (ESC, TAB, CR, …) would be written verbatim by the
61
+ // renderer and interpreted by the terminal as a command. Neutralise it to a
62
+ // space, which matches the single-column width wcwidth() reports for it, so
63
+ // the layout stays exactly as the caller measured it.
64
+ if (isControlChar(ch))
65
+ ch = " ";
59
66
  if (cellWidth(ch) === 2) {
60
67
  if (x + 1 >= this.width) {
61
68
  // No room for the tail: degrade to a space so the line never overflows
@@ -70,8 +77,6 @@ export class Buffer {
70
77
  this.overwriteCell(x, y, ch, style);
71
78
  }
72
79
  set(x, y, ch, style = EMPTY_STYLE) {
73
- if (ch === "\r" || ch === "\n")
74
- return;
75
80
  if (x < 0 || x >= this.width || y < 0 || y >= this.height)
76
81
  return;
77
82
  this.writeChar(x, y, ch, style);
@@ -81,8 +86,14 @@ export class Buffer {
81
86
  return;
82
87
  let col = x;
83
88
  for (const ch of text) {
89
+ // Measure the character as the caller will have measured it, *then*
90
+ // neutralise control codes, so the column bookkeeping stays identical to
91
+ // the wrapping done upstream (see sanitize.ts).
92
+ const ctrl = isControlChar(ch);
84
93
  const w = wcwidth(ch.codePointAt(0) ?? 0);
85
94
  if (w === 0) {
95
+ if (ctrl)
96
+ continue; // zero-width control (NUL): drop it
86
97
  // Combining mark: attach it to the glyph immediately to the left.
87
98
  const px = col - 1;
88
99
  if (px >= 0 && px < this.width && this.cells[y][px].ch !== "") {
@@ -96,7 +107,7 @@ export class Buffer {
96
107
  col += w;
97
108
  continue;
98
109
  }
99
- this.writeChar(col, y, ch, style);
110
+ this.writeChar(col, y, ctrl ? " " : ch, style);
100
111
  col += w;
101
112
  }
102
113
  }
@@ -124,6 +135,16 @@ export class Buffer {
124
135
  }
125
136
  this.writeText(x, y + h - 1, bottom, style);
126
137
  }
138
+ /**
139
+ * Flag a cell as non-copyable chrome (e.g. a scrollbar). The flag lives on
140
+ * the cell and is cleared automatically whenever the cell is written again,
141
+ * since every frame starts from a fresh buffer.
142
+ */
143
+ markChrome(x, y) {
144
+ if (x < 0 || x >= this.width || y < 0 || y >= this.height)
145
+ return;
146
+ this.cells[y][x].chrome = true;
147
+ }
127
148
  clear() {
128
149
  for (let j = 0; j < this.height; j++) {
129
150
  const row = this.cells[j];
@@ -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,6 +213,15 @@ 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
  /**
@@ -207,6 +276,11 @@ export class InputReader {
207
276
  },
208
277
  });
209
278
  }
279
+ emitPaste(text) {
280
+ if (!this.handler || !text)
281
+ return;
282
+ this.handler({ type: "paste", text });
283
+ }
210
284
  emitMouse(event) {
211
285
  if (!this.handler)
212
286
  return;
@@ -1,4 +1,5 @@
1
1
  import process from "node:process";
2
+ import { isControlChar } from "../sanitize.js";
2
3
  function styleKey(st) {
3
4
  let k = "";
4
5
  if (st.fg)
@@ -46,18 +47,52 @@ export class Renderer {
46
47
  prevW = 0;
47
48
  prevH = 0;
48
49
  out = process.stdout;
50
+ /**
51
+ * The most recent frame and the rows that were force-repainted for it. Kept
52
+ * so `repaint()` can re-emit the exact same frame — without re-running the
53
+ * screen's (potentially expensive) render — to heal any drift the terminal
54
+ * introduced behind our back.
55
+ */
56
+ lastBuf = null;
57
+ lastForce = [];
49
58
  init() {
50
59
  this.write("\x1b[?1049h"); // alternate screen
51
60
  this.write("\x1b[?25l"); // hide cursor
52
61
  this.write("\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h\x1b[?1015h"); // mouse tracking (incl. any-event for hover)
62
+ this.write("\x1b[?2004h"); // bracketed paste (so pastes can be told apart from typing)
53
63
  }
54
64
  dispose() {
65
+ this.write("\x1b[?2004l");
55
66
  this.write("\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l");
56
67
  this.write("\x1b[?25h");
57
68
  this.write("\x1b[?1049l");
58
69
  this.write("\x1b[0m");
59
70
  }
60
- render(buf) {
71
+ /**
72
+ * @param forceRows rows to repaint unconditionally, even when their contents
73
+ * are unchanged. Screens use this for persistent chrome (input box, hints)
74
+ * so that terminal-side drift can never leave it stale (see
75
+ * `Screen.alwaysRepaintRows`).
76
+ */
77
+ render(buf, forceRows = []) {
78
+ this.lastBuf = buf;
79
+ this.lastForce = forceRows;
80
+ this.draw(buf, forceRows);
81
+ }
82
+ /**
83
+ * Re-emit the last frame in full. Cheap (no screen re-render) and visually a
84
+ * no-op when nothing drifted, but it repairs rows the terminal moved on its
85
+ * own (resize reflow, stray scroll). Called periodically as a safety net.
86
+ */
87
+ repaint() {
88
+ if (!this.lastBuf)
89
+ return;
90
+ const all = [];
91
+ for (let y = 0; y < this.lastBuf.height; y++)
92
+ all.push(y);
93
+ this.draw(this.lastBuf, all);
94
+ }
95
+ draw(buf, forceRows) {
61
96
  const w = buf.width;
62
97
  const h = buf.height;
63
98
  if (w !== this.prevW || h !== this.prevH) {
@@ -65,11 +100,12 @@ export class Renderer {
65
100
  this.prevW = w;
66
101
  this.prevH = h;
67
102
  }
103
+ const force = forceRows.length ? new Set(forceRows) : null;
68
104
  let out = "";
69
105
  for (let y = 0; y < h; y++) {
70
106
  const row = buf.cells[y];
71
107
  const sig = rowSignature(row);
72
- if (sig !== this.prevSig[y]) {
108
+ if (sig !== this.prevSig[y] || (force !== null && force.has(y))) {
73
109
  out += this.renderRow(y, row);
74
110
  this.prevSig[y] = sig;
75
111
  }
@@ -94,7 +130,11 @@ export class Renderer {
94
130
  s += sgr(cell.style);
95
131
  lastKey = sk;
96
132
  }
97
- s += cell.ch;
133
+ // Final safety net: the byte stream is the one place a stray control
134
+ // character would be executed by the terminal (cursor moves, screen
135
+ // clears, …). The Buffer already neutralises them; this guarantees it
136
+ // even if a cell is ever populated by some other path.
137
+ s += isControlChar(cell.ch) ? " " : cell.ch;
98
138
  }
99
139
  s += "\x1b[0m";
100
140
  return s;
@@ -1,6 +1,35 @@
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
+ * Rows this screen draws as persistent "chrome" (an input box, key hints, …)
14
+ * that must be repainted on *every* frame, even when their contents are
15
+ * unchanged.
16
+ *
17
+ * The renderer normally emits only rows whose cells changed, which is what
18
+ * keeps redraws cheap. But the terminal can shift its own contents behind our
19
+ * back — ConPTY reflowing the buffer mid-resize, a size-race repaint, a stray
20
+ * scroll — and a diffed row would then stay stale forever (the classic
21
+ * "input box swallowed by the log" artefact). Redrawing this small strip by
22
+ * absolute position every frame makes it self-healing.
23
+ */
24
+ alwaysRepaintRows() {
25
+ return [];
26
+ }
27
+ /**
28
+ * Handle a bracketed (or detected) paste. `text` already has its newlines
29
+ * flattened to spaces, so it can be inserted as a single line. Screens that
30
+ * have no text input can leave this as a no-op.
31
+ */
32
+ onPaste(_text) { }
4
33
  /** Whether this screen currently shows a blinking text cursor. Used by the
5
34
  * app to run (or stop) the cursor blink timer. */
6
35
  showsCursor() {
package/dist/ui.js CHANGED
@@ -23,52 +23,78 @@ function chunkByWidth(text, width) {
23
23
  export function wrapText(text, width) {
24
24
  if (width <= 0)
25
25
  width = 1;
26
- const lines = [];
27
- for (const para of text.split("\n")) {
26
+ const out = [];
27
+ const paragraphs = text.replace(/\r\n?/g, "\n").split("\n");
28
+ for (const para of paragraphs) {
28
29
  if (para.length === 0) {
29
- lines.push("");
30
+ out.push("");
30
31
  continue;
31
32
  }
32
- let line = "";
33
- let lineW = 0;
34
- for (const word of para.split(" ")) {
35
- const wordW = displayWidth(word);
36
- if (line.length === 0) {
37
- if (wordW <= width) {
38
- line = word;
39
- lineW = wordW;
40
- }
41
- else {
42
- const chunks = chunkByWidth(word, width);
43
- for (let i = 0; i < chunks.length - 1; i++)
44
- lines.push(chunks[i]);
45
- line = chunks[chunks.length - 1];
46
- lineW = displayWidth(line);
47
- }
48
- continue;
33
+ // Preserve the original indentation. Continuation lines reuse it so
34
+ // wrapped command output / code keeps its nesting instead of being
35
+ // flattened to the left margin.
36
+ const leadMatch = para.match(/^[ \t]*/);
37
+ const leadRaw = leadMatch ? leadMatch[0] : "";
38
+ const indent = leadRaw.replace(/\t/g, " ");
39
+ const indentW = displayWidth(indent);
40
+ const body = para.slice(leadRaw.length);
41
+ // Split into words and the whitespace runs between them so runs of spaces
42
+ // (alignment padding in command output) are kept verbatim rather than
43
+ // collapsed to a single space.
44
+ const parts = body.match(/\s+|\S+/g) ?? [];
45
+ let line = indent;
46
+ let lineW = indentW;
47
+ let hasContent = false;
48
+ // Whitespace seen since the last word. It is only emitted together with
49
+ // the next word, so a line break never leaves trailing blanks behind.
50
+ let pending = "";
51
+ const newLine = () => {
52
+ out.push(line);
53
+ line = " ".repeat(indentW);
54
+ lineW = indentW;
55
+ hasContent = false;
56
+ };
57
+ const placeWord = (word) => {
58
+ const gap = hasContent ? pending : "";
59
+ pending = "";
60
+ const gapW = displayWidth(gap);
61
+ const w = displayWidth(word);
62
+ if (lineW + gapW + w <= width) {
63
+ line += gap + word;
64
+ lineW += gapW + w;
65
+ hasContent = true;
66
+ return;
49
67
  }
50
- if (lineW + 1 + wordW <= width) {
51
- line += " " + word;
52
- lineW += 1 + wordW;
68
+ // Does not fit: break the line and drop the gap that caused the break.
69
+ if (hasContent)
70
+ newLine();
71
+ if (lineW + w <= width) {
72
+ line += word;
73
+ lineW += w;
74
+ hasContent = true;
75
+ return;
53
76
  }
54
- else {
55
- lines.push(line);
56
- if (wordW <= width) {
57
- line = word;
58
- lineW = wordW;
59
- }
60
- else {
61
- const chunks = chunkByWidth(word, width);
62
- for (let i = 0; i < chunks.length - 1; i++)
63
- lines.push(chunks[i]);
64
- line = chunks[chunks.length - 1];
65
- lineW = displayWidth(line);
66
- }
77
+ // Word is wider than an entire line: hard-wrap it by columns.
78
+ const avail = Math.max(1, width - lineW);
79
+ const chunks = chunkByWidth(word, avail);
80
+ for (let i = 0; i < chunks.length - 1; i++) {
81
+ line += chunks[i];
82
+ newLine();
83
+ }
84
+ line += chunks[chunks.length - 1];
85
+ lineW += displayWidth(chunks[chunks.length - 1]);
86
+ hasContent = true;
87
+ };
88
+ for (const part of parts) {
89
+ if (/^\s+$/.test(part)) {
90
+ pending += part.replace(/\t/g, " ");
91
+ continue;
67
92
  }
93
+ placeWord(part);
68
94
  }
69
- lines.push(line);
95
+ out.push(line);
70
96
  }
71
- return lines;
97
+ return out;
72
98
  }
73
99
  /**
74
100
  * Compute which part of the input value is visible inside the box, given the
@@ -137,6 +163,47 @@ export function drawHint(buf, y, text) {
137
163
  const t = displayWidth(text) <= max ? text : truncateByWidth(text, max - 1) + "…";
138
164
  buf.centerText(y, t, { fg: C.dim });
139
165
  }
166
+ /**
167
+ * Geometry of a vertical scrollbar, shared by the drawing code and the app's
168
+ * hit-testing / dragging so the two can never disagree.
169
+ */
170
+ export function scrollbarGeometry(top, bottom, total, viewport, offset) {
171
+ const trackH = Math.max(0, bottom - top + 1);
172
+ const thumbH = Math.max(1, Math.round((trackH * viewport) / total));
173
+ const maxThumbTop = Math.max(0, trackH - thumbH);
174
+ const maxOffset = Math.max(1, total - viewport);
175
+ const clamped = Math.max(0, Math.min(offset, maxOffset));
176
+ const thumbTop = maxThumbTop === 0 ? 0 : Math.round((maxThumbTop * clamped) / maxOffset);
177
+ return { trackH, thumbH, maxThumbTop, maxOffset, thumbTop };
178
+ }
179
+ /**
180
+ * Inverse of the thumb positioning above: the scroll offset that places the
181
+ * thumb's top at 'thumbTop'. Used while dragging the thumb; feeding the result
182
+ * back through scrollbarGeometry reproduces the same 'thumbTop'.
183
+ */
184
+ export function offsetFromThumbTop(g, thumbTop) {
185
+ if (g.maxThumbTop === 0)
186
+ return 0;
187
+ const t = Math.max(0, Math.min(thumbTop, g.maxThumbTop));
188
+ return Math.round((g.maxOffset * t) / g.maxThumbTop);
189
+ }
190
+ export function drawScrollbar(buf, x, top, bottom, total, viewport, offset, opts = {}) {
191
+ const trackH = bottom - top + 1;
192
+ if (trackH <= 0 || x < 0 || x >= buf.width)
193
+ return;
194
+ if (total <= viewport || viewport <= 0)
195
+ return; // nothing to scroll: hide it
196
+ const trackColor = opts.track ?? C.scrollTrack;
197
+ const thumbColor = opts.thumb ?? C.scrollThumb;
198
+ const g = scrollbarGeometry(top, bottom, total, viewport, offset);
199
+ for (let i = 0; i < g.trackH; i++) {
200
+ const isThumb = i >= g.thumbTop && i < g.thumbTop + g.thumbH;
201
+ buf.set(x, top + i, isThumb ? "█" : "│", { fg: isThumb ? thumbColor : trackColor });
202
+ // The bar is chrome, not content: mark it so copying a selection never
203
+ // drags the scrollbar (or the padding before it) into the clipboard.
204
+ buf.markChrome(x, top + i);
205
+ }
206
+ }
140
207
  export function drawInputBox(buf, x, y, w, value, cursorPos, opts = {}) {
141
208
  const active = opts.active ?? true;
142
209
  const dimmed = opts.dimmed ?? false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingjisoft/neutrino",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "XINGJI Neutrino - a CLI coding agent in the terminal.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -17,7 +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
+ "test": "npm run build && node scripts/test-paste.mjs && node scripts/test-mouse.mjs && node scripts/test-cursor.mjs && node scripts/test-selection.mjs && node scripts/test-copy.mjs && node scripts/test-scrollbar.mjs && node scripts/test-render.mjs && node scripts/test-drift.mjs && node scripts/test-sanitize.mjs && node scripts/test-markdown.mjs && node scripts/test-wrap.mjs && node scripts/test-fetch.mjs && node scripts/test-compact.mjs && node scripts/test-tools.mjs",
21
21
  "prepare": "npm run build"
22
22
  },
23
23
  "keywords": [