@xingjisoft/neutrino 0.5.2 → 0.6.2

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.2",
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-task-budget.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/README.md CHANGED
@@ -6,8 +6,9 @@ A small, dependency-free single page app that is served by the Neutrino CLI at
6
6
  - browse every saved conversation grouped by working directory (like Codex),
7
7
  with only the five latest transcripts per path shown and the rest collapsed;
8
8
  - watch a running terminal conversation stream in real time: the work behind every
9
- finished turn folds into one line that opens on a click, while the reply itself
10
- stays on screen (the turn being answered is open, and a pause is not a fold);
9
+ finished turn folds into one line that opens on a click, while the full user
10
+ input and final reply stay on screen; earlier history also folds only AI output
11
+ (the turn being answered is open, and a pause is not a fold);
11
12
  - read replies as rendered markdown: headings, nested/task lists, tables, block
12
13
  quotes, code fences, emphasis, safe links and images; raw HTML is escaped;
13
14
  - start a new conversation in a working directory you choose, including a folder
package/web/app.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createSettingsUI } from "./settings.js";
2
2
  import { createProUI } from "./pro.js";
3
3
  import { renderMarkdown as renderMarkdownHtml } from "./markdown.js";
4
- import { turnAnswerIndex, turnFolds, turnPreview, turnRanges } from "./turns.js";
4
+ import { turnAnswerIndex, turnFolds, turnRanges } from "./turns.js";
5
5
  import { applyTranslations, getLanguage as currentLanguage, locale as currentLocale, setLanguage as setI18nLanguage, t as translate } from "./i18n.js";
6
6
 
7
7
  // Neutrino web UI — a dependency-free single page app.
@@ -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
 
@@ -1766,30 +1953,48 @@ function renderMessageList(messages, streaming, options) {
1766
1953
  context.end <= original.length && typeof context.summary === "string" && context.summary.trim()
1767
1954
  ? context.end : 0;
1768
1955
  if (end) {
1769
- const details = document.createElement("details");
1770
- details.className = "conversation-history";
1771
- const summary = document.createElement("summary");
1772
- summary.textContent = typeof tr === "function"
1773
- ? tr("Earlier conversation · {n} messages", { n: end })
1774
- : `Earlier conversation · ${end} messages`;
1775
- details.appendChild(summary);
1776
- const body = document.createElement("div");
1777
- body.className = "history-messages";
1778
- details.appendChild(body);
1779
- const populate = () => {
1780
- if (body.childNodes.length) return;
1781
- original.slice(0, end).forEach((message, index) => body.appendChild(renderMessage(message, index)));
1782
- };
1783
- details.open = state.openContext === end;
1784
- if (details.open) populate();
1785
- details.addEventListener("toggle", () => {
1786
- // Detached disclosures can emit a delayed toggle during a stream repaint.
1787
- if (!details.isConnected) return;
1788
- state.openContext = details.open ? end : null;
1789
- if (details.open) populate();
1790
- else body.replaceChildren();
1791
- });
1792
- el.messages.appendChild(details);
1956
+ // Keep user input in place and fold only the AI output between prompts.
1957
+ // The disclosures share one expansion state for this compressed prefix.
1958
+ const sections = [];
1959
+ for (let index = 0; index < end;) {
1960
+ if (original[index].role === "user") {
1961
+ el.messages.appendChild(renderMessage(original[index], index));
1962
+ index++;
1963
+ continue;
1964
+ }
1965
+ const start = index;
1966
+ while (index < end && original[index].role !== "user") index++;
1967
+ const archived = original.slice(start, index);
1968
+ const details = document.createElement("details");
1969
+ details.className = "conversation-history";
1970
+ const summary = document.createElement("summary");
1971
+ summary.textContent = typeof tr === "function"
1972
+ ? tr("Earlier conversation · {n} messages", { n: archived.length })
1973
+ : `Earlier conversation · ${archived.length} messages`;
1974
+ details.appendChild(summary);
1975
+ const body = document.createElement("div");
1976
+ body.className = "history-messages";
1977
+ details.appendChild(body);
1978
+ const populate = () => {
1979
+ if (body.childNodes.length) return;
1980
+ archived.forEach((message, offset) => body.appendChild(renderMessage(message, start + offset)));
1981
+ };
1982
+ const sync = (open) => {
1983
+ if (details.open !== open) details.open = open;
1984
+ if (open) populate();
1985
+ else body.replaceChildren();
1986
+ };
1987
+ sections.push(sync);
1988
+ sync(state.openContext === end);
1989
+ details.addEventListener("toggle", () => {
1990
+ // Detached disclosures can emit a delayed toggle during a stream repaint.
1991
+ if (!details.isConnected) return;
1992
+ const open = details.open;
1993
+ state.openContext = open ? end : null;
1994
+ for (const sync of sections) sync(open);
1995
+ });
1996
+ el.messages.appendChild(details);
1997
+ }
1793
1998
  }
1794
1999
  // Render the tail one turn at a time: a finished turn folds its work behind a
1795
2000
  // summary line and keeps its reply; a running one stays fully open. The turn in
@@ -1842,6 +2047,12 @@ function renderMessage(message, index) {
1842
2047
  `<span class="tool-args">${escapeHtml(truncate(safeJson(message.arguments), 120))}</span>`;
1843
2048
  details.appendChild(summary);
1844
2049
 
2050
+ if (message.name === "execute_ts" && typeof message.arguments?.code === "string") {
2051
+ const program = document.createElement("pre");
2052
+ program.className = "tool-result program-source";
2053
+ program.textContent = message.arguments.code;
2054
+ details.appendChild(program);
2055
+ }
1845
2056
  const result = document.createElement("pre");
1846
2057
  result.className = "tool-result";
1847
2058
  result.textContent = message.result || "(no output)";
@@ -1865,8 +2076,8 @@ function renderMessage(message, index) {
1865
2076
  }
1866
2077
 
1867
2078
  /**
1868
- * A finished turn: its work folded behind one control line, its reply always on
1869
- * screen below it. The folded messages are rendered on first open and dropped
2079
+ * A finished turn: its complete user input above the fold, its reply below it.
2080
+ * The folded AI messages are rendered on first open and dropped
1870
2081
  * again on close, so a long transcript costs nothing until the reader asks for
1871
2082
  * it. Nothing is lost either way: this is presentation only, and the saved
1872
2083
  * transcript always holds every message.
@@ -1878,25 +2089,27 @@ function renderTurnFold(start, messages) {
1878
2089
  ? tr(text, values)
1879
2090
  : String(text).replace(/\{(\w+)\}/g, (match, name) =>
1880
2091
  values && Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match));
1881
- // The reply is never folded away, so a turn renders as a group: the fold (the
1882
- // prompt line plus the work it hides) and, right below it, the answer itself.
2092
+ // User input and the reply stay outside the disclosure, in transcript order.
1883
2093
  const reply = turnAnswerIndex(messages);
1884
2094
  const group = document.createElement("div");
1885
2095
  group.className = "turn-group";
2096
+ messages.forEach((message, offset) => {
2097
+ if (message.role === "user") group.appendChild(renderMessage(message, start + offset));
2098
+ });
1886
2099
 
1887
2100
  const details = document.createElement("details");
1888
2101
  details.className = "turn";
1889
2102
  details.open = state.openTurns.has(start);
1890
2103
 
1891
- const hidden = reply < 0 ? messages.length : messages.length - 1;
2104
+ const hidden = messages.filter((message, offset) => message.role !== "user" && offset !== reply).length;
1892
2105
  const summary = document.createElement("summary");
1893
2106
  const chevron = document.createElement("span");
1894
2107
  chevron.className = "turn-chevron";
1895
2108
  chevron.innerHTML =
1896
2109
  '<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>';
1897
- const you = document.createElement("span");
1898
- you.className = "turn-you";
1899
- you.textContent = tx("You: {text}", { text: truncate(turnPreview(messages, "user"), 110) });
2110
+ const label = document.createElement("span");
2111
+ label.className = "turn-label";
2112
+ label.textContent = "Neutrino";
1900
2113
  const meta = document.createElement("span");
1901
2114
  meta.className = "turn-meta";
1902
2115
  meta.textContent = tx("{n} of {m} messages folded", { n: hidden, m: messages.length });
@@ -1905,7 +2118,7 @@ function renderTurnFold(start, messages) {
1905
2118
  hint.className = "turn-hint";
1906
2119
  meta.appendChild(hint);
1907
2120
  summary.appendChild(chevron);
1908
- summary.appendChild(you);
2121
+ summary.appendChild(label);
1909
2122
  summary.appendChild(meta);
1910
2123
  details.appendChild(summary);
1911
2124
  group.appendChild(details);
@@ -1921,7 +2134,7 @@ function renderTurnFold(start, messages) {
1921
2134
  const populate = () => {
1922
2135
  if (body.childNodes.length) return;
1923
2136
  messages.forEach((message, offset) => {
1924
- if (offset === reply) return; // the answer is never inside the fold
2137
+ if (message.role === "user" || offset === reply) return;
1925
2138
  body.appendChild(renderMessage(message, start + offset));
1926
2139
  });
1927
2140
  };
@@ -1981,6 +2194,7 @@ function activateComposer() {
1981
2194
  }
1982
2195
 
1983
2196
  async function sendMessage() {
2197
+ try { await agentModePending; } catch (error) { el.sub.textContent = error.message; return; }
1984
2198
  const selection = state.selection;
1985
2199
  if (!selection) return;
1986
2200
  const text = el.input.value;
@@ -2119,6 +2333,7 @@ function showHome({ menu = true } = {}) {
2119
2333
 
2120
2334
  /** Show a folder as an empty workspace ready for its first message. */
2121
2335
  function renderWorkspaceLanding(path) {
2336
+ if (el.modePicker) { clearAgentModeFeedback(); clearLinkedConversation(); }
2122
2337
  const sameLanding = state.selection?.kind === "new" && state.selection.cwd === path;
2123
2338
  state.selection = { kind: "new", cwd: path };
2124
2339
  state.snapshot = null;
@@ -2394,11 +2609,14 @@ async function submitNewChat() {
2394
2609
  }
2395
2610
  }
2396
2611
 
2397
- /** Create a browser-owned conversation in `cwd` and reveal its folder. */
2612
+ /** Create a browser-owned conversation in `cwd` and show all sidebar directories. */
2398
2613
  async function createConversation(cwd, selection, approvalMode, proPreset = currentProPreset()) {
2399
2614
  const payload = { cwd };
2400
2615
  if (PRO_MODE) payload.pro_preset = proPreset;
2401
- else if (selection && selection.provider && selection.model) payload.selection = selection;
2616
+ else {
2617
+ payload.agent_mode = currentAgentMode();
2618
+ if (selection && selection.provider && selection.model) payload.selection = selection;
2619
+ }
2402
2620
  if (approvalMode === "request" || approvalMode === "auto") payload.approval_mode = approvalMode;
2403
2621
  const response = await apiFetch("/api/conversations", {
2404
2622
  method: "POST",
@@ -2407,7 +2625,7 @@ async function createConversation(cwd, selection, approvalMode, proPreset = curr
2407
2625
  });
2408
2626
  const data = await response.json().catch(() => ({}));
2409
2627
  if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
2410
- setWorkspace({ path: data.cwd || cwd });
2628
+ setWorkspace(null);
2411
2629
  await loadOverview({ silent: true });
2412
2630
  return data;
2413
2631
  }
@@ -2588,6 +2806,9 @@ function planStatusLabel(status) {
2588
2806
  if (status === "done") return tr("Done");
2589
2807
  if (status === "active") return tr("Active");
2590
2808
  if (status === "failed") return tr("Failed");
2809
+ if (status === "blocked") return tr("Blocked");
2810
+ if (status === "cancelled") return tr("Cancelled");
2811
+ if (status === "skipped") return tr("Skipped");
2591
2812
  return tr("Pending");
2592
2813
  }
2593
2814
 
@@ -2632,7 +2853,7 @@ function renderPlanCard(source) {
2632
2853
  steps.className = "plan-steps";
2633
2854
  let done = 0;
2634
2855
  for (const step of plan.steps) {
2635
- const status = ["done", "active", "failed", "pending"].includes(step.status) ? step.status : "pending";
2856
+ const status = ["done", "active", "failed", "pending", "blocked", "cancelled", "skipped"].includes(step.status) ? step.status : "pending";
2636
2857
  if (status === "done") done += 1;
2637
2858
  const row = document.createElement("div");
2638
2859
  row.className = `plan-step ${status}`;
@@ -2990,7 +3211,7 @@ function proDelay(callback, ms) {
2990
3211
  if (typeof setTimeout === "function") setTimeout(callback, ms);
2991
3212
  }
2992
3213
 
2993
- function startProTransition() {
3214
+ function startProTransition(target = "/pro") {
2994
3215
  if (PRO_MODE || proTransitionStarted) return;
2995
3216
  proTransitionStarted = true;
2996
3217
  const started = Date.now();
@@ -3006,7 +3227,7 @@ function startProTransition() {
3006
3227
  curtain.classList.add("pro-curtain-visible");
3007
3228
 
3008
3229
  proDelay(() => {
3009
- try { location.assign("/pro"); } catch { location.href = "/pro"; }
3230
+ try { location.assign(typeof target === "string" ? target : "/pro"); } catch { location.href = typeof target === "string" ? target : "/pro"; }
3010
3231
  }, PRO_TRANSITION_COVER_MS);
3011
3232
  }
3012
3233
 
@@ -3027,6 +3248,8 @@ function removeProCurtains() {
3027
3248
  */
3028
3249
  function resetProTransition({ clearRecord = true } = {}) {
3029
3250
  removeProCurtains();
3251
+ if (typeof workspaceNavigation !== "undefined") workspaceNavigation = false;
3252
+ if (typeof el !== "undefined" && el.modePicker) renderAgentMode();
3030
3253
  document.documentElement?.classList?.remove?.("pro-transitioning");
3031
3254
  proTransitionStarted = false;
3032
3255
  if (!clearRecord) return;
@@ -3133,7 +3356,7 @@ function initializeProWorkspace() {
3133
3356
  // the record and the pre-paint cover behind. Neither may ever affect the
3134
3357
  // regular console, so clear them before wiring the badge.
3135
3358
  resetProTransition();
3136
- el.brandLogo?.addEventListener("click", startProTransition);
3359
+ el.brandLogo?.addEventListener("click", () => startProTransition());
3137
3360
  initializeProTip();
3138
3361
  }
3139
3362
 
@@ -3250,6 +3473,7 @@ forgetStaleConversationEntry();
3250
3473
 
3251
3474
  initializeLanguage();
3252
3475
  initializeProWorkspace();
3476
+ initializeAgentMode();
3253
3477
  preferences.load()
3254
3478
  .then(async (config) => {
3255
3479
  const language = config?.settings?.language;
@@ -3264,7 +3488,8 @@ preferences.load()
3264
3488
  maybePromptBackground(config);
3265
3489
  })
3266
3490
  .catch(error => console.error(error));
3267
- loadSeen().then(() => loadOverview()).then(() => {
3491
+ loadSeen().then(() => loadOverview()).then(async () => {
3492
+ if (await openLinkedConversation()) return;
3268
3493
  // Reopen the folder the user had open last time, without stealing a click
3269
3494
  // that landed on a saved conversation while the overview was loading.
3270
3495
  if (state.workspace && !state.selection) renderWorkspaceLanding(state.workspace.path);