@xingjisoft/neutrino 0.1.7 → 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
@@ -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
  }
@@ -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
 
@@ -979,7 +1014,8 @@ function renderTurnFold(start, messages) {
979
1014
  const summary = document.createElement("summary");
980
1015
  const chevron = document.createElement("span");
981
1016
  chevron.className = "turn-chevron";
982
- 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>';
983
1019
  const you = document.createElement("span");
984
1020
  you.className = "turn-you";
985
1021
  you.textContent = `You: ${truncate(turnPreview(messages, "user"), 110)}`;
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>