@xingjisoft/neutrino 0.1.0 → 0.1.2
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 +35 -2
- package/dist/app.js +180 -2
- package/dist/clipboard.js +113 -0
- package/dist/colors.js +2 -0
- package/dist/config.js +19 -0
- package/dist/header.js +11 -6
- package/dist/markdown.js +630 -0
- package/dist/screens/agentConversation.js +12 -14
- package/dist/screens/agentHistory.js +76 -24
- package/dist/screens/agentSelect.js +54 -7
- package/dist/screens/chat.js +48 -22
- package/dist/screens/history.js +73 -20
- package/dist/screens/modelSelect.js +66 -7
- package/dist/screens/settings.js +64 -2
- package/dist/screens/start.js +153 -14
- package/dist/selection.js +75 -0
- package/dist/state.js +3 -0
- package/dist/terminal/buffer.js +63 -8
- package/dist/terminal/input.js +24 -6
- package/dist/terminal/renderer.js +13 -4
- package/dist/terminal/screen.js +5 -0
- package/dist/ui.js +93 -27
- package/package.json +2 -1
package/dist/screens/settings.js
CHANGED
|
@@ -2,9 +2,11 @@ import { Screen } from "../terminal/screen.js";
|
|
|
2
2
|
import { C } from "../colors.js";
|
|
3
3
|
import { INTENSITY_LEVELS, MODEL_FILE, loadConfig, saveConfig } from "../config.js";
|
|
4
4
|
import { drawHeader } from "../header.js";
|
|
5
|
-
import { centerVertically } from "../ui.js";
|
|
5
|
+
import { centerVertically, drawHint } from "../ui.js";
|
|
6
6
|
export class SettingsScreen extends Screen {
|
|
7
7
|
intensityIdx = 2;
|
|
8
|
+
lastHoverX = -1;
|
|
9
|
+
lastHoverY = -1;
|
|
8
10
|
onActivate() {
|
|
9
11
|
const config = loadConfig();
|
|
10
12
|
const current = String(config.default_intensity ?? "High");
|
|
@@ -28,6 +30,66 @@ export class SettingsScreen extends Screen {
|
|
|
28
30
|
this.app.popScreen();
|
|
29
31
|
}
|
|
30
32
|
}
|
|
33
|
+
onMouse(ev) {
|
|
34
|
+
const layout = this.layout();
|
|
35
|
+
if (!layout)
|
|
36
|
+
return;
|
|
37
|
+
if (ev.type === "move") {
|
|
38
|
+
if (ev.x === this.lastHoverX && ev.y === this.lastHoverY)
|
|
39
|
+
return;
|
|
40
|
+
this.lastHoverX = ev.x;
|
|
41
|
+
this.lastHoverY = ev.y;
|
|
42
|
+
for (let i = 0; i < layout.optionYs.length; i++) {
|
|
43
|
+
if (ev.y === layout.optionYs[i] && i !== this.intensityIdx) {
|
|
44
|
+
this.intensityIdx = i;
|
|
45
|
+
this.app.render();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (ev.type === "wheel") {
|
|
52
|
+
this.intensityIdx =
|
|
53
|
+
(this.intensityIdx + (ev.dir === -1 ? -1 : 1) + INTENSITY_LEVELS.length) % INTENSITY_LEVELS.length;
|
|
54
|
+
this.app.render();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (ev.type === "click") {
|
|
58
|
+
if (ev.button === 2) {
|
|
59
|
+
this.save();
|
|
60
|
+
this.app.popScreen();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
for (let i = 0; i < layout.optionYs.length; i++) {
|
|
64
|
+
if (ev.y === layout.optionYs[i]) {
|
|
65
|
+
this.intensityIdx = i;
|
|
66
|
+
this.save();
|
|
67
|
+
this.app.render();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (ev.y === layout.hintY) {
|
|
72
|
+
this.save();
|
|
73
|
+
this.app.popScreen();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
layout() {
|
|
78
|
+
const h = this.app.height;
|
|
79
|
+
const lines = [
|
|
80
|
+
"Settings",
|
|
81
|
+
"",
|
|
82
|
+
`Model: ${this.app.state.selectedProvider}/${this.app.state.selectedModelName}`,
|
|
83
|
+
`Default intensity:`,
|
|
84
|
+
];
|
|
85
|
+
const total = lines.length + INTENSITY_LEVELS.length + 2;
|
|
86
|
+
const top = centerVertically(h, total);
|
|
87
|
+
const optionYs = INTENSITY_LEVELS.map((_, i) => top + 4 + i);
|
|
88
|
+
const hintY = top + 4 + INTENSITY_LEVELS.length + 2;
|
|
89
|
+
if (hintY > h - 1)
|
|
90
|
+
return null;
|
|
91
|
+
return { optionYs, hintY };
|
|
92
|
+
}
|
|
31
93
|
save() {
|
|
32
94
|
const config = loadConfig();
|
|
33
95
|
config.default_intensity = INTENSITY_LEVELS[this.intensityIdx];
|
|
@@ -64,7 +126,7 @@ export class SettingsScreen extends Screen {
|
|
|
64
126
|
buf.centerText(y, `model.json: ${MODEL_FILE}`, { fg: C.dim });
|
|
65
127
|
y += 1;
|
|
66
128
|
if (y < h) {
|
|
67
|
-
buf
|
|
129
|
+
drawHint(buf, y, "↑/↓ change · Enter apply · click to choose · right-click back · Esc back");
|
|
68
130
|
}
|
|
69
131
|
}
|
|
70
132
|
}
|
package/dist/screens/start.js
CHANGED
|
@@ -1,21 +1,46 @@
|
|
|
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
|
-
import { centerVertically, drawInputBox, drawTabButton } from "../ui.js";
|
|
5
|
+
import { centerVertically, computeInputWindow, cursorFromColumn, drawHint, drawInputBox, drawTabButton, inRect, } from "../ui.js";
|
|
6
6
|
const TABS = ["Model", "History", "Settings"];
|
|
7
7
|
const LOGO = loadLogo();
|
|
8
8
|
const FOCUS_COUNT = TABS.length + 1; // input box + 3 tabs
|
|
9
|
+
const INPUT_H = 3;
|
|
10
|
+
const TAB_H = 3;
|
|
11
|
+
function isLowSurrogate(code) {
|
|
12
|
+
return code >= 0xdc00 && code <= 0xdfff;
|
|
13
|
+
}
|
|
9
14
|
export class StartScreen extends Screen {
|
|
10
15
|
inputValue = "";
|
|
11
16
|
cursor = 0;
|
|
12
17
|
focusIdx = 0; // 0 = input box, 1..3 = tabs
|
|
18
|
+
hoverIdx = -1; // -1 = nothing, 0 = input box, 1..3 = tabs
|
|
19
|
+
lastHoverX = -1;
|
|
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
|
+
}
|
|
13
25
|
onKey(key) {
|
|
14
|
-
|
|
26
|
+
// When the input box is focused and holds text, left/right move the text
|
|
27
|
+
// cursor instead of switching between the input box and the tabs. When the
|
|
28
|
+
// input box is empty (or another control is focused), left/right switch.
|
|
29
|
+
// Up/down always switch.
|
|
30
|
+
if (key.name === "left" || key.name === "right") {
|
|
31
|
+
if (this.focusIdx === 0 && this.inputValue.length > 0) {
|
|
32
|
+
this.moveCursor(key.name === "left" ? -1 : 1);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
this.move(key.name === "left" ? -1 : 1);
|
|
36
|
+
}
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (key.name === "up") {
|
|
15
40
|
this.move(-1);
|
|
16
41
|
return;
|
|
17
42
|
}
|
|
18
|
-
if (key.name === "
|
|
43
|
+
if (key.name === "down") {
|
|
19
44
|
this.move(1);
|
|
20
45
|
return;
|
|
21
46
|
}
|
|
@@ -72,8 +97,110 @@ export class StartScreen extends Screen {
|
|
|
72
97
|
this.app.render();
|
|
73
98
|
}
|
|
74
99
|
}
|
|
100
|
+
onMouse(ev) {
|
|
101
|
+
if (ev.type === "move") {
|
|
102
|
+
// avoid redundant work when the pointer hasn't moved to a new cell
|
|
103
|
+
if (ev.x === this.lastHoverX && ev.y === this.lastHoverY)
|
|
104
|
+
return;
|
|
105
|
+
this.lastHoverX = ev.x;
|
|
106
|
+
this.lastHoverY = ev.y;
|
|
107
|
+
const layout = this.layout();
|
|
108
|
+
let hover = -1;
|
|
109
|
+
if (layout && inRect(ev.x, ev.y, { x: layout.inputX, y: layout.inputY, w: layout.inputW, h: INPUT_H })) {
|
|
110
|
+
hover = 0;
|
|
111
|
+
}
|
|
112
|
+
else if (layout && ev.y >= layout.tabY && ev.y < layout.tabY + TAB_H) {
|
|
113
|
+
for (let i = 0; i < layout.tabs.length; i++) {
|
|
114
|
+
if (inRect(ev.x, ev.y, { x: layout.tabs[i].x, y: layout.tabY, w: layout.tabs[i].w, h: TAB_H })) {
|
|
115
|
+
hover = i + 1;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (hover !== this.hoverIdx) {
|
|
121
|
+
this.hoverIdx = hover;
|
|
122
|
+
this.app.render();
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (ev.type === "click") {
|
|
127
|
+
const layout = this.layout();
|
|
128
|
+
if (!layout)
|
|
129
|
+
return;
|
|
130
|
+
if (ev.button === 2)
|
|
131
|
+
return; // right click: no-op on start screen
|
|
132
|
+
if (ev.y === 0) {
|
|
133
|
+
this.app.openModelConfig();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (inRect(ev.x, ev.y, { x: layout.inputX, y: layout.inputY, w: layout.inputW, h: INPUT_H })) {
|
|
137
|
+
this.focusIdx = 0;
|
|
138
|
+
if (ev.y === layout.inputY + 1 && ev.x >= layout.inputX + 2) {
|
|
139
|
+
const innerW = layout.inputW - 4;
|
|
140
|
+
const col = Math.min(innerW - 1, ev.x - (layout.inputX + 2));
|
|
141
|
+
const win = computeInputWindow(this.inputValue, this.cursor, innerW);
|
|
142
|
+
this.cursor = cursorFromColumn(this.inputValue, col, win);
|
|
143
|
+
}
|
|
144
|
+
this.app.render();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (ev.y >= layout.tabY && ev.y < layout.tabY + TAB_H) {
|
|
148
|
+
for (let i = 0; i < layout.tabs.length; i++) {
|
|
149
|
+
if (inRect(ev.x, ev.y, { x: layout.tabs[i].x, y: layout.tabY, w: layout.tabs[i].w, h: TAB_H })) {
|
|
150
|
+
this.focusIdx = i + 1;
|
|
151
|
+
this.hoverIdx = i + 1;
|
|
152
|
+
this.enterTab();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
layout() {
|
|
161
|
+
const w = this.app.width;
|
|
162
|
+
const h = this.app.height;
|
|
163
|
+
const inputW = Math.min(78, w - 4);
|
|
164
|
+
const inputX = Math.floor((w - inputW) / 2);
|
|
165
|
+
const logoLines = LOGO.replace(/\n+$/, "").split("\n");
|
|
166
|
+
const gap = 1;
|
|
167
|
+
const contentH = logoLines.length + 1 + INPUT_H + gap + TAB_H;
|
|
168
|
+
const inputY = centerVertically(h, contentH) + logoLines.length + 1;
|
|
169
|
+
const tabY = inputY + INPUT_H + gap;
|
|
170
|
+
const tabGap = 2;
|
|
171
|
+
const tabInner = inputW - tabGap * (TABS.length - 1);
|
|
172
|
+
const baseW = Math.floor(tabInner / TABS.length);
|
|
173
|
+
const extra = tabInner - baseW * TABS.length;
|
|
174
|
+
const tabs = [];
|
|
175
|
+
let tx = inputX;
|
|
176
|
+
for (let i = 0; i < TABS.length; i++) {
|
|
177
|
+
const tw = baseW + (i < extra ? 1 : 0);
|
|
178
|
+
tabs.push({ x: tx, w: tw });
|
|
179
|
+
tx += tw + tabGap;
|
|
180
|
+
}
|
|
181
|
+
return { inputX, inputY, inputW, tabY, tabs };
|
|
182
|
+
}
|
|
75
183
|
move(delta) {
|
|
76
184
|
this.focusIdx = (this.focusIdx + delta + FOCUS_COUNT) % FOCUS_COUNT;
|
|
185
|
+
this.hoverIdx = -1;
|
|
186
|
+
this.app.render();
|
|
187
|
+
}
|
|
188
|
+
/** Move the text cursor by one character, staying within bounds and on
|
|
189
|
+
* surrogate-pair boundaries. */
|
|
190
|
+
moveCursor(delta) {
|
|
191
|
+
const len = this.inputValue.length;
|
|
192
|
+
let next = Math.max(0, Math.min(len, this.cursor + delta));
|
|
193
|
+
if (delta === 1) {
|
|
194
|
+
if (next < len && isLowSurrogate(this.inputValue.charCodeAt(next))) {
|
|
195
|
+
next = Math.min(len, next + 1);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
else if (next > 0 && isLowSurrogate(this.inputValue.charCodeAt(next))) {
|
|
199
|
+
next = Math.max(0, next - 1);
|
|
200
|
+
}
|
|
201
|
+
if (next === this.cursor)
|
|
202
|
+
return;
|
|
203
|
+
this.cursor = next;
|
|
77
204
|
this.app.render();
|
|
78
205
|
}
|
|
79
206
|
enterTab() {
|
|
@@ -94,9 +221,7 @@ export class StartScreen extends Screen {
|
|
|
94
221
|
const logoMax = logoLines.reduce((m, l) => Math.max(m, l.length), 0);
|
|
95
222
|
const logoX = Math.max(0, Math.floor((w - logoMax) / 2));
|
|
96
223
|
const gap = 1;
|
|
97
|
-
const
|
|
98
|
-
const tabH = 3;
|
|
99
|
-
const contentH = logoLines.length + 1 + inputBoxH + gap + tabH;
|
|
224
|
+
const contentH = logoLines.length + 1 + INPUT_H + gap + TAB_H;
|
|
100
225
|
const top = centerVertically(h, contentH);
|
|
101
226
|
let y = top;
|
|
102
227
|
for (const line of logoLines) {
|
|
@@ -106,9 +231,10 @@ export class StartScreen extends Screen {
|
|
|
106
231
|
y += 1;
|
|
107
232
|
drawInputBox(buf, inputX, y, inputW, this.inputValue, this.cursor, {
|
|
108
233
|
active: this.focusIdx === 0,
|
|
234
|
+
cursorOn: this.app.state.cursorOn,
|
|
109
235
|
placeholder: "Type a message to start a new chat...",
|
|
110
236
|
});
|
|
111
|
-
y +=
|
|
237
|
+
y += INPUT_H + gap;
|
|
112
238
|
const tabGap = 2;
|
|
113
239
|
const tabInner = inputW - tabGap * (TABS.length - 1);
|
|
114
240
|
const baseW = Math.floor(tabInner / TABS.length);
|
|
@@ -116,14 +242,27 @@ export class StartScreen extends Screen {
|
|
|
116
242
|
let tx = inputX;
|
|
117
243
|
for (let i = 0; i < TABS.length; i++) {
|
|
118
244
|
const tw = baseW + (i < extra ? 1 : 0);
|
|
119
|
-
|
|
245
|
+
const selected = i === this.focusIdx - 1 || i === this.hoverIdx - 1;
|
|
246
|
+
drawTabButton(buf, tx, y, tw, TABS[i], selected);
|
|
120
247
|
tx += tw + tabGap;
|
|
121
248
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
249
|
+
// The version label is pinned one row above the very bottom of the screen,
|
|
250
|
+
// leaving the last row blank. It is read from package.json at runtime so it
|
|
251
|
+
// can never drift out of sync with the published package version.
|
|
252
|
+
const version = loadVersion();
|
|
253
|
+
const versionY = h - 2;
|
|
254
|
+
const showVersion = version.length > 0 && versionY >= 1;
|
|
255
|
+
// The hint sits right below the tabs, but is dropped when it would land on
|
|
256
|
+
// or below the reserved version row so the two never collide.
|
|
257
|
+
const hintY = y + TAB_H + 1;
|
|
258
|
+
if (hintY < h && (!showVersion || hintY < versionY)) {
|
|
259
|
+
const nav = this.focusIdx === 0 && this.inputValue.length > 0
|
|
260
|
+
? "←/→ move cursor · ↑/↓ switch"
|
|
261
|
+
: "←/→ / ↑/↓ switch";
|
|
262
|
+
drawHint(buf, hintY, `${nav} · Enter confirm · drag to copy · Ctrl+E model · Ctrl+Q quit`);
|
|
263
|
+
}
|
|
264
|
+
if (showVersion) {
|
|
265
|
+
buf.centerText(versionY, `v${version}`, { fg: C.dim });
|
|
127
266
|
}
|
|
128
267
|
}
|
|
129
268
|
}
|
|
@@ -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
|
@@ -155,6 +155,15 @@ export class InputReader {
|
|
|
155
155
|
this.emitKey("end", false, false, false, "\x1b[4~");
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* Parse SGR (extended) mouse sequences: ESC [ < b ; x ; y M/m
|
|
160
|
+
*
|
|
161
|
+
* `b` is a bit field: the low two bits are the button (0 left, 1 middle,
|
|
162
|
+
* 2 right, 3 none/release), bit 5 (32) marks a motion event and bit 6 (64)
|
|
163
|
+
* marks a wheel event. A trailing `M` is a press/motion while `m` is a
|
|
164
|
+
* release. So motion with the left button held (a drag) is `b = 32`, whereas
|
|
165
|
+
* plain hover is `b = 35` (32 | 3).
|
|
166
|
+
*/
|
|
158
167
|
handleMouse(s, final) {
|
|
159
168
|
const parts = s.slice(1).split(";");
|
|
160
169
|
if (parts.length < 3)
|
|
@@ -164,14 +173,23 @@ export class InputReader {
|
|
|
164
173
|
const y = parseInt(parts[2], 10) - 1;
|
|
165
174
|
if (Number.isNaN(b) || Number.isNaN(x) || Number.isNaN(y))
|
|
166
175
|
return;
|
|
167
|
-
|
|
168
|
-
|
|
176
|
+
const button = b & 3;
|
|
177
|
+
const motion = (b & 32) !== 0;
|
|
178
|
+
const wheel = (b & 64) !== 0;
|
|
179
|
+
if (wheel) {
|
|
180
|
+
this.emitMouse({ type: "wheel", dir: button === 0 ? -1 : 1, x, y });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (final === "m") {
|
|
184
|
+
this.emitMouse({ type: "release", x, y, button });
|
|
185
|
+
return;
|
|
169
186
|
}
|
|
170
|
-
|
|
171
|
-
this.emitMouse({ type: "
|
|
187
|
+
if (motion) {
|
|
188
|
+
this.emitMouse({ type: "move", x, y, drag: button === 0 });
|
|
189
|
+
return;
|
|
172
190
|
}
|
|
173
|
-
|
|
174
|
-
this.emitMouse({ type: "click", x, y, button
|
|
191
|
+
if (button !== 3) {
|
|
192
|
+
this.emitMouse({ type: "click", x, y, button });
|
|
175
193
|
}
|
|
176
194
|
}
|
|
177
195
|
emitKey(name, ctrl, meta, shift, char) {
|
|
@@ -11,6 +11,8 @@ function styleKey(st) {
|
|
|
11
11
|
k += "d";
|
|
12
12
|
if (st.inverse)
|
|
13
13
|
k += "i";
|
|
14
|
+
if (st.italic)
|
|
15
|
+
k += "I";
|
|
14
16
|
return k;
|
|
15
17
|
}
|
|
16
18
|
function sgr(st) {
|
|
@@ -19,6 +21,8 @@ function sgr(st) {
|
|
|
19
21
|
codes.push("1");
|
|
20
22
|
if (st.dim)
|
|
21
23
|
codes.push("2");
|
|
24
|
+
if (st.italic)
|
|
25
|
+
codes.push("3");
|
|
22
26
|
if (st.inverse)
|
|
23
27
|
codes.push("7");
|
|
24
28
|
if (st.fg)
|
|
@@ -45,10 +49,10 @@ export class Renderer {
|
|
|
45
49
|
init() {
|
|
46
50
|
this.write("\x1b[?1049h"); // alternate screen
|
|
47
51
|
this.write("\x1b[?25l"); // hide cursor
|
|
48
|
-
this.write("\x1b[?1000h\x1b[?1002h\x1b[?1006h\x1b[?1015h"); // mouse tracking
|
|
52
|
+
this.write("\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h\x1b[?1015h"); // mouse tracking (incl. any-event for hover)
|
|
49
53
|
}
|
|
50
54
|
dispose() {
|
|
51
|
-
this.write("\x1b[?1000l\x1b[?1002l\x1b[?1006l\x1b[?1015l");
|
|
55
|
+
this.write("\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l");
|
|
52
56
|
this.write("\x1b[?25h");
|
|
53
57
|
this.write("\x1b[?1049l");
|
|
54
58
|
this.write("\x1b[0m");
|
|
@@ -74,12 +78,17 @@ export class Renderer {
|
|
|
74
78
|
this.write(out);
|
|
75
79
|
}
|
|
76
80
|
renderRow(y, row) {
|
|
77
|
-
|
|
81
|
+
// Move to the row, reset attributes and erase the whole line first. The
|
|
82
|
+
// erase is belt-and-braces: every cell is written below anyway, but this
|
|
83
|
+
// guarantees no stale glyph can survive even if a row somehow ends up
|
|
84
|
+
// shorter than what was on screen before (this is what produced the
|
|
85
|
+
// "residual line above the input box" artefact).
|
|
86
|
+
let s = `\x1b[${y + 1};1H\x1b[0m\x1b[K`;
|
|
78
87
|
let lastKey = "";
|
|
79
88
|
for (let x = 0; x < row.length; x++) {
|
|
80
89
|
const cell = row[x];
|
|
81
90
|
if (cell.ch === "")
|
|
82
|
-
continue;
|
|
91
|
+
continue; // tail of the wide glyph to the left
|
|
83
92
|
const sk = styleKey(cell.style);
|
|
84
93
|
if (sk !== lastKey) {
|
|
85
94
|
s += sgr(cell.style);
|
package/dist/terminal/screen.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
export class Screen {
|
|
2
2
|
app;
|
|
3
3
|
onMouse(_ev) { }
|
|
4
|
+
/** Whether this screen currently shows a blinking text cursor. Used by the
|
|
5
|
+
* app to run (or stop) the cursor blink timer. */
|
|
6
|
+
showsCursor() {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
4
9
|
onActivate() { }
|
|
5
10
|
onDeactivate() { }
|
|
6
11
|
}
|