@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.
- package/README.md +33 -549
- package/assets/neutrino.ico +0 -0
- package/assets/neutrino.svg +7 -0
- 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/agents.js +22 -3
- package/dist/app.js +199 -26
- package/dist/background.js +358 -0
- package/dist/bin.js +184 -44
- package/dist/browser.js +57 -0
- package/dist/config.js +174 -26
- package/dist/header.js +2 -1
- 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/llm/tools.js +3 -1
- package/dist/migration.js +33 -0
- package/dist/pro/agents.js +104 -0
- package/dist/pro/config.js +88 -0
- package/dist/pro/preflight.js +172 -0
- package/dist/screens/agentConversation.js +48 -5
- package/dist/screens/agentHistory.js +4 -2
- package/dist/screens/chat.js +14 -8
- package/dist/screens/modelSelect.js +15 -4
- package/dist/screens/settings.js +52 -9
- package/dist/screens/start.js +48 -15
- package/dist/state.js +1 -0
- package/dist/terminal/restore.js +3 -12
- package/dist/tray.js +138 -0
- package/dist/ui.js +58 -0
- package/dist/web/hub.js +26 -4
- package/dist/web/server.js +495 -74
- package/dist/web/service.js +135 -0
- package/dist/web/session.js +278 -25
- package/dist/web/settings.js +97 -24
- package/dist/web/terminalBridge.js +250 -0
- package/package.json +37 -37
- package/web/README.md +150 -16
- package/web/app.js +490 -25
- package/web/i18n.js +396 -345
- package/web/index.html +120 -15
- package/web/neutrino-pro.gif +0 -0
- package/web/neutrino.png +0 -0
- package/web/pro.js +184 -0
- package/web/settings.js +373 -247
- 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
|
-
|
|
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 -
|
|
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
|
-
|
|
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 = [];
|
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,
|
|
@@ -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,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 =
|
|
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:
|
|
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
|
-
?
|
|
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 =
|
|
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;
|
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
|
}
|