@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/screens/start.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Screen } from "../terminal/screen.js";
|
|
2
2
|
import { C } from "../colors.js";
|
|
3
|
-
import { loadLogo } from "../config.js";
|
|
3
|
+
import { loadLogo, loadVersion } from "../config.js";
|
|
4
4
|
import { drawHeader } from "../header.js";
|
|
5
5
|
import { centerVertically, computeInputWindow, cursorFromColumn, drawHint, drawInputBox, drawTabButton, inRect, } from "../ui.js";
|
|
6
6
|
const TABS = ["Model", "History", "Settings"];
|
|
@@ -8,6 +8,9 @@ const LOGO = loadLogo();
|
|
|
8
8
|
const FOCUS_COUNT = TABS.length + 1; // input box + 3 tabs
|
|
9
9
|
const INPUT_H = 3;
|
|
10
10
|
const TAB_H = 3;
|
|
11
|
+
function isLowSurrogate(code) {
|
|
12
|
+
return code >= 0xdc00 && code <= 0xdfff;
|
|
13
|
+
}
|
|
11
14
|
export class StartScreen extends Screen {
|
|
12
15
|
inputValue = "";
|
|
13
16
|
cursor = 0;
|
|
@@ -15,12 +18,40 @@ export class StartScreen extends Screen {
|
|
|
15
18
|
hoverIdx = -1; // -1 = nothing, 0 = input box, 1..3 = tabs
|
|
16
19
|
lastHoverX = -1;
|
|
17
20
|
lastHoverY = -1;
|
|
21
|
+
showsCursor() {
|
|
22
|
+
// The input cursor is only drawn for the focused, non-empty input box.
|
|
23
|
+
return this.focusIdx === 0 && this.inputValue.length > 0;
|
|
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
|
+
}
|
|
18
36
|
onKey(key) {
|
|
19
|
-
|
|
37
|
+
// When the input box is focused and holds text, left/right move the text
|
|
38
|
+
// cursor instead of switching between the input box and the tabs. When the
|
|
39
|
+
// input box is empty (or another control is focused), left/right switch.
|
|
40
|
+
// Up/down always switch.
|
|
41
|
+
if (key.name === "left" || key.name === "right") {
|
|
42
|
+
if (this.focusIdx === 0 && this.inputValue.length > 0) {
|
|
43
|
+
this.moveCursor(key.name === "left" ? -1 : 1);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
this.move(key.name === "left" ? -1 : 1);
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (key.name === "up") {
|
|
20
51
|
this.move(-1);
|
|
21
52
|
return;
|
|
22
53
|
}
|
|
23
|
-
if (key.name === "
|
|
54
|
+
if (key.name === "down") {
|
|
24
55
|
this.move(1);
|
|
25
56
|
return;
|
|
26
57
|
}
|
|
@@ -165,6 +196,24 @@ export class StartScreen extends Screen {
|
|
|
165
196
|
this.hoverIdx = -1;
|
|
166
197
|
this.app.render();
|
|
167
198
|
}
|
|
199
|
+
/** Move the text cursor by one character, staying within bounds and on
|
|
200
|
+
* surrogate-pair boundaries. */
|
|
201
|
+
moveCursor(delta) {
|
|
202
|
+
const len = this.inputValue.length;
|
|
203
|
+
let next = Math.max(0, Math.min(len, this.cursor + delta));
|
|
204
|
+
if (delta === 1) {
|
|
205
|
+
if (next < len && isLowSurrogate(this.inputValue.charCodeAt(next))) {
|
|
206
|
+
next = Math.min(len, next + 1);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
else if (next > 0 && isLowSurrogate(this.inputValue.charCodeAt(next))) {
|
|
210
|
+
next = Math.max(0, next - 1);
|
|
211
|
+
}
|
|
212
|
+
if (next === this.cursor)
|
|
213
|
+
return;
|
|
214
|
+
this.cursor = next;
|
|
215
|
+
this.app.render();
|
|
216
|
+
}
|
|
168
217
|
enterTab() {
|
|
169
218
|
if (this.focusIdx === 1)
|
|
170
219
|
this.app.openModelConfig();
|
|
@@ -193,6 +242,7 @@ export class StartScreen extends Screen {
|
|
|
193
242
|
y += 1;
|
|
194
243
|
drawInputBox(buf, inputX, y, inputW, this.inputValue, this.cursor, {
|
|
195
244
|
active: this.focusIdx === 0,
|
|
245
|
+
cursorOn: this.app.state.cursorOn,
|
|
196
246
|
placeholder: "Type a message to start a new chat...",
|
|
197
247
|
});
|
|
198
248
|
y += INPUT_H + gap;
|
|
@@ -207,9 +257,23 @@ export class StartScreen extends Screen {
|
|
|
207
257
|
drawTabButton(buf, tx, y, tw, TABS[i], selected);
|
|
208
258
|
tx += tw + tabGap;
|
|
209
259
|
}
|
|
260
|
+
// The version label is pinned one row above the very bottom of the screen,
|
|
261
|
+
// leaving the last row blank. It is read from package.json at runtime so it
|
|
262
|
+
// can never drift out of sync with the published package version.
|
|
263
|
+
const version = loadVersion();
|
|
264
|
+
const versionY = h - 2;
|
|
265
|
+
const showVersion = version.length > 0 && versionY >= 1;
|
|
266
|
+
// The hint sits right below the tabs, but is dropped when it would land on
|
|
267
|
+
// or below the reserved version row so the two never collide.
|
|
210
268
|
const hintY = y + TAB_H + 1;
|
|
211
|
-
if (hintY < h) {
|
|
212
|
-
|
|
269
|
+
if (hintY < h && (!showVersion || hintY < versionY)) {
|
|
270
|
+
const nav = this.focusIdx === 0 && this.inputValue.length > 0
|
|
271
|
+
? "←/→ move cursor · ↑/↓ switch"
|
|
272
|
+
: "←/→ / ↑/↓ switch";
|
|
273
|
+
drawHint(buf, hintY, `${nav} · Enter confirm · drag to copy · Ctrl+E model · Ctrl+Q quit`);
|
|
274
|
+
}
|
|
275
|
+
if (showVersion) {
|
|
276
|
+
buf.centerText(versionY, `v${version}`, { fg: C.dim });
|
|
213
277
|
}
|
|
214
278
|
}
|
|
215
279
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { C } from "./colors.js";
|
|
2
|
+
/** Order the two ends of a selection into reading order (top-left first). */
|
|
3
|
+
export function normalizeSelection(sel) {
|
|
4
|
+
const { anchor: a, cursor: b } = sel;
|
|
5
|
+
if (a.y < b.y || (a.y === b.y && a.x <= b.x))
|
|
6
|
+
return { start: a, end: b };
|
|
7
|
+
return { start: b, end: a };
|
|
8
|
+
}
|
|
9
|
+
/** Index of the last non-blank cell on a row, or -1 when the row is empty. */
|
|
10
|
+
function lastContentCol(buf, y) {
|
|
11
|
+
const row = buf.cells[y];
|
|
12
|
+
for (let x = row.length - 1; x >= 0; x--) {
|
|
13
|
+
if (row[x].ch !== " ")
|
|
14
|
+
return x;
|
|
15
|
+
}
|
|
16
|
+
return -1;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Paint the selection highlight directly onto the rendered buffer. Working on
|
|
20
|
+
* the buffer (rather than on each screen) means the highlight automatically
|
|
21
|
+
* follows whatever is on screen — chat output, the input box, menus, …
|
|
22
|
+
*
|
|
23
|
+
* Intermediate lines stop at the last non-blank cell so a multi-line selection
|
|
24
|
+
* doesn't paint a block out to the right edge of the terminal.
|
|
25
|
+
*/
|
|
26
|
+
export function applySelection(buf, sel) {
|
|
27
|
+
const { start, end } = normalizeSelection(sel);
|
|
28
|
+
const y0 = Math.max(0, start.y);
|
|
29
|
+
const y1 = Math.min(buf.height - 1, end.y);
|
|
30
|
+
for (let y = y0; y <= y1; y++) {
|
|
31
|
+
const x0 = y === start.y ? Math.max(0, start.x) : 0;
|
|
32
|
+
const x1 = y === end.y ? Math.min(buf.width - 1, end.x) : lastContentCol(buf, y);
|
|
33
|
+
if (x1 < x0)
|
|
34
|
+
continue;
|
|
35
|
+
for (let x = x0; x <= x1; x++) {
|
|
36
|
+
const cell = buf.cells[y][x];
|
|
37
|
+
if (!cell)
|
|
38
|
+
continue;
|
|
39
|
+
cell.style = {
|
|
40
|
+
fg: C.white,
|
|
41
|
+
bg: C.selection,
|
|
42
|
+
bold: cell.style.bold,
|
|
43
|
+
italic: cell.style.italic,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
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.
|
|
52
|
+
*/
|
|
53
|
+
export function selectionText(buf, sel) {
|
|
54
|
+
const { start, end } = normalizeSelection(sel);
|
|
55
|
+
const y0 = Math.max(0, start.y);
|
|
56
|
+
const y1 = Math.min(buf.height - 1, end.y);
|
|
57
|
+
const lines = [];
|
|
58
|
+
for (let y = y0; y <= y1; y++) {
|
|
59
|
+
const x0 = y === start.y ? Math.max(0, start.x) : 0;
|
|
60
|
+
let x1 = y === end.y ? end.x : buf.width - 1;
|
|
61
|
+
let line = "";
|
|
62
|
+
for (let x = x0; x <= Math.min(buf.width - 1, x1); x++) {
|
|
63
|
+
const cell = buf.cells[y][x];
|
|
64
|
+
if (!cell || cell.ch === "")
|
|
65
|
+
continue; // "" is the tail of a wide glyph
|
|
66
|
+
line += cell.ch;
|
|
67
|
+
}
|
|
68
|
+
lines.push(line.replace(/[ \t]+$/, ""));
|
|
69
|
+
}
|
|
70
|
+
while (lines.length > 0 && lines[0] === "")
|
|
71
|
+
lines.shift();
|
|
72
|
+
while (lines.length > 0 && lines[lines.length - 1] === "")
|
|
73
|
+
lines.pop();
|
|
74
|
+
return lines.join("\n");
|
|
75
|
+
}
|
package/dist/state.js
CHANGED
package/dist/terminal/buffer.js
CHANGED
|
@@ -4,6 +4,9 @@ const emptyCell = () => ({ ch: " ", style: { bg: C.black } });
|
|
|
4
4
|
function normalize(style) {
|
|
5
5
|
return style.bg ? style : { ...style, bg: C.black };
|
|
6
6
|
}
|
|
7
|
+
function cellWidth(ch) {
|
|
8
|
+
return ch ? wcwidth(ch.codePointAt(0) ?? 0) : 0;
|
|
9
|
+
}
|
|
7
10
|
export class Buffer {
|
|
8
11
|
width;
|
|
9
12
|
height;
|
|
@@ -13,12 +16,65 @@ export class Buffer {
|
|
|
13
16
|
this.height = height;
|
|
14
17
|
this.cells = Array.from({ length: height }, () => Array.from({ length: width }, emptyCell));
|
|
15
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Write a single cell, keeping the wide-glyph invariant intact. The renderer
|
|
21
|
+
* (and the selection logic) rely on the rule that a `""` cell is *always* the
|
|
22
|
+
* right half of a double-width glyph at `x - 1`. If the invariant is broken an
|
|
23
|
+
* orphan tail is left behind: the renderer skips it, so that column is never
|
|
24
|
+
* repainted and a stale glyph from the previous frame stays on screen (the
|
|
25
|
+
* "residual line" bug). To preserve it we blank the *other* half whenever we
|
|
26
|
+
* overwrite one half of a wide glyph.
|
|
27
|
+
*/
|
|
28
|
+
overwriteCell(x, y, ch, style) {
|
|
29
|
+
if (x < 0 || x >= this.width || y < 0 || y >= this.height)
|
|
30
|
+
return;
|
|
31
|
+
const row = this.cells[y];
|
|
32
|
+
const prev = row[x];
|
|
33
|
+
if (prev.ch === "" && x > 0) {
|
|
34
|
+
// We're overwriting a wide glyph's tail: blank its head.
|
|
35
|
+
row[x - 1] = { ch: " ", style: row[x - 1].style };
|
|
36
|
+
}
|
|
37
|
+
else if (cellWidth(prev.ch) === 2 && x + 1 < this.width) {
|
|
38
|
+
// We're overwriting a wide glyph's head: blank its tail.
|
|
39
|
+
row[x + 1] = { ch: " ", style: row[x + 1].style };
|
|
40
|
+
}
|
|
41
|
+
row[x] = { ch, style: normalize(style) };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Mark `x` as the tail (`""`) of the wide glyph at `x - 1`. Unlike
|
|
45
|
+
* `overwriteCell` this never blanks the cell to the left, because that cell is
|
|
46
|
+
* the glyph we are currently writing.
|
|
47
|
+
*/
|
|
48
|
+
writeTail(x, y, style) {
|
|
49
|
+
if (x < 0 || x >= this.width || y < 0 || y >= this.height)
|
|
50
|
+
return;
|
|
51
|
+
const row = this.cells[y];
|
|
52
|
+
const prev = row[x];
|
|
53
|
+
if (cellWidth(prev.ch) === 2 && x + 1 < this.width) {
|
|
54
|
+
row[x + 1] = { ch: " ", style: row[x + 1].style };
|
|
55
|
+
}
|
|
56
|
+
row[x] = { ch: "", style: normalize(style) };
|
|
57
|
+
}
|
|
58
|
+
writeChar(x, y, ch, style) {
|
|
59
|
+
if (cellWidth(ch) === 2) {
|
|
60
|
+
if (x + 1 >= this.width) {
|
|
61
|
+
// No room for the tail: degrade to a space so the line never overflows
|
|
62
|
+
// (which would make the terminal wrap and scroll).
|
|
63
|
+
this.overwriteCell(x, y, " ", style);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
this.overwriteCell(x, y, ch, style);
|
|
67
|
+
this.writeTail(x + 1, y, style);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
this.overwriteCell(x, y, ch, style);
|
|
71
|
+
}
|
|
16
72
|
set(x, y, ch, style = EMPTY_STYLE) {
|
|
17
73
|
if (ch === "\r" || ch === "\n")
|
|
18
74
|
return;
|
|
19
|
-
if (
|
|
75
|
+
if (x < 0 || x >= this.width || y < 0 || y >= this.height)
|
|
20
76
|
return;
|
|
21
|
-
this.
|
|
77
|
+
this.writeChar(x, y, ch, style);
|
|
22
78
|
}
|
|
23
79
|
writeText(x, y, text, style = EMPTY_STYLE) {
|
|
24
80
|
if (y < 0 || y >= this.height)
|
|
@@ -27,8 +83,10 @@ export class Buffer {
|
|
|
27
83
|
for (const ch of text) {
|
|
28
84
|
const w = wcwidth(ch.codePointAt(0) ?? 0);
|
|
29
85
|
if (w === 0) {
|
|
30
|
-
|
|
31
|
-
|
|
86
|
+
// Combining mark: attach it to the glyph immediately to the left.
|
|
87
|
+
const px = col - 1;
|
|
88
|
+
if (px >= 0 && px < this.width && this.cells[y][px].ch !== "") {
|
|
89
|
+
this.cells[y][px].ch += ch;
|
|
32
90
|
}
|
|
33
91
|
continue;
|
|
34
92
|
}
|
|
@@ -38,10 +96,7 @@ export class Buffer {
|
|
|
38
96
|
col += w;
|
|
39
97
|
continue;
|
|
40
98
|
}
|
|
41
|
-
this.
|
|
42
|
-
if (w === 2 && col + 1 < this.width) {
|
|
43
|
-
this.cells[y][col + 1] = { ch: "", style: normalize(style) };
|
|
44
|
-
}
|
|
99
|
+
this.writeChar(col, y, ch, style);
|
|
45
100
|
col += w;
|
|
46
101
|
}
|
|
47
102
|
}
|
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,17 +213,25 @@ 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
|
/**
|
|
159
228
|
* Parse SGR (extended) mouse sequences: ESC [ < b ; x ; y M/m
|
|
160
229
|
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
* 64/65 wheel up / wheel down
|
|
230
|
+
* `b` is a bit field: the low two bits are the button (0 left, 1 middle,
|
|
231
|
+
* 2 right, 3 none/release), bit 5 (32) marks a motion event and bit 6 (64)
|
|
232
|
+
* marks a wheel event. A trailing `M` is a press/motion while `m` is a
|
|
233
|
+
* release. So motion with the left button held (a drag) is `b = 32`, whereas
|
|
234
|
+
* plain hover is `b = 35` (32 | 3).
|
|
167
235
|
*/
|
|
168
236
|
handleMouse(s, final) {
|
|
169
237
|
const parts = s.slice(1).split(";");
|
|
@@ -174,18 +242,23 @@ export class InputReader {
|
|
|
174
242
|
const y = parseInt(parts[2], 10) - 1;
|
|
175
243
|
if (Number.isNaN(b) || Number.isNaN(x) || Number.isNaN(y))
|
|
176
244
|
return;
|
|
177
|
-
|
|
178
|
-
|
|
245
|
+
const button = b & 3;
|
|
246
|
+
const motion = (b & 32) !== 0;
|
|
247
|
+
const wheel = (b & 64) !== 0;
|
|
248
|
+
if (wheel) {
|
|
249
|
+
this.emitMouse({ type: "wheel", dir: button === 0 ? -1 : 1, x, y });
|
|
250
|
+
return;
|
|
179
251
|
}
|
|
180
|
-
|
|
181
|
-
this.emitMouse({ type: "
|
|
252
|
+
if (final === "m") {
|
|
253
|
+
this.emitMouse({ type: "release", x, y, button });
|
|
254
|
+
return;
|
|
182
255
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
256
|
+
if (motion) {
|
|
257
|
+
this.emitMouse({ type: "move", x, y, drag: button === 0 });
|
|
258
|
+
return;
|
|
186
259
|
}
|
|
187
|
-
|
|
188
|
-
this.emitMouse({ type: "click", x, y, button
|
|
260
|
+
if (button !== 3) {
|
|
261
|
+
this.emitMouse({ type: "click", x, y, button });
|
|
189
262
|
}
|
|
190
263
|
}
|
|
191
264
|
emitKey(name, ctrl, meta, shift, char) {
|
|
@@ -203,6 +276,11 @@ export class InputReader {
|
|
|
203
276
|
},
|
|
204
277
|
});
|
|
205
278
|
}
|
|
279
|
+
emitPaste(text) {
|
|
280
|
+
if (!this.handler || !text)
|
|
281
|
+
return;
|
|
282
|
+
this.handler({ type: "paste", text });
|
|
283
|
+
}
|
|
206
284
|
emitMouse(event) {
|
|
207
285
|
if (!this.handler)
|
|
208
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");
|
|
@@ -78,12 +80,17 @@ export class Renderer {
|
|
|
78
80
|
this.write(out);
|
|
79
81
|
}
|
|
80
82
|
renderRow(y, row) {
|
|
81
|
-
|
|
83
|
+
// Move to the row, reset attributes and erase the whole line first. The
|
|
84
|
+
// erase is belt-and-braces: every cell is written below anyway, but this
|
|
85
|
+
// guarantees no stale glyph can survive even if a row somehow ends up
|
|
86
|
+
// shorter than what was on screen before (this is what produced the
|
|
87
|
+
// "residual line above the input box" artefact).
|
|
88
|
+
let s = `\x1b[${y + 1};1H\x1b[0m\x1b[K`;
|
|
82
89
|
let lastKey = "";
|
|
83
90
|
for (let x = 0; x < row.length; x++) {
|
|
84
91
|
const cell = row[x];
|
|
85
92
|
if (cell.ch === "")
|
|
86
|
-
continue;
|
|
93
|
+
continue; // tail of the wide glyph to the left
|
|
87
94
|
const sk = styleKey(cell.style);
|
|
88
95
|
if (sk !== lastKey) {
|
|
89
96
|
s += sgr(cell.style);
|
package/dist/terminal/screen.js
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
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) { }
|
|
18
|
+
/** Whether this screen currently shows a blinking text cursor. Used by the
|
|
19
|
+
* app to run (or stop) the cursor blink timer. */
|
|
20
|
+
showsCursor() {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
4
23
|
onActivate() { }
|
|
5
24
|
onDeactivate() { }
|
|
6
25
|
}
|