@xingjisoft/neutrino 0.1.6 → 0.2.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/app.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createSettingsUI } from "./settings.js";
2
- import { turnFolds, turnPreview, turnRanges } from "./turns.js";
2
+ import { turnAnswerIndex, turnFolds, turnPreview, turnRanges } from "./turns.js";
3
3
 
4
4
  // Neutrino web UI — a dependency-free single page app.
5
5
  //
@@ -61,6 +61,13 @@ async function apiFetch(path, init = {}) {
61
61
  return response;
62
62
  }
63
63
 
64
+ // The orbit mark is shared by the sidebar, the favicon and the login card, so
65
+ // the console never falls back to a typed glyph for its own identity.
66
+ const LOGO_MARK =
67
+ '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">' +
68
+ '<ellipse cx="12" cy="12" rx="9.4" ry="3.8" transform="rotate(-30 12 12)" stroke="currentColor" stroke-width="1.7"/>' +
69
+ '<circle cx="12" cy="12" r="3.1" stroke="currentColor" stroke-width="1.7"/></svg>';
70
+
64
71
  let gateShown = false;
65
72
 
66
73
  function showGate(message = "") {
@@ -76,8 +83,9 @@ function showGate(message = "") {
76
83
  gate.setAttribute("aria-labelledby", "login-title");
77
84
  gate.innerHTML =
78
85
  '<form class="gate-card">' +
79
- '<h2 id="login-title">Sign in to Neutrino</h2>' +
80
- '<p>Enter your Web password. The default password is 114514.</p>' +
86
+ '<div class="gate-brand"><span class="logo">' + LOGO_MARK + "</span><strong>Neutrino</strong></div>" +
87
+ '<h2 id="login-title">Sign in</h2>' +
88
+ '<p>Enter your Web password to open this console. The default password is 114514.</p>' +
81
89
  '<label for="login-password">Password</label>' +
82
90
  '<input id="login-password" name="password" type="password" autocomplete="current-password" maxlength="256" required autofocus>' +
83
91
  '<p class="form-feedback error" role="alert"></p>' +
@@ -240,6 +248,16 @@ function truncate(value, max) {
240
248
  return text.length > max ? `${text.slice(0, max)}…` : text;
241
249
  }
242
250
 
251
+ // Sidebar paths are identifiers, not navigation: keep the project end of a long
252
+ // path so two checkouts under different parents stay tellable apart.
253
+ function shortenPath(value, depth = 2) {
254
+ const text = String(value ?? "");
255
+ const separator = text.includes("\\") ? "\\" : "/";
256
+ const parts = text.split(/[\\/]+/).filter(Boolean);
257
+ if (parts.length <= depth + 1) return text;
258
+ return `…${separator}${parts.slice(-depth).join(separator)}`;
259
+ }
260
+
243
261
  function safeJson(value) {
244
262
  try {
245
263
  return JSON.stringify(value ?? {});
@@ -364,8 +382,10 @@ function renderGroup(group) {
364
382
  const header = document.createElement("button");
365
383
  header.className = "group-header";
366
384
  header.innerHTML =
367
- `<span class="chevron">${collapsed ? "▸" : "▾"}</span>` +
368
- `<span class="path" title="${escapeHtml(group.cwd)}">${escapeHtml(group.cwd)}</span>` +
385
+ `<span class="chevron${collapsed ? " collapsed" : ""}">` +
386
+ '<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="m9 18 6-6-6-6"/></svg>' +
387
+ "</span>" +
388
+ `<span class="path" title="${escapeHtml(group.cwd)}">${escapeHtml(shortenPath(group.cwd))}</span>` +
369
389
  `<span class="count">${group.sessions.length}</span>`;
370
390
  header.addEventListener("click", () => {
371
391
  if (state.collapsed.has(group.key)) state.collapsed.delete(group.key);
@@ -413,12 +433,14 @@ function renderSession(session) {
413
433
  if (session.live) item.classList.add("live");
414
434
 
415
435
  const title = session.title || "New conversation";
436
+ const count = Number(session.message_count ?? 0);
437
+ item.title = title;
416
438
  item.innerHTML =
417
439
  `<div class="session-top"><span class="session-title">${escapeHtml(title)}</span>` +
418
440
  `${session.live ? '<span class="dot" title="Live"></span>' : ""}</div>` +
419
441
  `<div class="session-meta"><span class="model">${escapeHtml(session.model || "?")}</span>` +
420
442
  `<span class="time">${escapeHtml(formatTime(session.updated_at))}</span>` +
421
- `<span class="count">${Number(session.message_count ?? 0)} msg</span></div>`;
443
+ `<span class="count">${count} message${count === 1 ? "" : "s"}</span></div>`;
422
444
 
423
445
  item.addEventListener("click", () => {
424
446
  if (session.liveKey) selectLive(session.liveKey);
@@ -459,6 +481,7 @@ async function selectLive(key) {
459
481
  state.openContext = null;
460
482
  renderSidebar();
461
483
  el.controls.innerHTML = "";
484
+ renderApproval(null);
462
485
  el.title.textContent = "Connecting…";
463
486
  el.sub.textContent = "";
464
487
  setComposerMode("live");
@@ -516,6 +539,7 @@ async function selectFile(id) {
516
539
  state.openContext = null;
517
540
  renderSidebar();
518
541
  el.controls.innerHTML = "";
542
+ renderApproval(null);
519
543
  setComposerMode("resume");
520
544
  el.title.textContent = "Loading…";
521
545
  el.sub.textContent = "";
@@ -552,12 +576,12 @@ function renderFileControls(data) {
552
576
 
553
577
  const badge = document.createElement("span");
554
578
  badge.className = "badge readonly";
555
- badge.textContent = "read-only";
579
+ badge.textContent = "Read-only";
556
580
  el.controls.appendChild(badge);
557
581
 
558
582
  const button = document.createElement("button");
559
583
  button.className = "control primary";
560
- button.textContent = "✎ Continue here";
584
+ button.textContent = "Continue here";
561
585
  button.title = "Resume this conversation in the browser and keep chatting";
562
586
  button.addEventListener("click", async () => {
563
587
  button.disabled = true;
@@ -567,7 +591,7 @@ function renderFileControls(data) {
567
591
  el.input.focus();
568
592
  } catch (error) {
569
593
  button.disabled = false;
570
- button.textContent = "✎ Continue here";
594
+ button.textContent = "Continue here";
571
595
  el.sub.textContent = `Could not continue this conversation: ${error.message}`;
572
596
  }
573
597
  });
@@ -595,8 +619,8 @@ function setComposerMode(mode) {
595
619
  el.composer.classList.remove("hidden");
596
620
  el.input.placeholder =
597
621
  mode === "resume"
598
- ? "Continue this conversation… (Enter to send)"
599
- : "Message the agent… (Enter to send, Shift+Enter for a new line)";
622
+ ? "Continue this conversation…"
623
+ : "Message Neutrino…";
600
624
  }
601
625
 
602
626
  function hideComposer() {
@@ -709,7 +733,7 @@ function renderApproval(snapshot) {
709
733
  const head = document.createElement("div");
710
734
  head.className = "approval-head";
711
735
  const title = document.createElement("strong");
712
- title.textContent = "Request Approval";
736
+ title.textContent = "Approval required";
713
737
  const risk = document.createElement("span");
714
738
  risk.className = "approval-risk";
715
739
  risk.textContent = extreme ? "EXTREME RISK" : "HIGH RISK";
@@ -800,12 +824,21 @@ function renderControls(snapshot) {
800
824
  const badge = document.createElement("span");
801
825
  const mode = snapshot.paused ? "paused" : snapshot.busy ? "busy" : "idle";
802
826
  badge.className = `badge ${mode}`;
827
+ // A retried request leaves the session busy but silent: name the retry so the
828
+ // badge does not just say "working" for minutes. The same goes for a
829
+ // sub-agent, whose own messages never reach this transcript.
830
+ const retrying = snapshot.retrying;
831
+ const subagent = snapshot.subagent;
803
832
  badge.textContent = snapshot.paused
804
833
  ? "paused"
805
834
  : snapshot.busy
806
835
  ? snapshot.compressing
807
836
  ? "compressing"
808
- : "working"
837
+ : retrying
838
+ ? `retrying ${retrying.attempt}/${retrying.attempts}`
839
+ : subagent
840
+ ? `sub-agent: ${subagent}`
841
+ : "working"
809
842
  : "idle";
810
843
  el.controls.appendChild(badge);
811
844
 
@@ -819,7 +852,7 @@ function renderControls(snapshot) {
819
852
  if (snapshot.busy || snapshot.paused) {
820
853
  const button = document.createElement("button");
821
854
  button.className = "control";
822
- button.textContent = snapshot.paused ? "▶ Resume" : "❚❚ Pause";
855
+ button.textContent = snapshot.paused ? "Resume" : "Pause";
823
856
  button.addEventListener("click", () => {
824
857
  postAction(snapshot.key, snapshot.paused ? "resume" : "pause");
825
858
  });
@@ -852,7 +885,7 @@ function renderMessageList(messages, streaming, options) {
852
885
  if (messages.length === 0 && !streaming) {
853
886
  const empty = document.createElement("div");
854
887
  empty.className = "empty";
855
- empty.textContent = options?.readOnly ? "This conversation is empty." : "Say something to get started.";
888
+ empty.textContent = options?.readOnly ? "This conversation is empty." : "Send a message to get started.";
856
889
  el.messages.appendChild(empty);
857
890
  return;
858
891
  }
@@ -886,10 +919,10 @@ function renderMessageList(messages, streaming, options) {
886
919
  });
887
920
  el.messages.appendChild(details);
888
921
  }
889
- // Render the tail one turn at a time: a turn the agent has finished folds into
890
- // a summary line, a running one stays open. The turn in flight is the last one,
891
- // so `active` decides its state; a turn cut in half by the compressed prefix is
892
- // rendered as plain messages instead.
922
+ // Render the tail one turn at a time: a finished turn folds its work behind a
923
+ // summary line and keeps its reply; a running one stays fully open. The turn in
924
+ // flight is the last one, so `active` decides its state; a turn cut in half by
925
+ // the compressed prefix is rendered as plain messages instead.
893
926
  const turns = turnRanges(original);
894
927
  const running = options?.active && turns.length > 0 ? turns[turns.length - 1].start : -1;
895
928
  for (const turn of turns) {
@@ -931,7 +964,9 @@ function renderMessage(message, index) {
931
964
 
932
965
  const summary = document.createElement("summary");
933
966
  summary.innerHTML =
934
- `<span class="tool-name">⚙ ${escapeHtml(message.name || "tool")}</span>` +
967
+ '<span class="tool-name">' +
968
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m4 17 6-6-6-6"/><path d="M12 19h8"/></svg>' +
969
+ `${escapeHtml(message.name || "tool")}</span>` +
935
970
  `<span class="tool-args">${escapeHtml(truncate(safeJson(message.arguments), 120))}</span>`;
936
971
  details.appendChild(summary);
937
972
 
@@ -958,29 +993,35 @@ function renderMessage(message, index) {
958
993
  }
959
994
 
960
995
  /**
961
- * A finished turn, folded to one summary line. The messages are rendered on
962
- * first open and dropped again on close, so a long transcript costs nothing
963
- * until the reader asks for it. Nothing is lost either way: this is presentation
964
- * only, and the saved transcript always holds every message.
996
+ * A finished turn: its work folded behind one control line, its reply always on
997
+ * screen below it. The folded messages are rendered on first open and dropped
998
+ * again on close, so a long transcript costs nothing until the reader asks for
999
+ * it. Nothing is lost either way: this is presentation only, and the saved
1000
+ * transcript always holds every message.
965
1001
  */
966
1002
  function renderTurnFold(start, messages) {
1003
+ // The reply is never folded away, so a turn renders as a group: the fold (the
1004
+ // prompt line plus the work it hides) and, right below it, the answer itself.
1005
+ const reply = turnAnswerIndex(messages);
1006
+ const group = document.createElement("div");
1007
+ group.className = "turn-group";
1008
+
967
1009
  const details = document.createElement("details");
968
1010
  details.className = "turn";
969
1011
  details.open = state.openTurns.has(start);
970
1012
 
971
- const count = messages.length;
1013
+ const hidden = reply < 0 ? messages.length : messages.length - 1;
972
1014
  const summary = document.createElement("summary");
973
1015
  const chevron = document.createElement("span");
974
1016
  chevron.className = "turn-chevron";
975
- chevron.textContent = "▸";
1017
+ chevron.innerHTML =
1018
+ '<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="m9 18 6-6-6-6"/></svg>';
976
1019
  const you = document.createElement("span");
977
1020
  you.className = "turn-you";
978
1021
  you.textContent = `You: ${truncate(turnPreview(messages, "user"), 110)}`;
979
1022
  const meta = document.createElement("span");
980
1023
  meta.className = "turn-meta";
981
- meta.textContent =
982
- `${count} message${count === 1 ? "" : "s"} · ` +
983
- truncate(turnPreview(messages, "assistant", true), 90);
1024
+ meta.textContent = `${hidden} of ${messages.length} message${messages.length === 1 ? "" : "s"} folded`;
984
1025
  // The hint cannot live in the static text: it says the opposite once open.
985
1026
  const hint = document.createElement("span");
986
1027
  hint.className = "turn-hint";
@@ -989,6 +1030,7 @@ function renderTurnFold(start, messages) {
989
1030
  summary.appendChild(you);
990
1031
  summary.appendChild(meta);
991
1032
  details.appendChild(summary);
1033
+ group.appendChild(details);
992
1034
 
993
1035
  const syncHint = () => {
994
1036
  hint.textContent = details.open ? " — click to collapse" : " — click to expand";
@@ -1000,7 +1042,10 @@ function renderTurnFold(start, messages) {
1000
1042
  details.appendChild(body);
1001
1043
  const populate = () => {
1002
1044
  if (body.childNodes.length) return;
1003
- messages.forEach((message, offset) => body.appendChild(renderMessage(message, start + offset)));
1045
+ messages.forEach((message, offset) => {
1046
+ if (offset === reply) return; // the answer is never inside the fold
1047
+ body.appendChild(renderMessage(message, start + offset));
1048
+ });
1004
1049
  };
1005
1050
  if (details.open) populate();
1006
1051
  details.addEventListener("toggle", () => {
@@ -1015,7 +1060,10 @@ function renderTurnFold(start, messages) {
1015
1060
  }
1016
1061
  syncHint();
1017
1062
  });
1018
- return details;
1063
+ // The answer comes with the fold, so a finished exchange ends on its reply
1064
+ // even while the work that produced it stays hidden.
1065
+ if (reply >= 0) group.appendChild(renderMessage(messages[reply], start + reply));
1066
+ return group;
1019
1067
  }
1020
1068
 
1021
1069
  function firstUserMessage(messages) {
package/web/index.html CHANGED
@@ -4,28 +4,44 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
6
6
  <meta name="color-scheme" content="dark" />
7
+ <meta name="theme-color" content="#151517" />
7
8
  <title>Neutrino</title>
9
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23f9fafb' stroke-width='1.7'%3E%3Cellipse cx='12' cy='12' rx='9.4' ry='3.8' transform='rotate(-30 12 12)'/%3E%3Ccircle cx='12' cy='12' r='3.1'/%3E%3C/svg%3E" />
8
10
  <link rel="stylesheet" href="style.css" />
9
11
  </head>
10
12
  <body>
11
13
  <div id="app">
12
14
  <aside id="sidebar">
13
15
  <header class="brand">
14
- <div class="logo" aria-hidden="true">◈</div>
16
+ <div class="logo" aria-hidden="true">
17
+ <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
18
+ <ellipse cx="12" cy="12" rx="9.4" ry="3.8" transform="rotate(-30 12 12)" stroke="currentColor" stroke-width="1.7" />
19
+ <circle cx="12" cy="12" r="3.1" stroke="currentColor" stroke-width="1.7" />
20
+ </svg>
21
+ </div>
15
22
  <div class="brand-text">
16
23
  <strong>Neutrino</strong>
17
24
  <span id="status" class="status">connecting…</span>
18
25
  </div>
19
- <button id="refresh" class="icon-btn" title="Refresh conversations" aria-label="Refresh">⟳</button>
20
- <button id="settings-open" class="icon-btn" title="Settings" aria-label="Settings">⚙</button>
26
+ <div class="brand-actions">
27
+ <button id="refresh" class="icon-btn" type="button" title="Refresh conversations" aria-label="Refresh conversations">
28
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.64-6.36" /><path d="M21 3v6h-6" /></svg>
29
+ </button>
30
+ <button id="settings-open" class="icon-btn" type="button" title="Settings" aria-label="Settings">
31
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" /><circle cx="12" cy="12" r="3" /></svg>
32
+ </button>
33
+ </div>
21
34
  </header>
22
35
  <div id="sessions" class="sessions"></div>
36
+ <footer class="sidebar-foot">
37
+ <span>Local agent console</span>
38
+ </footer>
23
39
  </aside>
24
40
 
25
41
  <main id="main">
26
42
  <header id="main-header" class="main-header">
27
43
  <button id="back-to-menu" class="icon-btn back-to-menu" type="button" title="Back to conversations" aria-label="Back to conversations" aria-controls="sidebar">
28
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m12 5-7 7 7 7M5 12h14" /></svg>
44
+ <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="m12 5-7 7 7 7M5 12h14" /></svg>
29
45
  </button>
30
46
  <div class="main-title">
31
47
  <h1 id="session-title">Select a conversation</h1>
@@ -37,19 +53,36 @@
37
53
  <form id="model-picker" class="model-picker hidden" aria-label="Conversation model">
38
54
  <label>Provider<select id="model-provider"></select></label>
39
55
  <label>Model<select id="model-name"></select></label>
40
- <label>Thinking intensity<select id="model-intensity"></select></label>
41
- <button id="model-apply" class="control" type="submit">Apply to conversation</button>
56
+ <label>Thinking<select id="model-intensity"></select></label>
57
+ <button id="model-apply" class="control" type="submit">Apply</button>
42
58
  <p id="model-feedback" class="form-feedback" role="status" aria-live="polite"></p>
43
59
  </form>
44
60
 
45
61
  <section id="messages" class="messages">
46
- <div class="empty">Choose a conversation from the sidebar to view it.</div>
62
+ <div class="empty">
63
+ <span class="empty-mark" aria-hidden="true">
64
+ <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
65
+ <ellipse cx="12" cy="12" rx="9.4" ry="3.8" transform="rotate(-30 12 12)" stroke="currentColor" stroke-width="1.7" />
66
+ <circle cx="12" cy="12" r="3.1" stroke="currentColor" stroke-width="1.7" />
67
+ </svg>
68
+ </span>
69
+ <strong>No conversation open</strong>
70
+ <span>Choose a conversation from the sidebar to view it.</span>
71
+ </div>
47
72
  </section>
48
73
 
49
74
  <div id="approval" class="approval hidden" role="alert" aria-live="assertive"></div>
75
+
50
76
  <footer id="composer" class="composer hidden">
51
- <textarea id="input" rows="2" placeholder="Send a message… (Enter to send, Shift+Enter for newline)"></textarea>
52
- <button id="send" class="primary">Send</button>
77
+ <div class="composer-inner">
78
+ <div class="composer-card">
79
+ <textarea id="input" rows="1" placeholder="Message Neutrino…" aria-label="Message"></textarea>
80
+ <button id="send" class="primary" type="button" title="Send message" aria-label="Send message">
81
+ <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>
82
+ </button>
83
+ </div>
84
+ <div class="composer-hint"><kbd>Enter</kbd> to send · <kbd>Shift</kbd> + <kbd>Enter</kbd> for a new line</div>
85
+ </div>
53
86
  </footer>
54
87
  </main>
55
88
  </div>
@@ -57,8 +90,13 @@
57
90
  <dialog id="settings-dialog" class="settings-dialog" aria-labelledby="settings-title">
58
91
  <form id="settings-form">
59
92
  <header class="settings-header">
60
- <h2 id="settings-title">Settings</h2>
61
- <button id="settings-close" class="icon-btn" type="button" aria-label="Close settings">×</button>
93
+ <div>
94
+ <h2 id="settings-title">Settings</h2>
95
+ <p class="settings-sub">Models, approvals, and console access.</p>
96
+ </div>
97
+ <button id="settings-close" class="icon-btn" type="button" aria-label="Close settings">
98
+ <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>
99
+ </button>
62
100
  </header>
63
101
  <div class="settings-fields">
64
102
  <h3>Defaults for new conversations</h3>
@@ -66,21 +104,21 @@
66
104
  <label>Model<select id="default-model"></select></label>
67
105
  <label>Thinking intensity<select id="default-intensity"></select></label>
68
106
  <h3>Conversation and approvals</h3>
69
- <label>Model context window (tokens)<input id="settings-context" type="number" min="1" max="9007199254740991" step="1" required></label>
107
+ <label>Model context window (tokens)<input id="settings-context" type="number" min="1" max="9007199254740991" step="1" required /></label>
70
108
  <p id="settings-compress-note" class="settings-note">Auto-compress at 80% of this model's context window. Default: 1,000,000 tokens.</p>
71
109
  <label>Approval reviewer model<select id="settings-reviewer"></select></label>
72
- <label class="checkbox-label"><input id="settings-extreme" type="checkbox"> Extreme-risk auto-approval (del class, 3 separate review rounds)</label>
73
- <label class="checkbox-label"><input id="settings-sound" type="checkbox"> Sound alerts (turn finished, approval waiting)</label>
110
+ <label class="checkbox-label"><input id="settings-extreme" type="checkbox" /> Extreme-risk auto-approval (del class, 3 separate review rounds)</label>
111
+ <label class="checkbox-label"><input id="settings-sound" type="checkbox" /> Sound alerts (turn finished, approval waiting)</label>
74
112
  <p class="settings-note">Safety determines automatic approval; task relevance only blocks unanimously confirmed, extreme mismatches. Delete-class automatic approval also requires this setting.</p>
75
113
  <h3>Web access</h3>
76
- <label>New Web password<input id="settings-password" type="password" autocomplete="new-password" maxlength="256" placeholder="Leave blank to keep current password"></label>
77
- <label>Confirm password<input id="settings-password-confirm" type="password" autocomplete="new-password" maxlength="256" placeholder="Repeat new password"></label>
114
+ <label>New Web password<input id="settings-password" type="password" autocomplete="new-password" maxlength="256" placeholder="Leave blank to keep current password" /></label>
115
+ <label>Confirm password<input id="settings-password-confirm" type="password" autocomplete="new-password" maxlength="256" placeholder="Repeat new password" /></label>
78
116
  <p class="settings-note">The password is permanent and survives restarts. Default: 114514. Saving a new password signs out other browsers.</p>
79
117
  <p id="settings-feedback" class="form-feedback" role="status" aria-live="polite"></p>
80
118
  </div>
81
119
  <footer class="settings-actions">
82
120
  <button id="settings-cancel" class="control" type="button">Cancel</button>
83
- <button id="settings-save" class="primary" type="submit">Save settings</button>
121
+ <button id="settings-save" class="primary" type="submit">Save changes</button>
84
122
  </footer>
85
123
  </form>
86
124
  </dialog>