@xingjisoft/neutrino 0.4.1 → 0.5.1
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 +1 -1
- package/dist/app.js +55 -0
- package/dist/bin.js +21 -0
- package/dist/pro/config.js +139 -9
- package/dist/screens/start.js +26 -14
- package/dist/screens/updatePrompt.js +200 -0
- package/dist/ui.js +32 -16
- package/dist/update.js +213 -0
- package/dist/web/clientState.js +82 -0
- package/dist/web/server.js +87 -2
- package/dist/web/service.js +25 -0
- package/dist/web/session.js +1 -0
- package/dist/web/settings.js +9 -0
- package/package.json +2 -2
- package/web/README.md +28 -9
- package/web/app.js +375 -14
- package/web/i18n.js +20 -0
- package/web/index.html +41 -8
- package/web/settings.js +170 -44
- package/web/style.css +44 -78
- package/assets/pro/model-template.json +0 -376
package/README.md
CHANGED
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
|
}
|
package/dist/pro/config.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
30
|
-
|
|
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(
|
|
136
|
+
value = JSON.parse(text);
|
|
34
137
|
}
|
|
35
138
|
catch {
|
|
36
|
-
throw new Error(
|
|
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
|
-
|
|
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,34 @@ 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. Their place in the
|
|
174
|
+
// list is read as strength (the slider runs from weak to strong), so entries that
|
|
175
|
+
// are not official rungs lead it: a preset named after an official one edits that
|
|
176
|
+
// rung in place, every other one sits left of the graded presets.
|
|
177
|
+
if (fs.existsSync(PRO_CUSTOM_MODEL_FILE)) {
|
|
178
|
+
const own = [];
|
|
179
|
+
for (const preset of readProModels(PRO_CUSTOM_MODEL_FILE, "model-custom.json").tier) {
|
|
180
|
+
const index = tier.findIndex((entry) => entry.name === preset.name);
|
|
181
|
+
if (index === -1)
|
|
182
|
+
own.push(preset);
|
|
183
|
+
else
|
|
184
|
+
tier[index] = preset;
|
|
185
|
+
}
|
|
186
|
+
tier.unshift(...own);
|
|
187
|
+
}
|
|
188
|
+
if (tier.length === 0)
|
|
189
|
+
throw new Error("Pro model.json must contain a nonempty tier array");
|
|
190
|
+
return { tier };
|
|
191
|
+
}
|
|
62
192
|
export function requireProPreset(value) {
|
|
63
193
|
if (typeof value !== "string" || !loadProModels().tier.some((preset) => preset.name === value)) {
|
|
64
194
|
throw new Error("Unknown Pro preset");
|
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
|
+
}
|