@xingjisoft/neutrino 0.4.0 → 0.5.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/web/i18n.js CHANGED
@@ -106,6 +106,15 @@ const ZH = {
106
106
  "Professional model availability": "专业模型可用性检查",
107
107
  "These model substitutions apply only to this task in this conversation. Your preset configuration is unchanged.": "以下模型替换仅对当前对话的本次任务生效,不会修改预设配置文件。",
108
108
  "Got it": "知道了",
109
+ "A new version is available": "有新版本可用",
110
+ "Neutrino updates itself with npm. Restart it afterwards to use the new version.": "Neutrino 通过 npm 自动更新,更新完成后请重新启动以使用新版本。",
111
+ "Running {current} · published {latest}": "当前版本 {current} · 最新版本 {latest}",
112
+ "Update now": "立即更新",
113
+ "Later": "稍后",
114
+ "Updating with npm…": "正在通过 npm 更新…",
115
+ "Updated to {version}. Restart Neutrino to use it.": "已更新到 {version},请重新启动 Neutrino 以使用新版本。",
116
+ "Update failed: {error}": "更新失败:{error}",
117
+ "Update by hand with:": "也可以手动更新:",
109
118
  // -- chrome -------------------------------------------------------------
110
119
  "Neutrino": "Neutrino",
111
120
  "Switch to Pro mode": "切换至Pro模式",
@@ -132,6 +141,12 @@ const ZH = {
132
141
  "Working directory": "工作目录",
133
142
  "Working directories": "工作目录",
134
143
  "Other agents": "其他 Agent",
144
+ "Migrate session": "迁移会话",
145
+ "Migrating…": "迁移中…",
146
+ "Copy this history into a new Neutrino conversation in the same folder": "将历史复制为同一工作目录下的新 Neutrino 会话",
147
+ "Could not migrate this session: {error}": "无法迁移此会话:{error}",
148
+ "session not found in this working directory": "此工作目录中未找到该会话",
149
+ "this session has no messages to migrate": "此会话没有可迁移的消息",
135
150
  "Read-only sessions recorded by other coding agents in this working directory.": "其他编码 Agent 在此工作目录中记录的只读会话。",
136
151
  "No saved sessions from other agents in this folder.": "此文件夹中暂无其他 Agent 保存的会话。",
137
152
  "Claude Code": "Claude Code",
@@ -172,6 +187,15 @@ const ZH = {
172
187
  "Creating…": "正在创建…",
173
188
  "Provider": "服务商",
174
189
  "Model": "模型",
190
+ "Conversation model": "对话模型",
191
+ "Conversation model: {selection}": "对话模型:{selection}",
192
+ "Choose a model": "选择模型",
193
+ "(unavailable)": "(不可用)",
194
+ "Low": "低",
195
+ "Medium": "中",
196
+ "High": "高",
197
+ "Max": "极高",
198
+ "Ultra": "超高",
175
199
  "Thinking": "思考强度",
176
200
  "Default approval mode": "默认审批模式",
177
201
  "Request Approval": "请求审批",
@@ -215,6 +239,8 @@ const ZH = {
215
239
  "Show less": "收起",
216
240
  "Show {n} more": "再显示 {n} 个",
217
241
  "Live": "实时",
242
+ "Running": "运行中",
243
+ "Waiting for approval": "等待审批",
218
244
  "Earlier conversation · {n} messages": "更早的对话 · {n} 条消息",
219
245
  "Context summary · {n} messages": "上下文摘要 · {n} 条消息",
220
246
  "You": "你",
@@ -298,6 +324,14 @@ const ZH = {
298
324
  "Extreme-risk auto-approval (del class, 3 separate review rounds)": "极高风险自动审批(删除类命令,独立审查 3 轮)",
299
325
  "Sound alerts (turn finished, approval waiting)": "声音提醒(回合结束、等待审批)",
300
326
  "Start the Web console with the terminal": "随终端启动 Web 控制台(下次启动生效)",
327
+ "Background residency": "后台常驻",
328
+ "Run the Web console in the background and keep browser tasks alive after the CLI exits": "让 Web 控制台在后台运行,CLI 退出后浏览器中的任务继续执行",
329
+ "Off by default. Changes take effect immediately, including for running browser tasks. The regular and Pro workspaces share this setting. Terminal conversations still end when the terminal closes.": "默认关闭。切换立即生效,正在运行的浏览器任务也会受到保护;普通模式与 Pro 模式共用此设置。终端内的对话仍会在终端关闭时结束。",
330
+ "Enable background residency to avoid accidentally interrupting tasks": "建议开启后台常驻,避免误触中断任务",
331
+ "Enable": "开启",
332
+ "Background residency is on. Browser tasks keep running after the terminal closes.": "后台常驻已立即开启。关闭终端后,浏览器任务会继续执行。",
333
+ "Background residency is off. Current tasks can finish before the console closes.": "后台常驻已立即关闭。控制台退出前会等待当前任务完成。",
334
+ "Background residency could not be applied. Restart the console and try again.": "后台常驻未能生效,请重启控制台后重试。",
301
335
  "Safety determines automatic approval; task relevance only blocks unanimously confirmed, extreme mismatches. Delete-class automatic approval also requires this setting.": "自动审批取决于安全性;任务相关性只有在两位审查者一致确认极端不匹配时才会阻止执行。删除类命令的自动审批还需要开启此设置。",
302
336
  "Web access": "Web 访问",
303
337
  "Allow access from other devices (LAN, 0.0.0.0)": "允许其他设备访问(局域网,0.0.0.0)",
package/web/index.html CHANGED
@@ -102,14 +102,6 @@
102
102
  </button>
103
103
  </header>
104
104
 
105
- <form id="model-picker" class="model-picker hidden" aria-label="Conversation model">
106
- <label><span data-i18n="Provider">Provider</span><select id="model-provider"></select></label>
107
- <label><span data-i18n="Model">Model</span><select id="model-name"></select></label>
108
- <label><span data-i18n="Thinking">Thinking</span><select id="model-intensity"></select></label>
109
- <button id="model-apply" class="control" type="submit" data-i18n="Apply">Apply</button>
110
- <p id="model-feedback" class="form-feedback" role="status" aria-live="polite"></p>
111
- </form>
112
-
113
105
  <aside id="plan-card" class="plan-card hidden" aria-live="polite"></aside>
114
106
 
115
107
  <section id="messages" class="messages">
@@ -128,10 +120,33 @@
128
120
 
129
121
  <div id="approval" class="approval hidden" role="alert" aria-live="assertive"></div>
130
122
 
123
+ <footer id="agent-actions" class="agent-actions hidden">
124
+ <p id="agent-migrate-feedback" class="form-feedback error" role="status" aria-live="polite"></p>
125
+ <button id="agent-migrate" class="control primary" type="button" data-i18n="Migrate session" title="Copy this history into a new Neutrino conversation in the same folder" data-i18n-title="Copy this history into a new Neutrino conversation in the same folder">Migrate session</button>
126
+ </footer>
127
+
131
128
  <footer id="composer" class="composer hidden">
132
129
  <div class="composer-inner">
133
130
  <div class="composer-card">
134
131
  <textarea id="input" rows="1" placeholder="Message Neutrino…" aria-label="Message" data-i18n-placeholder="Message Neutrino…"></textarea>
132
+ <details id="model-picker" class="model-picker pro-preset-control hidden">
133
+ <summary id="model-picker-summary" aria-label="Conversation model" data-i18n-aria-label="Conversation model"><span id="model-picker-label"></span><svg class="pro-preset-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7 10 5 5 5-5" /></svg></summary>
134
+ <div class="pro-preset-card model-picker-card">
135
+ <div class="pro-preset-heading"><span data-i18n="Conversation model">Conversation model</span><strong id="model-intensity-label"></strong></div>
136
+ <div class="model-picker-fields">
137
+ <label><span data-i18n="Provider">Provider</span><select id="model-provider"></select></label>
138
+ <label><span data-i18n="Model">Model</span><select id="model-name"></select></label>
139
+ </div>
140
+ <label class="pro-slider-label" for="model-intensity" data-i18n="Thinking intensity">Thinking intensity</label>
141
+ <div id="model-intensity-slider" class="pro-preset-slider">
142
+ <div class="pro-slider-track" aria-hidden="true"><div class="pro-slider-fill"></div></div>
143
+ <div id="model-intensity-stops" class="pro-slider-stops" aria-hidden="true"></div>
144
+ <input id="model-intensity" type="range" min="0" max="0" step="1" value="0" aria-label="Thinking intensity" data-i18n-aria-label="Thinking intensity" />
145
+ </div>
146
+ <div id="model-intensity-ticks" class="pro-preset-ticks"></div>
147
+ <p id="model-feedback" class="form-feedback" role="status" aria-live="polite"></p>
148
+ </div>
149
+ </details>
135
150
  <details id="pro-preset-control" class="pro-preset-control hidden">
136
151
  <summary aria-label="Pro preset" data-i18n-aria-label="Pro preset"><span id="pro-preset-label">Pro</span><svg class="pro-preset-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7 10 5 5 5-5" /></svg></summary>
137
152
  <div class="pro-preset-card">
@@ -157,7 +172,7 @@
157
172
  </div>
158
173
  </details>
159
174
  <button id="send" class="primary" type="button" title="Send message" aria-label="Send message" data-i18n-title="Send message" data-i18n-aria-label="Send message">
160
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7" /></svg>
175
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path class="send-icon" d="M12 19V5M5 12l7-7 7 7" /><rect class="pause-icon" x="5" y="5" width="14" height="14" rx="2" fill="currentColor" stroke="none" /></svg>
161
176
  </button>
162
177
  </div>
163
178
  <div class="composer-hint">
@@ -222,6 +237,9 @@
222
237
  <label class="checkbox-label"><input id="settings-sound" type="checkbox" /> <span data-i18n="Sound alerts (turn finished, approval waiting)">Sound alerts (turn finished, approval waiting)</span></label>
223
238
  <label class="checkbox-label"><input id="settings-web-console" type="checkbox" /> <span data-i18n="Start the Web console with the terminal">Start the Web console with the terminal</span></label>
224
239
  <p class="settings-note" data-i18n="Safety determines automatic approval; task relevance only blocks unanimously confirmed, extreme mismatches. Delete-class automatic approval also requires this setting.">Safety determines automatic approval; task relevance only blocks unanimously confirmed, extreme mismatches. Delete-class automatic approval also requires this setting.</p>
240
+ <h3 data-i18n="Background residency">Background residency</h3>
241
+ <label class="checkbox-label"><input id="settings-background" type="checkbox" /> <span data-i18n="Run the Web console in the background and keep browser tasks alive after the CLI exits">Run the Web console in the background and keep browser tasks alive after the CLI exits</span></label>
242
+ <p class="settings-note" data-i18n="Off by default. Changes take effect immediately, including for running browser tasks. The regular and Pro workspaces share this setting. Terminal conversations still end when the terminal closes.">Off by default. Changes take effect immediately, including for running browser tasks. The regular and Pro workspaces share this setting. Terminal conversations still end when the terminal closes.</p>
225
243
  <h3 data-i18n="Web access">Web access</h3>
226
244
  <label class="checkbox-label"><input id="settings-web-lan" type="checkbox" /> <span data-i18n="Allow access from other devices (LAN, 0.0.0.0)">Allow access from other devices (LAN, 0.0.0.0)</span></label>
227
245
  <p class="settings-note" data-i18n="Binds the Web console to 0.0.0.0 so other devices on your network can open it, like --lan. Takes effect immediately; turning it off returns the console to 127.0.0.1. Keep the Web password on: anyone who can reach this port can drive the agent.">Binds the Web console to 0.0.0.0 so other devices on your network can open it, like --lan. Takes effect immediately; turning it off returns the console to 127.0.0.1. Keep the Web password on: anyone who can reach this port can drive the agent.</p>
@@ -239,6 +257,27 @@
239
257
  </form>
240
258
  </dialog>
241
259
 
260
+ <dialog id="background-dialog" class="sheet-dialog background-dialog" aria-labelledby="background-title">
261
+ <div class="sheet background-sheet">
262
+ <header class="sheet-header background-header">
263
+ <span class="background-mark" aria-hidden="true">
264
+ <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
265
+ <ellipse cx="12" cy="12" rx="9.4" ry="3.8" transform="rotate(-30 12 12)" stroke="currentColor" stroke-width="1.7" />
266
+ <circle cx="12" cy="12" r="3.1" stroke="currentColor" stroke-width="1.7" />
267
+ </svg>
268
+ </span>
269
+ <div class="background-copy">
270
+ <h2 id="background-title" data-i18n="Enable background residency to avoid accidentally interrupting tasks">Enable background residency to avoid accidentally interrupting tasks</h2>
271
+ </div>
272
+ </header>
273
+ <p id="background-feedback" class="form-feedback background-feedback" role="status" aria-live="polite"></p>
274
+ <footer class="sheet-actions background-actions">
275
+ <button id="background-cancel" class="control" type="button" data-i18n="Cancel">Cancel</button>
276
+ <button id="background-enable" class="primary" type="button" data-i18n="Enable" autofocus>Enable</button>
277
+ </footer>
278
+ </div>
279
+ </dialog>
280
+
242
281
  <dialog id="new-chat-dialog" class="sheet-dialog" aria-labelledby="new-chat-title">
243
282
  <form id="new-chat-form" class="sheet">
244
283
  <header class="sheet-header">
@@ -375,6 +414,29 @@
375
414
  </div>
376
415
  </dialog>
377
416
 
417
+ <dialog id="update-dialog" class="sheet-dialog" aria-labelledby="update-title">
418
+ <div class="sheet">
419
+ <header class="sheet-header">
420
+ <div>
421
+ <h2 id="update-title" data-i18n="A new version is available">A new version is available</h2>
422
+ <p id="update-versions" class="sheet-sub"></p>
423
+ </div>
424
+ <button id="update-close" class="icon-btn" type="button" aria-label="Close" data-i18n-aria-label="Close">
425
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
426
+ </button>
427
+ </header>
428
+ <div class="sheet-body">
429
+ <p id="update-note" class="settings-note" data-i18n="Neutrino updates itself with npm. Restart it afterwards to use the new version.">Neutrino updates itself with npm. Restart it afterwards to use the new version.</p>
430
+ <p id="update-command-block" class="settings-note hidden"><span data-i18n="Update by hand with:">Update by hand with:</span> <code id="update-command">npm install -g @xingjisoft/neutrino</code></p>
431
+ <p id="update-feedback" class="form-feedback" role="status" aria-live="polite"></p>
432
+ </div>
433
+ <footer class="sheet-actions">
434
+ <button id="update-later" class="control" type="button" data-i18n="Later">Later</button>
435
+ <button id="update-now" class="primary" type="button" data-i18n="Update now" autofocus>Update now</button>
436
+ </footer>
437
+ </div>
438
+ </dialog>
439
+
378
440
  <script src="app.js" type="module"></script>
379
441
  </body>
380
442
  </html>
package/web/settings.js CHANGED
@@ -3,24 +3,32 @@
3
3
  import { t, getLanguage } from "./i18n.js";
4
4
 
5
5
  export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMode = false, onCatalogChange }) {
6
- const ids = ["model-picker", "model-provider", "model-name", "model-intensity", "model-apply", "model-feedback",
6
+ const ids = ["model-picker", "model-picker-summary", "model-picker-label", "model-provider", "model-name", "model-intensity",
7
+ "model-intensity-label", "model-intensity-slider", "model-intensity-stops", "model-intensity-ticks", "model-feedback",
7
8
  "settings-open", "settings-dialog", "settings-form", "settings-close", "settings-cancel", "settings-save", "settings-feedback",
8
9
  "default-provider", "default-model", "default-intensity", "settings-context", "settings-compress-note", "settings-reviewer",
9
10
  "settings-extreme", "settings-sound", "settings-password", "settings-password-confirm",
10
- "settings-language", "settings-plan", "settings-approval", "settings-web-console", "settings-web-lan",
11
+ "settings-language", "settings-plan", "settings-approval", "settings-web-console", "settings-web-lan", "settings-background",
11
12
  "settings-pro", "settings-pro-preset", "settings-pro-depth"];
12
13
  const el = Object.fromEntries(ids.map(id => [id, document.getElementById(id)]));
13
14
  let config = null;
14
15
  let target = null;
15
16
  let conversation = null;
16
17
  let dirty = false;
17
- let applying = false;
18
+ let draftIntensity = "";
19
+ let applying = null;
20
+ let generation = 0;
21
+ let failure = null;
18
22
  let feedback = "";
19
23
  let feedbackError = false;
20
24
  // Value the LAN switch had when the dialog opened. Only an actual flip is
21
25
  // saved, so editing another setting never pins an interface that came from
22
26
  // --lan/--host on the command line.
23
27
  let lanInitial = false;
28
+ // Same idea for background residency: the dialog must not overwrite a value
29
+ // changed from the terminal while it was open.
30
+ let backgroundInitial = false;
31
+ let backgroundChanging = false;
24
32
 
25
33
  async function json(path, body) {
26
34
  const response = await apiFetch(path, body === undefined ? { cache: "no-store" } : {
@@ -84,32 +92,90 @@ export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMo
84
92
  value: preset.name, label: getLanguage() === "zh" ? preset.chinese_name || preset.name : preset.name,
85
93
  })), selected);
86
94
  }
95
+ function unavailableOption(select, value) {
96
+ const option = document.createElement("option");
97
+ option.value = value;
98
+ option.textContent = `${value} ${t("(unavailable)")}`;
99
+ option.disabled = true;
100
+ select.appendChild(option);
101
+ select.value = value;
102
+ }
87
103
  function choice() {
88
- return { provider: el["model-provider"].value, model: el["model-name"].value, intensity: el["model-intensity"].value };
104
+ return { provider: el["model-provider"].value, model: el["model-name"].value, intensity: draftIntensity };
89
105
  }
90
106
  function renderPicker() {
91
107
  el["model-picker"].classList.toggle("hidden", proMode || !config || !conversation);
92
108
  if (proMode || !config || !conversation) return;
93
109
  if (!dirty) {
94
- providers(el["model-provider"], conversation.provider);
95
- models(el["model-provider"], el["model-name"], conversation.model);
96
- intensities(el["model-intensity"], conversation.intensity, conversation.provider, conversation.model);
110
+ providers(el["model-provider"], conversation.provider || config.selection?.provider);
111
+ if (conversation.provider && !config.providers.some(entry => entry.name === conversation.provider)) {
112
+ unavailableOption(el["model-provider"], conversation.provider);
113
+ }
114
+ models(el["model-provider"], el["model-name"], conversation.model || config.selection?.model);
115
+ if (conversation.model && !modelEntry(el["model-provider"].value, conversation.model)) {
116
+ unavailableOption(el["model-name"], conversation.model);
117
+ }
118
+ draftIntensity = pickIntensity(el["model-provider"].value, el["model-name"].value,
119
+ conversation.intensity || config.selection?.intensity);
97
120
  } else {
98
- // A provider/model change narrows the intensity list even though the rest
99
- // of the draft must survive the next server snapshot.
100
- intensities(el["model-intensity"], el["model-intensity"].value,
101
- el["model-provider"].value, el["model-name"].value);
121
+ // Keep a draft across live snapshots, while honoring the chosen model's limits.
122
+ draftIntensity = pickIntensity(el["model-provider"].value, el["model-name"].value, draftIntensity);
102
123
  }
124
+ const selection = choice();
125
+ const displayed = dirty || target?.startsWith("new:") ? selection : {
126
+ model: conversation.model || selection.model,
127
+ intensity: conversation.intensity || selection.intensity,
128
+ };
129
+ el["model-picker-label"].textContent = [displayed.model, displayed.intensity && t(displayed.intensity)]
130
+ .filter(Boolean).join(" · ") || t("Choose a model");
131
+ const summary = t("Conversation model: {selection}", { selection: el["model-picker-label"].textContent });
132
+ el["model-picker-summary"].setAttribute("aria-label", summary);
133
+ el["model-picker-summary"].title = summary;
134
+ el["model-intensity-label"].textContent = selection.intensity ? t(selection.intensity) : "";
135
+
136
+ const levels = modelEntry(selection.provider, selection.model) ? intensitiesFor(selection.provider, selection.model) : [];
137
+ const index = Math.max(0, levels.indexOf(selection.intensity));
138
+ const last = Math.max(0, levels.length - 1);
103
139
  const busy = Boolean(conversation.busy || conversation.approval);
104
- el["model-apply"].disabled = applying || busy || !choice().provider || !choice().model;
105
- el["model-apply"].textContent = applying
106
- ? t("Applying…")
107
- : target?.startsWith("file:") ? t("Continue with model") : t("Apply to conversation");
108
- message(
109
- el["model-feedback"],
110
- feedback || (busy ? t("Pause the current turn to change models.") : t("Applies to the next turn.")),
111
- feedbackError,
112
- );
140
+ const locked = busy || Boolean(applying && applying.generation === generation);
141
+ const slider = el["model-intensity-slider"];
142
+ slider.setAttribute("style", `--pro-slider-position: ${last ? index / last : 0}`);
143
+ slider.classList.toggle("is-disabled", locked || !levels.length);
144
+ el["model-picker"].classList.toggle("is-max", last > 0 && index === last);
145
+ el["model-provider"].disabled = locked;
146
+ el["model-name"].disabled = locked || !selection.provider;
147
+ el["model-intensity"].disabled = locked || !levels.length;
148
+ el["model-intensity"].max = String(last);
149
+ el["model-intensity"].value = String(index);
150
+ el["model-intensity"].setAttribute("aria-valuetext", selection.intensity ? t(selection.intensity) : "");
151
+
152
+ const stops = el["model-intensity-stops"];
153
+ const ticks = el["model-intensity-ticks"];
154
+ stops.replaceChildren();
155
+ ticks.replaceChildren();
156
+ levels.forEach((level, levelIndex) => {
157
+ const position = last ? levelIndex / last * 100 : 0;
158
+ const stop = document.createElement("span");
159
+ stop.className = "pro-slider-stop";
160
+ stop.setAttribute("style", `left: ${position}%`);
161
+ stop.classList.toggle("is-filled", levelIndex <= index);
162
+ stops.appendChild(stop);
163
+ const tick = document.createElement("button");
164
+ tick.type = "button";
165
+ tick.textContent = t(level);
166
+ tick.disabled = locked;
167
+ tick.setAttribute("style", `left: ${position}%`);
168
+ tick.setAttribute("aria-pressed", String(level === selection.intensity));
169
+ tick.addEventListener("click", () => {
170
+ if (tick.disabled) return;
171
+ draftIntensity = level;
172
+ changeChoice();
173
+ });
174
+ ticks.appendChild(tick);
175
+ });
176
+ const status = busy ? t("Pause the current turn to change models.")
177
+ : feedback || (locked ? t("Applying…") : t("Applies to the next turn."));
178
+ message(el["model-feedback"], status, !busy && feedbackError);
113
179
  }
114
180
  async function load() {
115
181
  config = await json("/api/settings");
@@ -118,44 +184,109 @@ export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMo
118
184
  return config;
119
185
  }
120
186
  function showConversation(info, key) {
121
- if (target !== key) { dirty = false; feedback = ""; feedbackError = false; }
187
+ if (target !== key) {
188
+ generation++;
189
+ dirty = false;
190
+ failure = null;
191
+ feedback = "";
192
+ feedbackError = false;
193
+ el["model-picker"].open = false;
194
+ }
122
195
  target = key;
123
196
  conversation = info;
197
+ // A turn may start elsewhere while the slider is being dragged. Discard
198
+ // that uncommitted preview so it cannot masquerade as the active setting.
199
+ if (target?.startsWith("live:") && dirty && (info?.busy || info?.approval) && applying?.generation !== generation) {
200
+ dirty = false;
201
+ feedback = "";
202
+ feedbackError = false;
203
+ }
124
204
  renderPicker();
125
205
  }
126
206
  function clear() {
207
+ generation++;
127
208
  target = null;
128
209
  conversation = null;
129
210
  dirty = false;
211
+ failure = null;
212
+ feedback = "";
213
+ el["model-picker"].open = false;
214
+ renderPicker();
215
+ }
216
+ function changeChoice() {
217
+ if (!config || !conversation || conversation.busy || conversation.approval || applying?.generation === generation) return;
218
+ dirty = true;
219
+ failure = null;
130
220
  feedback = "";
221
+ feedbackError = false;
222
+ renderPicker();
223
+ // Saved and not-yet-created conversations keep the choice as a local draft.
224
+ if (!target?.startsWith("live:")) return;
225
+ const selection = choice();
226
+ if (!selection.provider || !modelEntry(selection.provider, selection.model) || !selection.intensity) return;
227
+ if (selection.provider === conversation.provider && selection.model === conversation.model && selection.intensity === conversation.intensity) {
228
+ dirty = false;
229
+ renderPicker();
230
+ return;
231
+ }
232
+ const request = { generation, promise: null };
233
+ applying = request;
131
234
  renderPicker();
235
+ request.promise = (async () => {
236
+ try {
237
+ await applyModel(selection);
238
+ if (request.generation !== generation) return;
239
+ conversation = { ...conversation, ...selection };
240
+ dirty = false;
241
+ feedback = t("Model and intensity updated.");
242
+ } catch (error) {
243
+ if (request.generation !== generation) return;
244
+ dirty = false;
245
+ failure = error;
246
+ feedback = t(error.message);
247
+ feedbackError = true;
248
+ } finally {
249
+ if (applying === request) applying = null;
250
+ renderPicker();
251
+ }
252
+ })();
132
253
  }
133
254
  el["model-provider"].addEventListener("change", () => {
134
255
  if (!config) return;
135
- models(el["model-provider"], el["model-name"]);
136
- dirty = true; feedback = ""; feedbackError = false;
137
- renderPicker();
256
+ const provider = config.providers.find(entry => entry.name === el["model-provider"].value);
257
+ models(el["model-provider"], el["model-name"], provider?.models.find(model => model.configured)?.name);
258
+ changeChoice();
138
259
  });
139
- for (const id of ["model-name", "model-intensity"]) {
140
- el[id].addEventListener("change", () => { dirty = true; feedback = ""; feedbackError = false; renderPicker(); });
141
- }
142
- el["model-picker"].addEventListener("submit", async event => {
143
- event.preventDefault();
144
- if (el["model-apply"].disabled) return;
145
- const originalTarget = target;
146
- applying = true;
260
+ el["model-name"].addEventListener("change", changeChoice);
261
+ el["model-intensity"].addEventListener("input", () => {
262
+ if (!config || !conversation || conversation.busy || conversation.approval || applying?.generation === generation) return;
263
+ const levels = intensitiesFor(el["model-provider"].value, el["model-name"].value);
264
+ draftIntensity = levels[Number(el["model-intensity"].value)] || draftIntensity;
265
+ dirty = true;
266
+ failure = null;
147
267
  feedback = "";
268
+ feedbackError = false;
148
269
  renderPicker();
149
- try {
150
- await applyModel(choice());
151
- if (target === originalTarget) { dirty = false; feedback = t("Model and intensity updated."); feedbackError = false; }
152
- } catch (error) {
153
- if (target === originalTarget) { feedback = t(error.message); feedbackError = true; }
154
- } finally {
155
- applying = false;
156
- renderPicker();
157
- }
158
270
  });
271
+ el["model-intensity"].addEventListener("change", changeChoice);
272
+ el["model-picker"].addEventListener("keydown", event => {
273
+ if (event.key === "Escape") { el["model-picker"].open = false; event.stopPropagation(); }
274
+ });
275
+ document.addEventListener?.("pointerdown", event => {
276
+ if (el["model-picker"].open && !el["model-picker"].contains(event.target)) el["model-picker"].open = false;
277
+ });
278
+ async function flush() {
279
+ // A keyboard send can happen before a range input emits its change event.
280
+ if (dirty && target?.startsWith("live:") && applying?.generation !== generation) changeChoice();
281
+ const request = applying;
282
+ const currentGeneration = generation;
283
+ if (request?.generation === currentGeneration) await request.promise;
284
+ if (currentGeneration === generation && failure) {
285
+ const error = failure;
286
+ failure = null;
287
+ throw error;
288
+ }
289
+ }
159
290
 
160
291
  function renderSettings() {
161
292
  el["settings-password"].value = "";
@@ -178,8 +309,45 @@ export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMo
178
309
  el["settings-sound"].checked = config.settings.sound_alerts !== false;
179
310
  el["settings-web-console"].checked = config.settings.web_console !== false;
180
311
  el["settings-web-lan"].checked = config.settings.web_lan === true;
312
+ el["settings-background"].checked = config.settings.background_running === true;
313
+ backgroundInitial = el["settings-background"].checked;
181
314
  lanInitial = el["settings-web-lan"].checked;
182
315
  }
316
+ el["settings-background"].addEventListener("change", async () => {
317
+ if (!config || backgroundChanging) return;
318
+ const enabled = el["settings-background"].checked;
319
+ backgroundChanging = true;
320
+ el["settings-background"].disabled = true;
321
+ el["settings-save"].disabled = true;
322
+ message(el["settings-feedback"], t("Applying…"));
323
+ try {
324
+ const updated = await json("/api/settings", { settings: { background: enabled } });
325
+ if (updated.settings.background_running !== enabled) {
326
+ throw new Error(t("Background residency could not be applied. Restart the console and try again."));
327
+ }
328
+ config = updated;
329
+ backgroundInitial = config.settings.background_running === true;
330
+ el["settings-background"].checked = backgroundInitial;
331
+ onCatalogChange?.(config);
332
+ message(el["settings-feedback"], t(enabled
333
+ ? "Background residency is on. Browser tasks keep running after the terminal closes."
334
+ : "Background residency is off. Current tasks can finish before the console closes."));
335
+ } catch (error) {
336
+ // A response can be lost after the service applied the change. Ask the
337
+ // service for its actual state before putting the switch back.
338
+ try {
339
+ config = await json("/api/settings");
340
+ backgroundInitial = config.settings.background_running === true;
341
+ } catch { /* Keep the last confirmed value and show the error. */ }
342
+ el["settings-background"].checked = backgroundInitial;
343
+ message(el["settings-feedback"], t(error.message), true);
344
+ } finally {
345
+ backgroundChanging = false;
346
+ el["settings-background"].disabled = false;
347
+ el["settings-save"].disabled = false;
348
+ }
349
+ });
350
+
183
351
  function renderContextWindow() {
184
352
  const provider = config.providers.find(entry => entry.name === el["default-provider"].value);
185
353
  const model = provider?.models.find(entry => entry.name === el["default-model"].value);
@@ -200,8 +368,6 @@ export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMo
200
368
  if (!config) return;
201
369
  providers(el["model-provider"], el["model-provider"].value);
202
370
  models(el["model-provider"], el["model-name"], el["model-name"].value);
203
- intensities(el["model-intensity"], el["model-intensity"].value,
204
- el["model-provider"].value, el["model-name"].value);
205
371
  if (el["settings-dialog"].open) {
206
372
  providers(el["default-provider"], el["default-provider"].value);
207
373
  models(el["default-provider"], el["default-model"], el["default-model"].value);
@@ -260,6 +426,7 @@ export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMo
260
426
  el["settings-save"].disabled = true;
261
427
  const wasLan = config?.settings?.web_lan === true;
262
428
  const lanChanged = el["settings-web-lan"].checked !== lanInitial;
429
+ const backgroundChanged = el["settings-background"].checked !== backgroundInitial;
263
430
  const selection = { provider: el["default-provider"].value, model: el["default-model"].value, intensity: el["default-intensity"].value };
264
431
  const settings = {
265
432
  ...(!proMode ? {
@@ -275,6 +442,7 @@ export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMo
275
442
  sound_alerts: el["settings-sound"].checked,
276
443
  web_console: el["settings-web-console"].checked,
277
444
  ...(lanChanged ? { web_lan: el["settings-web-lan"].checked } : {}),
445
+ ...(backgroundChanged ? { background: el["settings-background"].checked } : {}),
278
446
  language: el["settings-language"].value,
279
447
  approval_mode: el["settings-approval"].value,
280
448
  };
@@ -289,16 +457,24 @@ export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMo
289
457
  // server bound and (when available) the address to open elsewhere.
290
458
  el["settings-web-lan"].checked = config.settings.web_lan === true;
291
459
  lanInitial = el["settings-web-lan"].checked;
460
+ el["settings-background"].checked = config.settings.background_running === true;
461
+ backgroundInitial = el["settings-background"].checked;
292
462
  const urls = Array.isArray(config.settings.web_urls) ? config.settings.web_urls : [];
293
463
  const lanNote = config.settings.web_lan === wasLan ? "" : config.settings.web_lan
294
464
  ? urls.length
295
465
  ? t("LAN access is on. Open {urls} from another device.", { urls: urls.join(" · ") })
296
466
  : t("LAN access is on, but no network address was found. Only this machine can connect.")
297
467
  : t("LAN access is off. Only this machine can connect.");
468
+ const backgroundNote = !backgroundChanged
469
+ ? ""
470
+ : config.settings.background
471
+ ? t("Background residency is on. Browser tasks keep running after the terminal closes.")
472
+ : t("Background residency is off. Current tasks can finish before the console closes.");
298
473
  const savedText = password
299
474
  ? t("Settings saved. Web password updated permanently.")
300
475
  : t("Settings saved.");
301
- message(el["settings-feedback"], lanNote ? `${savedText} ${lanNote}` : savedText);
476
+ const notes = [lanNote, backgroundNote].filter(Boolean).join(" ");
477
+ message(el["settings-feedback"], notes ? `${savedText} ${notes}` : savedText);
302
478
  } catch (error) { message(el["settings-feedback"], t(error.message), true); }
303
479
  finally { el["settings-save"].disabled = false; }
304
480
  });
@@ -311,12 +487,13 @@ export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMo
311
487
  clear,
312
488
  soundAlerts,
313
489
  refreshTexts,
490
+ flush,
314
491
  catalog: () => config,
315
- defaultSelection: () => (config ? {
492
+ defaultSelection: () => (config ? (target?.startsWith("new:") && dirty ? choice() : {
316
493
  provider: config.selection.provider,
317
494
  model: config.selection.model,
318
495
  intensity: config.selection.intensity,
319
- } : undefined),
496
+ }) : undefined),
320
497
  selectionForFile: id => target === `file:${id}` && dirty ? choice() : undefined,
321
498
  };
322
499
  }