@xingjisoft/neutrino 0.2.1 → 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.
- package/README.md +312 -436
- package/assets/pro/backend_output_model.txt +5 -0
- package/assets/pro/claude-module-prompt.txt +65 -0
- package/assets/pro/common.txt +9 -0
- package/assets/pro/confirm_model.txt +5 -0
- package/assets/pro/front_output_model.txt +9 -0
- package/assets/pro/genimage_model.txt +1 -0
- package/assets/pro/main_model.txt +5 -0
- package/assets/pro/model-template.json +376 -0
- package/assets/pro/modeling_model.txt +5 -0
- package/assets/pro/plan_model.txt +8 -0
- package/assets/pro/thinking_model.txt +3 -0
- package/dist/app.js +43 -7
- package/dist/bin.js +5 -2
- package/dist/browser.js +57 -0
- package/dist/config.js +179 -26
- package/dist/index.js +1 -1
- package/dist/llm/approval.js +6 -2
- package/dist/llm/client.js +197 -28
- package/dist/llm/subagent.js +37 -1
- package/dist/pro/agents.js +104 -0
- package/dist/pro/config.js +88 -0
- package/dist/pro/preflight.js +172 -0
- package/dist/screens/chat.js +54 -38
- package/dist/screens/modelSelect.js +15 -4
- package/dist/screens/settings.js +9 -7
- package/dist/screens/start.js +48 -15
- package/dist/ui.js +58 -0
- package/dist/web/hub.js +31 -4
- package/dist/web/server.js +611 -50
- package/dist/web/session.js +320 -20
- package/dist/web/settings.js +117 -24
- package/package.json +37 -37
- package/web/README.md +158 -16
- package/web/app.js +1603 -128
- package/web/i18n.js +382 -0
- package/web/index.html +289 -37
- package/web/markdown.js +710 -0
- package/web/neutrino-pro.gif +0 -0
- package/web/neutrino.png +0 -0
- package/web/pro.js +184 -0
- package/web/settings.js +322 -193
- package/web/style.css +1378 -42
|
@@ -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
|
+
}
|
package/dist/screens/chat.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Screen } from "../terminal/screen.js";
|
|
2
2
|
import { C } from "../colors.js";
|
|
3
|
-
import { findModel, contextCompressionThreshold, loadConfig,
|
|
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 =
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
this.model.
|
|
183
|
-
|
|
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,
|
|
@@ -327,6 +333,45 @@ export class ChatScreen extends Screen {
|
|
|
327
333
|
this.app.render();
|
|
328
334
|
this.publishLive();
|
|
329
335
|
}
|
|
336
|
+
/**
|
|
337
|
+
* Start a fresh conversation in the same working directory. Used by Ctrl+L
|
|
338
|
+
* and by the browser's Clear button, so both screens behave identically. The
|
|
339
|
+
* previous transcript is saved first and is never overwritten.
|
|
340
|
+
*/
|
|
341
|
+
liveClear() {
|
|
342
|
+
this.save();
|
|
343
|
+
if (this.saveError) {
|
|
344
|
+
this.app.render();
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
this.turnToken++;
|
|
348
|
+
if (this.pendingApproval)
|
|
349
|
+
this.settleApproval(false);
|
|
350
|
+
this.busy = false;
|
|
351
|
+
this.paused = false;
|
|
352
|
+
this.queued = null;
|
|
353
|
+
this.compressing = false;
|
|
354
|
+
this.retryNotice = null;
|
|
355
|
+
this.app.state.isThinking = false;
|
|
356
|
+
this.lastPromptTokens = 0;
|
|
357
|
+
this.sessionId = null;
|
|
358
|
+
this.context = null;
|
|
359
|
+
this.expandedContext = false;
|
|
360
|
+
this.plan = null;
|
|
361
|
+
this.planning = false;
|
|
362
|
+
this.subagent = null;
|
|
363
|
+
const sysText = this.messages[0] && this.messages[0].role === "system" ? this.messages[0].content : "";
|
|
364
|
+
this.messages = [{ role: "system", content: sysText }];
|
|
365
|
+
this.expandedTools.clear();
|
|
366
|
+
this.finishedTurns.clear();
|
|
367
|
+
this.expandedTurns.clear();
|
|
368
|
+
this.activeTurnStart = null;
|
|
369
|
+
this.streamingContent = null;
|
|
370
|
+
this.scrollTop = 0;
|
|
371
|
+
this.followBottom = true;
|
|
372
|
+
this.publishLive();
|
|
373
|
+
this.app.render();
|
|
374
|
+
}
|
|
330
375
|
liveSend(text) {
|
|
331
376
|
if (!text.trim())
|
|
332
377
|
return;
|
|
@@ -494,36 +539,7 @@ export class ChatScreen extends Screen {
|
|
|
494
539
|
return;
|
|
495
540
|
}
|
|
496
541
|
if (key.ctrl && key.name === "l") {
|
|
497
|
-
|
|
498
|
-
this.save();
|
|
499
|
-
if (this.saveError) {
|
|
500
|
-
this.app.render();
|
|
501
|
-
return;
|
|
502
|
-
}
|
|
503
|
-
this.turnToken++;
|
|
504
|
-
this.busy = false;
|
|
505
|
-
this.paused = false;
|
|
506
|
-
this.queued = null;
|
|
507
|
-
this.compressing = false;
|
|
508
|
-
this.app.state.isThinking = false;
|
|
509
|
-
this.lastPromptTokens = 0;
|
|
510
|
-
this.sessionId = null;
|
|
511
|
-
this.context = null;
|
|
512
|
-
this.expandedContext = false;
|
|
513
|
-
this.plan = null;
|
|
514
|
-
this.planning = false;
|
|
515
|
-
this.subagent = null;
|
|
516
|
-
const sysText = this.messages[0] && this.messages[0].role === "system" ? this.messages[0].content : "";
|
|
517
|
-
this.messages = [{ role: "system", content: sysText }];
|
|
518
|
-
this.expandedTools.clear();
|
|
519
|
-
this.finishedTurns.clear();
|
|
520
|
-
this.expandedTurns.clear();
|
|
521
|
-
this.activeTurnStart = null;
|
|
522
|
-
this.streamingContent = null;
|
|
523
|
-
this.scrollTop = 0;
|
|
524
|
-
this.followBottom = true;
|
|
525
|
-
this.publishLive();
|
|
526
|
-
this.app.render();
|
|
542
|
+
this.liveClear();
|
|
527
543
|
return;
|
|
528
544
|
}
|
|
529
545
|
if (key.ctrl && key.name === "u") {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Screen } from "../terminal/screen.js";
|
|
2
2
|
import { C } from "../colors.js";
|
|
3
|
-
import {
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
}
|
package/dist/screens/settings.js
CHANGED
|
@@ -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 =
|
|
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:
|
|
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 =
|
|
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;
|
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, } 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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
52
|
-
|
|
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)
|
|
@@ -175,6 +193,15 @@ class LiveHub {
|
|
|
175
193
|
this.notify(key);
|
|
176
194
|
return true;
|
|
177
195
|
}
|
|
196
|
+
/** Empty a live conversation in place, keeping its key and working folder. */
|
|
197
|
+
clear(key) {
|
|
198
|
+
const entry = this.entries.get(key);
|
|
199
|
+
if (!entry?.agent.liveClear)
|
|
200
|
+
return false;
|
|
201
|
+
entry.agent.liveClear();
|
|
202
|
+
this.notify(key);
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
178
205
|
}
|
|
179
206
|
/** Process-wide singleton shared by the TUI and the web server. */
|
|
180
207
|
export const hub = new LiveHub();
|