@xingjisoft/neutrino 0.5.2 → 0.6.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.
@@ -1,6 +1,6 @@
1
1
  import { randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import http from "node:http";
3
- import { resolveModelSelection } from "../config.js";
3
+ import { resolveModelSelection, isAgentMode } from "../config.js";
4
4
  import { hub } from "./hub.js";
5
5
  function sendJson(res, status, data) {
6
6
  res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
@@ -82,6 +82,15 @@ export async function startTerminalBridge() {
82
82
  return;
83
83
  }
84
84
  }
85
+ else if (action === "agent-mode") {
86
+ if (!isAgentMode(body.mode))
87
+ throw new Error("invalid agent mode");
88
+ const result = hub.setAgentMode(key, body.mode);
89
+ if (!result.ok) {
90
+ sendJson(res, 409, { error: result.error });
91
+ return;
92
+ }
93
+ }
85
94
  else if (action === "model") {
86
95
  const result = hub.configure(key, resolveModelSelection(body));
87
96
  if (!result.ok) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingjisoft/neutrino",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "XINGJI Neutrino - a CLI coding agent in the terminal.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -14,11 +14,11 @@
14
14
  "web"
15
15
  ],
16
16
  "engines": {
17
- "node": ">=18.0.0"
17
+ "node": ">=18.17.0"
18
18
  },
19
19
  "scripts": {
20
20
  "build": "tsc -p tsconfig.json",
21
- "test": "npm run build && node scripts/test-paste.mjs && node scripts/test-mouse.mjs && node scripts/test-cursor.mjs && node scripts/test-selection.mjs && node scripts/test-drag-scroll.mjs && node scripts/test-selection-performance.mjs && node scripts/test-copy.mjs && node scripts/test-scrollbar.mjs && node scripts/test-render.mjs && node scripts/test-drift.mjs && node scripts/test-sanitize.mjs && node scripts/test-markdown.mjs && node scripts/test-wrap.mjs && node scripts/test-fetch.mjs && node scripts/test-compact.mjs && node scripts/test-history-fold.mjs && node scripts/test-web-history.mjs && node scripts/test-web-mobile-history.mjs && node scripts/test-session-migration.mjs && node scripts/test-web-session-migration.mjs && node scripts/test-web-default.mjs && node scripts/test-turn-fold.mjs && node scripts/test-long-turn.mjs && node scripts/test-plan.mjs && node scripts/test-subagent.mjs && node scripts/test-web-settings.mjs && node scripts/test-update.mjs && node scripts/test-update-ui.mjs && node scripts/test-update-web.mjs && node scripts/test-settings-update.mjs && node scripts/test-update-check.mjs && node scripts/test-update-watch.mjs && node scripts/test-model-intensity.mjs && node scripts/test-background.mjs && node scripts/test-background-lifetime.mjs && node scripts/test-web-markdown.mjs && node scripts/test-web-i18n.mjs && node scripts/test-web-workspaces.mjs && node scripts/test-tools.mjs && node scripts/test-approval.mjs && node scripts/test-approval-review.mjs && node scripts/test-approval-ui.mjs && node scripts/test-web-approval-details.mjs && node scripts/test-web-sidebar-status.mjs && node scripts/test-web-seen.mjs && node scripts/test-approval-mode.mjs && node scripts/test-approval-context.mjs && node scripts/test-settings-render.mjs && node scripts/test-approval-flow.mjs && node scripts/test-web.mjs && node scripts/test-web-settings-http.mjs && node scripts/test-web-lan.mjs && node scripts/test-web-auth.mjs && node scripts/test-web-auth-ui.mjs && node scripts/test-web-pro.mjs && node scripts/test-pro-preflight.mjs && node scripts/test-pro-runtime.mjs && node scripts/test-pro-team.mjs && node scripts/test-pro-ui.mjs && node scripts/test-interrupt.mjs && node scripts/test-terminal-lifecycle.mjs && node scripts/test-persistence.mjs && node scripts/test-resilience.mjs && node scripts/test-approval-timeout.mjs && node scripts/test-approval-hardening.mjs && node scripts/test-approval-extreme.mjs && node scripts/test-approval-reason.mjs",
21
+ "test": "npm run build && node scripts/test-paste.mjs && node scripts/test-mouse.mjs && node scripts/test-cursor.mjs && node scripts/test-selection.mjs && node scripts/test-drag-scroll.mjs && node scripts/test-selection-performance.mjs && node scripts/test-copy.mjs && node scripts/test-scrollbar.mjs && node scripts/test-render.mjs && node scripts/test-drift.mjs && node scripts/test-sanitize.mjs && node scripts/test-markdown.mjs && node scripts/test-wrap.mjs && node scripts/test-fetch.mjs && node scripts/test-web-search.mjs && node scripts/test-search-network.mjs && node scripts/test-compact.mjs && node scripts/test-history-fold.mjs && node scripts/test-web-history.mjs && node scripts/test-web-mobile-history.mjs && node scripts/test-session-migration.mjs && node scripts/test-web-session-migration.mjs && node scripts/test-web-default.mjs && node scripts/test-turn-fold.mjs && node scripts/test-long-turn.mjs && node scripts/test-plan.mjs && node scripts/test-agent-modes.mjs && node scripts/test-subagent.mjs && node scripts/test-task-runtime.mjs && node scripts/test-web-settings.mjs && node scripts/test-update.mjs && node scripts/test-update-ui.mjs && node scripts/test-update-web.mjs && node scripts/test-settings-update.mjs && node scripts/test-update-check.mjs && node scripts/test-update-watch.mjs && node scripts/test-model-intensity.mjs && node scripts/test-background.mjs && node scripts/test-background-lifetime.mjs && node scripts/test-web-markdown.mjs && node scripts/test-web-i18n.mjs && node scripts/test-web-workspaces.mjs && node scripts/test-tools.mjs && node scripts/test-approval.mjs && node scripts/test-approval-review.mjs && node scripts/test-approval-ui.mjs && node scripts/test-web-approval-details.mjs && node scripts/test-web-sidebar-status.mjs && node scripts/test-web-seen.mjs && node scripts/test-approval-mode.mjs && node scripts/test-approval-context.mjs && node scripts/test-settings-render.mjs && node scripts/test-approval-flow.mjs && node scripts/test-web.mjs && node scripts/test-web-settings-http.mjs && node scripts/test-web-lan.mjs && node scripts/test-web-auth.mjs && node scripts/test-web-auth-ui.mjs && node scripts/test-web-pro.mjs && node scripts/test-pro-preflight.mjs && node scripts/test-pro-runtime.mjs && node scripts/test-pro-team.mjs && node scripts/test-pro-ui.mjs && node scripts/test-interrupt.mjs && node scripts/test-terminal-lifecycle.mjs && node scripts/test-persistence.mjs && node scripts/test-resilience.mjs && node scripts/test-approval-timeout.mjs && node scripts/test-approval-hardening.mjs && node scripts/test-approval-extreme.mjs && node scripts/test-approval-reason.mjs",
22
22
  "prepare": "npm run build",
23
23
  "web": "node dist/bin.js web"
24
24
  },
@@ -31,7 +31,12 @@
31
31
  "coding"
32
32
  ],
33
33
  "devDependencies": {
34
- "@types/node": "^22.0.0",
35
- "typescript": "^5.5.0"
34
+ "@types/node": "^22.0.0"
35
+ },
36
+ "dependencies": {
37
+ "@cloudflare/codemode": "0.5.6",
38
+ "quickjs-emscripten": "0.32.0",
39
+ "typescript": "^5.5.0",
40
+ "undici": "^6.29.1"
36
41
  }
37
42
  }
package/web/app.js CHANGED
@@ -445,6 +445,9 @@ const el = {
445
445
  messages: document.getElementById("messages"),
446
446
  composer: document.getElementById("composer"),
447
447
  input: document.getElementById("input"),
448
+ modePicker: document.getElementById("agent-mode-picker"),
449
+ modeLabel: document.getElementById("agent-mode-label"),
450
+ modeFeedback: document.getElementById("agent-mode-feedback"),
448
451
  send: document.getElementById("send"),
449
452
  approval: document.getElementById("approval"),
450
453
  emptyNewChat: document.getElementById("empty-new-chat"),
@@ -593,6 +596,148 @@ function storedWorkspace() {
593
596
  return { key: pathKey(path), path };
594
597
  }
595
598
 
599
+ const AGENT_MODE_LABELS = { standard: "Standard mode", ptc: "PTC mode", fast: "Fast mode", pro: "Professional mode" };
600
+ let newAgentMode = readStorage("neutrino.agent-mode");
601
+ if (!["standard", "ptc", "fast"].includes(newAgentMode)) newAgentMode = "standard";
602
+ let agentModePending = Promise.resolve();
603
+ let agentModeChanging = false;
604
+ let workspaceNavigation = false;
605
+ let agentModeError = "";
606
+
607
+ function clearAgentModeFeedback() {
608
+ agentModeError = "";
609
+ agentModePending = Promise.resolve();
610
+ }
611
+
612
+ function currentAgentMode() {
613
+ if (PRO_MODE) return "pro";
614
+ if (state.selection?.kind === "live") return state.snapshot?.agentMode || "standard";
615
+ if (state.selection?.kind === "file") return state.fileData?.agent_mode || "standard";
616
+ return newAgentMode;
617
+ }
618
+
619
+ function renderAgentMode() {
620
+ if (!el.modePicker || !el.modeLabel) return;
621
+ const mode = currentAgentMode();
622
+ el.modeLabel.removeAttribute?.("data-i18n");
623
+ el.modeLabel.textContent = tr(AGENT_MODE_LABELS[mode] || AGENT_MODE_LABELS.standard);
624
+ const nextTurn = state.snapshot?.busy && state.snapshot.activeAgentMode !== state.snapshot.agentMode;
625
+ el.modePicker.classList.toggle("is-pending", Boolean(agentModeChanging || nextTurn));
626
+ for (const button of el.modePicker.querySelectorAll?.("[data-agent-mode]") || []) {
627
+ button.setAttribute("aria-checked", String(button.dataset.agentMode === mode));
628
+ button.disabled = agentModeChanging || workspaceNavigation;
629
+ }
630
+ if (el.modeFeedback) {
631
+ el.modeFeedback.textContent = agentModeError || (agentModeChanging ? tr("Changing mode…") : nextTurn ? tr("Applies to the next turn") : "");
632
+ el.modeFeedback.classList.toggle("error", Boolean(agentModeError));
633
+ }
634
+ }
635
+
636
+ async function chooseAgentMode(mode) {
637
+ if (!Object.hasOwn(AGENT_MODE_LABELS, mode) || agentModeChanging || workspaceNavigation || mode === currentAgentMode()) return;
638
+ const selected = state.selection;
639
+ agentModeChanging = true;
640
+ agentModeError = "";
641
+ if (el.modePicker) renderAgentMode();
642
+ el.modePicker.open = false;
643
+ try {
644
+ if (mode === "pro" || PRO_MODE) {
645
+ await switchWorkspaceMode(mode);
646
+ } else if (selected?.kind === "live") {
647
+ const response = await apiFetch(`/api/live/${encodeURIComponent(selected.key)}/agent-mode`, {
648
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode }),
649
+ });
650
+ const body = await response.json();
651
+ if (!response.ok) throw new Error(apiError(body, `HTTP ${response.status}`));
652
+ if (state.selection === selected) { state.snapshot = body.snapshot; renderLive(); }
653
+ } else if (selected?.kind === "file") {
654
+ await continueSession(selected.id, preferences.selectionForFile(selected.id), mode);
655
+ } else {
656
+ newAgentMode = mode;
657
+ }
658
+ if (mode !== "pro") { newAgentMode = mode; writeStorage("neutrino.agent-mode", mode); }
659
+ } catch (error) {
660
+ if (state.selection !== selected) return;
661
+ agentModeError = tr("Could not change mode: {error}", { error: error.message });
662
+ throw error;
663
+ } finally { agentModeChanging = false; renderAgentMode(); }
664
+ }
665
+
666
+ async function switchWorkspaceMode(mode) {
667
+ const cwd = selectionCwd() || state.workspace?.path;
668
+ if (!cwd) throw new Error(tr("Select a working directory first."));
669
+ const targetPro = mode === "pro";
670
+ const response = await fetch(targetPro ? "/pro/api/conversations" : "/api/conversations", {
671
+ method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" },
672
+ body: JSON.stringify({ cwd, ...(targetPro ? {} : { agent_mode: mode }) }),
673
+ });
674
+ const body = await response.json();
675
+ if (response.status === 401) showGate();
676
+ if (!response.ok) throw new Error(apiError(body, `HTTP ${response.status}`));
677
+ workspaceNavigation = true;
678
+ if (el.modePicker) renderAgentMode();
679
+ try { sessionStorage.setItem(`neutrino.workspace-draft.${body.key}`, el.input.value); } catch { /* Navigation still works without storage. */ }
680
+ const target = `${targetPro ? "/pro" : "/"}?live=${encodeURIComponent(body.key)}`;
681
+ if (targetPro) startProTransition(target);
682
+ else { try { location.assign(target); } catch { location.href = target; } }
683
+ }
684
+
685
+ /** A transferred chat remains reloadable until the user chooses another view. */
686
+ function clearLinkedConversation(keepKey) {
687
+ const params = new URLSearchParams(location.search);
688
+ if (!params.has("live") || params.get("live") === keepKey) return;
689
+ params.delete("live");
690
+ const query = params.toString();
691
+ try { history.replaceState(history.state, "", location.pathname + (query ? `?${query}` : "") + location.hash); } catch { /* History is optional. */ }
692
+ }
693
+
694
+ async function openLinkedConversation() {
695
+ const key = new URLSearchParams(location.search).get("live");
696
+ if (!key || state.selection) return false;
697
+ try {
698
+ const response = await apiFetch(`/api/live/${encodeURIComponent(key)}`, { cache: "no-store" });
699
+ const body = await response.json();
700
+ if (!response.ok) throw new Error(apiError(body, `HTTP ${response.status}`));
701
+ if (state.selection) return false;
702
+ setWorkspace({ path: body.cwd });
703
+ await selectLive(key);
704
+ state.snapshot = body;
705
+ renderLive();
706
+ setConversationView(true);
707
+ try {
708
+ const storageKey = `neutrino.workspace-draft.${key}`;
709
+ el.input.value = sessionStorage.getItem(storageKey) || "";
710
+ sessionStorage.removeItem(storageKey);
711
+ } catch { /* Storage is optional. */ }
712
+ autosize(); el.input.focus();
713
+ return true;
714
+ } catch (error) { el.sub.textContent = error.message; return false; }
715
+ }
716
+
717
+ function initializeAgentMode() {
718
+ if (!el.modePicker?.querySelectorAll) return;
719
+ for (const button of el.modePicker.querySelectorAll("[data-agent-mode]")) {
720
+ button.addEventListener("click", () => {
721
+ agentModePending = chooseAgentMode(button.dataset.agentMode);
722
+ // Keep the rejection available to sendMessage while avoiding an unhandled promise.
723
+ void agentModePending.catch(() => {});
724
+ });
725
+ }
726
+ el.modePicker.addEventListener("keydown", event => {
727
+ if (event.key === "Escape") { el.modePicker.open = false; el.modePicker.querySelector("summary")?.focus(); event.stopPropagation(); }
728
+ if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
729
+ event.preventDefault(); el.modePicker.open = true;
730
+ const buttons = [...el.modePicker.querySelectorAll("[data-agent-mode]")];
731
+ let index = buttons.indexOf(document.activeElement);
732
+ index = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : (index + (event.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length;
733
+ buttons[index]?.focus();
734
+ });
735
+ document.addEventListener("pointerdown", event => {
736
+ if (el.modePicker.open && !el.modePicker.contains(event.target)) el.modePicker.open = false;
737
+ });
738
+ if (el.modePicker) renderAgentMode();
739
+ }
740
+
596
741
  let proUI = null;
597
742
  const preferences = createSettingsUI({
598
743
  apiFetch,
@@ -909,20 +1054,42 @@ function renderGroup(group, options = {}) {
909
1054
  wrap.className = "group";
910
1055
 
911
1056
  const collapsed = !options.forceOpen && state.collapsed.has(group.key);
1057
+ // The row answers "start something here", not "how much is already here": a
1058
+ // plus beside the folder opens a fresh conversation in it, the way Codex puts
1059
+ // a project's own new-chat control next to the project. Two actions cannot
1060
+ // share one button, so the row holds the folder and the plus side by side.
1061
+ const head = document.createElement("div");
1062
+ head.className = "group-head";
1063
+
912
1064
  const header = document.createElement("button");
913
1065
  header.className = "group-header";
1066
+ header.type = "button";
1067
+ header.setAttribute("aria-expanded", String(!collapsed));
914
1068
  header.innerHTML =
915
1069
  `<span class="chevron${collapsed ? " collapsed" : ""}">` +
916
1070
  '<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>' +
917
1071
  "</span>" +
918
- `<span class="path" title="${escapeHtml(group.cwd)}">${escapeHtml(shortenPath(group.cwd))}</span>` +
919
- `<span class="count">${group.sessions.length}</span>`;
1072
+ `<span class="path" title="${escapeHtml(group.cwd)}">${escapeHtml(shortenPath(group.cwd))}</span>`;
920
1073
  header.addEventListener("click", () => {
921
1074
  if (state.collapsed.has(group.key)) state.collapsed.delete(group.key);
922
1075
  else state.collapsed.add(group.key);
923
1076
  renderSidebar();
924
1077
  });
925
- wrap.appendChild(header);
1078
+ head.appendChild(header);
1079
+
1080
+ if (group.cwd) {
1081
+ const start = document.createElement("button");
1082
+ start.className = "icon-btn group-new";
1083
+ start.type = "button";
1084
+ start.title = tr("New conversation in this folder");
1085
+ start.setAttribute("aria-label", tr("New conversation in this folder"));
1086
+ start.innerHTML =
1087
+ '<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 5v14M5 12h14"/></svg>';
1088
+ start.addEventListener("click", () => startConversationIn(group.cwd));
1089
+ head.appendChild(start);
1090
+ }
1091
+
1092
+ wrap.appendChild(head);
926
1093
 
927
1094
  if (collapsed) return wrap;
928
1095
 
@@ -956,6 +1123,19 @@ function renderGroup(group, options = {}) {
956
1123
  return wrap;
957
1124
  }
958
1125
 
1126
+ /**
1127
+ * The plus on a folder header. It opens that folder's landing view, where the
1128
+ * next message becomes a new conversation in that directory, so starting one
1129
+ * costs a click instead of a dialog.
1130
+ */
1131
+ function startConversationIn(cwd) {
1132
+ const path = String(cwd ?? "").trim();
1133
+ if (!path) return;
1134
+ renderWorkspaceLanding(path);
1135
+ setConversationView(true);
1136
+ el.input?.focus?.();
1137
+ }
1138
+
959
1139
  function isSelected(session) {
960
1140
  const selection = state.selection;
961
1141
  if (!selection) return false;
@@ -1243,6 +1423,7 @@ function resetMessagePane(text) {
1243
1423
  }
1244
1424
 
1245
1425
  async function selectLive(key) {
1426
+ if (el.modePicker) { clearAgentModeFeedback(); clearLinkedConversation(key); }
1246
1427
  preferences.clear();
1247
1428
  closeStream();
1248
1429
  state.selection = { kind: "live", key };
@@ -1319,6 +1500,7 @@ function closeStream() {
1319
1500
  }
1320
1501
 
1321
1502
  async function selectFile(id) {
1503
+ if (el.modePicker) { clearAgentModeFeedback(); clearLinkedConversation(); }
1322
1504
  preferences.clear();
1323
1505
  closeStream();
1324
1506
  state.selection = { kind: "file", id };
@@ -1364,6 +1546,7 @@ function renderFile(data) {
1364
1546
  renderFileControls(data);
1365
1547
  maybeOpenPlan(data.plan);
1366
1548
  renderPlanCard(data);
1549
+ if (el.modePicker) renderAgentMode();
1367
1550
  }
1368
1551
 
1369
1552
  /**
@@ -1410,8 +1593,10 @@ function renderFileControls(data) {
1410
1593
  }
1411
1594
 
1412
1595
  /** Attach to a resumed conversation, creating it server-side when needed. */
1413
- async function continueSession(id, selection = preferences.selectionForFile(id)) {
1414
- const payload = PRO_MODE ? { pro_preset: currentProPreset() } : selection ? { selection } : undefined;
1596
+ async function continueSession(id, selection = preferences.selectionForFile(id), agentMode) {
1597
+ const payload = PRO_MODE ? { pro_preset: currentProPreset() } : {
1598
+ ...(selection ? { selection } : {}), ...(agentMode ? { agent_mode: agentMode } : {}),
1599
+ };
1415
1600
  const response = await apiFetch(`/api/session/${encodeURIComponent(id)}/continue`, {
1416
1601
  method: "POST",
1417
1602
  headers: payload ? { "Content-Type": "application/json" } : undefined,
@@ -1425,6 +1610,7 @@ async function continueSession(id, selection = preferences.selectionForFile(id))
1425
1610
 
1426
1611
  function setComposerMode(mode) {
1427
1612
  state.composerMode = mode;
1613
+ if (el.modePicker) renderAgentMode();
1428
1614
  el.agentActions.classList.add("hidden");
1429
1615
  el.composer.classList.remove("hidden");
1430
1616
  el.input.placeholder = mode === "resume"
@@ -1626,6 +1812,7 @@ function renderLive() {
1626
1812
  });
1627
1813
  maybeOpenPlan(snapshot.plan);
1628
1814
  renderPlanCard(snapshot);
1815
+ if (el.modePicker) renderAgentMode();
1629
1816
  }
1630
1817
 
1631
1818
  async function changeApprovalMode(key, mode) {
@@ -1687,7 +1874,7 @@ function renderControls(snapshot) {
1687
1874
  // review callback is still winding down.
1688
1875
  const reviewing = snapshot.approval ? null : snapshot.reviewing;
1689
1876
  const subagent = snapshot.subagent;
1690
- badge.textContent = snapshot.paused
1877
+ badge.textContent = snapshot.task?.state === "cancelling" ? tr("Stopping tasks…") : snapshot.paused
1691
1878
  ? tr("paused")
1692
1879
  : snapshot.busy
1693
1880
  ? snapshot.compressing
@@ -1702,10 +1889,10 @@ function renderControls(snapshot) {
1702
1889
  : tr("idle");
1703
1890
  el.controls.appendChild(badge);
1704
1891
 
1705
- if (snapshot.saveError) {
1892
+ if (snapshot.saveError || snapshot.task?.persistenceError) {
1706
1893
  const warning = document.createElement("span");
1707
1894
  warning.className = "badge paused";
1708
- warning.textContent = tr("History save failed: {error}", { error: snapshot.saveError });
1895
+ warning.textContent = tr("History save failed: {error}", { error: snapshot.saveError || snapshot.task?.persistenceError });
1709
1896
  el.controls.appendChild(warning);
1710
1897
  }
1711
1898
 
@@ -1842,6 +2029,12 @@ function renderMessage(message, index) {
1842
2029
  `<span class="tool-args">${escapeHtml(truncate(safeJson(message.arguments), 120))}</span>`;
1843
2030
  details.appendChild(summary);
1844
2031
 
2032
+ if (message.name === "execute_ts" && typeof message.arguments?.code === "string") {
2033
+ const program = document.createElement("pre");
2034
+ program.className = "tool-result program-source";
2035
+ program.textContent = message.arguments.code;
2036
+ details.appendChild(program);
2037
+ }
1845
2038
  const result = document.createElement("pre");
1846
2039
  result.className = "tool-result";
1847
2040
  result.textContent = message.result || "(no output)";
@@ -1981,6 +2174,7 @@ function activateComposer() {
1981
2174
  }
1982
2175
 
1983
2176
  async function sendMessage() {
2177
+ try { await agentModePending; } catch (error) { el.sub.textContent = error.message; return; }
1984
2178
  const selection = state.selection;
1985
2179
  if (!selection) return;
1986
2180
  const text = el.input.value;
@@ -2119,6 +2313,7 @@ function showHome({ menu = true } = {}) {
2119
2313
 
2120
2314
  /** Show a folder as an empty workspace ready for its first message. */
2121
2315
  function renderWorkspaceLanding(path) {
2316
+ if (el.modePicker) { clearAgentModeFeedback(); clearLinkedConversation(); }
2122
2317
  const sameLanding = state.selection?.kind === "new" && state.selection.cwd === path;
2123
2318
  state.selection = { kind: "new", cwd: path };
2124
2319
  state.snapshot = null;
@@ -2394,11 +2589,14 @@ async function submitNewChat() {
2394
2589
  }
2395
2590
  }
2396
2591
 
2397
- /** Create a browser-owned conversation in `cwd` and reveal its folder. */
2592
+ /** Create a browser-owned conversation in `cwd` and show all sidebar directories. */
2398
2593
  async function createConversation(cwd, selection, approvalMode, proPreset = currentProPreset()) {
2399
2594
  const payload = { cwd };
2400
2595
  if (PRO_MODE) payload.pro_preset = proPreset;
2401
- else if (selection && selection.provider && selection.model) payload.selection = selection;
2596
+ else {
2597
+ payload.agent_mode = currentAgentMode();
2598
+ if (selection && selection.provider && selection.model) payload.selection = selection;
2599
+ }
2402
2600
  if (approvalMode === "request" || approvalMode === "auto") payload.approval_mode = approvalMode;
2403
2601
  const response = await apiFetch("/api/conversations", {
2404
2602
  method: "POST",
@@ -2407,7 +2605,7 @@ async function createConversation(cwd, selection, approvalMode, proPreset = curr
2407
2605
  });
2408
2606
  const data = await response.json().catch(() => ({}));
2409
2607
  if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
2410
- setWorkspace({ path: data.cwd || cwd });
2608
+ setWorkspace(null);
2411
2609
  await loadOverview({ silent: true });
2412
2610
  return data;
2413
2611
  }
@@ -2588,6 +2786,9 @@ function planStatusLabel(status) {
2588
2786
  if (status === "done") return tr("Done");
2589
2787
  if (status === "active") return tr("Active");
2590
2788
  if (status === "failed") return tr("Failed");
2789
+ if (status === "blocked") return tr("Blocked");
2790
+ if (status === "cancelled") return tr("Cancelled");
2791
+ if (status === "skipped") return tr("Skipped");
2591
2792
  return tr("Pending");
2592
2793
  }
2593
2794
 
@@ -2632,7 +2833,7 @@ function renderPlanCard(source) {
2632
2833
  steps.className = "plan-steps";
2633
2834
  let done = 0;
2634
2835
  for (const step of plan.steps) {
2635
- const status = ["done", "active", "failed", "pending"].includes(step.status) ? step.status : "pending";
2836
+ const status = ["done", "active", "failed", "pending", "blocked", "cancelled", "skipped"].includes(step.status) ? step.status : "pending";
2636
2837
  if (status === "done") done += 1;
2637
2838
  const row = document.createElement("div");
2638
2839
  row.className = `plan-step ${status}`;
@@ -2990,7 +3191,7 @@ function proDelay(callback, ms) {
2990
3191
  if (typeof setTimeout === "function") setTimeout(callback, ms);
2991
3192
  }
2992
3193
 
2993
- function startProTransition() {
3194
+ function startProTransition(target = "/pro") {
2994
3195
  if (PRO_MODE || proTransitionStarted) return;
2995
3196
  proTransitionStarted = true;
2996
3197
  const started = Date.now();
@@ -3006,7 +3207,7 @@ function startProTransition() {
3006
3207
  curtain.classList.add("pro-curtain-visible");
3007
3208
 
3008
3209
  proDelay(() => {
3009
- try { location.assign("/pro"); } catch { location.href = "/pro"; }
3210
+ try { location.assign(typeof target === "string" ? target : "/pro"); } catch { location.href = typeof target === "string" ? target : "/pro"; }
3010
3211
  }, PRO_TRANSITION_COVER_MS);
3011
3212
  }
3012
3213
 
@@ -3027,6 +3228,8 @@ function removeProCurtains() {
3027
3228
  */
3028
3229
  function resetProTransition({ clearRecord = true } = {}) {
3029
3230
  removeProCurtains();
3231
+ if (typeof workspaceNavigation !== "undefined") workspaceNavigation = false;
3232
+ if (typeof el !== "undefined" && el.modePicker) renderAgentMode();
3030
3233
  document.documentElement?.classList?.remove?.("pro-transitioning");
3031
3234
  proTransitionStarted = false;
3032
3235
  if (!clearRecord) return;
@@ -3133,7 +3336,7 @@ function initializeProWorkspace() {
3133
3336
  // the record and the pre-paint cover behind. Neither may ever affect the
3134
3337
  // regular console, so clear them before wiring the badge.
3135
3338
  resetProTransition();
3136
- el.brandLogo?.addEventListener("click", startProTransition);
3339
+ el.brandLogo?.addEventListener("click", () => startProTransition());
3137
3340
  initializeProTip();
3138
3341
  }
3139
3342
 
@@ -3250,6 +3453,7 @@ forgetStaleConversationEntry();
3250
3453
 
3251
3454
  initializeLanguage();
3252
3455
  initializeProWorkspace();
3456
+ initializeAgentMode();
3253
3457
  preferences.load()
3254
3458
  .then(async (config) => {
3255
3459
  const language = config?.settings?.language;
@@ -3264,7 +3468,8 @@ preferences.load()
3264
3468
  maybePromptBackground(config);
3265
3469
  })
3266
3470
  .catch(error => console.error(error));
3267
- loadSeen().then(() => loadOverview()).then(() => {
3471
+ loadSeen().then(() => loadOverview()).then(async () => {
3472
+ if (await openLinkedConversation()) return;
3268
3473
  // Reopen the folder the user had open last time, without stealing a click
3269
3474
  // that landed on a saved conversation while the overview was loading.
3270
3475
  if (state.workspace && !state.selection) renderWorkspaceLanding(state.workspace.path);
package/web/i18n.js CHANGED
@@ -79,6 +79,21 @@ export function locale() {
79
79
  }
80
80
 
81
81
  const ZH = {
82
+ "Agent mode": "Agent 模式",
83
+ "Standard mode": "标准模式",
84
+ "PTC mode": "PTC模式",
85
+ "Fast mode": "快速模式",
86
+ "Professional mode": "专业模式",
87
+ "Full-featured Agent for every scenario": "功能完整的Agent,适用于所有场景",
88
+ "All standard capabilities, with code-based planning to save tokens": "拥有标准模式所有功能,通过代码实现规划,节省Token",
89
+ "Less thinking and fewer calls for quick answers": "减少思考和调用,迅速回答问题",
90
+ "Assign tasks to the best-suited models so multiple models complement each other": "适合需要多个模型取长补短的任务,将任务分给最适合他的模型",
91
+ "Applies to the next turn": "从下一轮开始生效",
92
+ "Changing mode…": "正在切换模式…",
93
+ "Could not change mode: {error}": "无法切换模式:{error}",
94
+ "Select a working directory first.": "请先选择工作目录。",
95
+ "Program": "程序",
96
+
82
97
  "Pro preset": "Pro 预设",
83
98
  "Preset": "预设",
84
99
  "Neutrino Pro": "Neutrino Pro",
@@ -218,6 +233,7 @@ const ZH = {
218
233
  "Choose a conversation from the sidebar to view it.": "从侧边栏选择一个对话查看。",
219
234
  "No conversations yet.": "暂无对话。",
220
235
  "No conversations in this folder yet.": "此文件夹中暂无对话。",
236
+ "New conversation in this folder": "在此文件夹中新建对话",
221
237
  "Type a message below to start a conversation in this folder.": "在下方输入消息,即可在此文件夹中开始对话。",
222
238
  "Send a message to get started.": "发送一条消息开始对话。",
223
239
  "This conversation is empty.": "此对话为空。",
@@ -268,6 +284,10 @@ const ZH = {
268
284
 
269
285
  // -- live status --------------------------------------------------------
270
286
  "paused": "已暂停",
287
+ "Stopping tasks…": "正在停止任务…",
288
+ "Blocked": "受阻",
289
+ "Cancelled": "已取消",
290
+ "Skipped": "已跳过",
271
291
  "working": "处理中",
272
292
  "idle": "空闲",
273
293
  "compressing": "正在压缩上下文",
package/web/index.html CHANGED
@@ -129,6 +129,16 @@
129
129
  <div class="composer-inner">
130
130
  <div class="composer-card">
131
131
  <textarea id="input" rows="1" placeholder="Message Neutrino…" aria-label="Message" data-i18n-placeholder="Message Neutrino…"></textarea>
132
+ <details id="agent-mode-picker" class="agent-mode-picker">
133
+ <summary aria-label="Agent mode" data-i18n-aria-label="Agent mode"><strong id="agent-mode-label" data-i18n="Standard mode">Standard mode</strong><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="m7 10 5 5 5-5" /></svg></summary>
134
+ <div class="agent-mode-menu" role="menu" aria-label="Agent mode" data-i18n-aria-label="Agent mode">
135
+ <button type="button" role="menuitemradio" aria-checked="true" data-agent-mode="standard"><span><strong data-i18n="Standard mode">Standard mode</strong><small data-i18n="Full-featured Agent for every scenario">Full-featured Agent for every scenario</small></span><svg class="agent-mode-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m5 12 4 4L19 6" /></svg></button>
136
+ <button type="button" role="menuitemradio" aria-checked="false" data-agent-mode="ptc"><span><strong data-i18n="PTC mode">PTC mode</strong><small data-i18n="All standard capabilities, with code-based planning to save tokens">All standard capabilities, with code-based planning to save tokens</small></span><svg class="agent-mode-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m5 12 4 4L19 6" /></svg></button>
137
+ <button type="button" role="menuitemradio" aria-checked="false" data-agent-mode="fast"><span><strong data-i18n="Fast mode">Fast mode</strong><small data-i18n="Less thinking and fewer calls for quick answers">Less thinking and fewer calls for quick answers</small></span><svg class="agent-mode-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m5 12 4 4L19 6" /></svg></button>
138
+ <button type="button" role="menuitemradio" aria-checked="false" data-agent-mode="pro"><span><strong data-i18n="Professional mode">Professional mode</strong><small data-i18n="Assign tasks to the best-suited models so multiple models complement each other">Assign tasks to the best-suited models so multiple models complement each other</small></span><svg class="agent-mode-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m5 12 4 4L19 6" /></svg></button>
139
+ </div>
140
+ <p id="agent-mode-feedback" class="agent-mode-feedback" role="status" aria-live="polite"></p>
141
+ </details>
132
142
  <details id="model-picker" class="model-picker pro-preset-control hidden">
133
143
  <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
144
  <div class="pro-preset-card model-picker-card">
package/web/style.css CHANGED
@@ -413,11 +413,20 @@ svg {
413
413
  margin-bottom: 4px;
414
414
  }
415
415
 
416
+ /* A folder row is two controls: the name folds the list, the plus beside it
417
+ opens a new conversation in that folder. */
418
+ .group-head {
419
+ display: flex;
420
+ align-items: center;
421
+ gap: 2px;
422
+ }
423
+
416
424
  .group-header {
417
425
  display: flex;
418
426
  align-items: center;
419
427
  gap: 7px;
420
- width: 100%;
428
+ flex: 1;
429
+ min-width: 0;
421
430
  height: 30px;
422
431
  padding: 0 8px;
423
432
  background: transparent;
@@ -461,16 +470,22 @@ svg {
461
470
  font-size: 12px;
462
471
  }
463
472
 
464
- .group-header .count {
465
- flex: none;
466
- min-width: 20px;
467
- padding: 0 6px;
468
- border-radius: 999px;
469
- background: var(--surface-1);
473
+ /* Small enough to sit on the folder row without competing with the name, and
474
+ dimmed so a long list of folders never reads as a wall of plus signs. */
475
+ .group-new {
476
+ width: 22px;
477
+ height: 22px;
470
478
  color: var(--text-caption);
471
- font-size: 10.5px;
472
- line-height: 18px;
473
- text-align: center;
479
+ }
480
+
481
+ .group-new svg {
482
+ width: 13px;
483
+ height: 13px;
484
+ }
485
+
486
+ .group-new:hover {
487
+ background: var(--surface-1);
488
+ color: var(--text-secondary);
474
489
  }
475
490
 
476
491
  .group-items {
@@ -1474,6 +1489,26 @@ svg {
1474
1489
  outline: none;
1475
1490
  }
1476
1491
 
1492
+ .agent-mode-picker { position: relative; align-self: center; margin-right: auto; min-width: 0; }
1493
+ .agent-mode-picker > summary { display: flex; align-items: center; gap: 7px; padding: 7px 9px; border-radius: var(--radius-sm); cursor: pointer; list-style: none; color: var(--text-secondary); font-size: 12px; }
1494
+ .agent-mode-picker > summary::-webkit-details-marker { display: none; }
1495
+ .agent-mode-picker > summary strong { font-weight: 600; }
1496
+ .agent-mode-picker > summary svg { width: 14px; height: 14px; }
1497
+ .agent-mode-picker > summary:hover, .agent-mode-picker[open] > summary { background: var(--surface-3); }
1498
+ .agent-mode-picker.is-pending > summary { color: var(--accent); }
1499
+ .agent-mode-menu { position: absolute; z-index: 35; left: 0; bottom: calc(100% + 10px); width: min(370px, calc(100vw - 48px)); max-height: min(520px, 60dvh); overflow-y: auto; padding: 6px; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface-1); box-shadow: var(--shadow-strong); }
1500
+ .agent-mode-menu > button { display: flex; align-items: center; gap: 12px; width: 100%; padding: 11px 12px; text-align: left; color: var(--text); background: transparent; border: 0; border-radius: var(--radius-sm); cursor: pointer; font-family: var(--font); }
1501
+ .agent-mode-menu > button:hover, .agent-mode-menu > button:focus-visible, .agent-mode-menu > button[aria-checked="true"] { background: var(--surface-3); }
1502
+ .agent-mode-menu > button > span { flex: 1; min-width: 0; }
1503
+ .agent-mode-menu strong { display: block; margin-bottom: 4px; font-size: 13px; font-weight: 700; }
1504
+ .agent-mode-menu small { display: block; color: var(--text-tertiary); font-size: 12px; font-weight: 400; line-height: 1.55; }
1505
+ .agent-mode-check { flex: none; width: 16px; height: 16px; color: var(--accent); visibility: hidden; }
1506
+ .agent-mode-menu [aria-checked="true"] .agent-mode-check { visibility: visible; }
1507
+ .agent-mode-menu > button:disabled { opacity: .55; cursor: wait; }
1508
+ .agent-mode-feedback { position: absolute; bottom: 100%; left: 0; width: min(370px, calc(100vw - 48px)); margin: 0 0 5px; font-size: 11px; color: var(--text-secondary); }
1509
+ .agent-mode-feedback:empty, .agent-mode-picker[open] .agent-mode-feedback { display: none; }
1510
+ .agent-mode-feedback.error { color: var(--error, #ef8181); }
1511
+ .program-source { border-bottom: 1px solid var(--border-soft); }
1477
1512
  .pro-preset-control { position: relative; margin-left: auto; align-self: center; }
1478
1513
  .pro-preset-control > summary {
1479
1514
  display: flex; align-items: center; gap: 10px; padding: 7px 10px;
@@ -3308,6 +3343,13 @@ html.pro-transitioning body::before {
3308
3343
  min-height: 40px;
3309
3344
  }
3310
3345
 
3346
+ /* The plus takes the same 40px target as the other icon buttons here, so
3347
+ its icon grows with it instead of staying at the desktop size. */
3348
+ .group-new svg {
3349
+ width: 16px;
3350
+ height: 16px;
3351
+ }
3352
+
3311
3353
  .main-header {
3312
3354
  display: grid;
3313
3355
  grid-template-columns: 40px minmax(0, 1fr);
@@ -3533,3 +3575,7 @@ html.pro-transitioning body::before {
3533
3575
  min-height: 64px;
3534
3576
  }
3535
3577
  }
3578
+
3579
+ .plan-step.blocked .plan-node,
3580
+ .plan-step.blocked .plan-step-title strong { color: var(--orange, #dca548); }
3581
+ .plan-step.cancelled, .plan-step.skipped { opacity: .65; }