@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.
- package/README.md +27 -1
- package/dist/app.js +20 -1
- package/dist/compact.js +68 -0
- package/dist/config.js +15 -0
- package/dist/llm/client.js +309 -84
- package/dist/sanitize.js +61 -0
- package/dist/screens/chat.js +243 -26
- package/dist/screens/settings.js +83 -44
- package/dist/screens/start.js +14 -0
- package/dist/selection.js +25 -11
- package/dist/terminal/buffer.js +24 -3
- package/dist/terminal/renderer.js +41 -3
- package/dist/terminal/screen.js +15 -0
- package/dist/ui.js +3 -0
- package/package.json +2 -2
package/dist/screens/start.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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.
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
-
|
|
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 <=
|
|
76
|
+
for (let x = x0; x <= x1; x++) {
|
|
63
77
|
const cell = buf.cells[y][x];
|
|
64
|
-
if (!cell || cell.ch === "")
|
|
65
|
-
continue;
|
|
78
|
+
if (!cell || cell.chrome || cell.ch === "")
|
|
79
|
+
continue;
|
|
66
80
|
line += cell.ch;
|
|
67
81
|
}
|
|
68
82
|
lines.push(line.replace(/[ \t]+$/, ""));
|
package/dist/terminal/buffer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/terminal/screen.js
CHANGED
|
@@ -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
|
+
"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": [
|