@sean.holung/minicode 0.3.5 → 0.3.6

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.
@@ -1523,10 +1523,135 @@ function resolveGraphNodeIds(nodes, symbolName) {
1523
1523
  return [...nodes.entries()].filter(([id, node]) => matchesGraphNodeQuery(query, node, id)).map(([id]) => id).sort((a, b2) => compareGraphNodeIds(a, b2, nodes));
1524
1524
  }
1525
1525
 
1526
+ // src/shared/graph-search.ts
1527
+ function getGraphNodeFilePath(node) {
1528
+ return node.filePath || node.file || "";
1529
+ }
1530
+ function buildGraphFileIndex(nodes) {
1531
+ const files = /* @__PURE__ */ new Map();
1532
+ for (const [id, node] of nodes) {
1533
+ const filePath = getGraphNodeFilePath(node);
1534
+ if (!filePath) continue;
1535
+ const existing = files.get(filePath);
1536
+ if (existing) {
1537
+ existing.push(id);
1538
+ } else {
1539
+ files.set(filePath, [id]);
1540
+ }
1541
+ }
1542
+ for (const symbolIds of files.values()) {
1543
+ symbolIds.sort((a, b2) => compareGraphNodeIds(a, b2, nodes));
1544
+ }
1545
+ return files;
1546
+ }
1547
+ function compareGraphFilePaths(a, b2, fileIndex) {
1548
+ const countDifference = (fileIndex.get(b2)?.length ?? 0) - (fileIndex.get(a)?.length ?? 0);
1549
+ if (countDifference !== 0) {
1550
+ return countDifference;
1551
+ }
1552
+ return a.localeCompare(b2, void 0, { sensitivity: "base" });
1553
+ }
1554
+ function matchesGraphFileQuery(query, filePath) {
1555
+ const normalizedQuery = query.trim().toLowerCase();
1556
+ if (normalizedQuery.length === 0) {
1557
+ return false;
1558
+ }
1559
+ return filePath.toLowerCase().includes(normalizedQuery);
1560
+ }
1561
+ function buildGraphSearchResults({
1562
+ query,
1563
+ symbolIds,
1564
+ nodes,
1565
+ fileIndex,
1566
+ symbolLimit = 12,
1567
+ fileLimit = 8
1568
+ }) {
1569
+ const normalizedQuery = query.trim().toLowerCase();
1570
+ const showDefaultResults = normalizedQuery.length < 2;
1571
+ const rankedFiles = [...fileIndex.keys()].sort((a, b2) => compareGraphFilePaths(a, b2, fileIndex));
1572
+ const symbolResults = symbolIds.filter((id) => {
1573
+ if (showDefaultResults) {
1574
+ return true;
1575
+ }
1576
+ return matchesGraphNodeQuery(normalizedQuery, nodes.get(id) || {}, id);
1577
+ }).slice(0, symbolLimit).map((id) => {
1578
+ const node = nodes.get(id) || {};
1579
+ return {
1580
+ type: "symbol",
1581
+ id,
1582
+ label: getGraphNodeLabel(node, id),
1583
+ subtitle: getGraphNodeFilePath(node),
1584
+ kind: (node.kind || "symbol").toLowerCase()
1585
+ };
1586
+ });
1587
+ const fileResults = rankedFiles.filter((filePath) => {
1588
+ if (showDefaultResults) {
1589
+ return true;
1590
+ }
1591
+ return matchesGraphFileQuery(normalizedQuery, filePath);
1592
+ }).slice(0, fileLimit).map((filePath) => {
1593
+ const symbolCount = fileIndex.get(filePath)?.length ?? 0;
1594
+ return {
1595
+ type: "file",
1596
+ id: filePath,
1597
+ label: filePath,
1598
+ subtitle: `${symbolCount} symbol${symbolCount === 1 ? "" : "s"}`,
1599
+ kind: "file",
1600
+ symbolCount
1601
+ };
1602
+ });
1603
+ return [...symbolResults, ...fileResults];
1604
+ }
1605
+
1606
+ // src/shared/graph-selection.ts
1607
+ function buildGraphEdgeId(edge) {
1608
+ return `${edge.source}->${edge.target}:${edge.kind}`;
1609
+ }
1610
+ function buildGraphEdgeIndex(edges) {
1611
+ const edgeIndex2 = /* @__PURE__ */ new Map();
1612
+ for (const edge of edges) {
1613
+ const sourceEdges = edgeIndex2.get(edge.source);
1614
+ if (sourceEdges) {
1615
+ sourceEdges.push(edge);
1616
+ } else {
1617
+ edgeIndex2.set(edge.source, [edge]);
1618
+ }
1619
+ const targetEdges = edgeIndex2.get(edge.target);
1620
+ if (targetEdges) {
1621
+ targetEdges.push(edge);
1622
+ } else {
1623
+ edgeIndex2.set(edge.target, [edge]);
1624
+ }
1625
+ }
1626
+ return edgeIndex2;
1627
+ }
1628
+ function buildFileFocusedSelection({
1629
+ filePath,
1630
+ fileIndex,
1631
+ edgeIndex: edgeIndex2
1632
+ }) {
1633
+ const fileSymbolIds = fileIndex.get(filePath) || [];
1634
+ const nodeIds = /* @__PURE__ */ new Set();
1635
+ const edges = /* @__PURE__ */ new Map();
1636
+ for (const symbolId of fileSymbolIds) {
1637
+ nodeIds.add(symbolId);
1638
+ for (const edge of edgeIndex2.get(symbolId) || []) {
1639
+ nodeIds.add(edge.source);
1640
+ nodeIds.add(edge.target);
1641
+ edges.set(buildGraphEdgeId(edge), edge);
1642
+ }
1643
+ }
1644
+ return {
1645
+ nodeIds: [...nodeIds],
1646
+ edges: [...edges.values()]
1647
+ };
1648
+ }
1649
+
1526
1650
  // src/web/graph.ts
1527
1651
  var cy = null;
1528
1652
  var graphNodes = /* @__PURE__ */ new Map();
1529
1653
  var graphEdges = [];
1654
+ var fileToSymbolIds = /* @__PURE__ */ new Map();
1530
1655
  var edgeIndex = /* @__PURE__ */ new Map();
1531
1656
  var pinnedNames = /* @__PURE__ */ new Set();
1532
1657
  var allSymbolNames = [];
@@ -1585,7 +1710,8 @@ async function initGraph() {
1585
1710
  target: e.target || e.to || "",
1586
1711
  kind: (e.kind || e.type || "references").toLowerCase()
1587
1712
  }));
1588
- buildEdgeIndex();
1713
+ edgeIndex = buildGraphEdgeIndex(graphEdges);
1714
+ fileToSymbolIds = buildGraphFileIndex(graphNodes);
1589
1715
  allSymbolNames = Array.from(graphNodes.keys()).sort();
1590
1716
  const pinned = focusData.pinned || [];
1591
1717
  for (const f of pinned) {
@@ -1632,30 +1758,13 @@ function showOnboardingHint(container) {
1632
1758
  if (container.querySelector(".graph-onboarding")) return;
1633
1759
  const hint = document.createElement("div");
1634
1760
  hint.className = "graph-onboarding";
1635
- hint.innerHTML = '<div class="graph-onboarding-icon">&#9670; &#8212; &#9670;</div><div class="graph-onboarding-title">Code dependency graph</div><div class="graph-onboarding-subtitle">Search for a symbol above to start exploring.<br/>Nodes expand on click to reveal connections.</div>';
1761
+ hint.innerHTML = '<div class="graph-onboarding-icon">&#9670; &#8212; &#9670;</div><div class="graph-onboarding-title">Code dependency graph</div><div class="graph-onboarding-subtitle">Search for a symbol or file above to start exploring.<br/>Nodes expand on click to reveal connections.</div>';
1636
1762
  container.appendChild(hint);
1637
1763
  }
1638
1764
  function removeOnboardingHint() {
1639
1765
  const hint = document.querySelector(".graph-onboarding");
1640
1766
  if (hint) hint.remove();
1641
1767
  }
1642
- function buildEdgeIndex() {
1643
- edgeIndex.clear();
1644
- for (const edge of graphEdges) {
1645
- let srcList = edgeIndex.get(edge.source);
1646
- if (!srcList) {
1647
- srcList = [];
1648
- edgeIndex.set(edge.source, srcList);
1649
- }
1650
- srcList.push(edge);
1651
- let tgtList = edgeIndex.get(edge.target);
1652
- if (!tgtList) {
1653
- tgtList = [];
1654
- edgeIndex.set(edge.target, tgtList);
1655
- }
1656
- tgtList.push(edge);
1657
- }
1658
- }
1659
1768
  function addNodeNeighborhood(symbolId, maxDegrees = 1) {
1660
1769
  const visited = /* @__PURE__ */ new Set();
1661
1770
  let frontier = /* @__PURE__ */ new Set([symbolId]);
@@ -1690,6 +1799,30 @@ function renderNodeNeighborhoodAndLayout(symbolId, maxDegrees = 1) {
1690
1799
  runLayout();
1691
1800
  }
1692
1801
  }
1802
+ function focusFileInGraph(filePath) {
1803
+ if (!cy) return;
1804
+ const selection = buildFileFocusedSelection({
1805
+ filePath,
1806
+ fileIndex: fileToSymbolIds,
1807
+ edgeIndex
1808
+ });
1809
+ cy.elements().remove();
1810
+ document.getElementById("symbol-detail")?.classList.add("hidden");
1811
+ if (selection.nodeIds.length === 0) {
1812
+ const cyEl = document.getElementById("cy");
1813
+ if (cyEl) showOnboardingHint(cyEl);
1814
+ refreshAnalysisGraphState();
1815
+ return;
1816
+ }
1817
+ for (const symbolId of selection.nodeIds) {
1818
+ addNodeToGraph(symbolId);
1819
+ }
1820
+ for (const edge of selection.edges) {
1821
+ addEdgeToGraph(edge);
1822
+ }
1823
+ refreshAnalysisGraphState();
1824
+ runLayout();
1825
+ }
1693
1826
  async function focusSymbolInGraph(symbolId, options = {}) {
1694
1827
  await focusSymbolsInGraph([symbolId], options);
1695
1828
  }
@@ -2442,27 +2575,32 @@ function setupToolbar() {
2442
2575
  searchInput.parentNode.style.position = "relative";
2443
2576
  searchInput.parentNode.appendChild(dropdown);
2444
2577
  const rankedSymbols = allSymbolNames.slice().sort((a, b2) => compareGraphNodeIds(a, b2, graphNodes));
2445
- function showDropdownResults(matches) {
2446
- if (matches.length === 0) {
2578
+ function showDropdownResults(results) {
2579
+ if (results.length === 0) {
2447
2580
  dropdown.classList.add("hidden");
2448
2581
  return;
2449
2582
  }
2450
- dropdown.innerHTML = matches.map((name) => {
2451
- const node = graphNodes.get(name);
2452
- const kind = node ? (node.kind || "").toLowerCase() : "";
2453
- const shortName = getGraphNodeLabel(node || {}, name);
2454
- const kindColor = KIND_COLORS[kind] ? KIND_COLORS[kind].border : "#565f89";
2455
- return `<div class="search-result" data-id="${escapeHtml(name)}">
2456
- <span class="search-result-name">${escapeHtml(shortName)}</span>
2457
- <span class="search-result-kind" style="color:${kindColor}">${kind}</span>
2583
+ dropdown.innerHTML = results.map((result) => {
2584
+ const kindColor = result.type === "file" ? "var(--accent)" : KIND_COLORS[result.kind] ? KIND_COLORS[result.kind].border : "#565f89";
2585
+ return `<div class="search-result" data-id="${escapeHtml(result.id)}" data-type="${result.type}">
2586
+ <div class="search-result-body">
2587
+ <span class="search-result-name">${escapeHtml(result.label)}</span>
2588
+ <span class="search-result-subtitle">${escapeHtml(result.subtitle)}</span>
2589
+ </div>
2590
+ <span class="search-result-kind${result.type === "file" ? " file" : ""}" style="color:${kindColor}">${escapeHtml(result.kind)}</span>
2458
2591
  </div>`;
2459
2592
  }).join("");
2460
2593
  dropdown.classList.remove("hidden");
2461
2594
  dropdown.querySelectorAll(".search-result").forEach((el) => {
2462
2595
  el.addEventListener("click", () => {
2463
2596
  const id = el.dataset.id || "";
2597
+ const type = el.dataset.type || "symbol";
2464
2598
  searchInput.value = "";
2465
2599
  dropdown.classList.add("hidden");
2600
+ if (type === "file") {
2601
+ focusFileInGraph(id);
2602
+ return;
2603
+ }
2466
2604
  void focusSymbolInGraph(id, {
2467
2605
  maxDegrees: 1,
2468
2606
  animate: true,
@@ -2473,23 +2611,22 @@ function setupToolbar() {
2473
2611
  });
2474
2612
  });
2475
2613
  }
2614
+ function updateDropdownResults() {
2615
+ const results = buildGraphSearchResults({
2616
+ query: searchInput.value,
2617
+ symbolIds: rankedSymbols,
2618
+ nodes: graphNodes,
2619
+ fileIndex: fileToSymbolIds
2620
+ });
2621
+ showDropdownResults(results);
2622
+ }
2476
2623
  searchInput.addEventListener("focus", () => {
2477
- if (searchInput.value.trim().length < 2) {
2478
- showDropdownResults(rankedSymbols.slice(0, 20));
2479
- }
2624
+ updateDropdownResults();
2480
2625
  });
2481
2626
  searchInput.addEventListener("input", () => {
2482
2627
  clearTimeout(searchTimeout);
2483
2628
  searchTimeout = setTimeout(() => {
2484
- const query = searchInput.value.trim().toLowerCase();
2485
- if (query.length < 2) {
2486
- showDropdownResults(rankedSymbols.slice(0, 20));
2487
- return;
2488
- }
2489
- const matches = rankedSymbols.filter((name) => {
2490
- return matchesGraphNodeQuery(query, graphNodes.get(name) || {}, name);
2491
- }).slice(0, 15);
2492
- showDropdownResults(matches);
2629
+ updateDropdownResults();
2493
2630
  }, 150);
2494
2631
  });
2495
2632
  searchInput.addEventListener("keydown", (e) => {
@@ -2572,25 +2709,45 @@ var settingsBtn = document.getElementById("settings-btn");
2572
2709
  var settingsModal = document.getElementById("settings-modal");
2573
2710
  var settingsBackdrop = document.getElementById("settings-backdrop");
2574
2711
  var settingsCloseBtn = document.getElementById("settings-close");
2712
+ var openRouterConnectModal = document.getElementById("openrouter-connect-modal");
2713
+ var openRouterConnectBackdrop = document.getElementById("openrouter-connect-backdrop");
2714
+ var openRouterConnectCloseBtn = document.getElementById("openrouter-connect-close");
2715
+ var openRouterConnectCancelBtn = document.getElementById("openrouter-connect-cancel");
2716
+ var openRouterConnectContinueBtn = document.getElementById("openrouter-connect-continue");
2717
+ var openRouterPersistCheckbox = document.getElementById("openrouter-persist-checkbox");
2575
2718
  var settingsPath = document.getElementById("settings-path");
2576
2719
  var settingsList = document.getElementById("settings-list");
2577
2720
  var settingsBanner = document.getElementById("settings-banner");
2721
+ var settingsOpenRouterSession = document.getElementById("settings-openrouter-session");
2722
+ var settingsOpenRouterSessionMeta = document.getElementById("settings-openrouter-session-meta");
2578
2723
  var settingsSaveBtn = document.getElementById("settings-save");
2579
2724
  var settingsResetBtn = document.getElementById("settings-reset");
2725
+ var disconnectOpenRouterBtn = document.getElementById("disconnect-openrouter-btn");
2726
+ var connectOpenRouterButtons = Array.from(
2727
+ document.querySelectorAll("[data-openrouter-connect]")
2728
+ );
2729
+ var configOverlaySpotlight = document.getElementById("config-overlay-spotlight");
2730
+ var configOverlayIntro = document.getElementById("config-overlay-intro");
2731
+ var configConnectStatus = document.getElementById("config-connect-status");
2580
2732
  var ws;
2581
2733
  var currentAssistantEl = null;
2582
2734
  var assistantText = "";
2583
2735
  var hadToolCalls = false;
2584
2736
  var settingsPayload = null;
2585
2737
  var activeSavedSession = null;
2738
+ var activeBaseUrl = "";
2739
+ var sessionOpenRouterConnected = false;
2586
2740
  var sessionRefreshTracker = createLatestRequestTracker();
2587
2741
  var TOOL_RESULT_MAX = 500;
2742
+ var OPENROUTER_PKCE_VERIFIER_KEY = "minicode:openrouter:pkce-verifier";
2743
+ var OPENROUTER_PERSIST_TO_ENV_KEY = "minicode:openrouter:persist-to-env";
2588
2744
  function connect() {
2589
2745
  const protocol = location.protocol === "https:" ? "wss:" : "ws:";
2590
2746
  ws = new WebSocket(`${protocol}//${location.host}`);
2591
2747
  ws.onopen = () => {
2592
2748
  setStatus("ready");
2593
2749
  fetchStatus();
2750
+ refreshModelList();
2594
2751
  fetchContext();
2595
2752
  };
2596
2753
  ws.onclose = () => {
@@ -2617,6 +2774,127 @@ function setBusy(busy) {
2617
2774
  }
2618
2775
  }
2619
2776
  var configOverlay = document.getElementById("config-overlay");
2777
+ function setConfigConnectStatus(message, tone) {
2778
+ if (!configConnectStatus) {
2779
+ return;
2780
+ }
2781
+ configConnectStatus.textContent = message;
2782
+ configConnectStatus.className = `config-connect-status ${tone}`;
2783
+ }
2784
+ function clearConfigConnectStatus() {
2785
+ if (!configConnectStatus) {
2786
+ return;
2787
+ }
2788
+ configConnectStatus.textContent = "";
2789
+ configConnectStatus.className = "config-connect-status hidden";
2790
+ }
2791
+ function encodeBase64Url(bytes) {
2792
+ let binary = "";
2793
+ for (const byte of bytes) {
2794
+ binary += String.fromCharCode(byte);
2795
+ }
2796
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
2797
+ }
2798
+ function createPkceVerifier() {
2799
+ const bytes = new Uint8Array(32);
2800
+ crypto.getRandomValues(bytes);
2801
+ return encodeBase64Url(bytes);
2802
+ }
2803
+ async function createPkceChallenge(verifier) {
2804
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
2805
+ return encodeBase64Url(new Uint8Array(digest));
2806
+ }
2807
+ async function startOpenRouterConnect(persistToEnv) {
2808
+ const verifier = createPkceVerifier();
2809
+ const challenge = await createPkceChallenge(verifier);
2810
+ sessionStorage.setItem(OPENROUTER_PKCE_VERIFIER_KEY, verifier);
2811
+ sessionStorage.setItem(OPENROUTER_PERSIST_TO_ENV_KEY, persistToEnv ? "1" : "0");
2812
+ setConfigConnectStatus("Redirecting to OpenRouter\u2026", "info");
2813
+ const callbackUrl = new URL(location.pathname, location.origin).toString();
2814
+ const authUrl = new URL("https://openrouter.ai/auth");
2815
+ authUrl.searchParams.set("callback_url", callbackUrl);
2816
+ authUrl.searchParams.set("code_challenge", challenge);
2817
+ authUrl.searchParams.set("code_challenge_method", "S256");
2818
+ location.assign(authUrl.toString());
2819
+ }
2820
+ async function maybeHandleOpenRouterCallback() {
2821
+ const url = new URL(location.href);
2822
+ const code = url.searchParams.get("code");
2823
+ if (!code) {
2824
+ return;
2825
+ }
2826
+ const cleanedUrl = `${url.pathname}${url.hash}`;
2827
+ history.replaceState({}, document.title, cleanedUrl);
2828
+ const codeVerifier = sessionStorage.getItem(OPENROUTER_PKCE_VERIFIER_KEY);
2829
+ const persistToEnv = sessionStorage.getItem(OPENROUTER_PERSIST_TO_ENV_KEY) === "1";
2830
+ sessionStorage.removeItem(OPENROUTER_PKCE_VERIFIER_KEY);
2831
+ sessionStorage.removeItem(OPENROUTER_PERSIST_TO_ENV_KEY);
2832
+ if (!codeVerifier) {
2833
+ setConfigConnectStatus(
2834
+ "OpenRouter sign-in could not be completed because the local PKCE verifier was missing. Start the connect flow again.",
2835
+ "error"
2836
+ );
2837
+ return;
2838
+ }
2839
+ for (const button of connectOpenRouterButtons) {
2840
+ button.disabled = true;
2841
+ }
2842
+ setConfigConnectStatus("Connecting OpenRouter to this serve session\u2026", "info");
2843
+ try {
2844
+ const res = await fetch("/api/openrouter/connect", {
2845
+ method: "POST",
2846
+ headers: { "Content-Type": "application/json" },
2847
+ body: JSON.stringify({ code, codeVerifier, persistToEnv })
2848
+ });
2849
+ const body = await res.json();
2850
+ if (!res.ok) {
2851
+ throw new Error("error" in body ? body.error : `Failed to connect OpenRouter (${res.status})`);
2852
+ }
2853
+ activeBaseUrl = body.baseUrl;
2854
+ addMessage(body.message, "thinking");
2855
+ const statusTone = body.persistWarning ? "info" : body.needsSetup ? "info" : "success";
2856
+ setConfigConnectStatus(body.message, statusTone);
2857
+ await fetchStatus();
2858
+ await refreshModelList();
2859
+ const onlyModelMissing = body.needsSetup && body.missing.length === 1 && body.missing[0]?.includes("MODEL");
2860
+ if (onlyModelMissing) {
2861
+ modelDropdown.classList.remove("hidden");
2862
+ sessionDropdown.classList.add("hidden");
2863
+ }
2864
+ } catch (error) {
2865
+ const message = error instanceof Error ? error.message : "Failed to connect OpenRouter";
2866
+ setConfigConnectStatus(message, "error");
2867
+ } finally {
2868
+ for (const button of connectOpenRouterButtons) {
2869
+ button.disabled = false;
2870
+ }
2871
+ }
2872
+ }
2873
+ async function disconnectOpenRouter() {
2874
+ disconnectOpenRouterBtn.disabled = true;
2875
+ clearSettingsBanner();
2876
+ try {
2877
+ const res = await fetch("/api/openrouter/disconnect", {
2878
+ method: "POST",
2879
+ headers: { "Content-Type": "application/json" }
2880
+ });
2881
+ const body = await res.json();
2882
+ if (!res.ok) {
2883
+ throw new Error("error" in body ? body.error : `Failed to disconnect OpenRouter (${res.status})`);
2884
+ }
2885
+ activeBaseUrl = body.baseUrl;
2886
+ addMessage(body.message, "thinking");
2887
+ setSettingsBanner(body.message, body.disconnected ? "success" : "info");
2888
+ clearConfigConnectStatus();
2889
+ await fetchStatus();
2890
+ await refreshModelList();
2891
+ } catch (error) {
2892
+ const message = error instanceof Error ? error.message : "Failed to disconnect OpenRouter";
2893
+ setSettingsBanner(message, "error");
2894
+ } finally {
2895
+ disconnectOpenRouterBtn.disabled = false;
2896
+ }
2897
+ }
2620
2898
  async function fetchStatus() {
2621
2899
  try {
2622
2900
  const res = await fetch("/api/status");
@@ -2624,6 +2902,9 @@ async function fetchStatus() {
2624
2902
  modelInfo.textContent = data.model || "Select model";
2625
2903
  modelInfo.classList.toggle("placeholder", !data.model);
2626
2904
  activeModel = data.model;
2905
+ activeBaseUrl = data.baseUrl ?? "";
2906
+ sessionOpenRouterConnected = data.sessionOpenRouterConnected ?? false;
2907
+ renderOpenRouterSessionControls();
2627
2908
  if (data.needsSetup) {
2628
2909
  configOverlay.classList.remove("hidden");
2629
2910
  chatInput.disabled = true;
@@ -2631,6 +2912,11 @@ async function fetchStatus() {
2631
2912
  const missingEl = document.getElementById("config-missing");
2632
2913
  if (missingEl && data.missing && data.missing.length > 0) {
2633
2914
  const isOnlyModelMissing = data.missing.length === 1 && data.missing[0].includes("MODEL");
2915
+ const hasPersistedOpenRouter = isOnlyModelMissing && (data.baseUrl ?? "").includes("openrouter");
2916
+ if (configOverlayIntro) {
2917
+ configOverlayIntro.textContent = hasPersistedOpenRouter ? "OpenRouter is already configured. Select a model to continue:" : "minicode needs a model provider to run. Configure one of the following:";
2918
+ }
2919
+ configOverlaySpotlight?.classList.toggle("hidden", hasPersistedOpenRouter);
2634
2920
  const hint = isOnlyModelMissing ? ` \u2014 select one from the <strong>model dropdown</strong> above, or set it in config` : "";
2635
2921
  missingEl.innerHTML = `<strong>Missing:</strong> ${data.missing.map(escapeHtml).join(", ")}${hint}`;
2636
2922
  missingEl.classList.remove("hidden");
@@ -2638,6 +2924,16 @@ async function fetchStatus() {
2638
2924
  } else {
2639
2925
  configOverlay.classList.add("hidden");
2640
2926
  chatInput.disabled = false;
2927
+ sendBtn.disabled = false;
2928
+ const missingEl = document.getElementById("config-missing");
2929
+ if (missingEl) {
2930
+ missingEl.classList.add("hidden");
2931
+ missingEl.innerHTML = "";
2932
+ }
2933
+ if (configOverlayIntro) {
2934
+ configOverlayIntro.textContent = "minicode needs a model provider to run. Configure one of the following:";
2935
+ }
2936
+ configOverlaySpotlight?.classList.remove("hidden");
2641
2937
  }
2642
2938
  } catch {
2643
2939
  }
@@ -2746,6 +3042,50 @@ function addMessage(text, type, markdown = false) {
2746
3042
  scrollToBottom();
2747
3043
  return el;
2748
3044
  }
3045
+ function clearChatTranscript() {
3046
+ messagesEl.innerHTML = "";
3047
+ currentAssistantEl = null;
3048
+ assistantText = "";
3049
+ hadToolCalls = false;
3050
+ }
3051
+ function stringifyToolInput(input) {
3052
+ const entries = Object.entries(input).flatMap(([key, value]) => {
3053
+ if (value === void 0 || value === null) {
3054
+ return [];
3055
+ }
3056
+ if (typeof value === "string") {
3057
+ return [[key, value]];
3058
+ }
3059
+ return [[key, JSON.stringify(value)]];
3060
+ });
3061
+ return Object.fromEntries(entries);
3062
+ }
3063
+ function addToolResultPreview(name, result) {
3064
+ const toolEls = messagesEl.querySelectorAll(`.tool-call[data-tool-name="${name}"]`);
3065
+ if (toolEls.length === 0) {
3066
+ addToolCall(name, {});
3067
+ }
3068
+ finalizeToolCall(name, result);
3069
+ }
3070
+ function renderLoadedSessionMessages(messages) {
3071
+ clearChatTranscript();
3072
+ for (const message of messages) {
3073
+ if (message.role === "user") {
3074
+ addMessage(message.content, "user");
3075
+ continue;
3076
+ }
3077
+ if (message.role === "assistant") {
3078
+ if (message.content.trim().length > 0) {
3079
+ addMessage(message.content, "assistant", true);
3080
+ }
3081
+ for (const toolCall of message.toolCalls ?? []) {
3082
+ addToolCall(toolCall.name, stringifyToolInput(toolCall.input));
3083
+ }
3084
+ continue;
3085
+ }
3086
+ addToolResultPreview(message.toolName, message.content);
3087
+ }
3088
+ }
2749
3089
  function summarizeToolInput(name, input) {
2750
3090
  const key = input.path ?? input.file_path ?? input.command ?? input.query ?? input.pattern ?? input.name ?? input.old_string;
2751
3091
  if (typeof key === "string") {
@@ -2786,7 +3126,7 @@ function finalizeToolCall(name, result, elapsedMs) {
2786
3126
  if (!el) return;
2787
3127
  const timeEl = el.querySelector(".tool-time");
2788
3128
  if (timeEl) {
2789
- timeEl.textContent = `${elapsedMs}ms`;
3129
+ timeEl.textContent = elapsedMs && elapsedMs > 0 ? `${elapsedMs}ms` : "";
2790
3130
  }
2791
3131
  const resultEl = el.querySelector(".tool-result");
2792
3132
  if (resultEl && result) {
@@ -2833,6 +3173,13 @@ function closeHeaderMenus() {
2833
3173
  function isSettingsModalOpen() {
2834
3174
  return !settingsModal.classList.contains("hidden");
2835
3175
  }
3176
+ function isOpenRouterConnectModalOpen() {
3177
+ return !openRouterConnectModal.classList.contains("hidden");
3178
+ }
3179
+ function syncModalOpenState() {
3180
+ const anyModalOpen = isSettingsModalOpen() || isOpenRouterConnectModalOpen();
3181
+ document.body.classList.toggle("modal-open", anyModalOpen);
3182
+ }
2836
3183
  function formatSettingsValue(value) {
2837
3184
  return value === null ? "(unset)" : String(value);
2838
3185
  }
@@ -2844,6 +3191,17 @@ function clearSettingsBanner() {
2844
3191
  settingsBanner.textContent = "";
2845
3192
  settingsBanner.className = "settings-banner hidden";
2846
3193
  }
3194
+ function renderOpenRouterSessionControls() {
3195
+ if (sessionOpenRouterConnected) {
3196
+ settingsOpenRouterSession.classList.remove("hidden");
3197
+ settingsOpenRouterSessionMeta.textContent = activeBaseUrl ? `Endpoint: ${activeBaseUrl}. This session-only connection overrides your original provider settings until you disconnect or restart serve.` : "This session-only connection overrides your original provider settings until you disconnect or restart serve.";
3198
+ disconnectOpenRouterBtn.disabled = false;
3199
+ return;
3200
+ }
3201
+ settingsOpenRouterSession.classList.add("hidden");
3202
+ settingsOpenRouterSessionMeta.textContent = "";
3203
+ disconnectOpenRouterBtn.disabled = false;
3204
+ }
2847
3205
  function createSettingsControl(entry, inputId) {
2848
3206
  const value = entry.persistedValue;
2849
3207
  if (entry.type === "boolean") {
@@ -3038,15 +3396,29 @@ function openSettings() {
3038
3396
  closeHeaderMenus();
3039
3397
  settingsModal.classList.remove("hidden");
3040
3398
  settingsModal.setAttribute("aria-hidden", "false");
3041
- document.body.classList.add("modal-open");
3399
+ syncModalOpenState();
3042
3400
  void loadSettings();
3043
3401
  }
3044
3402
  function closeSettings() {
3045
3403
  settingsModal.classList.add("hidden");
3046
3404
  settingsModal.setAttribute("aria-hidden", "true");
3047
- document.body.classList.remove("modal-open");
3405
+ syncModalOpenState();
3048
3406
  clearSettingsBanner();
3049
3407
  }
3408
+ function openOpenRouterConnectModal() {
3409
+ closeHeaderMenus();
3410
+ openRouterConnectModal.classList.remove("hidden");
3411
+ openRouterConnectModal.setAttribute("aria-hidden", "false");
3412
+ openRouterPersistCheckbox.checked = false;
3413
+ openRouterConnectContinueBtn.disabled = false;
3414
+ syncModalOpenState();
3415
+ }
3416
+ function closeOpenRouterConnectModal() {
3417
+ openRouterConnectModal.classList.add("hidden");
3418
+ openRouterConnectModal.setAttribute("aria-hidden", "true");
3419
+ openRouterConnectContinueBtn.disabled = false;
3420
+ syncModalOpenState();
3421
+ }
3050
3422
  chatForm.addEventListener("submit", (e) => {
3051
3423
  e.preventDefault();
3052
3424
  const message = chatInput.value.trim();
@@ -3089,31 +3461,63 @@ async function refreshModelList() {
3089
3461
  const res = await fetch("/api/models");
3090
3462
  const data = await res.json();
3091
3463
  activeModel = data.activeModel;
3464
+ const hasActiveModel = !!activeModel && data.models.some((model) => model.id === activeModel);
3092
3465
  if (!data.models || data.models.length === 0) {
3466
+ modelInfo.textContent = "Select model";
3467
+ modelInfo.classList.add("placeholder");
3093
3468
  modelList.innerHTML = '<div class="dropdown-empty">No models available</div>';
3094
3469
  return;
3095
3470
  }
3471
+ if (hasActiveModel) {
3472
+ modelInfo.textContent = activeModel;
3473
+ modelInfo.classList.remove("placeholder");
3474
+ } else {
3475
+ modelInfo.textContent = "Select model";
3476
+ modelInfo.classList.add("placeholder");
3477
+ }
3096
3478
  modelList.innerHTML = "";
3097
3479
  for (const m2 of data.models) {
3098
3480
  const el = document.createElement("div");
3099
3481
  el.className = "model-item" + (m2.id === activeModel ? " active" : "");
3100
3482
  el.textContent = m2.name ?? m2.id;
3101
3483
  el.title = m2.id;
3102
- el.addEventListener("click", () => switchModel(m2.id));
3484
+ el.addEventListener("click", () => {
3485
+ void switchModel(m2.id);
3486
+ });
3103
3487
  modelList.appendChild(el);
3104
3488
  }
3105
3489
  } catch {
3106
3490
  modelList.innerHTML = '<div class="dropdown-empty">Failed to load models</div>';
3107
3491
  }
3108
3492
  }
3109
- function switchModel(modelId) {
3110
- ws.send(JSON.stringify({ type: "switch_model", model: modelId }));
3111
- modelInfo.textContent = modelId || "Select model";
3112
- modelInfo.classList.toggle("placeholder", !modelId);
3113
- activeModel = modelId;
3114
- modelDropdown.classList.add("hidden");
3115
- addMessage(`Model switched to: ${modelId}`, "thinking");
3116
- void fetchStatus();
3493
+ async function switchModel(modelId) {
3494
+ try {
3495
+ const res = await fetch("/api/model", {
3496
+ method: "POST",
3497
+ headers: { "Content-Type": "application/json" },
3498
+ body: JSON.stringify({
3499
+ model: modelId,
3500
+ persistToHomeEnv: true
3501
+ })
3502
+ });
3503
+ const body = await res.json();
3504
+ if (!res.ok) {
3505
+ throw new Error("error" in body ? body.error : `Failed to switch model (${res.status})`);
3506
+ }
3507
+ modelInfo.textContent = modelId || "Select model";
3508
+ modelInfo.classList.toggle("placeholder", !modelId);
3509
+ activeModel = modelId;
3510
+ modelDropdown.classList.add("hidden");
3511
+ if (body.persistedToEnv) {
3512
+ addMessage(`Model switched to: ${modelId}. Saved as the default in ~/.minicode/.env.`, "thinking");
3513
+ } else {
3514
+ addMessage(`Model switched to: ${modelId}`, "thinking");
3515
+ }
3516
+ await fetchStatus();
3517
+ } catch (error) {
3518
+ const message = error instanceof Error ? error.message : "Failed to switch model";
3519
+ addMessage(message, "error");
3520
+ }
3117
3521
  }
3118
3522
  sessionBtn.addEventListener("click", (e) => {
3119
3523
  e.stopPropagation();
@@ -3209,9 +3613,12 @@ async function loadSession(label) {
3209
3613
  body: JSON.stringify({ label })
3210
3614
  });
3211
3615
  if (res.ok) {
3616
+ const body = await res.json();
3212
3617
  sessionDropdown.classList.add("hidden");
3213
- messagesEl.innerHTML = "";
3214
- addMessage(`Session "${label}" restored`, "thinking");
3618
+ renderLoadedSessionMessages(body.messages);
3619
+ if (body.messages.length === 0) {
3620
+ addMessage(`Session "${body.label}" restored`, "thinking");
3621
+ }
3215
3622
  void refreshSessionList();
3216
3623
  }
3217
3624
  } catch {
@@ -3247,9 +3654,19 @@ settingsCloseBtn.addEventListener("click", () => {
3247
3654
  settingsBackdrop.addEventListener("click", () => {
3248
3655
  closeSettings();
3249
3656
  });
3657
+ openRouterConnectBackdrop.addEventListener("click", () => {
3658
+ closeOpenRouterConnectModal();
3659
+ });
3660
+ openRouterConnectCloseBtn.addEventListener("click", () => {
3661
+ closeOpenRouterConnectModal();
3662
+ });
3663
+ openRouterConnectCancelBtn.addEventListener("click", () => {
3664
+ closeOpenRouterConnectModal();
3665
+ });
3250
3666
  settingsResetBtn.addEventListener("click", () => {
3251
3667
  clearSettingsBanner();
3252
3668
  renderSettings();
3669
+ renderOpenRouterSessionControls();
3253
3670
  });
3254
3671
  settingsSaveBtn.addEventListener("click", async () => {
3255
3672
  if (!settingsPayload) {
@@ -3298,11 +3715,31 @@ settingsList.addEventListener("change", () => {
3298
3715
  clearSettingsBanner();
3299
3716
  updateSettingsActions();
3300
3717
  });
3718
+ disconnectOpenRouterBtn.addEventListener("click", () => {
3719
+ void disconnectOpenRouter();
3720
+ });
3301
3721
  document.addEventListener("keydown", (event) => {
3302
- if (event.key === "Escape" && isSettingsModalOpen()) {
3722
+ if (event.key !== "Escape") {
3723
+ return;
3724
+ }
3725
+ if (isOpenRouterConnectModalOpen()) {
3726
+ closeOpenRouterConnectModal();
3727
+ return;
3728
+ }
3729
+ if (isSettingsModalOpen()) {
3303
3730
  closeSettings();
3304
3731
  }
3305
3732
  });
3733
+ for (const button of connectOpenRouterButtons) {
3734
+ button.addEventListener("click", () => {
3735
+ openOpenRouterConnectModal();
3736
+ });
3737
+ }
3738
+ openRouterConnectContinueBtn.addEventListener("click", () => {
3739
+ openRouterConnectContinueBtn.disabled = true;
3740
+ closeOpenRouterConnectModal();
3741
+ void startOpenRouterConnect(openRouterPersistCheckbox.checked);
3742
+ });
3306
3743
  var chatPane = document.getElementById("chat-pane");
3307
3744
  var divider = document.getElementById("pane-divider");
3308
3745
  divider.addEventListener("mousedown", (e) => {
@@ -3337,3 +3774,4 @@ graphToggle.addEventListener("click", () => {
3337
3774
  });
3338
3775
  connect();
3339
3776
  initGraph();
3777
+ void maybeHandleOpenRouterCallback();