@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.
- package/README.md +36 -2
- package/dist/app.js +65 -1
- package/dist/colors.js +2 -0
- package/dist/compact.js +68 -0
- package/dist/config.js +15 -0
- package/dist/llm/client.js +309 -84
- package/dist/markdown.js +1 -1
- package/dist/sanitize.js +61 -0
- package/dist/screens/agentConversation.js +29 -2
- package/dist/screens/chat.js +284 -27
- package/dist/screens/settings.js +83 -44
- package/dist/screens/start.js +25 -0
- package/dist/selection.js +25 -11
- package/dist/terminal/buffer.js +24 -3
- package/dist/terminal/input.js +75 -1
- package/dist/terminal/renderer.js +43 -3
- package/dist/terminal/screen.js +29 -0
- package/dist/ui.js +105 -38
- package/package.json +2 -2
package/dist/screens/start.js
CHANGED
|
@@ -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
|
-
/**
|
|
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];
|
package/dist/terminal/input.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/terminal/screen.js
CHANGED
|
@@ -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
|
|
27
|
-
|
|
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
|
-
|
|
30
|
+
out.push("");
|
|
30
31
|
continue;
|
|
31
32
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
95
|
+
out.push(line);
|
|
70
96
|
}
|
|
71
|
-
return
|
|
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.
|
|
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": [
|