@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.
@@ -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.centerText(y, "↑/↓ change · Enter apply · Esc back", { fg: C.dim });
129
+ drawHint(buf, y, "↑/↓ change · Enter apply · click to choose · right-click back · Esc back");
68
130
  }
69
131
  }
70
132
  }
@@ -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
- if (key.name === "left" || key.name === "up") {
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 === "right" || key.name === "down") {
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 inputBoxH = 3;
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 += inputBoxH + gap;
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
- drawTabButton(buf, tx, y, tw, TABS[i], i === this.focusIdx - 1);
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
- const hintY = y + tabH + 1;
123
- if (hintY < h) {
124
- buf.centerText(hintY, "←/→ / ↑/↓ switch · Enter confirm · Ctrl+E model · Ctrl+Q quit", {
125
- fg: C.dim,
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
@@ -7,5 +7,8 @@ export function defaultState() {
7
7
  selectedModelStreaming: true,
8
8
  selectedIntensity: "High",
9
9
  isThinking: false,
10
+ blinkOn: false,
11
+ cursorOn: true,
12
+ selection: null,
10
13
  };
11
14
  }
@@ -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 (y < 0 || y >= this.height || x < 0 || x >= this.width)
75
+ if (x < 0 || x >= this.width || y < 0 || y >= this.height)
20
76
  return;
21
- this.cells[y][x] = { ch, style: normalize(style) };
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
- if (col - 1 >= x && col - 1 >= 0 && col - 1 < this.width) {
31
- this.cells[y][col - 1].ch += ch;
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.cells[y][col] = { ch, style: normalize(style) };
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
  }
@@ -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
- if (b === 64) {
168
- this.emitMouse({ type: "wheel", dir: -1, x, y });
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
- else if (b === 65) {
171
- this.emitMouse({ type: "wheel", dir: 1, x, y });
187
+ if (motion) {
188
+ this.emitMouse({ type: "move", x, y, drag: button === 0 });
189
+ return;
172
190
  }
173
- else if (final === "M" && (b === 0 || b === 1 || b === 2)) {
174
- this.emitMouse({ type: "click", x, y, button: b });
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
- let s = `\x1b[${y + 1};1H`;
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);
@@ -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
  }