@xingjisoft/neutrino 0.3.0 → 0.4.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.
Files changed (42) hide show
  1. package/README.md +312 -464
  2. package/assets/pro/backend_output_model.txt +5 -0
  3. package/assets/pro/claude-module-prompt.txt +65 -0
  4. package/assets/pro/common.txt +9 -0
  5. package/assets/pro/confirm_model.txt +5 -0
  6. package/assets/pro/front_output_model.txt +9 -0
  7. package/assets/pro/genimage_model.txt +1 -0
  8. package/assets/pro/main_model.txt +5 -0
  9. package/assets/pro/model-template.json +376 -0
  10. package/assets/pro/modeling_model.txt +5 -0
  11. package/assets/pro/plan_model.txt +8 -0
  12. package/assets/pro/thinking_model.txt +3 -0
  13. package/dist/app.js +43 -7
  14. package/dist/bin.js +5 -2
  15. package/dist/browser.js +57 -0
  16. package/dist/config.js +170 -26
  17. package/dist/index.js +1 -1
  18. package/dist/llm/approval.js +6 -2
  19. package/dist/llm/client.js +197 -28
  20. package/dist/llm/subagent.js +37 -1
  21. package/dist/pro/agents.js +104 -0
  22. package/dist/pro/config.js +88 -0
  23. package/dist/pro/preflight.js +172 -0
  24. package/dist/screens/chat.js +14 -8
  25. package/dist/screens/modelSelect.js +15 -4
  26. package/dist/screens/settings.js +9 -7
  27. package/dist/screens/start.js +48 -15
  28. package/dist/ui.js +58 -0
  29. package/dist/web/hub.js +22 -4
  30. package/dist/web/server.js +279 -66
  31. package/dist/web/session.js +258 -22
  32. package/dist/web/settings.js +91 -24
  33. package/package.json +37 -37
  34. package/web/README.md +87 -13
  35. package/web/app.js +304 -14
  36. package/web/i18n.js +382 -345
  37. package/web/index.html +90 -14
  38. package/web/neutrino-pro.gif +0 -0
  39. package/web/neutrino.png +0 -0
  40. package/web/pro.js +184 -0
  41. package/web/settings.js +322 -247
  42. package/web/style.css +297 -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,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,6 +1,6 @@
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";
@@ -8,6 +8,8 @@ import { hashWebPassword } from "../web/auth.js";
8
8
  const ROWS = ["intensity", "compress", "approval_model", "extreme_approval", "sound", "web_console", "web_password"];
9
9
  export class SettingsScreen extends Screen {
10
10
  intensityIdx = 2;
11
+ /** Levels the current chat model accepts, in canonical order. */
12
+ intensityLevels = [...INTENSITY_LEVELS];
11
13
  contextTokens = DEFAULT_CONTEXT_WINDOW;
12
14
  contextChanged = false;
13
15
  /** 0 = same model as the chat, otherwise index + 1 into `models`. */
@@ -29,11 +31,11 @@ export class SettingsScreen extends Screen {
29
31
  passwordMessage = "Press Enter to change";
30
32
  onActivate() {
31
33
  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
34
  const state = this.app.state;
36
35
  const model = getModelsByProvider()[state.selectedProvider]?.find((m) => m.name === state.selectedModelName);
36
+ this.intensityLevels = modelIntensities(model);
37
+ const current = intensityForModel(model, config.default_intensity);
38
+ this.intensityIdx = Math.max(0, this.intensityLevels.indexOf(current));
37
39
  this.contextTokens = contextWindow(model?.context_window);
38
40
  this.contextChanged = false;
39
41
  this.models = Object.values(getModelsByProvider()).flatMap((list) => list.map((m) => m.name));
@@ -56,7 +58,7 @@ export class SettingsScreen extends Screen {
56
58
  return;
57
59
  }
58
60
  if (row === "intensity") {
59
- const n = INTENSITY_LEVELS.length;
61
+ const n = this.intensityLevels.length;
60
62
  this.intensityIdx = (this.intensityIdx + delta + n) % n;
61
63
  }
62
64
  else if (row === "approval_model") {
@@ -210,7 +212,7 @@ export class SettingsScreen extends Screen {
210
212
  }
211
213
  rowTexts() {
212
214
  return [
213
- { label: "Default intensity", value: INTENSITY_LEVELS[this.intensityIdx] },
215
+ { label: "Default intensity", value: this.intensityLevels[this.intensityIdx] },
214
216
  { label: "Model context (80%)", value: `${this.contextTokens.toLocaleString("en-US")} tokens` },
215
217
  {
216
218
  label: "Approval reviewer model",
@@ -255,7 +257,7 @@ export class SettingsScreen extends Screen {
255
257
  }
256
258
  save() {
257
259
  const config = loadConfig();
258
- config.default_intensity = INTENSITY_LEVELS[this.intensityIdx];
260
+ config.default_intensity = this.intensityLevels[this.intensityIdx] ?? "High";
259
261
  if (this.contextChanged) {
260
262
  const data = loadModels();
261
263
  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/ui.js CHANGED
@@ -293,6 +293,64 @@ export function drawTabButton(buf, x, y, w, label, selected, align = "center") {
293
293
  buf.writeText(lx, y + 1, label, { fg: C.dim });
294
294
  }
295
295
  }
296
+ /** Label of the CLI entry point that opens the browser console's Pro workspace. */
297
+ export const PRO_MODE_LABEL = "启用Pro模式";
298
+ /**
299
+ * Geometry of the Pro-mode button: a small box in the top-left corner, directly
300
+ * below the status bar (row 0). Rendering and hit testing share it so a click
301
+ * always lands on the box that is drawn.
302
+ *
303
+ * Returns null when the terminal is too small to fit the box with a little
304
+ * breathing room around it. The height floor also keeps the start screen's
305
+ * centered block (which is pushed below the box) clear of the approval toggle
306
+ * on very short terminals, where the original layout is preserved instead.
307
+ */
308
+ export function proModeButton(width, height) {
309
+ // One blank column at the very edge, then the border: "flush to the edge
310
+ // with a single space", as the start screen expects.
311
+ const x = 1;
312
+ const y = 1;
313
+ const h = 3;
314
+ // Two cells of padding, one inside each border.
315
+ const w = displayWidth(PRO_MODE_LABEL) + 4;
316
+ if (width < w + 4 || height < 22)
317
+ return null;
318
+ return { x, y, w, h, label: PRO_MODE_LABEL };
319
+ }
320
+ /**
321
+ * Draw the Pro-mode button. It is painted as opaque chrome so content behind it
322
+ * can never bleed through; the label row turns into the highlight background
323
+ * while the pointer is over it.
324
+ */
325
+ export function drawProModeButton(buf, hovered = false) {
326
+ const button = proModeButton(buf.width, buf.height);
327
+ if (!button)
328
+ return;
329
+ const { x, y, w, h, label } = button;
330
+ if (w < 2 || h < 2)
331
+ return;
332
+ // Clear the footprint first: the button acts as an overlay over decorative
333
+ // content (the logo), which must not show through its interior.
334
+ buf.fillRect(x, y, w, h, { bg: C.black });
335
+ buf.drawBox(x, y, w, h, { fg: C.dim });
336
+ const innerX = x + 1;
337
+ const innerW = w - 2;
338
+ const labelY = y + Math.floor(h / 2);
339
+ if (hovered)
340
+ buf.fillRect(innerX, labelY, innerW, 1, { bg: C.highlightBg });
341
+ const text = truncateByWidth(label, innerW);
342
+ const textX = innerX + Math.max(0, Math.floor((innerW - displayWidth(text)) / 2));
343
+ const style = hovered
344
+ ? { fg: C.accent, bg: C.highlightBg, bold: true }
345
+ : { fg: C.accent, bold: true };
346
+ buf.writeText(textX, labelY, text, style);
347
+ // The button is chrome, not content: copying a selection across it must not
348
+ // drag its border or label into the clipboard.
349
+ for (let j = y; j < y + h; j++) {
350
+ for (let i = x; i < x + w; i++)
351
+ buf.markChrome(i, j);
352
+ }
353
+ }
296
354
  export function centerVertically(totalHeight, contentHeight) {
297
355
  const top = Math.floor((totalHeight - contentHeight) / 2);
298
356
  return Math.max(1, top);
package/dist/web/hub.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * {@link LiveHub.notify}, which coalesces rapid-fire updates (one per streamed
13
13
  * token) and pushes a snapshot to any connected browser.
14
14
  */
15
+ import { normalizeMode } from "../config.js";
15
16
  export function liveApprovalDetails(tool, args, risk, cwd) {
16
17
  return { tool, level: risk.level, arguments: args, cwd, summary: risk.summary, reasons: risk.reasons };
17
18
  }
@@ -32,6 +33,7 @@ class LiveHub {
32
33
  key,
33
34
  agent,
34
35
  cwd: agent.liveCwd(),
36
+ mode: normalizeMode(agent.mode),
35
37
  listeners: new Set(),
36
38
  timer: null,
37
39
  lastSent: "",
@@ -48,13 +50,20 @@ class LiveHub {
48
50
  clearTimeout(entry.timer);
49
51
  this.entries.delete(key);
50
52
  }
51
- has(key) {
52
- return this.entries.has(key);
53
+ /**
54
+ * Whether a key is live. With a mode, keys from the other workspace are
55
+ * treated as unknown: the Pro page must never reach a terminal session.
56
+ */
57
+ has(key, mode) {
58
+ const entry = this.entries.get(key);
59
+ return Boolean(entry) && (!mode || entry.mode === mode);
53
60
  }
54
- /** All live sessions, newest registration last. */
55
- list() {
61
+ /** All live sessions, newest registration last. Optionally one workspace only. */
62
+ list(mode) {
56
63
  const out = [];
57
64
  for (const entry of this.entries.values()) {
65
+ if (mode && entry.mode !== mode)
66
+ continue;
58
67
  out.push({ ...entry.agent.liveMeta(), key: entry.key, cwd: entry.cwd });
59
68
  }
60
69
  return out;
@@ -167,6 +176,15 @@ class LiveHub {
167
176
  this.notify(key);
168
177
  return result;
169
178
  }
179
+ setProPreset(key, preset) {
180
+ const entry = this.entries.get(key);
181
+ if (!entry?.agent.liveSetProPreset)
182
+ return { ok: false, error: "this session cannot change Pro presets" };
183
+ const result = entry.agent.liveSetProPreset(preset);
184
+ if (result.ok)
185
+ this.notify(key);
186
+ return result;
187
+ }
170
188
  setApprovalMode(key, mode) {
171
189
  const entry = this.entries.get(key);
172
190
  if (!entry?.agent.liveSetApprovalMode)