@xingjisoft/neutrino 0.1.1 → 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 +22 -3
- package/dist/app.js +160 -2
- package/dist/clipboard.js +113 -0
- package/dist/colors.js +1 -0
- package/dist/config.js +19 -0
- package/dist/screens/chat.js +6 -1
- package/dist/screens/start.js +58 -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 +19 -15
- package/dist/terminal/renderer.js +7 -2
- package/dist/terminal/screen.js +5 -0
- package/dist/ui.js +27 -6
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -6,6 +6,7 @@ A CLI coding agent that lives in your terminal. Neutrino gives you an interactiv
|
|
|
6
6
|
|
|
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
|
+
- **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).
|
|
9
10
|
- **LLM providers** — ships with DeepSeek, OpenAI, and Anthropic presets; works with any OpenAI-compatible endpoint.
|
|
10
11
|
- **Tool calling** — the agent can run shell commands, read files, write files, and list directories in your working directory.
|
|
11
12
|
- **Streaming responses** — token-by-token output with pause/resume (`ESC ESC`).
|
|
@@ -53,8 +54,10 @@ Type a message on the start screen and press `Enter` to begin a new chat.
|
|
|
53
54
|
| `Ctrl+B` | Back to menu (from chat) |
|
|
54
55
|
| `Ctrl+L` | Clear conversation (from chat) |
|
|
55
56
|
| `Esc Esc` | Pause / resume streaming |
|
|
57
|
+
| `Esc` | Clear an active selection |
|
|
56
58
|
| `PgUp` / `PgDn` | Scroll chat history |
|
|
57
|
-
| `Ctrl+C`
|
|
59
|
+
| `Ctrl+C` | Copy the selection, or quit if nothing is selected |
|
|
60
|
+
| `Ctrl+Q` | Quit |
|
|
58
61
|
|
|
59
62
|
### Mouse
|
|
60
63
|
|
|
@@ -63,11 +66,14 @@ Type a message on the start screen and press `Enter` to begin a new chat.
|
|
|
63
66
|
| Hover over an item | Highlight / select it in lists and menus |
|
|
64
67
|
| Click an item | Select and confirm (open a screen, load a session, apply a setting…) |
|
|
65
68
|
| Click the input box | Focus it and place the cursor at the clicked position |
|
|
69
|
+
| Click and drag | Select text under the rectangle; releasing copies it to the clipboard |
|
|
66
70
|
| Click the header (left of `ⓘ`) | Open the model selector |
|
|
67
71
|
| Click `ⓘ` (chat header) | Toggle token usage stats |
|
|
68
72
|
| Scroll wheel | Scroll the chat/log, or move through lists |
|
|
69
73
|
| Right-click | Go back (like `Esc`) |
|
|
70
74
|
|
|
75
|
+
The selection highlight is drawn over whatever is on screen, so it works uniformly on the chat log, the input box and every menu. Selected text is copied with trailing whitespace trimmed per line (shell-style), so it can be pasted straight back into a terminal.
|
|
76
|
+
|
|
71
77
|
## Configuration
|
|
72
78
|
|
|
73
79
|
Settings are stored under `~/.neutrino/`:
|
|
@@ -101,10 +107,23 @@ The agent can call the following tools during a conversation:
|
|
|
101
107
|
|
|
102
108
|
```sh
|
|
103
109
|
npm run build # compile TypeScript to dist/
|
|
104
|
-
|
|
105
|
-
node scripts/test-cursor.mjs # verify click-to-position-cursor math
|
|
110
|
+
npm test # build, then run every test below
|
|
106
111
|
```
|
|
107
112
|
|
|
113
|
+
Individual tests:
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
node scripts/test-mouse.mjs # verify mouse escape-sequence parsing
|
|
117
|
+
node scripts/test-cursor.mjs # verify click-to-position-cursor math
|
|
118
|
+
node scripts/test-selection.mjs # verify selection highlighting & text extraction
|
|
119
|
+
node scripts/test-render.mjs # fuzz the diff renderer + render the chat screen,
|
|
120
|
+
# asserting no stale/residual cells are left behind
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Rendering model
|
|
124
|
+
|
|
125
|
+
The screen is drawn into a `Buffer` of cells which is diffed against the previous frame, so only changed rows are emitted. Rows are erased to the end of the line before being repainted, and the buffer keeps double-width glyphs consistent (writing over one half of a wide character blanks the other half). Together these guarantee that a repainted row can never leave a stale cell behind.
|
|
126
|
+
|
|
108
127
|
## License
|
|
109
128
|
|
|
110
129
|
Apache-2.0. See [LICENSE](./LICENSE).
|
package/dist/app.js
CHANGED
|
@@ -4,12 +4,17 @@ import { InputReader } from "./terminal/input.js";
|
|
|
4
4
|
import { Renderer } from "./terminal/renderer.js";
|
|
5
5
|
import { defaultState } from "./state.js";
|
|
6
6
|
import { loadConfig, saveConfig } from "./config.js";
|
|
7
|
+
import { C } from "./colors.js";
|
|
8
|
+
import { applySelection, selectionText } from "./selection.js";
|
|
9
|
+
import { copyToClipboard } from "./clipboard.js";
|
|
7
10
|
import { ChatScreen } from "./screens/chat.js";
|
|
8
11
|
import { HistoryScreen } from "./screens/history.js";
|
|
9
12
|
import { AgentSelectScreen } from "./screens/agentSelect.js";
|
|
10
13
|
import { ModelSelectScreen } from "./screens/modelSelect.js";
|
|
11
14
|
import { SettingsScreen } from "./screens/settings.js";
|
|
12
15
|
import { StartScreen } from "./screens/start.js";
|
|
16
|
+
const CURSOR_BLINK_MS = 530;
|
|
17
|
+
const TOAST_MS = 1600;
|
|
13
18
|
export class App {
|
|
14
19
|
state = defaultState();
|
|
15
20
|
width;
|
|
@@ -20,6 +25,18 @@ export class App {
|
|
|
20
25
|
running = false;
|
|
21
26
|
sizeTimer = null;
|
|
22
27
|
blinkTimer = null;
|
|
28
|
+
cursorTimer = null;
|
|
29
|
+
toastTimer = null;
|
|
30
|
+
toast = "";
|
|
31
|
+
// Most recently rendered frame. Kept so a selection can be turned back into
|
|
32
|
+
// text without having to re-run the screen's render.
|
|
33
|
+
lastBuffer = null;
|
|
34
|
+
// Mouse selection bookkeeping. `pressed` is true between a left-button press
|
|
35
|
+
// and its release; `pressStart` is where the press landed, used as the anchor
|
|
36
|
+
// once the pointer starts moving (i.e. once it becomes a drag). A plain click
|
|
37
|
+
// never moves, so it is forwarded to the screen and leaves no selection.
|
|
38
|
+
pressed = false;
|
|
39
|
+
pressStart = null;
|
|
23
40
|
resizeHandler = () => this.onResize();
|
|
24
41
|
constructor() {
|
|
25
42
|
this.width = process.stdout.columns || 80;
|
|
@@ -72,11 +89,15 @@ export class App {
|
|
|
72
89
|
pushScreen(screen) {
|
|
73
90
|
screen.app = this;
|
|
74
91
|
this.screens.push(screen);
|
|
92
|
+
this.resetMouse();
|
|
93
|
+
this.state.selection = null;
|
|
75
94
|
screen.onActivate();
|
|
76
95
|
this.render();
|
|
77
96
|
}
|
|
78
97
|
popScreen() {
|
|
79
98
|
const screen = this.screens.pop();
|
|
99
|
+
this.resetMouse();
|
|
100
|
+
this.state.selection = null;
|
|
80
101
|
screen?.onDeactivate();
|
|
81
102
|
this.render();
|
|
82
103
|
}
|
|
@@ -96,17 +117,70 @@ export class App {
|
|
|
96
117
|
this.blinkTimer = null;
|
|
97
118
|
this.state.blinkOn = false;
|
|
98
119
|
}
|
|
120
|
+
// The text cursor blinks whenever the top screen shows one. The timer is
|
|
121
|
+
// only kept alive while needed so idle screens don't repaint needlessly.
|
|
122
|
+
this.updateCursorBlink();
|
|
99
123
|
const buf = new Buffer(this.width, this.height);
|
|
100
124
|
this.top?.render(buf);
|
|
125
|
+
this.lastBuffer = buf;
|
|
126
|
+
// The selection overlay is painted over the finished frame, so it works on
|
|
127
|
+
// every screen (chat log, input box, menus, …) without each having to know
|
|
128
|
+
// about selection at all.
|
|
129
|
+
if (this.state.selection)
|
|
130
|
+
applySelection(buf, this.state.selection);
|
|
131
|
+
this.drawToast(buf);
|
|
101
132
|
this.renderer.render(buf);
|
|
102
133
|
}
|
|
134
|
+
drawToast(buf) {
|
|
135
|
+
if (!this.toast)
|
|
136
|
+
return;
|
|
137
|
+
const y = buf.height - 1;
|
|
138
|
+
buf.fillRect(0, y, buf.width, 1, { bg: C.black });
|
|
139
|
+
buf.centerText(y, ` ${this.toast} `, { fg: C.black, bg: C.accent, bold: true });
|
|
140
|
+
}
|
|
141
|
+
updateCursorBlink() {
|
|
142
|
+
const want = this.top?.showsCursor() ?? false;
|
|
143
|
+
if (want && !this.cursorTimer) {
|
|
144
|
+
this.cursorTimer = setInterval(() => {
|
|
145
|
+
this.state.cursorOn = !this.state.cursorOn;
|
|
146
|
+
this.render();
|
|
147
|
+
}, CURSOR_BLINK_MS);
|
|
148
|
+
}
|
|
149
|
+
else if (!want && this.cursorTimer) {
|
|
150
|
+
clearInterval(this.cursorTimer);
|
|
151
|
+
this.cursorTimer = null;
|
|
152
|
+
this.state.cursorOn = true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** Reset the blink phase so the cursor is solid right after an interaction. */
|
|
156
|
+
wakeCursor() {
|
|
157
|
+
this.state.cursorOn = true;
|
|
158
|
+
}
|
|
103
159
|
handleEvent(ev) {
|
|
104
160
|
if (ev.type === "key") {
|
|
105
161
|
const k = ev.key;
|
|
106
|
-
|
|
162
|
+
// Any keystroke ends a pending mouse gesture (in case a release was lost).
|
|
163
|
+
this.resetMouse();
|
|
164
|
+
if (k.ctrl && k.name === "q") {
|
|
165
|
+
this.quit();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (k.ctrl && k.name === "c") {
|
|
169
|
+
// Ctrl+C copies the selection when there is one, otherwise it quits —
|
|
170
|
+
// the usual terminal convention.
|
|
171
|
+
if (this.state.selection) {
|
|
172
|
+
this.copySelection();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
107
175
|
this.quit();
|
|
108
176
|
return;
|
|
109
177
|
}
|
|
178
|
+
if (k.name === "escape" && this.state.selection) {
|
|
179
|
+
this.state.selection = null;
|
|
180
|
+
this.render();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
this.wakeCursor();
|
|
110
184
|
if (k.ctrl && (k.name === "m" || k.name === "e")) {
|
|
111
185
|
this.openModelConfig();
|
|
112
186
|
return;
|
|
@@ -114,9 +188,85 @@ export class App {
|
|
|
114
188
|
this.top?.onKey(k);
|
|
115
189
|
}
|
|
116
190
|
else {
|
|
117
|
-
this.
|
|
191
|
+
this.handleMouse(ev.event);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
handleMouse(ev) {
|
|
195
|
+
if (ev.type === "click") {
|
|
196
|
+
this.wakeCursor();
|
|
197
|
+
this.pressed = true;
|
|
198
|
+
this.pressStart = { x: ev.x, y: ev.y };
|
|
199
|
+
if (this.state.selection) {
|
|
200
|
+
this.state.selection = null;
|
|
201
|
+
this.render();
|
|
202
|
+
}
|
|
203
|
+
// Forward immediately so plain clicks (focus, tabs, header, …) keep
|
|
204
|
+
// working even in terminals that don't report button releases.
|
|
205
|
+
this.top?.onMouse(ev);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (ev.type === "move") {
|
|
209
|
+
if (this.pressed && this.pressStart) {
|
|
210
|
+
const s = this.pressStart;
|
|
211
|
+
if (ev.x !== s.x || ev.y !== s.y) {
|
|
212
|
+
// The pointer left the press cell, so this is a drag: grow/shrink the
|
|
213
|
+
// selection instead of forwarding a hover to the screen.
|
|
214
|
+
this.state.selection = { anchor: s, cursor: { x: ev.x, y: ev.y } };
|
|
215
|
+
this.render();
|
|
216
|
+
}
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
this.top?.onMouse(ev);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (ev.type === "release") {
|
|
223
|
+
const sel = this.state.selection;
|
|
224
|
+
this.pressed = false;
|
|
225
|
+
this.pressStart = null;
|
|
226
|
+
if (sel) {
|
|
227
|
+
if (this.lastBuffer) {
|
|
228
|
+
const text = selectionText(this.lastBuffer, sel);
|
|
229
|
+
if (text.trim().length > 0) {
|
|
230
|
+
copyToClipboard(text);
|
|
231
|
+
this.showToast(`Copied ${text.length} char${text.length === 1 ? "" : "s"}`);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
this.render();
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (ev.type === "wheel") {
|
|
240
|
+
if (this.state.selection)
|
|
241
|
+
this.state.selection = null;
|
|
242
|
+
this.top?.onMouse(ev);
|
|
118
243
|
}
|
|
119
244
|
}
|
|
245
|
+
resetMouse() {
|
|
246
|
+
this.pressed = false;
|
|
247
|
+
this.pressStart = null;
|
|
248
|
+
}
|
|
249
|
+
copySelection() {
|
|
250
|
+
const sel = this.state.selection;
|
|
251
|
+
if (!sel || !this.lastBuffer)
|
|
252
|
+
return;
|
|
253
|
+
const text = selectionText(this.lastBuffer, sel);
|
|
254
|
+
if (text.trim().length === 0)
|
|
255
|
+
return;
|
|
256
|
+
copyToClipboard(text);
|
|
257
|
+
this.showToast(`Copied ${text.length} char${text.length === 1 ? "" : "s"}`);
|
|
258
|
+
}
|
|
259
|
+
showToast(text) {
|
|
260
|
+
this.toast = text;
|
|
261
|
+
if (this.toastTimer)
|
|
262
|
+
clearTimeout(this.toastTimer);
|
|
263
|
+
this.toastTimer = setTimeout(() => {
|
|
264
|
+
this.toastTimer = null;
|
|
265
|
+
this.toast = "";
|
|
266
|
+
this.render();
|
|
267
|
+
}, TOAST_MS);
|
|
268
|
+
this.render();
|
|
269
|
+
}
|
|
120
270
|
setModelConfig(providerName, model, intensity) {
|
|
121
271
|
this.state.selectedProvider = providerName;
|
|
122
272
|
this.state.selectedModelName = model.name;
|
|
@@ -182,6 +332,14 @@ export class App {
|
|
|
182
332
|
clearInterval(this.blinkTimer);
|
|
183
333
|
this.blinkTimer = null;
|
|
184
334
|
}
|
|
335
|
+
if (this.cursorTimer) {
|
|
336
|
+
clearInterval(this.cursorTimer);
|
|
337
|
+
this.cursorTimer = null;
|
|
338
|
+
}
|
|
339
|
+
if (this.toastTimer) {
|
|
340
|
+
clearTimeout(this.toastTimer);
|
|
341
|
+
this.toastTimer = null;
|
|
342
|
+
}
|
|
185
343
|
this.input.stop();
|
|
186
344
|
this.renderer.dispose();
|
|
187
345
|
process.exit(0);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import process from "node:process";
|
|
7
|
+
/**
|
|
8
|
+
* Copy `text` to the system clipboard.
|
|
9
|
+
*
|
|
10
|
+
* Two independent, best-effort strategies are used so the copy works across as
|
|
11
|
+
* many terminals as possible:
|
|
12
|
+
*
|
|
13
|
+
* 1. OSC 52 — a terminal escape sequence that asks the host terminal to put
|
|
14
|
+
* the (base64-encoded) text on the clipboard. This is the only strategy
|
|
15
|
+
* that also works over SSH, and is supported by Windows Terminal, iTerm2,
|
|
16
|
+
* Kitty, WezTerm, Alacritty, …
|
|
17
|
+
* 2. A native helper process (`clip` / `pbcopy` / `wl-copy` / `xclip` / …),
|
|
18
|
+
* as a fallback for terminals that ignore OSC 52.
|
|
19
|
+
*
|
|
20
|
+
* Both are best-effort: any failure is silently ignored.
|
|
21
|
+
*/
|
|
22
|
+
export function copyToClipboard(text) {
|
|
23
|
+
if (!text)
|
|
24
|
+
return;
|
|
25
|
+
copyViaOsc52(text);
|
|
26
|
+
copyViaNative(text);
|
|
27
|
+
}
|
|
28
|
+
function copyViaOsc52(text) {
|
|
29
|
+
try {
|
|
30
|
+
const payload = Buffer.from(text, "utf8").toString("base64");
|
|
31
|
+
// OSC 52 ; <selection> ; <base64> BEL
|
|
32
|
+
process.stdout.write(`\x1b]52;c;${payload}\x07`);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
/* ignore */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function copyViaNative(text) {
|
|
39
|
+
if (process.platform === "win32") {
|
|
40
|
+
copyWindows(text);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const candidates = process.platform === "darwin"
|
|
44
|
+
? [["pbcopy", []]]
|
|
45
|
+
: process.env.WAYLAND_DISPLAY
|
|
46
|
+
? [
|
|
47
|
+
["wl-copy", []],
|
|
48
|
+
["xclip", ["-selection", "clipboard"]],
|
|
49
|
+
["xsel", ["--clipboard", "--input"]],
|
|
50
|
+
]
|
|
51
|
+
: [
|
|
52
|
+
["xclip", ["-selection", "clipboard"]],
|
|
53
|
+
["xsel", ["--clipboard", "--input"]],
|
|
54
|
+
["wl-copy", []],
|
|
55
|
+
];
|
|
56
|
+
pipeToFirstAvailable(candidates, text);
|
|
57
|
+
}
|
|
58
|
+
/** Spawn the first available helper and pipe the text into it. */
|
|
59
|
+
function pipeToFirstAvailable(candidates, text) {
|
|
60
|
+
let index = 0;
|
|
61
|
+
const tryNext = () => {
|
|
62
|
+
if (index >= candidates.length)
|
|
63
|
+
return;
|
|
64
|
+
const [cmd, args] = candidates[index++];
|
|
65
|
+
let child;
|
|
66
|
+
try {
|
|
67
|
+
child = spawn(cmd, args, { stdio: ["pipe", "ignore", "ignore"] });
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
tryNext();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
child.once("error", () => tryNext());
|
|
74
|
+
child.once("spawn", () => {
|
|
75
|
+
child.stdin?.end(text);
|
|
76
|
+
});
|
|
77
|
+
child.stdin?.on("error", () => {
|
|
78
|
+
/* the helper closed early — ignore */
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
tryNext();
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Windows: `clip.exe` decodes its input using the console code page, which
|
|
85
|
+
* mangles non-ASCII text. Instead we drop the text in a temp file and let
|
|
86
|
+
* PowerShell read it back as UTF-8 via the Unicode-aware Set-Clipboard cmdlet.
|
|
87
|
+
*/
|
|
88
|
+
function copyWindows(text) {
|
|
89
|
+
let tmp = null;
|
|
90
|
+
try {
|
|
91
|
+
tmp = path.join(os.tmpdir(), `neutrino-clip-${process.pid}-${Date.now()}.txt`);
|
|
92
|
+
fs.writeFileSync(tmp, text, "utf8");
|
|
93
|
+
const script = `Set-Clipboard -Value ([IO.File]::ReadAllText(${psQuote(tmp)}, [Text.Encoding]::UTF8)); ` +
|
|
94
|
+
`Remove-Item -LiteralPath ${psQuote(tmp)} -ErrorAction SilentlyContinue`;
|
|
95
|
+
const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { stdio: "ignore", windowsHide: true });
|
|
96
|
+
child.once("error", () => {
|
|
97
|
+
if (tmp) {
|
|
98
|
+
try {
|
|
99
|
+
fs.rmSync(tmp, { force: true });
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* ignore */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
/* ignore */
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function psQuote(value) {
|
|
112
|
+
return "'" + value.replace(/'/g, "''") + "'";
|
|
113
|
+
}
|
package/dist/colors.js
CHANGED
package/dist/config.js
CHANGED
|
@@ -194,3 +194,22 @@ export function loadPrompt() {
|
|
|
194
194
|
return (readAsset(path.join(PKG_ROOT, "assets", "prompt.txt"), path.join(PKG_ROOT, "prompt.txt")) ??
|
|
195
195
|
"You are Neutrino, a helpful AI coding assistant.");
|
|
196
196
|
}
|
|
197
|
+
let cachedVersion = null;
|
|
198
|
+
/**
|
|
199
|
+
* Read the CLI version straight from the package's `package.json` so the
|
|
200
|
+
* displayed version can never drift out of sync with the published package.
|
|
201
|
+
* The result is cached after the first successful read.
|
|
202
|
+
*/
|
|
203
|
+
export function loadVersion() {
|
|
204
|
+
if (cachedVersion !== null)
|
|
205
|
+
return cachedVersion;
|
|
206
|
+
try {
|
|
207
|
+
const raw = fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf-8");
|
|
208
|
+
const pkg = JSON.parse(raw);
|
|
209
|
+
cachedVersion = typeof pkg.version === "string" ? pkg.version : "";
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
cachedVersion = "";
|
|
213
|
+
}
|
|
214
|
+
return cachedVersion;
|
|
215
|
+
}
|
package/dist/screens/chat.js
CHANGED
|
@@ -78,6 +78,10 @@ export class ChatScreen extends Screen {
|
|
|
78
78
|
this.app.render();
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
|
+
showsCursor() {
|
|
82
|
+
// The chat input is always focused; a cursor is drawn when it holds text.
|
|
83
|
+
return this.inputValue.length > 0;
|
|
84
|
+
}
|
|
81
85
|
onKey(key) {
|
|
82
86
|
if (this.showInfo) {
|
|
83
87
|
this.showInfo = false;
|
|
@@ -469,10 +473,11 @@ export class ChatScreen extends Screen {
|
|
|
469
473
|
drawInputBox(buf, 1, inputY, inW, this.inputValue, this.cursor, {
|
|
470
474
|
active: true,
|
|
471
475
|
dimmed: this.busy,
|
|
476
|
+
cursorOn: this.app.state.cursorOn,
|
|
472
477
|
placeholder: "Type a message...",
|
|
473
478
|
});
|
|
474
479
|
const hintY = h - 1;
|
|
475
|
-
const hints = "Enter send
|
|
480
|
+
const hints = "Enter send · ESC ESC pause · Ctrl+B menu · Ctrl+L clear · drag to copy";
|
|
476
481
|
const hintW = displayWidth(hints);
|
|
477
482
|
const hintX = Math.max(0, w - 1 - hintW);
|
|
478
483
|
const pathAvail = Math.max(0, hintX - 2);
|
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,29 @@ 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
|
+
}
|
|
18
25
|
onKey(key) {
|
|
19
|
-
|
|
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") {
|
|
20
40
|
this.move(-1);
|
|
21
41
|
return;
|
|
22
42
|
}
|
|
23
|
-
if (key.name === "
|
|
43
|
+
if (key.name === "down") {
|
|
24
44
|
this.move(1);
|
|
25
45
|
return;
|
|
26
46
|
}
|
|
@@ -165,6 +185,24 @@ export class StartScreen extends Screen {
|
|
|
165
185
|
this.hoverIdx = -1;
|
|
166
186
|
this.app.render();
|
|
167
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;
|
|
204
|
+
this.app.render();
|
|
205
|
+
}
|
|
168
206
|
enterTab() {
|
|
169
207
|
if (this.focusIdx === 1)
|
|
170
208
|
this.app.openModelConfig();
|
|
@@ -193,6 +231,7 @@ export class StartScreen extends Screen {
|
|
|
193
231
|
y += 1;
|
|
194
232
|
drawInputBox(buf, inputX, y, inputW, this.inputValue, this.cursor, {
|
|
195
233
|
active: this.focusIdx === 0,
|
|
234
|
+
cursorOn: this.app.state.cursorOn,
|
|
196
235
|
placeholder: "Type a message to start a new chat...",
|
|
197
236
|
});
|
|
198
237
|
y += INPUT_H + gap;
|
|
@@ -207,9 +246,23 @@ export class StartScreen extends Screen {
|
|
|
207
246
|
drawTabButton(buf, tx, y, tw, TABS[i], selected);
|
|
208
247
|
tx += tw + tabGap;
|
|
209
248
|
}
|
|
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.
|
|
210
257
|
const hintY = y + TAB_H + 1;
|
|
211
|
-
if (hintY < h) {
|
|
212
|
-
|
|
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 });
|
|
213
266
|
}
|
|
214
267
|
}
|
|
215
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
|
@@ -158,12 +158,11 @@ export class InputReader {
|
|
|
158
158
|
/**
|
|
159
159
|
* Parse SGR (extended) mouse sequences: ESC [ < b ; x ; y M/m
|
|
160
160
|
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
* 64/65 wheel up / wheel down
|
|
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).
|
|
167
166
|
*/
|
|
168
167
|
handleMouse(s, final) {
|
|
169
168
|
const parts = s.slice(1).split(";");
|
|
@@ -174,18 +173,23 @@ export class InputReader {
|
|
|
174
173
|
const y = parseInt(parts[2], 10) - 1;
|
|
175
174
|
if (Number.isNaN(b) || Number.isNaN(x) || Number.isNaN(y))
|
|
176
175
|
return;
|
|
177
|
-
|
|
178
|
-
|
|
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;
|
|
179
182
|
}
|
|
180
|
-
|
|
181
|
-
this.emitMouse({ type: "
|
|
183
|
+
if (final === "m") {
|
|
184
|
+
this.emitMouse({ type: "release", x, y, button });
|
|
185
|
+
return;
|
|
182
186
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
187
|
+
if (motion) {
|
|
188
|
+
this.emitMouse({ type: "move", x, y, drag: button === 0 });
|
|
189
|
+
return;
|
|
186
190
|
}
|
|
187
|
-
|
|
188
|
-
this.emitMouse({ type: "click", x, y, button
|
|
191
|
+
if (button !== 3) {
|
|
192
|
+
this.emitMouse({ type: "click", x, y, button });
|
|
189
193
|
}
|
|
190
194
|
}
|
|
191
195
|
emitKey(name, ctrl, meta, shift, char) {
|
|
@@ -78,12 +78,17 @@ export class Renderer {
|
|
|
78
78
|
this.write(out);
|
|
79
79
|
}
|
|
80
80
|
renderRow(y, row) {
|
|
81
|
-
|
|
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`;
|
|
82
87
|
let lastKey = "";
|
|
83
88
|
for (let x = 0; x < row.length; x++) {
|
|
84
89
|
const cell = row[x];
|
|
85
90
|
if (cell.ch === "")
|
|
86
|
-
continue;
|
|
91
|
+
continue; // tail of the wide glyph to the left
|
|
87
92
|
const sk = styleKey(cell.style);
|
|
88
93
|
if (sk !== lastKey) {
|
|
89
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
|
}
|
package/dist/ui.js
CHANGED
|
@@ -141,6 +141,7 @@ export function drawInputBox(buf, x, y, w, value, cursorPos, opts = {}) {
|
|
|
141
141
|
const active = opts.active ?? true;
|
|
142
142
|
const dimmed = opts.dimmed ?? false;
|
|
143
143
|
const placeholder = opts.placeholder ?? "";
|
|
144
|
+
const cursorOn = opts.cursorOn ?? true;
|
|
144
145
|
const h = 3;
|
|
145
146
|
const innerX = x + 2;
|
|
146
147
|
const innerW = w - 4;
|
|
@@ -158,22 +159,42 @@ export function drawInputBox(buf, x, y, w, value, cursorPos, opts = {}) {
|
|
|
158
159
|
}
|
|
159
160
|
const win = computeInputWindow(value, cursorPos, innerW);
|
|
160
161
|
const chars = Array.from(value);
|
|
162
|
+
// Codepoint index of the cursor (cursorPos is a UTF-16 offset).
|
|
163
|
+
const cursorCp = Math.min(Array.from(value.slice(0, cursorPos)).length, chars.length);
|
|
161
164
|
const visible = chars.slice(win.startCp, win.startCp + win.visibleCount).join("");
|
|
162
165
|
buf.writeText(innerX, innerY, visible, textStyle);
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
+
// Blinking block cursor. While it is "on" the character underneath is drawn
|
|
167
|
+
// inside the block in the opposite colour so the text stays legible instead
|
|
168
|
+
// of being hidden by the cursor.
|
|
169
|
+
const cursorScreenCol = cursorColAt(chars, cursorCp, win);
|
|
170
|
+
if (active && cursorOn && cursorScreenCol >= 0 && cursorScreenCol < innerW) {
|
|
171
|
+
drawBlockCursor(buf, innerX + cursorScreenCol, innerY, chars, cursorCp, cursorScreenCol, innerW);
|
|
166
172
|
}
|
|
167
173
|
}
|
|
168
|
-
function cursorColAt(
|
|
169
|
-
const chars = Array.from(value);
|
|
170
|
-
const cursorCp = Math.min(Array.from(value.slice(0, cursorPos)).length, chars.length);
|
|
174
|
+
function cursorColAt(chars, cursorCp, win) {
|
|
171
175
|
let col = win.prefixW;
|
|
172
176
|
for (let i = win.startCp; i < cursorCp; i++) {
|
|
173
177
|
col += wcwidth(chars[i].codePointAt(0) ?? 0);
|
|
174
178
|
}
|
|
175
179
|
return col - win.prefixW;
|
|
176
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* Draw a single block cursor cell. The glyph under the cursor (the character
|
|
183
|
+
* immediately to its right) is rendered in the opposite colour — a dark glyph
|
|
184
|
+
* on the accent block — so it remains readable. Past the end of the text, or
|
|
185
|
+
* when the glyph would not fit, a solid block is drawn instead.
|
|
186
|
+
*/
|
|
187
|
+
function drawBlockCursor(buf, x, y, chars, cursorCp, col, innerW) {
|
|
188
|
+
const cursorStyle = { fg: C.black, bg: C.accent };
|
|
189
|
+
const ch = cursorCp < chars.length ? chars[cursorCp] : "";
|
|
190
|
+
const cw = ch ? wcwidth(ch.codePointAt(0) ?? 0) : 0;
|
|
191
|
+
if (ch && ch !== " " && cw >= 1 && col + cw <= innerW) {
|
|
192
|
+
buf.writeText(x, y, ch, cursorStyle);
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
buf.set(x, y, " ", cursorStyle);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
177
198
|
function textStylePlaceholder(active) {
|
|
178
199
|
return active ? { fg: C.dim, bg: C.highlightBg } : { fg: C.dim };
|
|
179
200
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xingjisoft/neutrino",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "XINGJI Neutrino - a CLI coding agent in the terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
},
|
|
18
18
|
"scripts": {
|
|
19
19
|
"build": "tsc -p tsconfig.json",
|
|
20
|
+
"test": "npm run build && node scripts/test-mouse.mjs && node scripts/test-cursor.mjs && node scripts/test-selection.mjs && node scripts/test-render.mjs",
|
|
20
21
|
"prepare": "npm run build"
|
|
21
22
|
},
|
|
22
23
|
"keywords": [
|