@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.
- package/README.md +31 -4
- package/dist/app.js +205 -2
- package/dist/clipboard.js +113 -0
- package/dist/colors.js +3 -0
- package/dist/config.js +19 -0
- package/dist/markdown.js +1 -1
- package/dist/screens/agentConversation.js +29 -2
- package/dist/screens/chat.js +47 -2
- package/dist/screens/start.js +69 -5
- package/dist/selection.js +75 -0
- package/dist/state.js +2 -0
- package/dist/terminal/buffer.js +63 -8
- package/dist/terminal/input.js +94 -16
- package/dist/terminal/renderer.js +9 -2
- package/dist/terminal/screen.js +19 -0
- package/dist/ui.js +129 -44
- package/package.json +2 -1
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,10 +163,49 @@ 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
|
+
}
|
|
203
|
+
}
|
|
140
204
|
export function drawInputBox(buf, x, y, w, value, cursorPos, opts = {}) {
|
|
141
205
|
const active = opts.active ?? true;
|
|
142
206
|
const dimmed = opts.dimmed ?? false;
|
|
143
207
|
const placeholder = opts.placeholder ?? "";
|
|
208
|
+
const cursorOn = opts.cursorOn ?? true;
|
|
144
209
|
const h = 3;
|
|
145
210
|
const innerX = x + 2;
|
|
146
211
|
const innerW = w - 4;
|
|
@@ -158,22 +223,42 @@ export function drawInputBox(buf, x, y, w, value, cursorPos, opts = {}) {
|
|
|
158
223
|
}
|
|
159
224
|
const win = computeInputWindow(value, cursorPos, innerW);
|
|
160
225
|
const chars = Array.from(value);
|
|
226
|
+
// Codepoint index of the cursor (cursorPos is a UTF-16 offset).
|
|
227
|
+
const cursorCp = Math.min(Array.from(value.slice(0, cursorPos)).length, chars.length);
|
|
161
228
|
const visible = chars.slice(win.startCp, win.startCp + win.visibleCount).join("");
|
|
162
229
|
buf.writeText(innerX, innerY, visible, textStyle);
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
230
|
+
// Blinking block cursor. While it is "on" the character underneath is drawn
|
|
231
|
+
// inside the block in the opposite colour so the text stays legible instead
|
|
232
|
+
// of being hidden by the cursor.
|
|
233
|
+
const cursorScreenCol = cursorColAt(chars, cursorCp, win);
|
|
234
|
+
if (active && cursorOn && cursorScreenCol >= 0 && cursorScreenCol < innerW) {
|
|
235
|
+
drawBlockCursor(buf, innerX + cursorScreenCol, innerY, chars, cursorCp, cursorScreenCol, innerW);
|
|
166
236
|
}
|
|
167
237
|
}
|
|
168
|
-
function cursorColAt(
|
|
169
|
-
const chars = Array.from(value);
|
|
170
|
-
const cursorCp = Math.min(Array.from(value.slice(0, cursorPos)).length, chars.length);
|
|
238
|
+
function cursorColAt(chars, cursorCp, win) {
|
|
171
239
|
let col = win.prefixW;
|
|
172
240
|
for (let i = win.startCp; i < cursorCp; i++) {
|
|
173
241
|
col += wcwidth(chars[i].codePointAt(0) ?? 0);
|
|
174
242
|
}
|
|
175
243
|
return col - win.prefixW;
|
|
176
244
|
}
|
|
245
|
+
/**
|
|
246
|
+
* Draw a single block cursor cell. The glyph under the cursor (the character
|
|
247
|
+
* immediately to its right) is rendered in the opposite colour — a dark glyph
|
|
248
|
+
* on the accent block — so it remains readable. Past the end of the text, or
|
|
249
|
+
* when the glyph would not fit, a solid block is drawn instead.
|
|
250
|
+
*/
|
|
251
|
+
function drawBlockCursor(buf, x, y, chars, cursorCp, col, innerW) {
|
|
252
|
+
const cursorStyle = { fg: C.black, bg: C.accent };
|
|
253
|
+
const ch = cursorCp < chars.length ? chars[cursorCp] : "";
|
|
254
|
+
const cw = ch ? wcwidth(ch.codePointAt(0) ?? 0) : 0;
|
|
255
|
+
if (ch && ch !== " " && cw >= 1 && col + cw <= innerW) {
|
|
256
|
+
buf.writeText(x, y, ch, cursorStyle);
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
buf.set(x, y, " ", cursorStyle);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
177
262
|
function textStylePlaceholder(active) {
|
|
178
263
|
return active ? { fg: C.dim, bg: C.highlightBg } : { fg: C.dim };
|
|
179
264
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xingjisoft/neutrino",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "XINGJI Neutrino - a CLI coding agent in the terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -17,6 +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
21
|
"prepare": "npm run build"
|
|
21
22
|
},
|
|
22
23
|
"keywords": [
|