@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.
@@ -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
- constructor(title, messages) {
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 - 2;
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
- drawHint(buf, h - 1, "↑/↓ scroll · wheel or drag the bar · right-click back · Esc back");
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 = [];
@@ -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
- ? `On · ${this.app.state.webUrl}`
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,
@@ -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, drawProModeButton, proModeButton, } from "../ui.js";
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 Pro button no other control is hovered
145
- // underneath it: the box is opaque, so its neighbours must not light up.
146
- if (!proHover) {
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 Pro button is a control of its own and is checked first: a left
176
- // click anywhere in its box opens Pro mode instead of the control below.
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 Pro button sits directly below the status bar; repainting its rows
228
- // makes it self-heal exactly like the input box and the tabs.
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 Pro button when that button is visible: the box is
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 its opaque footprint is never clipped by the decorative
357
- // content above it. Its hit box uses the same top-left geometry.
358
- drawProModeButton(buf, this.proHover);
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
@@ -11,6 +11,7 @@ export function defaultState() {
11
11
  cursorOn: true,
12
12
  approval: null,
13
13
  webUrl: null,
14
+ webBackground: false,
14
15
  selection: null,
15
16
  };
16
17
  }
@@ -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
- process.on("SIGHUP", () => process.exit(129));
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
+ }