@xingjisoft/neutrino 0.1.3 → 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.
@@ -168,6 +168,20 @@ export class StartScreen extends Screen {
168
168
  return;
169
169
  }
170
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
+ }
171
185
  layout() {
172
186
  const w = this.app.width;
173
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,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,6 +47,14 @@ 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
@@ -59,7 +68,31 @@ export class Renderer {
59
68
  this.write("\x1b[?1049l");
60
69
  this.write("\x1b[0m");
61
70
  }
62
- 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) {
63
96
  const w = buf.width;
64
97
  const h = buf.height;
65
98
  if (w !== this.prevW || h !== this.prevH) {
@@ -67,11 +100,12 @@ export class Renderer {
67
100
  this.prevW = w;
68
101
  this.prevH = h;
69
102
  }
103
+ const force = forceRows.length ? new Set(forceRows) : null;
70
104
  let out = "";
71
105
  for (let y = 0; y < h; y++) {
72
106
  const row = buf.cells[y];
73
107
  const sig = rowSignature(row);
74
- if (sig !== this.prevSig[y]) {
108
+ if (sig !== this.prevSig[y] || (force !== null && force.has(y))) {
75
109
  out += this.renderRow(y, row);
76
110
  this.prevSig[y] = sig;
77
111
  }
@@ -96,7 +130,11 @@ export class Renderer {
96
130
  s += sgr(cell.style);
97
131
  lastKey = sk;
98
132
  }
99
- 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;
100
138
  }
101
139
  s += "\x1b[0m";
102
140
  return s;
@@ -9,6 +9,21 @@ export class Screen {
9
9
  scrollbar() {
10
10
  return null;
11
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
+ }
12
27
  /**
13
28
  * Handle a bracketed (or detected) paste. `text` already has its newlines
14
29
  * flattened to spaces, so it can be inserted as a single line. Screens that
package/dist/ui.js CHANGED
@@ -199,6 +199,9 @@ export function drawScrollbar(buf, x, top, bottom, total, viewport, offset, opts
199
199
  for (let i = 0; i < g.trackH; i++) {
200
200
  const isThumb = i >= g.thumbTop && i < g.thumbTop + g.thumbH;
201
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);
202
205
  }
203
206
  }
204
207
  export function drawInputBox(buf, x, y, w, value, cursorPos, opts = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingjisoft/neutrino",
3
- "version": "0.1.3",
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-paste.mjs && node scripts/test-mouse.mjs && node scripts/test-cursor.mjs && node scripts/test-selection.mjs && node scripts/test-scrollbar.mjs && node scripts/test-render.mjs && node scripts/test-markdown.mjs && node scripts/test-wrap.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": [