@xingjisoft/neutrino 0.4.0 → 0.5.0
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 +33 -397
- package/assets/neutrino.ico +0 -0
- package/assets/neutrino.svg +7 -0
- package/dist/agents.js +22 -3
- package/dist/app.js +218 -26
- package/dist/background.js +358 -0
- package/dist/bin.js +200 -42
- package/dist/config.js +7 -3
- package/dist/header.js +2 -1
- package/dist/llm/tools.js +3 -1
- package/dist/migration.js +33 -0
- package/dist/pro/config.js +135 -9
- package/dist/screens/agentConversation.js +48 -5
- package/dist/screens/agentHistory.js +4 -2
- package/dist/screens/settings.js +43 -2
- package/dist/screens/start.js +26 -14
- package/dist/screens/updatePrompt.js +200 -0
- package/dist/state.js +1 -0
- package/dist/terminal/restore.js +3 -12
- package/dist/tray.js +138 -0
- package/dist/ui.js +32 -16
- package/dist/update.js +213 -0
- package/dist/web/clientState.js +82 -0
- package/dist/web/hub.js +4 -0
- package/dist/web/server.js +330 -37
- package/dist/web/service.js +160 -0
- package/dist/web/session.js +24 -6
- package/dist/web/settings.js +15 -0
- package/dist/web/terminalBridge.js +250 -0
- package/package.json +2 -2
- package/web/README.md +102 -23
- package/web/app.js +558 -22
- package/web/i18n.js +34 -0
- package/web/index.html +71 -9
- package/web/settings.js +223 -46
- package/web/style.css +145 -78
- package/assets/pro/model-template.json +0 -376
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import { Screen } from "../terminal/screen.js";
|
|
2
2
|
import { C } from "../colors.js";
|
|
3
3
|
import { drawHeader } from "../header.js";
|
|
4
|
-
import { drawHint, drawScrollbar } from "../ui.js";
|
|
4
|
+
import { drawHint, drawScrollbar, inRect } from "../ui.js";
|
|
5
5
|
import { drawSpans, renderMarkdownText } from "../markdown.js";
|
|
6
|
-
import { ellipsize } from "../width.js";
|
|
6
|
+
import { displayWidth, ellipsize, truncateByWidth } from "../width.js";
|
|
7
|
+
import { migrateAgentSession } from "../migration.js";
|
|
7
8
|
export class AgentConversationScreen extends Screen {
|
|
9
|
+
source;
|
|
8
10
|
title;
|
|
9
11
|
messages;
|
|
10
12
|
scrollTop = 0;
|
|
11
13
|
followBottom = true;
|
|
12
14
|
scrollbarGeom = null;
|
|
13
|
-
|
|
15
|
+
migrateButton = null;
|
|
16
|
+
migrationError = "";
|
|
17
|
+
constructor(title, messages, source) {
|
|
14
18
|
super();
|
|
19
|
+
this.source = source;
|
|
15
20
|
this.title = title;
|
|
16
21
|
this.messages = messages;
|
|
17
22
|
}
|
|
@@ -37,6 +42,10 @@ export class AgentConversationScreen extends Screen {
|
|
|
37
42
|
else if (key.name === "escape") {
|
|
38
43
|
this.app.popScreen();
|
|
39
44
|
}
|
|
45
|
+
else if (key.name === "enter" || key.name === "return" ||
|
|
46
|
+
(!key.ctrl && !key.meta && key.char.toLowerCase() === "m")) {
|
|
47
|
+
this.migrate();
|
|
48
|
+
}
|
|
40
49
|
}
|
|
41
50
|
onMouse(ev) {
|
|
42
51
|
if (ev.type === "wheel") {
|
|
@@ -52,6 +61,28 @@ export class AgentConversationScreen extends Screen {
|
|
|
52
61
|
else if (ev.type === "click" && ev.button === 2) {
|
|
53
62
|
this.app.popScreen();
|
|
54
63
|
}
|
|
64
|
+
else if (ev.type === "click" && ev.button === 0 && this.migrateButton &&
|
|
65
|
+
inRect(ev.x, ev.y, this.migrateButton)) {
|
|
66
|
+
this.migrate();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
migrate() {
|
|
70
|
+
if (!this.messages.length)
|
|
71
|
+
return;
|
|
72
|
+
try {
|
|
73
|
+
const state = this.app.state;
|
|
74
|
+
const session = migrateAgentSession(this.source.agent, this.source.id, this.source.cwd, {
|
|
75
|
+
provider: state.selectedProvider, model: state.selectedModelName, intensity: state.selectedIntensity,
|
|
76
|
+
});
|
|
77
|
+
this.app.loadSession(session);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
this.migrationError = error instanceof Error ? error.message : String(error);
|
|
81
|
+
this.app.render();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
alwaysRepaintRows() {
|
|
85
|
+
return [this.app.height - 3, this.app.height - 2, this.app.height - 1];
|
|
55
86
|
}
|
|
56
87
|
scrollbar() {
|
|
57
88
|
const g = this.scrollbarGeom;
|
|
@@ -93,7 +124,7 @@ export class AgentConversationScreen extends Screen {
|
|
|
93
124
|
const h = buf.height;
|
|
94
125
|
buf.centerText(1, ellipsize(this.title, Math.max(10, w - 4)), { fg: C.dim });
|
|
95
126
|
const logTop = 3;
|
|
96
|
-
const logBottom = h -
|
|
127
|
+
const logBottom = h - 4;
|
|
97
128
|
const viewH = Math.max(1, logBottom - logTop + 1);
|
|
98
129
|
const lines = this.buildLines(Math.max(10, w - 4));
|
|
99
130
|
const maxScroll = Math.max(0, lines.length - viewH);
|
|
@@ -118,6 +149,18 @@ export class AgentConversationScreen extends Screen {
|
|
|
118
149
|
}
|
|
119
150
|
// Scrollbar on the right edge, visible only when the log overflows.
|
|
120
151
|
drawScrollbar(buf, w - 1, logTop, logBottom, lines.length, viewH, this.scrollTop);
|
|
121
|
-
|
|
152
|
+
if (this.migrationError) {
|
|
153
|
+
buf.writeText(2, h - 3, ellipsize(`迁移失败:${this.migrationError}`, Math.max(0, w - 4)), { fg: C.red });
|
|
154
|
+
}
|
|
155
|
+
const label = truncateByWidth("[ 迁移会话 ]", Math.max(0, w - 2));
|
|
156
|
+
const buttonWidth = displayWidth(label);
|
|
157
|
+
this.migrateButton = { x: Math.max(0, w - buttonWidth - 2), y: h - 2, w: buttonWidth, h: 1 };
|
|
158
|
+
buf.writeText(this.migrateButton.x, this.migrateButton.y, label, {
|
|
159
|
+
fg: this.messages.length ? C.accent : C.dim, bg: C.highlightBg, bold: true,
|
|
160
|
+
});
|
|
161
|
+
for (let x = this.migrateButton.x; x < this.migrateButton.x + buttonWidth; x++) {
|
|
162
|
+
buf.markChrome(x, this.migrateButton.y);
|
|
163
|
+
}
|
|
164
|
+
drawHint(buf, h - 1, "↑/↓ scroll · M/Enter migrate · right-click back · Esc back");
|
|
122
165
|
}
|
|
123
166
|
}
|
|
@@ -97,8 +97,10 @@ export class AgentHistoryScreen extends Screen {
|
|
|
97
97
|
if (this.selectedIdx < 0 || this.selectedIdx >= this.sessions.length)
|
|
98
98
|
return;
|
|
99
99
|
const s = this.sessions[this.selectedIdx];
|
|
100
|
-
const messages = loadAgentMessages(this.agent, s.id);
|
|
101
|
-
this.app.pushScreen(new AgentConversationScreen(`${this.label} · ${s.title || s.id}`, messages
|
|
100
|
+
const messages = loadAgentMessages(this.agent, s.id, this.cwd);
|
|
101
|
+
this.app.pushScreen(new AgentConversationScreen(`${this.label} · ${s.title || s.id}`, messages, {
|
|
102
|
+
agent: this.agent, id: s.id, cwd: this.cwd,
|
|
103
|
+
}));
|
|
102
104
|
}
|
|
103
105
|
buildRows() {
|
|
104
106
|
const rows = [];
|
package/dist/screens/settings.js
CHANGED
|
@@ -4,8 +4,9 @@ import { CONTEXT_WINDOW_OPTIONS, DEFAULT_CONTEXT_WINDOW, contextWindow, CONFIG_F
|
|
|
4
4
|
import { drawHeader } from "../header.js";
|
|
5
5
|
import { centerVertically, drawHint } from "../ui.js";
|
|
6
6
|
import { hashWebPassword } from "../web/auth.js";
|
|
7
|
+
import { runningBackground } from "../background.js";
|
|
7
8
|
/** The settings that can be changed here, top to bottom. */
|
|
8
|
-
const ROWS = ["intensity", "compress", "approval_model", "extreme_approval", "sound", "web_console", "web_password"];
|
|
9
|
+
const ROWS = ["intensity", "compress", "approval_model", "extreme_approval", "sound", "web_console", "background", "web_password"];
|
|
9
10
|
export class SettingsScreen extends Screen {
|
|
10
11
|
intensityIdx = 2;
|
|
11
12
|
/** Levels the current chat model accepts, in canonical order. */
|
|
@@ -20,8 +21,11 @@ export class SettingsScreen extends Screen {
|
|
|
20
21
|
soundIdx = 1;
|
|
21
22
|
/** 0 = web UI off, 1 = mounted alongside the terminal. */
|
|
22
23
|
webIdx = 1;
|
|
24
|
+
/** 0 = terminal only, 1 = the Web console is run by a detached service. */
|
|
25
|
+
backgroundIdx = 0;
|
|
23
26
|
/** Only a row the user actually touched rewrites the saved default. */
|
|
24
27
|
webChanged = false;
|
|
28
|
+
backgroundPending = false;
|
|
25
29
|
models = [];
|
|
26
30
|
rowIdx = 0;
|
|
27
31
|
lastHoverX = -1;
|
|
@@ -48,6 +52,9 @@ export class SettingsScreen extends Screen {
|
|
|
48
52
|
// off, so reflect what is actually running first.
|
|
49
53
|
this.webIdx = (this.app.state.webUrl || config.web_console !== false) ? 1 : 0;
|
|
50
54
|
this.webChanged = false;
|
|
55
|
+
const background = runningBackground();
|
|
56
|
+
this.backgroundIdx = (state.webUrl ? state.webBackground : background?.resident ?? config.background === true) ? 1 : 0;
|
|
57
|
+
this.backgroundPending = false;
|
|
51
58
|
}
|
|
52
59
|
/** Change the value of a row by `delta` steps and persist it immediately. */
|
|
53
60
|
cycle(row, delta) {
|
|
@@ -81,6 +88,13 @@ export class SettingsScreen extends Screen {
|
|
|
81
88
|
void this.app.setWebConsole(this.webIdx === 1);
|
|
82
89
|
return;
|
|
83
90
|
}
|
|
91
|
+
else if (row === "background") {
|
|
92
|
+
if (this.backgroundPending)
|
|
93
|
+
return;
|
|
94
|
+
const service = runningBackground();
|
|
95
|
+
const current = this.app.state.webUrl ? this.app.state.webBackground : service?.resident ?? this.backgroundIdx === 1;
|
|
96
|
+
return this.applyBackground(!current);
|
|
97
|
+
}
|
|
84
98
|
else {
|
|
85
99
|
const options = [...new Set([...CONTEXT_WINDOW_OPTIONS, this.contextTokens])].sort((a, b) => a - b);
|
|
86
100
|
const i = options.indexOf(this.contextTokens);
|
|
@@ -89,6 +103,23 @@ export class SettingsScreen extends Screen {
|
|
|
89
103
|
}
|
|
90
104
|
this.save();
|
|
91
105
|
}
|
|
106
|
+
async applyBackground(enabled) {
|
|
107
|
+
this.backgroundPending = true;
|
|
108
|
+
this.app.render();
|
|
109
|
+
try {
|
|
110
|
+
const result = await this.app.setBackgroundResidency(enabled);
|
|
111
|
+
if (result.ok)
|
|
112
|
+
this.backgroundIdx = enabled ? 1 : 0;
|
|
113
|
+
else
|
|
114
|
+
this.backgroundIdx = loadConfig().background === true ? 1 : 0;
|
|
115
|
+
if (this.app.state.webUrl)
|
|
116
|
+
this.webIdx = 1;
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
this.backgroundPending = false;
|
|
120
|
+
this.app.render();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
92
123
|
onKey(key) {
|
|
93
124
|
if (this.editingPassword) {
|
|
94
125
|
if (key.name === "escape") {
|
|
@@ -233,9 +264,19 @@ export class SettingsScreen extends Screen {
|
|
|
233
264
|
value: this.webIdx === 0
|
|
234
265
|
? "Off"
|
|
235
266
|
: this.app.state.webUrl
|
|
236
|
-
?
|
|
267
|
+
? this.app.state.webBackground
|
|
268
|
+
? `On (background) · ${this.app.state.webUrl}`
|
|
269
|
+
: `On · ${this.app.state.webUrl}`
|
|
237
270
|
: "On (not running)",
|
|
238
271
|
},
|
|
272
|
+
{
|
|
273
|
+
label: "Background residency",
|
|
274
|
+
value: this.backgroundPending
|
|
275
|
+
? "Applying…"
|
|
276
|
+
: this.app.state.webUrl
|
|
277
|
+
? this.app.state.webBackground ? "On (running)" : "Off"
|
|
278
|
+
: runningBackground()?.resident ? "On (running)" : this.backgroundIdx === 1 ? "On (not running)" : "Off",
|
|
279
|
+
},
|
|
239
280
|
{
|
|
240
281
|
label: "Web password",
|
|
241
282
|
value: this.editingPassword ? "*".repeat(Math.min(this.passwordDraft.length, 24)) || "Type new password" : this.passwordMessage,
|
package/dist/screens/start.js
CHANGED
|
@@ -2,7 +2,7 @@ import { Screen } from "../terminal/screen.js";
|
|
|
2
2
|
import { C } from "../colors.js";
|
|
3
3
|
import { loadLogo, loadVersion, loadConfig, saveConfig, normalizeApprovalMode } from "../config.js";
|
|
4
4
|
import { drawHeader } from "../header.js";
|
|
5
|
-
import { centerVertically, computeInputWindow, cursorFromColumn, drawHint, drawInputBox, drawTabButton, inRect, approvalToggle, drawApprovalToggle,
|
|
5
|
+
import { centerVertically, computeInputWindow, cursorFromColumn, drawHint, drawInputBox, drawTabButton, inRect, approvalToggle, drawApprovalToggle, drawHeaderButton, proModeButton, webModeButton, } from "../ui.js";
|
|
6
6
|
const TABS = ["Model", "History", "Settings"];
|
|
7
7
|
const LOGO = loadLogo();
|
|
8
8
|
const FOCUS_COUNT = TABS.length + 2; // input box + 3 tabs + approval mode
|
|
@@ -31,6 +31,7 @@ export class StartScreen extends Screen {
|
|
|
31
31
|
focusIdx = 0; // 0 = input box, 1..3 = tabs, 4 = approval mode
|
|
32
32
|
hoverIdx = -1; // -1 = nothing, otherwise the control index
|
|
33
33
|
proHover = false; // pointer is over the Pro-mode button
|
|
34
|
+
webHover = false; // pointer is over the Web-mode button
|
|
34
35
|
lastHoverX = -1;
|
|
35
36
|
lastHoverY = -1;
|
|
36
37
|
showsCursor() {
|
|
@@ -138,12 +139,15 @@ export class StartScreen extends Screen {
|
|
|
138
139
|
this.lastHoverX = ev.x;
|
|
139
140
|
this.lastHoverY = ev.y;
|
|
140
141
|
const pro = proModeButton(this.app.width, this.app.height);
|
|
142
|
+
const web = webModeButton(this.app.width, this.app.height);
|
|
141
143
|
const proHover = pro !== null && inRect(ev.x, ev.y, pro);
|
|
144
|
+
const webHover = web !== null && inRect(ev.x, ev.y, web);
|
|
142
145
|
const layout = this.layout();
|
|
143
146
|
let hover = -1;
|
|
144
|
-
// While the pointer is over the
|
|
145
|
-
// underneath it: the
|
|
146
|
-
|
|
147
|
+
// While the pointer is over one of the top-left buttons no other control
|
|
148
|
+
// is hovered underneath it: the boxes are opaque, so their neighbours
|
|
149
|
+
// must not light up.
|
|
150
|
+
if (!proHover && !webHover) {
|
|
147
151
|
if (inRect(ev.x, ev.y, approvalToggle(this.app.width, this.app.height - 4, this.approvalMode))) {
|
|
148
152
|
hover = FOCUS_COUNT - 1;
|
|
149
153
|
}
|
|
@@ -159,8 +163,9 @@ export class StartScreen extends Screen {
|
|
|
159
163
|
}
|
|
160
164
|
}
|
|
161
165
|
}
|
|
162
|
-
if (proHover !== this.proHover || hover !== this.hoverIdx) {
|
|
166
|
+
if (proHover !== this.proHover || webHover !== this.webHover || hover !== this.hoverIdx) {
|
|
163
167
|
this.proHover = proHover;
|
|
168
|
+
this.webHover = webHover;
|
|
164
169
|
this.hoverIdx = hover;
|
|
165
170
|
this.app.render();
|
|
166
171
|
}
|
|
@@ -172,13 +177,19 @@ export class StartScreen extends Screen {
|
|
|
172
177
|
return;
|
|
173
178
|
if (ev.button === 2)
|
|
174
179
|
return; // right click: no-op on start screen
|
|
175
|
-
// The
|
|
176
|
-
// click anywhere in
|
|
180
|
+
// The top-left buttons are controls of their own and are checked first:
|
|
181
|
+
// a left click anywhere in a box opens its workspace instead of the
|
|
182
|
+
// control below.
|
|
177
183
|
const pro = proModeButton(this.app.width, this.app.height);
|
|
178
184
|
if (ev.button === 0 && pro && inRect(ev.x, ev.y, pro)) {
|
|
179
185
|
void this.app.openProMode();
|
|
180
186
|
return;
|
|
181
187
|
}
|
|
188
|
+
const web = webModeButton(this.app.width, this.app.height);
|
|
189
|
+
if (ev.button === 0 && web && inRect(ev.x, ev.y, web)) {
|
|
190
|
+
void this.app.openWebMode();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
182
193
|
if (ev.y >= 1 && inRect(ev.x, ev.y, approvalToggle(this.app.width, this.app.height - 4, this.approvalMode))) {
|
|
183
194
|
this.focusIdx = FOCUS_COUNT - 1;
|
|
184
195
|
this.toggleApproval();
|
|
@@ -224,8 +235,8 @@ export class StartScreen extends Screen {
|
|
|
224
235
|
for (let y = inputY; y <= tabY + TAB_H; y++)
|
|
225
236
|
if (y >= 0 && y < h)
|
|
226
237
|
rows.push(y);
|
|
227
|
-
// The
|
|
228
|
-
// makes
|
|
238
|
+
// The top-left buttons sit directly below the status bar; repainting their
|
|
239
|
+
// rows makes them self-heal exactly like the input box and the tabs.
|
|
229
240
|
for (let y = 1; y <= 3; y++)
|
|
230
241
|
if (y >= 0 && y < h)
|
|
231
242
|
rows.push(y);
|
|
@@ -259,8 +270,8 @@ export class StartScreen extends Screen {
|
|
|
259
270
|
}
|
|
260
271
|
/**
|
|
261
272
|
* First content row. The centered block normally floats in the middle, but
|
|
262
|
-
* it must start below the
|
|
263
|
-
* opaque chrome and would otherwise clip the top of the logo.
|
|
273
|
+
* it must start below the top-left buttons when they are visible: the boxes
|
|
274
|
+
* are opaque chrome and would otherwise clip the top of the logo.
|
|
264
275
|
*/
|
|
265
276
|
contentTop(height, contentH) {
|
|
266
277
|
const top = centerVertically(height - 4, contentH);
|
|
@@ -353,8 +364,9 @@ export class StartScreen extends Screen {
|
|
|
353
364
|
buf.centerText(versionY, `v${version}`, { fg: C.dim });
|
|
354
365
|
}
|
|
355
366
|
drawApprovalToggle(buf, approvalY, this.approvalMode, this.focusIdx === FOCUS_COUNT - 1 || this.hoverIdx === FOCUS_COUNT - 1);
|
|
356
|
-
// Painted last so
|
|
357
|
-
// content
|
|
358
|
-
|
|
367
|
+
// Painted last so their opaque footprints are never clipped by the
|
|
368
|
+
// decorative content drawn before them. Hit testing shares the geometry.
|
|
369
|
+
drawHeaderButton(buf, proModeButton(w, h), this.proHover);
|
|
370
|
+
drawHeaderButton(buf, webModeButton(w, h), this.webHover);
|
|
359
371
|
}
|
|
360
372
|
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { Screen } from "../terminal/screen.js";
|
|
2
|
+
import { C } from "../colors.js";
|
|
3
|
+
import { drawHeader } from "../header.js";
|
|
4
|
+
import { drawHint, drawTabButton, inRect, wrapText } from "../ui.js";
|
|
5
|
+
import { MANUAL_UPDATE_COMMAND, installUpdate } from "../update.js";
|
|
6
|
+
const TITLE_ROW = 2;
|
|
7
|
+
const META_ROW = 3;
|
|
8
|
+
const BODY_TOP = 5;
|
|
9
|
+
const BUTTON_H = 3;
|
|
10
|
+
const BUTTON_X = 1;
|
|
11
|
+
const BODY_X = 3;
|
|
12
|
+
/**
|
|
13
|
+
* The "a newer Neutrino is published" notice.
|
|
14
|
+
*
|
|
15
|
+
* It is pushed over the start screen only, never over a conversation: the
|
|
16
|
+
* answer arrives a moment after launch, and stealing a chat the user has
|
|
17
|
+
* already opened would be worse than a missed notice.
|
|
18
|
+
*
|
|
19
|
+
* Confirming runs npm here, in this process; the running code is not replaced,
|
|
20
|
+
* so a successful update ends by telling the user to restart. A failed one
|
|
21
|
+
* shows the command to run by hand, because npm usually fails for a reason the
|
|
22
|
+
* user has to fix (permissions, a proxy, an offline machine).
|
|
23
|
+
*/
|
|
24
|
+
export class UpdatePromptScreen extends Screen {
|
|
25
|
+
phase = "prompt";
|
|
26
|
+
/** Why the install failed, or which version landed. */
|
|
27
|
+
detail = "";
|
|
28
|
+
selectedIdx = 0;
|
|
29
|
+
lastHoverX = -1;
|
|
30
|
+
lastHoverY = -1;
|
|
31
|
+
current;
|
|
32
|
+
latest;
|
|
33
|
+
constructor(status) {
|
|
34
|
+
super();
|
|
35
|
+
this.current = status.currentVersion;
|
|
36
|
+
this.latest = status.latestVersion;
|
|
37
|
+
}
|
|
38
|
+
/** The buttons drawn right now; a click always lands on one of these. */
|
|
39
|
+
buttons() {
|
|
40
|
+
if (this.phase === "prompt")
|
|
41
|
+
return ["Update now", "Later"];
|
|
42
|
+
if (this.phase === "installing")
|
|
43
|
+
return [];
|
|
44
|
+
return ["Close"];
|
|
45
|
+
}
|
|
46
|
+
buttonRow(index) {
|
|
47
|
+
const count = this.buttons().length;
|
|
48
|
+
const top = this.app.height - 1 - count * BUTTON_H;
|
|
49
|
+
return top + index * BUTTON_H;
|
|
50
|
+
}
|
|
51
|
+
/** Columns the body paragraphs may use before wrapping. */
|
|
52
|
+
bodyWidth() {
|
|
53
|
+
return Math.max(10, this.app.width - BODY_X * 2);
|
|
54
|
+
}
|
|
55
|
+
start() {
|
|
56
|
+
if (this.phase !== "prompt")
|
|
57
|
+
return;
|
|
58
|
+
this.phase = "installing";
|
|
59
|
+
this.selectedIdx = 0;
|
|
60
|
+
this.app.render();
|
|
61
|
+
void installUpdate().then((result) => {
|
|
62
|
+
this.phase = result.ok ? "updated" : "failed";
|
|
63
|
+
this.detail = result.ok
|
|
64
|
+
? result.version || this.latest
|
|
65
|
+
: result.error || "npm did not report a reason.";
|
|
66
|
+
this.selectedIdx = 0;
|
|
67
|
+
this.app.render();
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
close() {
|
|
71
|
+
if (this.phase === "installing")
|
|
72
|
+
return;
|
|
73
|
+
this.app.popScreen();
|
|
74
|
+
}
|
|
75
|
+
activate(index) {
|
|
76
|
+
const label = this.buttons()[index];
|
|
77
|
+
if (!label)
|
|
78
|
+
return;
|
|
79
|
+
if (label === "Update now")
|
|
80
|
+
this.start();
|
|
81
|
+
else
|
|
82
|
+
this.close();
|
|
83
|
+
}
|
|
84
|
+
move(delta) {
|
|
85
|
+
const count = this.buttons().length;
|
|
86
|
+
if (count === 0)
|
|
87
|
+
return;
|
|
88
|
+
this.selectedIdx = (this.selectedIdx + delta + count) % count;
|
|
89
|
+
this.app.render();
|
|
90
|
+
}
|
|
91
|
+
onKey(key) {
|
|
92
|
+
if (key.name === "up")
|
|
93
|
+
this.move(-1);
|
|
94
|
+
else if (key.name === "down")
|
|
95
|
+
this.move(1);
|
|
96
|
+
else if (key.name === "return" || key.name === "enter")
|
|
97
|
+
this.activate(this.selectedIdx);
|
|
98
|
+
else if (key.name === "escape")
|
|
99
|
+
this.close();
|
|
100
|
+
}
|
|
101
|
+
onMouse(ev) {
|
|
102
|
+
const count = this.buttons().length;
|
|
103
|
+
const indexAt = (x, y) => {
|
|
104
|
+
for (let i = 0; i < count; i++) {
|
|
105
|
+
if (inRect(x, y, { x: BUTTON_X, y: this.buttonRow(i), w: this.app.width - 2, h: BUTTON_H }))
|
|
106
|
+
return i;
|
|
107
|
+
}
|
|
108
|
+
return -1;
|
|
109
|
+
};
|
|
110
|
+
if (ev.type === "move") {
|
|
111
|
+
if (ev.x === this.lastHoverX && ev.y === this.lastHoverY)
|
|
112
|
+
return;
|
|
113
|
+
this.lastHoverX = ev.x;
|
|
114
|
+
this.lastHoverY = ev.y;
|
|
115
|
+
const hover = indexAt(ev.x, ev.y);
|
|
116
|
+
if (hover >= 0 && hover !== this.selectedIdx) {
|
|
117
|
+
this.selectedIdx = hover;
|
|
118
|
+
this.app.render();
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (ev.type === "wheel") {
|
|
123
|
+
this.move(ev.dir === -1 ? -1 : 1);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (ev.type === "click") {
|
|
127
|
+
if (ev.button === 2) {
|
|
128
|
+
this.close();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const index = indexAt(ev.x, ev.y);
|
|
132
|
+
if (index < 0)
|
|
133
|
+
return;
|
|
134
|
+
this.selectedIdx = index;
|
|
135
|
+
this.activate(index);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** What the dialog says, in the order it is drawn. */
|
|
139
|
+
body() {
|
|
140
|
+
const normal = { fg: C.white };
|
|
141
|
+
const note = { fg: C.dim };
|
|
142
|
+
if (this.phase === "installing") {
|
|
143
|
+
return [
|
|
144
|
+
{ text: `Installing Neutrino ${this.latest} with npm…`, style: normal },
|
|
145
|
+
{ text: "This can take a minute. Neutrino keeps running while npm works.", style: note },
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
if (this.phase === "updated") {
|
|
149
|
+
return [
|
|
150
|
+
{ text: `Neutrino ${this.detail} was installed.`, style: { fg: C.green, bold: true } },
|
|
151
|
+
{ text: "Restart Neutrino to run the new version: this terminal keeps the old one until it exits.", style: normal },
|
|
152
|
+
];
|
|
153
|
+
}
|
|
154
|
+
if (this.phase === "failed") {
|
|
155
|
+
return [
|
|
156
|
+
{ text: "The automatic update failed. Neutrino was not changed.", style: { fg: C.red, bold: true } },
|
|
157
|
+
{ text: this.detail, style: normal },
|
|
158
|
+
{ text: "Update by hand with:", style: note },
|
|
159
|
+
{ text: MANUAL_UPDATE_COMMAND, style: { fg: C.accent, bold: true } },
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
return [
|
|
163
|
+
{ text: `Neutrino ${this.latest} is available.`, style: normal },
|
|
164
|
+
{ text: "Update now installs it with npm. Restart Neutrino afterwards to use it.", style: note },
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
render(buf) {
|
|
168
|
+
drawHeader(buf, this.app.state);
|
|
169
|
+
const h = buf.height;
|
|
170
|
+
const title = this.phase === "prompt" ? "Update available"
|
|
171
|
+
: this.phase === "installing" ? "Updating Neutrino"
|
|
172
|
+
: this.phase === "updated" ? "Update installed"
|
|
173
|
+
: "Update failed";
|
|
174
|
+
buf.centerText(TITLE_ROW, title, { fg: C.accent, bold: true });
|
|
175
|
+
buf.centerText(META_ROW, `Running ${this.current} · Published ${this.latest}`, { fg: C.dim });
|
|
176
|
+
// Body paragraphs wrap to the terminal width so a narrow window still reads,
|
|
177
|
+
// and stop above the buttons so the two can never overlap.
|
|
178
|
+
const width = this.bodyWidth();
|
|
179
|
+
const bodyEnd = h - 1 - this.buttons().length * BUTTON_H;
|
|
180
|
+
let y = BODY_TOP;
|
|
181
|
+
for (const line of this.body()) {
|
|
182
|
+
for (const piece of wrapText(line.text, width)) {
|
|
183
|
+
if (y >= bodyEnd)
|
|
184
|
+
break;
|
|
185
|
+
buf.writeText(BODY_X, y, piece, line.style);
|
|
186
|
+
y++;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const buttons = this.buttons();
|
|
190
|
+
for (let i = 0; i < buttons.length; i++) {
|
|
191
|
+
drawTabButton(buf, BUTTON_X, this.buttonRow(i), buf.width - BUTTON_X * 2, buttons[i], i === this.selectedIdx);
|
|
192
|
+
}
|
|
193
|
+
const hint = this.phase === "installing"
|
|
194
|
+
? "npm is running; this screen closes when it finishes"
|
|
195
|
+
: buttons.length > 1
|
|
196
|
+
? "↑/↓ select · Enter confirm · click to choose · Esc later"
|
|
197
|
+
: "Enter or Esc close";
|
|
198
|
+
drawHint(buf, h - 1, hint);
|
|
199
|
+
}
|
|
200
|
+
}
|
package/dist/state.js
CHANGED
package/dist/terminal/restore.js
CHANGED
|
@@ -89,17 +89,7 @@ export function writeCrashLog(kind, err) {
|
|
|
89
89
|
return null;
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
|
-
|
|
93
|
-
* Installs process-wide handlers that restore the terminal before the process
|
|
94
|
-
* dies.
|
|
95
|
-
*
|
|
96
|
-
* Without this, any throw that escapes a timer, a stream callback or a floating
|
|
97
|
-
* promise kills the process with raw mode still on and the alternate screen
|
|
98
|
-
* still active. The visible result is a terminal that works but spews
|
|
99
|
-
* `[<b>;<x>;<y>M` mouse reports at the shell prompt, because the shell now
|
|
100
|
-
* receives the mouse events the TUI asked the terminal to send.
|
|
101
|
-
*/
|
|
102
|
-
export function installCrashGuard() {
|
|
92
|
+
export function installCrashGuard(options = {}) {
|
|
103
93
|
if (installed)
|
|
104
94
|
return;
|
|
105
95
|
installed = true;
|
|
@@ -123,7 +113,8 @@ export function installCrashGuard() {
|
|
|
123
113
|
});
|
|
124
114
|
// These signals otherwise bypass Node's exit hook, including Ctrl+Break on
|
|
125
115
|
// Windows. The app handles SIGINT as an interrupt and SIGTERM as a quit.
|
|
126
|
-
|
|
116
|
+
if (!options.surviveHangup)
|
|
117
|
+
process.on("SIGHUP", () => process.exit(129));
|
|
127
118
|
process.on("SIGBREAK", () => process.exit(149));
|
|
128
119
|
const fatal = (kind, err) => {
|
|
129
120
|
// Restore first: the terminal must be usable even if logging itself fails.
|
package/dist/tray.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows notification-area icon for the background Web console.
|
|
3
|
+
*
|
|
4
|
+
* Node cannot create a tray icon by itself, and the project deliberately has
|
|
5
|
+
* no native dependencies, so a tiny Windows PowerShell process hosts a
|
|
6
|
+
* `System.Windows.Forms.NotifyIcon`. It uses the same orbit badge as the
|
|
7
|
+
* regular console's top-left mark. Everything is best-effort: an unavailable
|
|
8
|
+
* icon must never stop the service.
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import process from "node:process";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { BACKGROUND_LOG_FILE, backgroundLog } from "./background.js";
|
|
17
|
+
import { NEUTRINO_DIR } from "./config.js";
|
|
18
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const PKG_ROOT = path.resolve(__dirname, "..");
|
|
20
|
+
const ICON_FILE = path.join(PKG_ROOT, "assets", "neutrino.ico");
|
|
21
|
+
/** Quote a value as a PowerShell single-quoted literal. */
|
|
22
|
+
function psString(value) {
|
|
23
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
24
|
+
}
|
|
25
|
+
/** The icon itself. Only Windows has a notification area, and only it can run this. */
|
|
26
|
+
export function startBackgroundTray(options) {
|
|
27
|
+
if (process.platform !== "win32")
|
|
28
|
+
return null;
|
|
29
|
+
if (process.env.NEUTRINO_NO_TRAY === "1")
|
|
30
|
+
return null;
|
|
31
|
+
if (!fs.existsSync(ICON_FILE)) {
|
|
32
|
+
backgroundLog(`tray icon not found at ${ICON_FILE}`);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
// Separate files let an old icon close cleanly even if residency is turned
|
|
36
|
+
// back on before its PowerShell message loop has processed the shutdown.
|
|
37
|
+
const suffix = `${process.pid}-${randomBytes(8).toString("hex")}`;
|
|
38
|
+
const scriptFile = path.join(NEUTRINO_DIR, `tray-${suffix}.ps1`);
|
|
39
|
+
const closeFile = path.join(NEUTRINO_DIR, `tray-${suffix}.stop`);
|
|
40
|
+
const script = `
|
|
41
|
+
$ErrorActionPreference = 'Continue'
|
|
42
|
+
$scriptFile = ${psString(scriptFile)}
|
|
43
|
+
$closeFile = ${psString(closeFile)}
|
|
44
|
+
try {
|
|
45
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
46
|
+
Add-Type -AssemblyName System.Drawing
|
|
47
|
+
$iconPath = ${psString(ICON_FILE)}
|
|
48
|
+
$url = ${psString(options.url)}
|
|
49
|
+
$stopUrl = ${psString(options.stopUrl)}
|
|
50
|
+
$token = ${psString(options.token)}
|
|
51
|
+
$parent = ${options.pid}
|
|
52
|
+
$notify = New-Object System.Windows.Forms.NotifyIcon
|
|
53
|
+
$notify.Icon = New-Object System.Drawing.Icon($iconPath)
|
|
54
|
+
$notify.Text = 'Neutrino - background console'
|
|
55
|
+
$notify.Visible = $true
|
|
56
|
+
$menu = New-Object System.Windows.Forms.ContextMenuStrip
|
|
57
|
+
$open = $menu.Items.Add('Open Neutrino console')
|
|
58
|
+
$open.add_Click({ Start-Process -FilePath $url })
|
|
59
|
+
$stop = $menu.Items.Add('Stop background service')
|
|
60
|
+
$stop.add_Click({
|
|
61
|
+
try {
|
|
62
|
+
Invoke-RestMethod -Method Post -Uri $stopUrl -Headers @{ Authorization = "Bearer $token" } -TimeoutSec 5 | Out-Null
|
|
63
|
+
} catch {
|
|
64
|
+
Stop-Process -Id $parent -Force
|
|
65
|
+
}
|
|
66
|
+
$notify.Visible = $false
|
|
67
|
+
[System.Windows.Forms.Application]::Exit()
|
|
68
|
+
})
|
|
69
|
+
$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) | Out-Null
|
|
70
|
+
$notify.ContextMenuStrip = $menu
|
|
71
|
+
$notify.add_MouseClick({ param($sender, $eventArgs) if ($eventArgs.Button -eq [System.Windows.Forms.MouseButtons]::Left) { Start-Process -FilePath $url } })
|
|
72
|
+
$notify.add_DoubleClick({ Start-Process -FilePath $url })
|
|
73
|
+
$timer = New-Object System.Windows.Forms.Timer
|
|
74
|
+
$timer.Interval = 250
|
|
75
|
+
$timer.add_Tick({
|
|
76
|
+
if ((Test-Path -LiteralPath $closeFile) -or -not (Get-Process -Id $parent -ErrorAction SilentlyContinue)) {
|
|
77
|
+
$notify.Visible = $false
|
|
78
|
+
[System.Windows.Forms.Application]::Exit()
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
$timer.Start()
|
|
82
|
+
[System.Windows.Forms.Application]::Run()
|
|
83
|
+
} finally {
|
|
84
|
+
if ($notify) { $notify.Visible = $false; $notify.Dispose() }
|
|
85
|
+
Remove-Item -LiteralPath $closeFile, $scriptFile -Force -ErrorAction SilentlyContinue
|
|
86
|
+
}
|
|
87
|
+
`;
|
|
88
|
+
const cleanup = () => {
|
|
89
|
+
for (const file of [scriptFile, closeFile]) {
|
|
90
|
+
try {
|
|
91
|
+
fs.rmSync(file, { force: true });
|
|
92
|
+
}
|
|
93
|
+
catch { /* The helper also cleans up. */ }
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
try {
|
|
97
|
+
fs.mkdirSync(NEUTRINO_DIR, { recursive: true });
|
|
98
|
+
fs.writeFileSync(scriptFile, `\ufeff${script}`, { encoding: "utf-8", mode: 0o600 });
|
|
99
|
+
const logFd = fs.openSync(BACKGROUND_LOG_FILE, "a");
|
|
100
|
+
let child;
|
|
101
|
+
try {
|
|
102
|
+
child = spawn("powershell.exe", [
|
|
103
|
+
"-NoProfile", "-NonInteractive", "-STA", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden",
|
|
104
|
+
"-File", scriptFile,
|
|
105
|
+
],
|
|
106
|
+
// Not detached: on Windows a detached PowerShell exits before its
|
|
107
|
+
// message loop starts. The icon outlives the service on its own; its
|
|
108
|
+
// timer notices the parent pid disappearing and shuts it down.
|
|
109
|
+
{ stdio: ["ignore", "ignore", logFd], windowsHide: true });
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
fs.closeSync(logFd);
|
|
113
|
+
}
|
|
114
|
+
let active = true;
|
|
115
|
+
child.once("error", (error) => { active = false; cleanup(); backgroundLog(`tray failed: ${error.message}`); });
|
|
116
|
+
child.once("exit", (code) => { active = false; cleanup(); backgroundLog(`tray exited (${code ?? "signal"})`); });
|
|
117
|
+
child.unref();
|
|
118
|
+
backgroundLog(`tray icon started (pid ${child.pid ?? "unknown"})`);
|
|
119
|
+
return { close: () => {
|
|
120
|
+
if (!active)
|
|
121
|
+
return;
|
|
122
|
+
try {
|
|
123
|
+
fs.writeFileSync(closeFile, "", { mode: 0o600 });
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
try {
|
|
127
|
+
child.kill();
|
|
128
|
+
}
|
|
129
|
+
catch { /* Already gone. */ }
|
|
130
|
+
}
|
|
131
|
+
} };
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
cleanup();
|
|
135
|
+
backgroundLog(`tray failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|