@xingjisoft/neutrino 0.1.2 → 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 +9 -1
- package/dist/app.js +45 -0
- package/dist/colors.js +2 -0
- package/dist/markdown.js +1 -1
- package/dist/screens/agentConversation.js +29 -2
- package/dist/screens/chat.js +41 -1
- package/dist/screens/start.js +11 -0
- package/dist/terminal/input.js +75 -1
- package/dist/terminal/renderer.js +2 -0
- package/dist/terminal/screen.js +14 -0
- package/dist/ui.js +102 -38
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -7,6 +7,9 @@ A CLI coding agent that lives in your terminal. Neutrino gives you an interactiv
|
|
|
7
7
|
- **Interactive terminal UI** — logo screen, chat, model select, history, and settings screens.
|
|
8
8
|
- **Full mouse support** — hover to highlight, click to select/open/position the cursor, wheel to scroll, right-click to go back (like OpenCode).
|
|
9
9
|
- **Select & copy** — drag the mouse over any text (agent output, the input box, menus, …) to select it; releasing the button copies the selection to the clipboard (via OSC 52, with a native fallback).
|
|
10
|
+
- **Paste-friendly input** — like OpenCode, a pasted block has its newlines turned into spaces so it stays one line, and only a manually typed `Enter` submits the message.
|
|
11
|
+
- **Faithful log rendering** — text in the chat log keeps its indentation, blank lines and column alignment; when a long tool-output line wraps, continuation lines stay indented instead of snapping back to the left margin.
|
|
12
|
+
- **Scrollable context log** — a vertical scrollbar is drawn on the right edge of the chat (and the agent-conversation viewer) whenever the conversation is taller than the view. Scroll with the wheel, `PgUp`/`PgDn` or `↑`/`↓`, or grab the scrollbar with the mouse and drag it like in a GUI.
|
|
10
13
|
- **LLM providers** — ships with DeepSeek, OpenAI, and Anthropic presets; works with any OpenAI-compatible endpoint.
|
|
11
14
|
- **Tool calling** — the agent can run shell commands, read files, write files, and list directories in your working directory.
|
|
12
15
|
- **Streaming responses** — token-by-token output with pause/resume (`ESC ESC`).
|
|
@@ -48,7 +51,7 @@ Type a message on the start screen and press `Enter` to begin a new chat.
|
|
|
48
51
|
|
|
49
52
|
| Keys | Action |
|
|
50
53
|
| --- | --- |
|
|
51
|
-
| `Enter` | Send message / confirm |
|
|
54
|
+
| `Enter` | Send message / confirm (only a manually typed Enter sends) |
|
|
52
55
|
| `←`/`→`, `↑`/`↓` | Move focus / navigate |
|
|
53
56
|
| `Ctrl+E` | Open model config |
|
|
54
57
|
| `Ctrl+B` | Back to menu (from chat) |
|
|
@@ -69,6 +72,7 @@ Type a message on the start screen and press `Enter` to begin a new chat.
|
|
|
69
72
|
| Click and drag | Select text under the rectangle; releasing copies it to the clipboard |
|
|
70
73
|
| Click the header (left of `ⓘ`) | Open the model selector |
|
|
71
74
|
| Click `ⓘ` (chat header) | Toggle token usage stats |
|
|
75
|
+
| Drag the scrollbar | Grab the thumb on the right edge of the chat/log and drag it to scroll |
|
|
72
76
|
| Scroll wheel | Scroll the chat/log, or move through lists |
|
|
73
77
|
| Right-click | Go back (like `Esc`) |
|
|
74
78
|
|
|
@@ -113,9 +117,13 @@ npm test # build, then run every test below
|
|
|
113
117
|
Individual tests:
|
|
114
118
|
|
|
115
119
|
```sh
|
|
120
|
+
node scripts/test-paste.mjs # verify pasted newlines are flattened to spaces
|
|
116
121
|
node scripts/test-mouse.mjs # verify mouse escape-sequence parsing
|
|
117
122
|
node scripts/test-cursor.mjs # verify click-to-position-cursor math
|
|
118
123
|
node scripts/test-selection.mjs # verify selection highlighting & text extraction
|
|
124
|
+
node scripts/test-scrollbar.mjs # verify the conversation scrollbar size/position and drag maths
|
|
125
|
+
node scripts/test-markdown.mjs # verify markdown/table rendering (incl. ragged rows)
|
|
126
|
+
node scripts/test-wrap.mjs # verify indentation & spacing are preserved when wrapping
|
|
119
127
|
node scripts/test-render.mjs # fuzz the diff renderer + render the chat screen,
|
|
120
128
|
# asserting no stale/residual cells are left behind
|
|
121
129
|
```
|
package/dist/app.js
CHANGED
|
@@ -6,6 +6,7 @@ import { defaultState } from "./state.js";
|
|
|
6
6
|
import { loadConfig, saveConfig } from "./config.js";
|
|
7
7
|
import { C } from "./colors.js";
|
|
8
8
|
import { applySelection, selectionText } from "./selection.js";
|
|
9
|
+
import { offsetFromThumbTop, scrollbarGeometry } from "./ui.js";
|
|
9
10
|
import { copyToClipboard } from "./clipboard.js";
|
|
10
11
|
import { ChatScreen } from "./screens/chat.js";
|
|
11
12
|
import { HistoryScreen } from "./screens/history.js";
|
|
@@ -37,6 +38,9 @@ export class App {
|
|
|
37
38
|
// never moves, so it is forwarded to the screen and leaves no selection.
|
|
38
39
|
pressed = false;
|
|
39
40
|
pressStart = null;
|
|
41
|
+
// Active scrollbar drag: how many rows below the thumb's top the press
|
|
42
|
+
// landed, so the thumb stays under the pointer as it moves.
|
|
43
|
+
barDrag = null;
|
|
40
44
|
resizeHandler = () => this.onResize();
|
|
41
45
|
constructor() {
|
|
42
46
|
this.width = process.stdout.columns || 80;
|
|
@@ -157,6 +161,14 @@ export class App {
|
|
|
157
161
|
this.state.cursorOn = true;
|
|
158
162
|
}
|
|
159
163
|
handleEvent(ev) {
|
|
164
|
+
if (ev.type === "paste") {
|
|
165
|
+
// Pasted text arrives with its newlines already flattened to spaces, so
|
|
166
|
+
// it is inserted into the input instead of being submitted.
|
|
167
|
+
this.resetMouse();
|
|
168
|
+
this.wakeCursor();
|
|
169
|
+
this.top?.onPaste(ev.text);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
160
172
|
if (ev.type === "key") {
|
|
161
173
|
const k = ev.key;
|
|
162
174
|
// Any keystroke ends a pending mouse gesture (in case a release was lost).
|
|
@@ -194,6 +206,28 @@ export class App {
|
|
|
194
206
|
handleMouse(ev) {
|
|
195
207
|
if (ev.type === "click") {
|
|
196
208
|
this.wakeCursor();
|
|
209
|
+
// A left-press on the scrollbar starts a thumb drag rather than a text
|
|
210
|
+
// selection or a click on the screen behind it.
|
|
211
|
+
if (ev.button === 0) {
|
|
212
|
+
const bar = this.top?.scrollbar();
|
|
213
|
+
if (bar && ev.x >= bar.x && ev.y >= bar.top && ev.y <= bar.bottom) {
|
|
214
|
+
const g = scrollbarGeometry(bar.top, bar.bottom, bar.total, bar.viewport, bar.offset);
|
|
215
|
+
let grab = ev.y - bar.top - g.thumbTop;
|
|
216
|
+
if (grab < 0 || grab >= g.thumbH) {
|
|
217
|
+
// Pressed the groove: centre the thumb under the pointer.
|
|
218
|
+
grab = Math.floor(g.thumbH / 2);
|
|
219
|
+
const thumbTop = Math.max(0, Math.min(ev.y - bar.top - grab, g.maxThumbTop));
|
|
220
|
+
bar.scrollTo(offsetFromThumbTop(g, thumbTop));
|
|
221
|
+
}
|
|
222
|
+
this.barDrag = { grab };
|
|
223
|
+
this.pressed = false;
|
|
224
|
+
this.pressStart = null;
|
|
225
|
+
if (this.state.selection)
|
|
226
|
+
this.state.selection = null;
|
|
227
|
+
this.render();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
197
231
|
this.pressed = true;
|
|
198
232
|
this.pressStart = { x: ev.x, y: ev.y };
|
|
199
233
|
if (this.state.selection) {
|
|
@@ -206,6 +240,15 @@ export class App {
|
|
|
206
240
|
return;
|
|
207
241
|
}
|
|
208
242
|
if (ev.type === "move") {
|
|
243
|
+
if (this.barDrag) {
|
|
244
|
+
const bar = this.top?.scrollbar();
|
|
245
|
+
if (bar) {
|
|
246
|
+
const g = scrollbarGeometry(bar.top, bar.bottom, bar.total, bar.viewport, bar.offset);
|
|
247
|
+
const thumbTop = Math.max(0, Math.min(ev.y - bar.top - this.barDrag.grab, g.maxThumbTop));
|
|
248
|
+
bar.scrollTo(offsetFromThumbTop(g, thumbTop));
|
|
249
|
+
}
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
209
252
|
if (this.pressed && this.pressStart) {
|
|
210
253
|
const s = this.pressStart;
|
|
211
254
|
if (ev.x !== s.x || ev.y !== s.y) {
|
|
@@ -223,6 +266,7 @@ export class App {
|
|
|
223
266
|
const sel = this.state.selection;
|
|
224
267
|
this.pressed = false;
|
|
225
268
|
this.pressStart = null;
|
|
269
|
+
this.barDrag = null;
|
|
226
270
|
if (sel) {
|
|
227
271
|
if (this.lastBuffer) {
|
|
228
272
|
const text = selectionText(this.lastBuffer, sel);
|
|
@@ -245,6 +289,7 @@ export class App {
|
|
|
245
289
|
resetMouse() {
|
|
246
290
|
this.pressed = false;
|
|
247
291
|
this.pressStart = null;
|
|
292
|
+
this.barDrag = null;
|
|
248
293
|
}
|
|
249
294
|
copySelection() {
|
|
250
295
|
const sel = this.state.selection;
|
package/dist/colors.js
CHANGED
|
@@ -12,5 +12,7 @@ export const C = {
|
|
|
12
12
|
red: [247, 118, 142],
|
|
13
13
|
codeBg: [27, 35, 49], // dark blue-grey used behind code blocks
|
|
14
14
|
selection: [56, 92, 138], // muted blue used to highlight selected text
|
|
15
|
+
scrollTrack: [45, 58, 62], // dim groove behind the scrollbar thumb
|
|
16
|
+
scrollThumb: [130, 142, 190], // draggable scrollbar thumb
|
|
15
17
|
};
|
|
16
18
|
export const EMPTY_STYLE = {};
|
package/dist/markdown.js
CHANGED
|
@@ -566,7 +566,7 @@ function renderTable(t, width) {
|
|
|
566
566
|
for (let c = 0; c < nCols; c++) {
|
|
567
567
|
if (c === 0)
|
|
568
568
|
spans.push({ text: "│ ", style: borderStyle });
|
|
569
|
-
const cellLine = wrapped[c][li]?.spans ?? [];
|
|
569
|
+
const cellLine = wrapped[c]?.[li]?.spans ?? [];
|
|
570
570
|
for (const s of cellLine) {
|
|
571
571
|
spans.push(isHeader ? { ...s, style: { ...s.style, bold: true } } : s);
|
|
572
572
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Screen } from "../terminal/screen.js";
|
|
2
2
|
import { C } from "../colors.js";
|
|
3
3
|
import { drawHeader } from "../header.js";
|
|
4
|
-
import { drawHint } from "../ui.js";
|
|
4
|
+
import { drawHint, drawScrollbar } from "../ui.js";
|
|
5
5
|
import { drawSpans, renderMarkdownText } from "../markdown.js";
|
|
6
6
|
import { ellipsize } from "../width.js";
|
|
7
7
|
export class AgentConversationScreen extends Screen {
|
|
@@ -9,6 +9,7 @@ export class AgentConversationScreen extends Screen {
|
|
|
9
9
|
messages;
|
|
10
10
|
scrollTop = 0;
|
|
11
11
|
followBottom = true;
|
|
12
|
+
scrollbarGeom = null;
|
|
12
13
|
constructor(title, messages) {
|
|
13
14
|
super();
|
|
14
15
|
this.title = title;
|
|
@@ -52,6 +53,25 @@ export class AgentConversationScreen extends Screen {
|
|
|
52
53
|
this.app.popScreen();
|
|
53
54
|
}
|
|
54
55
|
}
|
|
56
|
+
scrollbar() {
|
|
57
|
+
const g = this.scrollbarGeom;
|
|
58
|
+
if (!g || g.total <= g.viewport)
|
|
59
|
+
return null;
|
|
60
|
+
return {
|
|
61
|
+
x: g.x,
|
|
62
|
+
top: g.top,
|
|
63
|
+
bottom: g.bottom,
|
|
64
|
+
total: g.total,
|
|
65
|
+
viewport: g.viewport,
|
|
66
|
+
offset: this.scrollTop,
|
|
67
|
+
scrollTo: (offset) => {
|
|
68
|
+
const maxScroll = Math.max(0, g.total - g.viewport);
|
|
69
|
+
this.scrollTop = Math.max(0, Math.min(offset, maxScroll));
|
|
70
|
+
this.followBottom = this.scrollTop >= maxScroll;
|
|
71
|
+
this.app.render();
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
55
75
|
buildLines(width) {
|
|
56
76
|
const lines = [];
|
|
57
77
|
for (const m of this.messages) {
|
|
@@ -85,12 +105,19 @@ export class AgentConversationScreen extends Screen {
|
|
|
85
105
|
}
|
|
86
106
|
if (this.scrollTop < 0)
|
|
87
107
|
this.scrollTop = 0;
|
|
108
|
+
// Expose the bar so the app can drag it (see the scrollbar() method).
|
|
109
|
+
this.scrollbarGeom =
|
|
110
|
+
lines.length > viewH
|
|
111
|
+
? { x: w - 1, top: logTop, bottom: logBottom, total: lines.length, viewport: viewH }
|
|
112
|
+
: null;
|
|
88
113
|
for (let y = logTop; y <= logBottom; y++) {
|
|
89
114
|
const li = this.scrollTop + (y - logTop);
|
|
90
115
|
if (li >= lines.length)
|
|
91
116
|
break;
|
|
92
117
|
drawSpans(buf, 2, y, lines[li].spans, Math.max(4, w - 4));
|
|
93
118
|
}
|
|
94
|
-
|
|
119
|
+
// Scrollbar on the right edge, visible only when the log overflows.
|
|
120
|
+
drawScrollbar(buf, w - 1, logTop, logBottom, lines.length, viewH, this.scrollTop);
|
|
121
|
+
drawHint(buf, h - 1, "↑/↓ scroll · wheel or drag the bar · right-click back · Esc back");
|
|
95
122
|
}
|
|
96
123
|
}
|
package/dist/screens/chat.js
CHANGED
|
@@ -2,7 +2,7 @@ import { Screen } from "../terminal/screen.js";
|
|
|
2
2
|
import { C } from "../colors.js";
|
|
3
3
|
import { loadPrompt, saveSession, } from "../config.js";
|
|
4
4
|
import { drawHeader } from "../header.js";
|
|
5
|
-
import { computeInputWindow, cursorFromColumn, drawInputBox, wrapText } from "../ui.js";
|
|
5
|
+
import { computeInputWindow, cursorFromColumn, drawInputBox, drawScrollbar, wrapText } from "../ui.js";
|
|
6
6
|
import { drawSpans, renderMarkdownText } from "../markdown.js";
|
|
7
7
|
import { ellipsize, displayWidth } from "../width.js";
|
|
8
8
|
import { LLMClient } from "../llm/client.js";
|
|
@@ -42,6 +42,7 @@ export class ChatScreen extends Screen {
|
|
|
42
42
|
cursor = 0;
|
|
43
43
|
scrollTop = 0;
|
|
44
44
|
followBottom = true;
|
|
45
|
+
scrollbarGeom = null;
|
|
45
46
|
lastEscTime = 0;
|
|
46
47
|
initialized = false;
|
|
47
48
|
constructor(initialMessage = "") {
|
|
@@ -82,6 +83,16 @@ export class ChatScreen extends Screen {
|
|
|
82
83
|
// The chat input is always focused; a cursor is drawn when it holds text.
|
|
83
84
|
return this.inputValue.length > 0;
|
|
84
85
|
}
|
|
86
|
+
onPaste(text) {
|
|
87
|
+
if (!text)
|
|
88
|
+
return;
|
|
89
|
+
if (this.showInfo)
|
|
90
|
+
this.showInfo = false;
|
|
91
|
+
this.inputValue =
|
|
92
|
+
this.inputValue.slice(0, this.cursor) + text + this.inputValue.slice(this.cursor);
|
|
93
|
+
this.cursor += text.length;
|
|
94
|
+
this.app.render();
|
|
95
|
+
}
|
|
85
96
|
onKey(key) {
|
|
86
97
|
if (this.showInfo) {
|
|
87
98
|
this.showInfo = false;
|
|
@@ -237,6 +248,26 @@ export class ChatScreen extends Screen {
|
|
|
237
248
|
this.app.render();
|
|
238
249
|
}
|
|
239
250
|
}
|
|
251
|
+
scrollbar() {
|
|
252
|
+
const g = this.scrollbarGeom;
|
|
253
|
+
if (!g || g.total <= g.viewport)
|
|
254
|
+
return null;
|
|
255
|
+
return {
|
|
256
|
+
x: g.x,
|
|
257
|
+
top: g.top,
|
|
258
|
+
bottom: g.bottom,
|
|
259
|
+
total: g.total,
|
|
260
|
+
viewport: g.viewport,
|
|
261
|
+
offset: this.scrollTop,
|
|
262
|
+
scrollTo: (offset) => {
|
|
263
|
+
const maxScroll = Math.max(0, g.total - g.viewport);
|
|
264
|
+
this.scrollTop = Math.max(0, Math.min(offset, maxScroll));
|
|
265
|
+
// Dragging to the very bottom resumes auto-following new output.
|
|
266
|
+
this.followBottom = this.scrollTop >= maxScroll;
|
|
267
|
+
this.app.render();
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
240
271
|
togglePause() {
|
|
241
272
|
if (!this.busy && !this.queued)
|
|
242
273
|
return;
|
|
@@ -463,12 +494,21 @@ export class ChatScreen extends Screen {
|
|
|
463
494
|
}
|
|
464
495
|
if (this.scrollTop < 0)
|
|
465
496
|
this.scrollTop = 0;
|
|
497
|
+
// Remember the bar's geometry so a drag on the last column can scroll the
|
|
498
|
+
// log (see the scrollbar() method).
|
|
499
|
+
this.scrollbarGeom =
|
|
500
|
+
lines.length > viewH
|
|
501
|
+
? { x: w - 1, top: logTop, bottom: logBottom, total: lines.length, viewport: viewH }
|
|
502
|
+
: null;
|
|
466
503
|
for (let y = logTop; y <= logBottom; y++) {
|
|
467
504
|
const li = this.scrollTop + (y - logTop);
|
|
468
505
|
if (li >= lines.length)
|
|
469
506
|
break;
|
|
470
507
|
drawSpans(buf, 2, y, lines[li].spans, Math.max(4, w - 4));
|
|
471
508
|
}
|
|
509
|
+
// A scrollbar on the right edge of the log, shown only when the
|
|
510
|
+
// conversation overflows the visible area.
|
|
511
|
+
drawScrollbar(buf, w - 1, logTop, logBottom, lines.length, viewH, this.scrollTop);
|
|
472
512
|
const inW = Math.max(12, w - 2);
|
|
473
513
|
drawInputBox(buf, 1, inputY, inW, this.inputValue, this.cursor, {
|
|
474
514
|
active: true,
|
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
|
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;
|
|
@@ -50,8 +50,10 @@ export class Renderer {
|
|
|
50
50
|
this.write("\x1b[?1049h"); // alternate screen
|
|
51
51
|
this.write("\x1b[?25l"); // hide cursor
|
|
52
52
|
this.write("\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h\x1b[?1015h"); // mouse tracking (incl. any-event for hover)
|
|
53
|
+
this.write("\x1b[?2004h"); // bracketed paste (so pastes can be told apart from typing)
|
|
53
54
|
}
|
|
54
55
|
dispose() {
|
|
56
|
+
this.write("\x1b[?2004l");
|
|
55
57
|
this.write("\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l");
|
|
56
58
|
this.write("\x1b[?25h");
|
|
57
59
|
this.write("\x1b[?1049l");
|
package/dist/terminal/screen.js
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
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
|
+
* Handle a bracketed (or detected) paste. `text` already has its newlines
|
|
14
|
+
* flattened to spaces, so it can be inserted as a single line. Screens that
|
|
15
|
+
* have no text input can leave this as a no-op.
|
|
16
|
+
*/
|
|
17
|
+
onPaste(_text) { }
|
|
4
18
|
/** Whether this screen currently shows a blinking text cursor. Used by the
|
|
5
19
|
* app to run (or stop) the cursor blink timer. */
|
|
6
20
|
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,44 @@ 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;
|
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,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-scrollbar.mjs && node scripts/test-render.mjs && node scripts/test-markdown.mjs && node scripts/test-wrap.mjs",
|
|
21
21
|
"prepare": "npm run build"
|
|
22
22
|
},
|
|
23
23
|
"keywords": [
|