@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/README.md
CHANGED
|
@@ -5,6 +5,8 @@ A CLI coding agent that lives in your terminal. Neutrino gives you an interactiv
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- **Interactive terminal UI** — logo screen, chat, model select, history, and settings screens.
|
|
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).
|
|
8
10
|
- **LLM providers** — ships with DeepSeek, OpenAI, and Anthropic presets; works with any OpenAI-compatible endpoint.
|
|
9
11
|
- **Tool calling** — the agent can run shell commands, read files, write files, and list directories in your working directory.
|
|
10
12
|
- **Streaming responses** — token-by-token output with pause/resume (`ESC ESC`).
|
|
@@ -52,9 +54,25 @@ Type a message on the start screen and press `Enter` to begin a new chat.
|
|
|
52
54
|
| `Ctrl+B` | Back to menu (from chat) |
|
|
53
55
|
| `Ctrl+L` | Clear conversation (from chat) |
|
|
54
56
|
| `Esc Esc` | Pause / resume streaming |
|
|
55
|
-
| `
|
|
57
|
+
| `Esc` | Clear an active selection |
|
|
58
|
+
| `PgUp` / `PgDn` | Scroll chat history |
|
|
59
|
+
| `Ctrl+C` | Copy the selection, or quit if nothing is selected |
|
|
60
|
+
| `Ctrl+Q` | Quit |
|
|
61
|
+
|
|
62
|
+
### Mouse
|
|
63
|
+
|
|
64
|
+
| Action | Effect |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| Hover over an item | Highlight / select it in lists and menus |
|
|
67
|
+
| Click an item | Select and confirm (open a screen, load a session, apply a setting…) |
|
|
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 |
|
|
70
|
+
| Click the header (left of `ⓘ`) | Open the model selector |
|
|
56
71
|
| Click `ⓘ` (chat header) | Toggle token usage stats |
|
|
57
|
-
|
|
|
72
|
+
| Scroll wheel | Scroll the chat/log, or move through lists |
|
|
73
|
+
| Right-click | Go back (like `Esc`) |
|
|
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.
|
|
58
76
|
|
|
59
77
|
## Configuration
|
|
60
78
|
|
|
@@ -89,8 +107,23 @@ The agent can call the following tools during a conversation:
|
|
|
89
107
|
|
|
90
108
|
```sh
|
|
91
109
|
npm run build # compile TypeScript to dist/
|
|
110
|
+
npm test # build, then run every test below
|
|
111
|
+
```
|
|
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
|
|
92
121
|
```
|
|
93
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
|
+
|
|
94
127
|
## License
|
|
95
128
|
|
|
96
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;
|
|
@@ -19,6 +24,19 @@ export class App {
|
|
|
19
24
|
input = new InputReader();
|
|
20
25
|
running = false;
|
|
21
26
|
sizeTimer = null;
|
|
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;
|
|
22
40
|
resizeHandler = () => this.onResize();
|
|
23
41
|
constructor() {
|
|
24
42
|
this.width = process.stdout.columns || 80;
|
|
@@ -71,26 +89,98 @@ export class App {
|
|
|
71
89
|
pushScreen(screen) {
|
|
72
90
|
screen.app = this;
|
|
73
91
|
this.screens.push(screen);
|
|
92
|
+
this.resetMouse();
|
|
93
|
+
this.state.selection = null;
|
|
74
94
|
screen.onActivate();
|
|
75
95
|
this.render();
|
|
76
96
|
}
|
|
77
97
|
popScreen() {
|
|
78
98
|
const screen = this.screens.pop();
|
|
99
|
+
this.resetMouse();
|
|
100
|
+
this.state.selection = null;
|
|
79
101
|
screen?.onDeactivate();
|
|
80
102
|
this.render();
|
|
81
103
|
}
|
|
82
104
|
render() {
|
|
105
|
+
// Start/stop the "thinking" blink animation. While the agent is thinking,
|
|
106
|
+
// a timer toggles the blink phase and re-renders so the top status bar
|
|
107
|
+
// flashes to draw the user's attention.
|
|
108
|
+
if (this.state.isThinking && !this.blinkTimer) {
|
|
109
|
+
this.state.blinkOn = true;
|
|
110
|
+
this.blinkTimer = setInterval(() => {
|
|
111
|
+
this.state.blinkOn = !this.state.blinkOn;
|
|
112
|
+
this.render();
|
|
113
|
+
}, 500);
|
|
114
|
+
}
|
|
115
|
+
else if (!this.state.isThinking && this.blinkTimer) {
|
|
116
|
+
clearInterval(this.blinkTimer);
|
|
117
|
+
this.blinkTimer = null;
|
|
118
|
+
this.state.blinkOn = false;
|
|
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();
|
|
83
123
|
const buf = new Buffer(this.width, this.height);
|
|
84
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);
|
|
85
132
|
this.renderer.render(buf);
|
|
86
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
|
+
}
|
|
87
159
|
handleEvent(ev) {
|
|
88
160
|
if (ev.type === "key") {
|
|
89
161
|
const k = ev.key;
|
|
90
|
-
|
|
162
|
+
// Any keystroke ends a pending mouse gesture (in case a release was lost).
|
|
163
|
+
this.resetMouse();
|
|
164
|
+
if (k.ctrl && k.name === "q") {
|
|
91
165
|
this.quit();
|
|
92
166
|
return;
|
|
93
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
|
+
}
|
|
175
|
+
this.quit();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (k.name === "escape" && this.state.selection) {
|
|
179
|
+
this.state.selection = null;
|
|
180
|
+
this.render();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
this.wakeCursor();
|
|
94
184
|
if (k.ctrl && (k.name === "m" || k.name === "e")) {
|
|
95
185
|
this.openModelConfig();
|
|
96
186
|
return;
|
|
@@ -98,9 +188,85 @@ export class App {
|
|
|
98
188
|
this.top?.onKey(k);
|
|
99
189
|
}
|
|
100
190
|
else {
|
|
101
|
-
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);
|
|
102
243
|
}
|
|
103
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
|
+
}
|
|
104
270
|
setModelConfig(providerName, model, intensity) {
|
|
105
271
|
this.state.selectedProvider = providerName;
|
|
106
272
|
this.state.selectedModelName = model.name;
|
|
@@ -162,6 +328,18 @@ export class App {
|
|
|
162
328
|
this.running = true;
|
|
163
329
|
if (this.sizeTimer)
|
|
164
330
|
clearInterval(this.sizeTimer);
|
|
331
|
+
if (this.blinkTimer) {
|
|
332
|
+
clearInterval(this.blinkTimer);
|
|
333
|
+
this.blinkTimer = null;
|
|
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
|
+
}
|
|
165
343
|
this.input.stop();
|
|
166
344
|
this.renderer.dispose();
|
|
167
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
|
@@ -10,5 +10,7 @@ export const C = {
|
|
|
10
10
|
white: [226, 232, 240],
|
|
11
11
|
green: [115, 218, 202],
|
|
12
12
|
red: [247, 118, 142],
|
|
13
|
+
codeBg: [27, 35, 49], // dark blue-grey used behind code blocks
|
|
14
|
+
selection: [56, 92, 138], // muted blue used to highlight selected text
|
|
13
15
|
};
|
|
14
16
|
export const EMPTY_STYLE = {};
|
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/header.js
CHANGED
|
@@ -2,14 +2,19 @@ import { C } from "./colors.js";
|
|
|
2
2
|
import { truncateByWidth } from "./width.js";
|
|
3
3
|
export function drawHeader(buf, state) {
|
|
4
4
|
const w = buf.width;
|
|
5
|
-
buf.fillRect(0, 0, w, 1, { bg: C.highlightBg });
|
|
6
|
-
let text;
|
|
7
5
|
if (state.isThinking) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
// Flashing indicator: the whole bar alternates between highlighted and
|
|
7
|
+
// dark while the agent is working, so the user always notices it.
|
|
8
|
+
const on = state.blinkOn;
|
|
9
|
+
const bg = on ? C.highlightBg : C.black;
|
|
10
|
+
const fg = on ? C.accent : C.dim;
|
|
11
|
+
const text = "⚡ thinking";
|
|
12
|
+
buf.fillRect(0, 0, w, 1, { bg });
|
|
13
|
+
buf.centerText(0, text, { fg, bg, bold: true });
|
|
14
|
+
return;
|
|
12
15
|
}
|
|
16
|
+
buf.fillRect(0, 0, w, 1, { bg: C.highlightBg });
|
|
17
|
+
let text = `${state.selectedProvider}/${state.selectedModelName} | ${state.selectedIntensity}`;
|
|
13
18
|
text = truncateByWidth(text, w);
|
|
14
19
|
buf.centerText(0, text, { fg: C.accent, bg: C.highlightBg, bold: true });
|
|
15
20
|
}
|