@xingjisoft/neutrino 0.3.0 → 0.4.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.
Files changed (56) hide show
  1. package/README.md +33 -549
  2. package/assets/neutrino.ico +0 -0
  3. package/assets/neutrino.svg +7 -0
  4. package/assets/pro/backend_output_model.txt +5 -0
  5. package/assets/pro/claude-module-prompt.txt +65 -0
  6. package/assets/pro/common.txt +9 -0
  7. package/assets/pro/confirm_model.txt +5 -0
  8. package/assets/pro/front_output_model.txt +9 -0
  9. package/assets/pro/genimage_model.txt +1 -0
  10. package/assets/pro/main_model.txt +5 -0
  11. package/assets/pro/model-template.json +376 -0
  12. package/assets/pro/modeling_model.txt +5 -0
  13. package/assets/pro/plan_model.txt +8 -0
  14. package/assets/pro/thinking_model.txt +3 -0
  15. package/dist/agents.js +22 -3
  16. package/dist/app.js +199 -26
  17. package/dist/background.js +358 -0
  18. package/dist/bin.js +184 -44
  19. package/dist/browser.js +57 -0
  20. package/dist/config.js +174 -26
  21. package/dist/header.js +2 -1
  22. package/dist/index.js +1 -1
  23. package/dist/llm/approval.js +6 -2
  24. package/dist/llm/client.js +197 -28
  25. package/dist/llm/subagent.js +37 -1
  26. package/dist/llm/tools.js +3 -1
  27. package/dist/migration.js +33 -0
  28. package/dist/pro/agents.js +104 -0
  29. package/dist/pro/config.js +88 -0
  30. package/dist/pro/preflight.js +172 -0
  31. package/dist/screens/agentConversation.js +48 -5
  32. package/dist/screens/agentHistory.js +4 -2
  33. package/dist/screens/chat.js +14 -8
  34. package/dist/screens/modelSelect.js +15 -4
  35. package/dist/screens/settings.js +52 -9
  36. package/dist/screens/start.js +48 -15
  37. package/dist/state.js +1 -0
  38. package/dist/terminal/restore.js +3 -12
  39. package/dist/tray.js +138 -0
  40. package/dist/ui.js +58 -0
  41. package/dist/web/hub.js +26 -4
  42. package/dist/web/server.js +495 -74
  43. package/dist/web/service.js +135 -0
  44. package/dist/web/session.js +278 -25
  45. package/dist/web/settings.js +97 -24
  46. package/dist/web/terminalBridge.js +250 -0
  47. package/package.json +37 -37
  48. package/web/README.md +150 -16
  49. package/web/app.js +490 -25
  50. package/web/i18n.js +396 -345
  51. package/web/index.html +120 -15
  52. package/web/neutrino-pro.gif +0 -0
  53. package/web/neutrino.png +0 -0
  54. package/web/pro.js +184 -0
  55. package/web/settings.js +373 -247
  56. package/web/style.css +398 -0
@@ -0,0 +1,172 @@
1
+ import { loadModels } from "../config.js";
2
+ import { ACTIVE_PRO_ROLES, loadProModels, } from "./config.js";
3
+ export class ProPreflightError extends Error {
4
+ fallbacks;
5
+ constructor(message, fallbacks) {
6
+ super(message);
7
+ this.fallbacks = fallbacks;
8
+ this.name = "ProPreflightError";
9
+ }
10
+ }
11
+ function reference(preset, setting) {
12
+ return { provider: setting?.provider ?? "", model: setting?.model ?? "", tier: preset.name };
13
+ }
14
+ function resolveSetting(role, preset, catalog) {
15
+ const setting = preset["model-setting"].find((entry) => entry.type === role);
16
+ if (!setting?.provider.trim() || !setting.model?.trim() || setting.model.trim().toLowerCase() === "null") {
17
+ return { reason: "role has no model configured" };
18
+ }
19
+ const provider = catalog.providers.find((entry) => entry.name === setting.provider);
20
+ const model = provider?.models.find((entry) => entry.name === setting.model);
21
+ if (!model)
22
+ return { reason: "provider/model is missing from the shared catalogue" };
23
+ if (!model.api_key?.trim())
24
+ return { reason: "API key is missing in the shared catalogue" };
25
+ let endpoint;
26
+ try {
27
+ endpoint = new URL(model.url);
28
+ }
29
+ catch {
30
+ return { reason: "model endpoint is invalid" };
31
+ }
32
+ if (!["http:", "https:"].includes(endpoint.protocol))
33
+ return { reason: "model endpoint must use HTTP or HTTPS" };
34
+ const intensity = setting.intensity.trim().toLowerCase() || "high";
35
+ if (!["low", "medium", "high", "xhigh", "max", "ultra"].includes(intensity)) {
36
+ return { reason: "thinking intensity is invalid" };
37
+ }
38
+ // The Pro file can use xhigh, which must not be normalized through the
39
+ // ordinary mode's five intensity labels. Explicit shared limits still apply.
40
+ if (model.intensity?.length && !model.intensity.some((level) => level.toLowerCase() === intensity)) {
41
+ return { reason: "thinking intensity is not supported by this model" };
42
+ }
43
+ return {
44
+ resolved: {
45
+ role, provider: provider.name, model: { ...model }, intensity,
46
+ context_window: setting.context_window, tier: preset.name,
47
+ },
48
+ };
49
+ }
50
+ /** An authenticated, tiny inference validates the same intensity/tool contract. */
51
+ async function probe(model, options) {
52
+ const controller = new AbortController();
53
+ const relay = () => controller.abort(options.signal?.reason);
54
+ if (options.signal?.aborted)
55
+ relay();
56
+ else
57
+ options.signal?.addEventListener("abort", relay, { once: true });
58
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 20_000);
59
+ const anthropic = model.provider === "Anthropic";
60
+ const parameters = { type: "object", properties: {}, additionalProperties: false };
61
+ const tool = {
62
+ name: "pro_availability_check", description: "A harmless availability check; this tool is never executed.",
63
+ };
64
+ const payload = anthropic ? {
65
+ model: model.model.name, stream: false, max_tokens: 4096,
66
+ output_config: { effort: model.intensity },
67
+ messages: [{ role: "user", content: "Availability check. Reply only OK. Do not call tools." }],
68
+ tools: [{ ...tool, input_schema: parameters }],
69
+ } : {
70
+ model: model.model.name, stream: false, reasoning_effort: model.intensity,
71
+ messages: [{ role: "user", content: "Availability check. Reply only OK. Do not call tools." }],
72
+ tools: [{ type: "function", function: { ...tool, parameters } }],
73
+ };
74
+ try {
75
+ const response = await fetch(model.model.url.replace(/\/+$/, "") + (anthropic ? "/messages" : "/chat/completions"), {
76
+ method: "POST", signal: controller.signal,
77
+ headers: {
78
+ "Content-Type": "application/json",
79
+ ...(anthropic
80
+ ? { "x-api-key": model.model.api_key, "anthropic-version": "2023-06-01" }
81
+ : { Authorization: `Bearer ${model.model.api_key}` }),
82
+ },
83
+ body: JSON.stringify(payload),
84
+ });
85
+ if (!response.ok) {
86
+ // Never echo endpoint responses: they can repeat credentials or URLs.
87
+ await response.body?.cancel();
88
+ return { ok: false, reason: `availability request returned HTTP ${response.status}` };
89
+ }
90
+ const body = await response.json();
91
+ if (body.error || (anthropic ? !Array.isArray(body.content) : !body.choices?.[0]?.message)) {
92
+ return { ok: false, reason: "endpoint did not return a valid model completion" };
93
+ }
94
+ if (body.usage) {
95
+ const prompt = body.usage.prompt_tokens ?? body.usage.input_tokens ?? 0;
96
+ const completion = body.usage.completion_tokens ?? body.usage.output_tokens ?? 0;
97
+ options.onUsage?.({ prompt, completion, total: prompt + completion });
98
+ }
99
+ return { ok: true, reason: "" };
100
+ }
101
+ catch {
102
+ if (options.signal?.aborted)
103
+ throw new Error("Pro availability check was cancelled");
104
+ return { ok: false, reason: controller.signal.aborted ? "availability request timed out" : "availability request failed" };
105
+ }
106
+ finally {
107
+ clearTimeout(timer);
108
+ options.signal?.removeEventListener("abort", relay);
109
+ }
110
+ }
111
+ /**
112
+ * Resolve each job independently, descending only within that job. Neither the
113
+ * shared catalogue nor the Pro presets are edited by a temporary replacement.
114
+ */
115
+ export async function resolveProTeam(presetName, options = {}) {
116
+ const presets = loadProModels().tier;
117
+ const selected = presets.find((preset) => preset.name === presetName);
118
+ if (!selected)
119
+ throw new Error("Unknown Pro preset");
120
+ const catalog = loadModels();
121
+ const ranked = presets.filter((preset) => preset.name !== "custom" && preset.name !== "auto");
122
+ const selectedIndex = ranked.findIndex((preset) => preset.name === selected.name);
123
+ // Custom/auto entries define their own team rather than a quality rank.
124
+ const candidates = selectedIndex < 0 ? [selected] : ranked.slice(0, selectedIndex + 1).reverse();
125
+ const cache = new Map();
126
+ const outcomes = await Promise.all(ACTIVE_PRO_ROLES.map(async (role) => {
127
+ const failures = [];
128
+ const requested = reference(selected, selected["model-setting"].find((entry) => entry.type === role));
129
+ for (const preset of candidates) {
130
+ if (options.signal?.aborted)
131
+ throw new Error("Pro availability check was cancelled");
132
+ const lookup = resolveSetting(role, preset, catalog);
133
+ if ("reason" in lookup) {
134
+ failures.push(`${preset.name}: ${lookup.reason}`);
135
+ continue;
136
+ }
137
+ const resolved = lookup.resolved;
138
+ const key = JSON.stringify([resolved.provider, resolved.model.name, resolved.intensity]);
139
+ let pending = cache.get(key);
140
+ if (!pending) {
141
+ pending = probe(resolved, options);
142
+ cache.set(key, pending);
143
+ }
144
+ const result = await pending;
145
+ if (!result.ok) {
146
+ failures.push(`${preset.name}: ${result.reason}`);
147
+ continue;
148
+ }
149
+ return {
150
+ role, resolved,
151
+ fallback: failures.length ? {
152
+ role, requested, replacement: { provider: resolved.provider, model: resolved.model.name, tier: resolved.tier },
153
+ reason: failures.join("; "),
154
+ } : null,
155
+ };
156
+ }
157
+ return { role, resolved: null, fallback: { role, requested, replacement: null, reason: failures.join("; ") } };
158
+ }));
159
+ const models = {};
160
+ const fallbacks = [];
161
+ for (const outcome of outcomes) {
162
+ if (outcome.resolved)
163
+ models[outcome.role] = outcome.resolved;
164
+ if (outcome.fallback)
165
+ fallbacks.push(outcome.fallback);
166
+ }
167
+ const unavailable = outcomes.filter((outcome) => !outcome.resolved).map((outcome) => outcome.role);
168
+ if (unavailable.length) {
169
+ throw new ProPreflightError(`No available Pro model for: ${unavailable.join(", ")}`, fallbacks);
170
+ }
171
+ return { models, fallbacks };
172
+ }
@@ -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 = [];
@@ -1,6 +1,6 @@
1
1
  import { Screen } from "../terminal/screen.js";
2
2
  import { C } from "../colors.js";
3
- import { findModel, contextCompressionThreshold, loadConfig, loadModels, loadPlannerPrompt, loadStepPrompt, loadSubAgentPrompt, normalizeApprovalMode, planFirstEnabled, loadPrompt, saveSession, } from "../config.js";
3
+ import { findModel, findConfiguredModel, intensityForModel, contextCompressionThreshold, loadConfig, loadPlannerPrompt, loadStepPrompt, loadSubAgentPrompt, normalizeApprovalMode, planFirstEnabled, loadPrompt, saveSession, } from "../config.js";
4
4
  import { drawHeader } from "../header.js";
5
5
  import { computeInputWindow, cursorFromColumn, drawInputBox, drawScrollbar, wrapText, approvalToggle, drawApprovalToggle, inRect } from "../ui.js";
6
6
  import { drawSpans, renderMarkdownText } from "../markdown.js";
@@ -175,12 +175,18 @@ export class ChatScreen extends Screen {
175
175
  };
176
176
  // The selected-state snapshot has no room for provider options; re-read the
177
177
  // thinking-mode flag from model.json so a hand-configured endpoint keeps it.
178
- const configured = loadModels().providers
179
- .find((provider) => provider.name === this.modelProvider)?.models
180
- .find((model) => model.name === this.model.name);
181
- if (configured && typeof configured.reasoning === "boolean")
182
- this.model.reasoning = configured.reasoning;
183
- this.intensity = this.loadedIntensity ?? st.selectedIntensity;
178
+ const configured = findConfiguredModel(this.modelProvider, this.model.name);
179
+ if (configured) {
180
+ if (typeof configured.reasoning === "boolean")
181
+ this.model.reasoning = configured.reasoning;
182
+ this.model.intensity = configured.intensity;
183
+ }
184
+ const savedIntensity = this.loadedIntensity ?? st.selectedIntensity;
185
+ this.intensity = intensityForModel(configured, savedIntensity, loadConfig().default_intensity);
186
+ // A resumed transcript can carry a level its model no longer accepts; the
187
+ // header and the next request must show and use the level actually in use.
188
+ if (this.intensity !== savedIntensity)
189
+ this.app.state.selectedIntensity = this.intensity;
184
190
  if (this.loadedTokens) {
185
191
  this.tokens = { prompt: this.loadedTokens.prompt, completion: this.loadedTokens.completion };
186
192
  }
@@ -230,7 +236,7 @@ export class ChatScreen extends Screen {
230
236
  }
231
237
  this.modelProvider = selection.provider;
232
238
  this.model = { ...selection.model };
233
- this.intensity = selection.intensity;
239
+ this.intensity = intensityForModel(this.model, selection.intensity, loadConfig().default_intensity);
234
240
  Object.assign(this.app.state, {
235
241
  selectedProvider: this.modelProvider, selectedModelName: this.model.name,
236
242
  selectedModelUrl: this.model.url, selectedModelApiKey: this.model.api_key,
@@ -1,6 +1,6 @@
1
1
  import { Screen } from "../terminal/screen.js";
2
2
  import { C } from "../colors.js";
3
- import { INTENSITY_LEVELS, getModelsByProvider } from "../config.js";
3
+ import { intensityForModel, modelIntensities, getModelsByProvider } from "../config.js";
4
4
  import { drawHeader } from "../header.js";
5
5
  import { drawHint, drawTabButton, inRect } from "../ui.js";
6
6
  const LIST_TOP = 5;
@@ -30,7 +30,12 @@ export class ModelSelectScreen extends Screen {
30
30
  return this.providers;
31
31
  if (this.level === 1)
32
32
  return (this.modelMap[this.providers[this.selectedProviderIdx]] ?? []).map((m) => m.name);
33
- return INTENSITY_LEVELS;
33
+ return this.currentIntensities();
34
+ }
35
+ /** Only the intensity names this exact model accepts. */
36
+ currentIntensities() {
37
+ const models = this.modelMap[this.providers[this.selectedProviderIdx]] ?? [];
38
+ return modelIntensities(models[this.selectedModelIdx]);
34
39
  }
35
40
  currentIdx() {
36
41
  if (this.level === 0)
@@ -73,8 +78,12 @@ export class ModelSelectScreen extends Screen {
73
78
  }
74
79
  if (this.level === 1) {
75
80
  const models = this.modelMap[this.providers[this.selectedProviderIdx]] ?? [];
76
- if (models.length === 0)
81
+ const model = models[this.selectedModelIdx];
82
+ if (!model)
77
83
  return;
84
+ // Start on the level in use when this model accepts it, otherwise on the
85
+ // model's own fallback (High, or the first level it declares).
86
+ this.selectedIntensityIdx = Math.max(0, this.currentIntensities().indexOf(intensityForModel(model, this.app.state.selectedIntensity)));
78
87
  this.level = 2;
79
88
  this.scrollBtn = 0;
80
89
  this.app.render();
@@ -85,7 +94,9 @@ export class ModelSelectScreen extends Screen {
85
94
  const model = models[this.selectedModelIdx];
86
95
  if (!model)
87
96
  return;
88
- const intensity = INTENSITY_LEVELS[this.selectedIntensityIdx];
97
+ const intensity = this.currentIntensities()[this.selectedIntensityIdx];
98
+ if (!intensity)
99
+ return;
89
100
  this.app.setModelConfig(providerName, model, intensity);
90
101
  this.app.popScreen();
91
102
  }
@@ -1,13 +1,16 @@
1
1
  import { Screen } from "../terminal/screen.js";
2
2
  import { C } from "../colors.js";
3
- import { CONTEXT_WINDOW_OPTIONS, DEFAULT_CONTEXT_WINDOW, contextWindow, CONFIG_FILE, INTENSITY_LEVELS, getModelsByProvider, loadConfig, loadModels, saveConfig, saveModels, } from "../config.js";
3
+ import { CONTEXT_WINDOW_OPTIONS, DEFAULT_CONTEXT_WINDOW, contextWindow, CONFIG_FILE, INTENSITY_LEVELS, intensityForModel, modelIntensities, getModelsByProvider, loadConfig, loadModels, saveConfig, saveModels, } from "../config.js";
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;
12
+ /** Levels the current chat model accepts, in canonical order. */
13
+ intensityLevels = [...INTENSITY_LEVELS];
11
14
  contextTokens = DEFAULT_CONTEXT_WINDOW;
12
15
  contextChanged = false;
13
16
  /** 0 = same model as the chat, otherwise index + 1 into `models`. */
@@ -18,8 +21,11 @@ export class SettingsScreen extends Screen {
18
21
  soundIdx = 1;
19
22
  /** 0 = web UI off, 1 = mounted alongside the terminal. */
20
23
  webIdx = 1;
24
+ /** 0 = terminal only, 1 = the Web console is run by a detached service. */
25
+ backgroundIdx = 0;
21
26
  /** Only a row the user actually touched rewrites the saved default. */
22
27
  webChanged = false;
28
+ backgroundPending = false;
23
29
  models = [];
24
30
  rowIdx = 0;
25
31
  lastHoverX = -1;
@@ -29,11 +35,11 @@ export class SettingsScreen extends Screen {
29
35
  passwordMessage = "Press Enter to change";
30
36
  onActivate() {
31
37
  const config = loadConfig();
32
- const current = String(config.default_intensity ?? "High");
33
- const i = INTENSITY_LEVELS.indexOf(current);
34
- this.intensityIdx = i >= 0 ? i : 2;
35
38
  const state = this.app.state;
36
39
  const model = getModelsByProvider()[state.selectedProvider]?.find((m) => m.name === state.selectedModelName);
40
+ this.intensityLevels = modelIntensities(model);
41
+ const current = intensityForModel(model, config.default_intensity);
42
+ this.intensityIdx = Math.max(0, this.intensityLevels.indexOf(current));
37
43
  this.contextTokens = contextWindow(model?.context_window);
38
44
  this.contextChanged = false;
39
45
  this.models = Object.values(getModelsByProvider()).flatMap((list) => list.map((m) => m.name));
@@ -46,6 +52,9 @@ export class SettingsScreen extends Screen {
46
52
  // off, so reflect what is actually running first.
47
53
  this.webIdx = (this.app.state.webUrl || config.web_console !== false) ? 1 : 0;
48
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;
49
58
  }
50
59
  /** Change the value of a row by `delta` steps and persist it immediately. */
51
60
  cycle(row, delta) {
@@ -56,7 +65,7 @@ export class SettingsScreen extends Screen {
56
65
  return;
57
66
  }
58
67
  if (row === "intensity") {
59
- const n = INTENSITY_LEVELS.length;
68
+ const n = this.intensityLevels.length;
60
69
  this.intensityIdx = (this.intensityIdx + delta + n) % n;
61
70
  }
62
71
  else if (row === "approval_model") {
@@ -79,6 +88,13 @@ export class SettingsScreen extends Screen {
79
88
  void this.app.setWebConsole(this.webIdx === 1);
80
89
  return;
81
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
+ }
82
98
  else {
83
99
  const options = [...new Set([...CONTEXT_WINDOW_OPTIONS, this.contextTokens])].sort((a, b) => a - b);
84
100
  const i = options.indexOf(this.contextTokens);
@@ -87,6 +103,23 @@ export class SettingsScreen extends Screen {
87
103
  }
88
104
  this.save();
89
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
+ }
90
123
  onKey(key) {
91
124
  if (this.editingPassword) {
92
125
  if (key.name === "escape") {
@@ -210,7 +243,7 @@ export class SettingsScreen extends Screen {
210
243
  }
211
244
  rowTexts() {
212
245
  return [
213
- { label: "Default intensity", value: INTENSITY_LEVELS[this.intensityIdx] },
246
+ { label: "Default intensity", value: this.intensityLevels[this.intensityIdx] },
214
247
  { label: "Model context (80%)", value: `${this.contextTokens.toLocaleString("en-US")} tokens` },
215
248
  {
216
249
  label: "Approval reviewer model",
@@ -231,9 +264,19 @@ export class SettingsScreen extends Screen {
231
264
  value: this.webIdx === 0
232
265
  ? "Off"
233
266
  : this.app.state.webUrl
234
- ? `On · ${this.app.state.webUrl}`
267
+ ? this.app.state.webBackground
268
+ ? `On (background) · ${this.app.state.webUrl}`
269
+ : `On · ${this.app.state.webUrl}`
235
270
  : "On (not running)",
236
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
+ },
237
280
  {
238
281
  label: "Web password",
239
282
  value: this.editingPassword ? "*".repeat(Math.min(this.passwordDraft.length, 24)) || "Type new password" : this.passwordMessage,
@@ -255,7 +298,7 @@ export class SettingsScreen extends Screen {
255
298
  }
256
299
  save() {
257
300
  const config = loadConfig();
258
- config.default_intensity = INTENSITY_LEVELS[this.intensityIdx];
301
+ config.default_intensity = this.intensityLevels[this.intensityIdx] ?? "High";
259
302
  if (this.contextChanged) {
260
303
  const data = loadModels();
261
304
  const state = this.app.state;
@@ -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, } from "../ui.js";
5
+ import { centerVertically, computeInputWindow, cursorFromColumn, drawHint, drawInputBox, drawTabButton, inRect, approvalToggle, drawApprovalToggle, drawProModeButton, proModeButton, } 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
@@ -30,6 +30,7 @@ export class StartScreen extends Screen {
30
30
  cursor = 0;
31
31
  focusIdx = 0; // 0 = input box, 1..3 = tabs, 4 = approval mode
32
32
  hoverIdx = -1; // -1 = nothing, otherwise the control index
33
+ proHover = false; // pointer is over the Pro-mode button
33
34
  lastHoverX = -1;
34
35
  lastHoverY = -1;
35
36
  showsCursor() {
@@ -136,23 +137,30 @@ export class StartScreen extends Screen {
136
137
  return;
137
138
  this.lastHoverX = ev.x;
138
139
  this.lastHoverY = ev.y;
140
+ const pro = proModeButton(this.app.width, this.app.height);
141
+ const proHover = pro !== null && inRect(ev.x, ev.y, pro);
139
142
  const layout = this.layout();
140
143
  let hover = -1;
141
- if (inRect(ev.x, ev.y, approvalToggle(this.app.width, this.app.height - 4, this.approvalMode))) {
142
- hover = FOCUS_COUNT - 1;
143
- }
144
- else if (layout && inRect(ev.x, ev.y, { x: layout.inputX, y: layout.inputY, w: layout.inputW, h: INPUT_H })) {
145
- hover = 0;
146
- }
147
- else if (layout && ev.y >= layout.tabY && ev.y < layout.tabY + TAB_H) {
148
- for (let i = 0; i < layout.tabs.length; i++) {
149
- if (inRect(ev.x, ev.y, { x: layout.tabs[i].x, y: layout.tabY, w: layout.tabs[i].w, h: TAB_H })) {
150
- hover = i + 1;
151
- break;
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
+ if (inRect(ev.x, ev.y, approvalToggle(this.app.width, this.app.height - 4, this.approvalMode))) {
148
+ hover = FOCUS_COUNT - 1;
149
+ }
150
+ else if (layout && inRect(ev.x, ev.y, { x: layout.inputX, y: layout.inputY, w: layout.inputW, h: INPUT_H })) {
151
+ hover = 0;
152
+ }
153
+ else if (layout && ev.y >= layout.tabY && ev.y < layout.tabY + TAB_H) {
154
+ for (let i = 0; i < layout.tabs.length; i++) {
155
+ if (inRect(ev.x, ev.y, { x: layout.tabs[i].x, y: layout.tabY, w: layout.tabs[i].w, h: TAB_H })) {
156
+ hover = i + 1;
157
+ break;
158
+ }
152
159
  }
153
160
  }
154
161
  }
155
- if (hover !== this.hoverIdx) {
162
+ if (proHover !== this.proHover || hover !== this.hoverIdx) {
163
+ this.proHover = proHover;
156
164
  this.hoverIdx = hover;
157
165
  this.app.render();
158
166
  }
@@ -164,6 +172,13 @@ export class StartScreen extends Screen {
164
172
  return;
165
173
  if (ev.button === 2)
166
174
  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.
177
+ const pro = proModeButton(this.app.width, this.app.height);
178
+ if (ev.button === 0 && pro && inRect(ev.x, ev.y, pro)) {
179
+ void this.app.openProMode();
180
+ return;
181
+ }
167
182
  if (ev.y >= 1 && inRect(ev.x, ev.y, approvalToggle(this.app.width, this.app.height - 4, this.approvalMode))) {
168
183
  this.focusIdx = FOCUS_COUNT - 1;
169
184
  this.toggleApproval();
@@ -209,6 +224,11 @@ export class StartScreen extends Screen {
209
224
  for (let y = inputY; y <= tabY + TAB_H; y++)
210
225
  if (y >= 0 && y < h)
211
226
  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.
229
+ for (let y = 1; y <= 3; y++)
230
+ if (y >= 0 && y < h)
231
+ rows.push(y);
212
232
  if (h - 4 >= 1)
213
233
  rows.push(h - 4);
214
234
  return rows;
@@ -222,7 +242,7 @@ export class StartScreen extends Screen {
222
242
  const gap = 1;
223
243
  const logoGap = logoLines.length ? 1 : 0;
224
244
  const contentH = logoLines.length + logoGap + INPUT_H + gap + TAB_H;
225
- const inputY = centerVertically(h - 4, contentH) + logoLines.length + logoGap;
245
+ const inputY = this.contentTop(h, contentH) + logoLines.length + logoGap;
226
246
  const tabY = inputY + INPUT_H + gap;
227
247
  const tabGap = 2;
228
248
  const tabInner = inputW - tabGap * (TABS.length - 1);
@@ -237,6 +257,16 @@ export class StartScreen extends Screen {
237
257
  }
238
258
  return { inputX, inputY, inputW, tabY, tabs };
239
259
  }
260
+ /**
261
+ * 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.
264
+ */
265
+ contentTop(height, contentH) {
266
+ const top = centerVertically(height - 4, contentH);
267
+ const pro = proModeButton(this.app.width, height);
268
+ return pro ? Math.max(top, pro.y + pro.h) : top;
269
+ }
240
270
  move(delta) {
241
271
  this.focusIdx = (this.focusIdx + delta + FOCUS_COUNT) % FOCUS_COUNT;
242
272
  this.hoverIdx = -1;
@@ -280,7 +310,7 @@ export class StartScreen extends Screen {
280
310
  const gap = 1;
281
311
  const logoGap = logoLines.length ? 1 : 0;
282
312
  const contentH = logoLines.length + logoGap + INPUT_H + gap + TAB_H;
283
- const top = centerVertically(h - 4, contentH);
313
+ const top = this.contentTop(h, contentH);
284
314
  let y = top;
285
315
  for (const line of logoLines) {
286
316
  buf.writeText(logoX, y, line, { fg: C.accent, bold: true });
@@ -323,5 +353,8 @@ export class StartScreen extends Screen {
323
353
  buf.centerText(versionY, `v${version}`, { fg: C.dim });
324
354
  }
325
355
  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);
326
359
  }
327
360
  }
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
  }