@xingjisoft/neutrino 0.4.1 → 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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ↓ 详细内容点这里 Learn More ↓
4
4
 
5
- [English](./README-en.md) | [简体中文](./README-en.md)
5
+ [English](./README-en.md) | [简体中文](./README-zh.md)
6
6
 
7
7
  ## 什么是 Neutrino ?
8
8
 
package/dist/app.js CHANGED
@@ -16,6 +16,7 @@ import { AgentSelectScreen } from "./screens/agentSelect.js";
16
16
  import { ModelSelectScreen } from "./screens/modelSelect.js";
17
17
  import { SettingsScreen } from "./screens/settings.js";
18
18
  import { StartScreen } from "./screens/start.js";
19
+ import { UpdatePromptScreen } from "./screens/updatePrompt.js";
19
20
  import { configureBackgroundService, readBackgroundStatus, isProcessAlive, runningBackground, startBackgroundService, stopBackgroundService, } from "./background.js";
20
21
  import { startTerminalBridge } from "./web/terminalBridge.js";
21
22
  const CURSOR_BLINK_MS = 530;
@@ -102,6 +103,10 @@ export class App {
102
103
  webPromptBackground = true;
103
104
  /** True while a click is bringing the Web console up, so double clicks are safe. */
104
105
  proOpening = false;
106
+ /** Same guard for the Web-mode button, which has its own click. */
107
+ webOpening = false;
108
+ /** An update offer that arrived before the start screen was mounted. */
109
+ pendingUpdate = null;
105
110
  constructor() {
106
111
  this.width = process.stdout.columns || 80;
107
112
  this.height = process.stdout.rows || 24;
@@ -340,6 +345,28 @@ export class App {
340
345
  this.proOpening = false;
341
346
  }
342
347
  }
348
+ /**
349
+ * Open the browser console's regular workspace, bringing the console up first
350
+ * when it is not running yet. Like openProMode it runs from a mouse handler:
351
+ * it never throws and never waits on the browser.
352
+ */
353
+ async openWebMode() {
354
+ if (this.webOpening)
355
+ return;
356
+ this.webOpening = true;
357
+ try {
358
+ const url = await this.ensureWebConsole();
359
+ openInBrowser(url);
360
+ this.showToast(`Opening Web console: ${url}`);
361
+ }
362
+ catch (err) {
363
+ const message = err instanceof Error ? err.message : String(err);
364
+ this.showToast(`Web console failed: ${message}`, "top");
365
+ }
366
+ finally {
367
+ this.webOpening = false;
368
+ }
369
+ }
343
370
  async start() {
344
371
  // Installed before anything touches the terminal: from here on, a crash
345
372
  // restores mouse tracking, raw mode and the alternate screen instead of
@@ -371,6 +398,14 @@ export class App {
371
398
  this.resetMouse();
372
399
  this.state.selection = null;
373
400
  screen.onActivate();
401
+ // An offer made while the start screen was not mounted yet belongs on it,
402
+ // and the start screen is exactly what has just appeared.
403
+ if (screen instanceof StartScreen && this.pendingUpdate) {
404
+ const status = this.pendingUpdate;
405
+ this.pendingUpdate = null;
406
+ this.pushScreen(new UpdatePromptScreen(status));
407
+ return;
408
+ }
374
409
  this.render();
375
410
  }
376
411
  popScreen() {
@@ -975,6 +1010,26 @@ export class App {
975
1010
  }
976
1011
  this.pushScreen(screen);
977
1012
  }
1013
+ /**
1014
+ * Offer a newer published release where a session starts.
1015
+ *
1016
+ * The registry answer arrives a moment after launch. If the user is already
1017
+ * in a conversation by then, the notice is dropped rather than breaking into
1018
+ * their work: the next start offers it again.
1019
+ */
1020
+ promptUpdate(status) {
1021
+ if (this.screens.some((screen) => screen instanceof UpdatePromptScreen))
1022
+ return;
1023
+ if (this.top instanceof StartScreen) {
1024
+ this.pushScreen(new UpdatePromptScreen(status));
1025
+ return;
1026
+ }
1027
+ // No screen is mounted yet: the launch is still starting the Web console.
1028
+ // Hold the offer until the start screen appears. Once the user has moved
1029
+ // on to a conversation the offer is dropped instead of interrupting it.
1030
+ if (this.screens.length === 0)
1031
+ this.pendingUpdate = status;
1032
+ }
978
1033
  openModelConfig() {
979
1034
  if (this.screens.some((s) => s instanceof ModelSelectScreen))
980
1035
  return;
package/dist/bin.js CHANGED
@@ -7,6 +7,8 @@ import { webUrls } from "./web/server.js";
7
7
  import { runningBackground, startBackgroundService, stopBackgroundService, } from "./background.js";
8
8
  import { runWebService } from "./web/service.js";
9
9
  import { openInBrowser } from "./browser.js";
10
+ import { PRO_MODEL_FILE, PRO_MODELS_URL, syncProModels } from "./pro/config.js";
11
+ import { checkForUpdate } from "./update.js";
10
12
  const DEFAULT_WEB_PORT = 8765;
11
13
  /** Binding here makes the UI reachable from every interface. */
12
14
  const WILDCARD_HOST = "0.0.0.0";
@@ -321,6 +323,21 @@ else {
321
323
  process.stderr.write("To serve the web UI without a terminal, run 'neutrino web --port <port>'.\n");
322
324
  process.exit(1);
323
325
  }
326
+ // The Pro presets are downloaded, not shipped, and kept in line with the
327
+ // official copy before any workspace can ask for a preset. A failure is not
328
+ // fatal: Pro mode reports the missing file (or keeps the local presets), and
329
+ // the next start tries again.
330
+ await syncProModels().then((result) => {
331
+ if (result.status === "updated") {
332
+ process.stderr.write(`neutrino: updated the Pro presets at ${PRO_MODEL_FILE} from ${PRO_MODELS_URL}\n`);
333
+ }
334
+ }).catch((error) => {
335
+ process.stderr.write(`neutrino: ${error instanceof Error ? error.message : String(error)}\n`);
336
+ });
337
+ // Ask the registry for the published version as early as possible, while the
338
+ // Web console is still starting: the first screen must not wait on the
339
+ // network. Offline is an ordinary state, hence no diagnostic here.
340
+ const updateCheck = checkForUpdate();
324
341
  const app = new App();
325
342
  const webPort = options.port ?? portFromEnv ?? DEFAULT_WEB_PORT;
326
343
  const saved = loadConfig();
@@ -342,5 +359,9 @@ else {
342
359
  process.stderr.write("The terminal UI will keep running. Pass --port <port> to use another port, or --no-web to skip the web UI.\n");
343
360
  }
344
361
  }
362
+ void updateCheck.then((status) => {
363
+ if (status.hasUpdate)
364
+ app.promptUpdate(status);
365
+ });
345
366
  await app.start();
346
367
  }
@@ -1,10 +1,16 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import process from "node:process";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { PRO_DIR, loadConfig } from "../config.js";
5
6
  /** Provider URLs and credentials stay in the shared, ordinary model catalogue. */
6
7
  export const PRO_MODEL_FILE = path.join(PRO_DIR, "model.json");
8
+ /** Presets the user adds or rewrites themselves; a same-named preset here wins. */
9
+ export const PRO_CUSTOM_MODEL_FILE = path.join(PRO_DIR, "model-custom.json");
10
+ /** The official presets, fetched on first use: nothing is bundled with the package. */
11
+ export const PRO_MODELS_URL = "https://ius.neutrino.xingjisoft.com/model.json";
7
12
  const ASSET_DIR = fileURLToPath(new URL("../../assets/pro/", import.meta.url));
13
+ const DOWNLOAD_TIMEOUT_MS = 10_000;
8
14
  export const PRO_ROLES = [
9
15
  "main_model", "thinking_model", "front_output_model", "backend_output_model",
10
16
  "confirm_model", "genimage_model", "plan_model", "modeling_model",
@@ -15,29 +21,125 @@ export const PRO_ROLE_LABELS = {
15
21
  backend_output_model: "Backend", confirm_model: "Approval", genimage_model: "Image generation",
16
22
  plan_model: "Planning", modeling_model: "Modeling",
17
23
  };
18
- /** Copy the bundled template byte-for-byte, exclusively on first use. */
24
+ let downloading = null;
25
+ /**
26
+ * Make sure the Pro presets are on disk, downloading the official set the first
27
+ * time. A file the user already has is never touched here; a failed download
28
+ * leaves nothing behind, so the next start simply tries again.
29
+ */
19
30
  export function ensureProModels() {
20
31
  fs.mkdirSync(PRO_DIR, { recursive: true });
32
+ if (fs.existsSync(PRO_MODEL_FILE))
33
+ return Promise.resolve();
34
+ downloading ??= downloadProModels().finally(() => { downloading = null; });
35
+ return downloading;
36
+ }
37
+ async function downloadProModels() {
38
+ const body = await fetchProModels();
39
+ // An error page or a truncated reply must never become the user's presets.
40
+ parseProModels(body, "model.json", PRO_MODELS_URL);
21
41
  try {
22
- fs.copyFileSync(path.join(ASSET_DIR, "model-template.json"), PRO_MODEL_FILE, fs.constants.COPYFILE_EXCL);
42
+ fs.writeFileSync(PRO_MODEL_FILE, body, { flag: "wx" });
23
43
  }
24
44
  catch (error) {
45
+ // Another process won the race and wrote the file first: that is fine.
25
46
  if (error.code !== "EEXIST")
26
47
  throw error;
27
48
  }
28
49
  }
29
- export function loadProModels() {
30
- ensureProModels();
50
+ /**
51
+ * Keep ~/.neutrino/pro/model.json in step with the official presets, which own
52
+ * that file: any difference is overwritten. Presets the user keeps for
53
+ * themselves belong in model-custom.json, so nothing personal is lost. When the
54
+ * official copy cannot be read, the local presets stay exactly as they are.
55
+ *
56
+ * Called once at start-up by both the terminal and the Web console.
57
+ */
58
+ export async function syncProModels() {
59
+ const failure = `could not download the Pro model presets from ${PRO_MODELS_URL}`;
60
+ let body;
61
+ try {
62
+ body = await fetchProModels();
63
+ }
64
+ catch (error) {
65
+ // Offline, or the server is down: the local presets are still usable.
66
+ if (!fs.existsSync(PRO_MODEL_FILE))
67
+ throw error;
68
+ return { status: "kept", detail: error instanceof Error ? error.message : String(error) };
69
+ }
70
+ const remote = parseProModels(body, "model.json", PRO_MODELS_URL);
71
+ const present = fs.existsSync(PRO_MODEL_FILE);
72
+ if (remote.tier.length === 0) {
73
+ if (!present)
74
+ throw new Error(`${failure}: it contains no presets`);
75
+ return { status: "kept", detail: `${failure}: it contains no presets` };
76
+ }
77
+ // Only formatting differences are not a difference: the file keeps its shape.
78
+ // A local file that does not parse is replaced, so a hand edit cannot block Pro.
79
+ if (present && samePresets(readProModelsQuietly(), remote))
80
+ return { status: "unchanged" };
81
+ writeProModels(body);
82
+ return { status: "updated" };
83
+ }
84
+ async function fetchProModels() {
85
+ try {
86
+ const response = await fetch(PRO_MODELS_URL, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
87
+ if (!response.ok)
88
+ throw new Error(`HTTP ${response.status}`);
89
+ return await response.text();
90
+ }
91
+ catch (error) {
92
+ throw new Error(`could not download the Pro model presets from ${PRO_MODELS_URL}: `
93
+ + `${error instanceof Error ? error.message : String(error)}`);
94
+ }
95
+ }
96
+ /** Write the official copy in one step, so no reader ever sees half a file. */
97
+ function writeProModels(body) {
98
+ fs.mkdirSync(PRO_DIR, { recursive: true });
99
+ const temp = `${PRO_MODEL_FILE}.${process.pid}.tmp`;
100
+ try {
101
+ fs.writeFileSync(temp, body);
102
+ fs.renameSync(temp, PRO_MODEL_FILE);
103
+ }
104
+ catch (error) {
105
+ fs.rmSync(temp, { force: true });
106
+ throw error;
107
+ }
108
+ }
109
+ function readProModelsQuietly() {
110
+ try {
111
+ return readProModels(PRO_MODEL_FILE, "model.json");
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ }
117
+ function samePresets(left, right) {
118
+ return left !== null && canonicalJson(left) === canonicalJson(right);
119
+ }
120
+ /** Compare documents by content, so reformatting alone is never a difference. */
121
+ function canonicalJson(value) {
122
+ if (Array.isArray(value))
123
+ return `[${value.map(canonicalJson).join(",")}]`;
124
+ if (value && typeof value === "object") {
125
+ const entries = Object.entries(value)
126
+ .filter(([, entry]) => entry !== undefined)
127
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
128
+ return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(",")}}`;
129
+ }
130
+ return JSON.stringify(value) ?? "null";
131
+ }
132
+ /** Parse and validate one presets document; each file has to be valid on its own. */
133
+ function parseProModels(text, label, where) {
31
134
  let value;
32
135
  try {
33
- value = JSON.parse(fs.readFileSync(PRO_MODEL_FILE, "utf8"));
136
+ value = JSON.parse(text);
34
137
  }
35
138
  catch {
36
- throw new Error("Pro model.json is not valid JSON; fix ~/.neutrino/pro/model.json");
139
+ throw new Error(`Pro ${label} is not valid JSON; fix ${where}`);
37
140
  }
38
- if (!value || typeof value !== "object" || !Array.isArray(value.tier) ||
39
- value.tier.length === 0) {
40
- throw new Error("Pro model.json must contain a nonempty tier array");
141
+ if (!value || typeof value !== "object" || !Array.isArray(value.tier)) {
142
+ throw new Error(`Pro ${label} must contain a tier array`);
41
143
  }
42
144
  const names = new Set();
43
145
  for (const preset of value.tier) {
@@ -59,6 +161,30 @@ export function loadProModels() {
59
161
  }
60
162
  return value;
61
163
  }
164
+ function readProModels(file, label) {
165
+ return parseProModels(fs.readFileSync(file, "utf8"), label, `~/.neutrino/pro/${label}`);
166
+ }
167
+ export function loadProModels() {
168
+ if (!fs.existsSync(PRO_MODEL_FILE)) {
169
+ // The download is asynchronous, so it happens at start-up (see ensureProModels).
170
+ throw new Error(`Pro model.json is missing; it is downloaded from ${PRO_MODELS_URL} on first use`);
171
+ }
172
+ const { tier } = readProModels(PRO_MODEL_FILE, "model.json");
173
+ // Presets the user keeps in model-custom.json are merged in: a same-named entry
174
+ // replaces the one from model.json in place, new ones are appended.
175
+ if (fs.existsSync(PRO_CUSTOM_MODEL_FILE)) {
176
+ for (const preset of readProModels(PRO_CUSTOM_MODEL_FILE, "model-custom.json").tier) {
177
+ const index = tier.findIndex((entry) => entry.name === preset.name);
178
+ if (index === -1)
179
+ tier.push(preset);
180
+ else
181
+ tier[index] = preset;
182
+ }
183
+ }
184
+ if (tier.length === 0)
185
+ throw new Error("Pro model.json must contain a nonempty tier array");
186
+ return { tier };
187
+ }
62
188
  export function requireProPreset(value) {
63
189
  if (typeof value !== "string" || !loadProModels().tier.some((preset) => preset.name === value)) {
64
190
  throw new Error("Unknown Pro preset");
@@ -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
+ }