@sean.holung/minicode 0.3.7 → 0.3.9

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.
Files changed (32) hide show
  1. package/dist/src/agent/config.js +25 -0
  2. package/dist/src/model-utils.js +18 -1
  3. package/dist/src/serve/agent-bridge.js +89 -15
  4. package/dist/src/serve/server.js +151 -3
  5. package/dist/src/session/session-store.js +29 -1
  6. package/dist/src/web/app.js +691 -105
  7. package/dist/src/web/index.html +117 -9
  8. package/dist/src/web/style.css +198 -10
  9. package/dist/tests/agent.test.js +16 -0
  10. package/dist/tests/config-integration.test.js +91 -1
  11. package/dist/tests/context-indicator.test.js +9 -0
  12. package/dist/tests/file-tools.test.js +12 -0
  13. package/dist/tests/graph-onboarding.test.js +8 -0
  14. package/dist/tests/model-client-openai.test.js +41 -0
  15. package/dist/tests/model-dropdown-ui.test.js +23 -0
  16. package/dist/tests/model-utils.test.js +26 -1
  17. package/dist/tests/serve.integration.test.js +194 -0
  18. package/dist/tests/session-store.test.js +32 -1
  19. package/dist/tests/session-ui.test.js +6 -0
  20. package/dist/tests/settings-ui.test.js +11 -0
  21. package/dist/tests/setup-overlay-state.test.js +49 -0
  22. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.d.ts.map +1 -1
  23. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js +10 -1
  24. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js.map +1 -1
  25. package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts.map +1 -1
  26. package/node_modules/@minicode/agent-sdk/dist/src/model/client.js +21 -0
  27. package/node_modules/@minicode/agent-sdk/dist/src/model/client.js.map +1 -1
  28. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.d.ts.map +1 -1
  29. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.js +60 -6
  30. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.js.map +1 -1
  31. package/node_modules/@minicode/agent-sdk/dist/tsconfig.tsbuildinfo +1 -1
  32. package/package.json +1 -1
@@ -1678,6 +1678,7 @@ var activeAnalysisFilter = "all";
1678
1678
  var analysisExplanationCache = /* @__PURE__ */ new Map();
1679
1679
  var filePreviewModalInitialized = false;
1680
1680
  var latestFilePreviewRequestId = 0;
1681
+ var pendingGraphRefreshTimer = null;
1681
1682
  var LAYOUT_OPTIONS = {
1682
1683
  name: "cose",
1683
1684
  nodeRepulsion: function() {
@@ -1704,39 +1705,8 @@ async function initGraph() {
1704
1705
  const cyEl = document.getElementById("cy");
1705
1706
  const detailEl = document.getElementById("symbol-detail");
1706
1707
  setupFilePreviewModal();
1708
+ cyEl.innerHTML = "";
1707
1709
  try {
1708
- const [graphRes, symbolsRes, focusRes] = await Promise.all([
1709
- fetch("/api/graph"),
1710
- fetch("/api/symbols"),
1711
- fetch("/api/focus")
1712
- ]);
1713
- if (!graphRes.ok || !symbolsRes.ok) {
1714
- cyEl.innerHTML = '<div class="graph-empty">No index available. Run minicode with a project to generate the code graph.</div>';
1715
- return;
1716
- }
1717
- const graphData = await graphRes.json();
1718
- const focusData = focusRes.ok ? await focusRes.json() : { pinned: [] };
1719
- if (!graphData.nodes || graphData.nodes.length === 0) {
1720
- cyEl.innerHTML = '<div class="graph-empty">No index available. Run minicode with a project to generate the code graph.</div>';
1721
- return;
1722
- }
1723
- for (const node of graphData.nodes) {
1724
- const id = node.qualifiedName || node.id || node.name || "";
1725
- graphNodes.set(id, node);
1726
- }
1727
- graphEdges = (graphData.edges || []).map((e) => ({
1728
- source: e.source || e.from || "",
1729
- target: e.target || e.to || "",
1730
- kind: (e.kind || e.type || "references").toLowerCase()
1731
- }));
1732
- edgeIndex = buildGraphEdgeIndex(graphEdges);
1733
- fileToSymbolIds = buildGraphFileIndex(graphNodes);
1734
- allSymbolNames = Array.from(graphNodes.keys()).sort();
1735
- const pinned = focusData.pinned || [];
1736
- for (const f of pinned) {
1737
- const name = typeof f === "string" ? f : f.name || f.qualifiedName;
1738
- if (name) pinnedNames.add(name);
1739
- }
1740
1710
  cy = cytoscape({
1741
1711
  container: cyEl,
1742
1712
  elements: [],
@@ -1746,20 +1716,64 @@ async function initGraph() {
1746
1716
  });
1747
1717
  setupInteractions(cy, detailEl);
1748
1718
  setupToolbar();
1749
- if (pinnedNames.size > 0) {
1750
- for (const name of pinnedNames) {
1751
- addNodeNeighborhood(name, 1);
1752
- }
1753
- runLayout();
1754
- } else {
1755
- showOnboardingHint(cyEl);
1719
+ const loaded = await loadGraphSnapshot(false);
1720
+ if (!loaded) {
1721
+ showOnboardingHint(cyEl, "No project index is available yet. Refresh after files are available, or restart minicode serve.");
1722
+ return;
1756
1723
  }
1724
+ seedPinnedSymbolsOrOnboarding(
1725
+ graphNodes.size === 0 ? "No JavaScript or TypeScript symbols are indexed yet. Create a file, then refresh the graph." : void 0
1726
+ );
1757
1727
  } catch (err) {
1758
1728
  console.error("Graph init failed:", err);
1759
1729
  const msg = err instanceof Error ? err.message : String(err);
1760
1730
  cyEl.innerHTML = `<div class="graph-empty">Failed to load graph: ${msg}</div>`;
1761
1731
  }
1762
1732
  }
1733
+ async function refreshGraphData(options = {}) {
1734
+ if (!initialized || !cy) return;
1735
+ const {
1736
+ refreshIndex = false,
1737
+ preserveVisible = true,
1738
+ showFeedback = false
1739
+ } = options;
1740
+ const refreshBtn = document.getElementById("graph-refresh");
1741
+ const visibleIds = preserveVisible ? cy.nodes().map((node) => node.id()) : [];
1742
+ if (showFeedback && refreshBtn) {
1743
+ refreshBtn.disabled = true;
1744
+ refreshBtn.textContent = "Refreshing...";
1745
+ }
1746
+ try {
1747
+ const loaded = await loadGraphSnapshot(refreshIndex);
1748
+ if (!loaded) {
1749
+ throw new Error("No project index available");
1750
+ }
1751
+ renderGraphAfterDataRefresh(visibleIds, preserveVisible);
1752
+ } catch (err) {
1753
+ console.error("Graph refresh failed:", err);
1754
+ if (showFeedback) {
1755
+ const cyEl = document.getElementById("cy");
1756
+ if (cyEl && graphNodes.size === 0) {
1757
+ showOnboardingHint(cyEl, "Could not refresh the project index. Check the minicode serve logs, then try again.");
1758
+ }
1759
+ }
1760
+ } finally {
1761
+ if (showFeedback && refreshBtn) {
1762
+ refreshBtn.disabled = false;
1763
+ refreshBtn.textContent = "Refresh";
1764
+ }
1765
+ }
1766
+ }
1767
+ function scheduleGraphDataRefresh() {
1768
+ if (!initialized) return;
1769
+ if (pendingGraphRefreshTimer) {
1770
+ clearTimeout(pendingGraphRefreshTimer);
1771
+ }
1772
+ pendingGraphRefreshTimer = setTimeout(() => {
1773
+ pendingGraphRefreshTimer = null;
1774
+ void refreshGraphData({ preserveVisible: true });
1775
+ }, 250);
1776
+ }
1763
1777
  function highlightAgentActivity(symbolName) {
1764
1778
  if (!cy) return;
1765
1779
  void focusResolvedSymbolsInGraph(symbolName, {
@@ -1773,11 +1787,106 @@ function highlightAgentActivity(symbolName) {
1773
1787
  function resizeGraph() {
1774
1788
  if (cy) cy.resize();
1775
1789
  }
1776
- function showOnboardingHint(container) {
1777
- if (container.querySelector(".graph-onboarding")) return;
1790
+ async function loadGraphSnapshot(refreshIndex) {
1791
+ if (refreshIndex) {
1792
+ const refreshRes = await fetch("/api/index/refresh", { method: "POST" });
1793
+ if (!refreshRes.ok) {
1794
+ return false;
1795
+ }
1796
+ }
1797
+ const [graphRes, focusRes] = await Promise.all([
1798
+ fetch("/api/graph"),
1799
+ fetch("/api/focus")
1800
+ ]);
1801
+ if (!graphRes.ok) {
1802
+ return false;
1803
+ }
1804
+ const graphData = await graphRes.json();
1805
+ const focusData = focusRes.ok ? await focusRes.json() : { pinned: [] };
1806
+ applyGraphSnapshot(graphData, focusData);
1807
+ return true;
1808
+ }
1809
+ function applyGraphSnapshot(graphData, focusData) {
1810
+ graphNodes.clear();
1811
+ for (const node of graphData.nodes || []) {
1812
+ const id = node.qualifiedName || node.id || node.name || "";
1813
+ if (id) {
1814
+ graphNodes.set(id, node);
1815
+ }
1816
+ }
1817
+ graphEdges = (graphData.edges || []).map((e) => ({
1818
+ source: e.source || e.from || "",
1819
+ target: e.target || e.to || "",
1820
+ kind: (e.kind || e.type || "references").toLowerCase()
1821
+ })).filter((edge) => edge.source && edge.target);
1822
+ edgeIndex = buildGraphEdgeIndex(graphEdges);
1823
+ fileToSymbolIds = buildGraphFileIndex(graphNodes);
1824
+ allSymbolNames = Array.from(graphNodes.keys()).sort();
1825
+ pinnedNames.clear();
1826
+ for (const pinned of focusData.pinned || []) {
1827
+ const name = typeof pinned === "string" ? pinned : pinned.name || pinned.qualifiedName;
1828
+ if (name) pinnedNames.add(name);
1829
+ }
1830
+ resetAnalysisForGraphRefresh();
1831
+ }
1832
+ function resetAnalysisForGraphRefresh() {
1833
+ analysisReport = null;
1834
+ activeAnalysisFindingId = null;
1835
+ activeAnalysisFilter = "all";
1836
+ analysisExplanationCache.clear();
1837
+ clearAnalysisGraphClasses();
1838
+ const panel = document.getElementById("analysis-panel");
1839
+ if (!panel || panel.classList.contains("hidden")) {
1840
+ return;
1841
+ }
1842
+ const { summary, findings } = getAnalysisPanelEls();
1843
+ summary.innerHTML = "";
1844
+ findings.innerHTML = '<div class="analysis-empty">Graph refreshed. Re-run analysis to inspect the latest dependency graph.</div>';
1845
+ setAnalysisStatus("Graph refreshed. Re-run analysis to inspect the latest snapshot.");
1846
+ }
1847
+ function renderGraphAfterDataRefresh(previousVisibleIds, preserveVisible) {
1848
+ if (!cy) return;
1849
+ cy.elements().remove();
1850
+ document.getElementById("symbol-detail")?.classList.add("hidden");
1851
+ const visibleIds = preserveVisible ? [...new Set(previousVisibleIds)].filter((id) => graphNodes.has(id)) : [];
1852
+ for (const id of visibleIds) {
1853
+ addNodeToGraph(id);
1854
+ }
1855
+ connectExistingNodes();
1856
+ if (cy.nodes().length > 0) {
1857
+ refreshAnalysisGraphState();
1858
+ runLayout();
1859
+ return;
1860
+ }
1861
+ seedPinnedSymbolsOrOnboarding(
1862
+ graphNodes.size === 0 ? "No JavaScript or TypeScript symbols are indexed yet. Create a file, then refresh the graph." : void 0
1863
+ );
1864
+ }
1865
+ function seedPinnedSymbolsOrOnboarding(subtitle) {
1866
+ if (!cy) return;
1867
+ for (const name of pinnedNames) {
1868
+ addNodeNeighborhood(name, 1);
1869
+ }
1870
+ connectExistingNodes();
1871
+ if (cy.nodes().length > 0) {
1872
+ refreshAnalysisGraphState();
1873
+ runLayout();
1874
+ return;
1875
+ }
1876
+ const cyEl = document.getElementById("cy");
1877
+ if (cyEl) showOnboardingHint(cyEl, subtitle);
1878
+ refreshAnalysisGraphState();
1879
+ }
1880
+ function showOnboardingHint(container, subtitle = "Search for a symbol or file above to start exploring.<br/>Nodes expand on click to reveal connections.") {
1881
+ const existing = container.querySelector(".graph-onboarding");
1882
+ if (existing) {
1883
+ const subtitleEl = existing.querySelector(".graph-onboarding-subtitle");
1884
+ if (subtitleEl) subtitleEl.innerHTML = subtitle;
1885
+ return;
1886
+ }
1778
1887
  const hint = document.createElement("div");
1779
1888
  hint.className = "graph-onboarding";
1780
- 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>';
1889
+ 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">${subtitle}</div>`;
1781
1890
  container.appendChild(hint);
1782
1891
  }
1783
1892
  function removeOnboardingHint() {
@@ -2660,6 +2769,7 @@ async function togglePin(name, node, btnEl) {
2660
2769
  function setupToolbar() {
2661
2770
  const searchInput = document.getElementById("graph-search");
2662
2771
  const analyzeBtn = document.getElementById("graph-analyze");
2772
+ const refreshBtn = document.getElementById("graph-refresh");
2663
2773
  const fitBtn = document.getElementById("graph-fit");
2664
2774
  const relayoutBtn = document.getElementById("graph-relayout");
2665
2775
  const clearBtn = document.getElementById("graph-clear");
@@ -2671,7 +2781,6 @@ function setupToolbar() {
2671
2781
  dropdown.className = "search-dropdown hidden";
2672
2782
  searchInput.parentNode.style.position = "relative";
2673
2783
  searchInput.parentNode.appendChild(dropdown);
2674
- const rankedSymbols = allSymbolNames.slice().sort((a, b2) => compareGraphNodeIds(a, b2, graphNodes));
2675
2784
  function showDropdownResults(results) {
2676
2785
  if (results.length === 0) {
2677
2786
  dropdown.classList.add("hidden");
@@ -2710,6 +2819,7 @@ function setupToolbar() {
2710
2819
  });
2711
2820
  }
2712
2821
  function updateDropdownResults() {
2822
+ const rankedSymbols = allSymbolNames.slice().sort((a, b2) => compareGraphNodeIds(a, b2, graphNodes));
2713
2823
  const results = buildGraphSearchResults({
2714
2824
  query: searchInput.value,
2715
2825
  symbolIds: rankedSymbols,
@@ -2743,6 +2853,9 @@ function setupToolbar() {
2743
2853
  cy.fit(40);
2744
2854
  }
2745
2855
  });
2856
+ refreshBtn.addEventListener("click", () => {
2857
+ void refreshGraphData({ refreshIndex: true, preserveVisible: true, showFeedback: true });
2858
+ });
2746
2859
  analyzeBtn.addEventListener("click", () => {
2747
2860
  void runStructuralAnalysis();
2748
2861
  });
@@ -2769,6 +2882,24 @@ function setupToolbar() {
2769
2882
  });
2770
2883
  }
2771
2884
 
2885
+ // src/model-utils.ts
2886
+ function getModelDisplayName(model) {
2887
+ return (model.name ?? model.id).trim();
2888
+ }
2889
+ function normalizeModelSearchValue(value) {
2890
+ return value.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
2891
+ }
2892
+ function filterModelsByQuery(models, query) {
2893
+ const tokens = normalizeModelSearchValue(query);
2894
+ if (tokens.length === 0) {
2895
+ return [...models];
2896
+ }
2897
+ return models.filter((model) => {
2898
+ const haystack = `${getModelDisplayName(model)} ${model.id}`.toLocaleLowerCase();
2899
+ return tokens.every((token) => haystack.includes(token));
2900
+ });
2901
+ }
2902
+
2772
2903
  // src/web/request-tracker.ts
2773
2904
  function createLatestRequestTracker() {
2774
2905
  let latestToken = 0;
@@ -2783,6 +2914,25 @@ function createLatestRequestTracker() {
2783
2914
  };
2784
2915
  }
2785
2916
 
2917
+ // src/web/setup-overlay-state.ts
2918
+ var DEFAULT_SETUP_INTRO = "minicode needs a model provider to run. Configure one of the following:";
2919
+ function deriveSetupOverlayState(input) {
2920
+ const missingItems = input.missing ?? [];
2921
+ const configuredProvider = input.configuredProvider ?? null;
2922
+ const isOnlyModelMissing = missingItems.length === 1 && typeof missingItems[0] === "string" && missingItems[0].includes("MODEL");
2923
+ const hasConfiguredProvider = isOnlyModelMissing && configuredProvider !== null;
2924
+ const filteredMissingItems = hasConfiguredProvider ? missingItems : missingItems.filter((item) => !item.includes("MODEL"));
2925
+ const introText = configuredProvider === "openrouter" && isOnlyModelMissing ? "OpenRouter is already configured. Select a model to continue:" : configuredProvider === "openai-compatible" && isOnlyModelMissing ? "An OpenAI-compatible provider is already configured. Select a model to continue:" : configuredProvider === "anthropic" && isOnlyModelMissing ? "Anthropic is already configured. Select a model to continue:" : DEFAULT_SETUP_INTRO;
2926
+ return {
2927
+ introText,
2928
+ hideQuickConnects: hasConfiguredProvider,
2929
+ hideOpenRouterSpotlight: configuredProvider === "openrouter" && isOnlyModelMissing,
2930
+ missingItems: filteredMissingItems,
2931
+ showModelSelectionHint: hasConfiguredProvider,
2932
+ modelSelectionNote: configuredProvider === "openrouter" && isOnlyModelMissing ? 'If you are on the OpenRouter free tier, search "free" in the model dropdown to find supported free models.' : null
2933
+ };
2934
+ }
2935
+
2786
2936
  // src/web/app.ts
2787
2937
  var messagesEl = document.getElementById("messages");
2788
2938
  var chatForm = document.getElementById("chat-form");
@@ -2793,12 +2943,14 @@ var statusBadge = document.getElementById("status-badge");
2793
2943
  var modelInfo = document.getElementById("model-info");
2794
2944
  var modelBtn = document.getElementById("model-btn");
2795
2945
  var modelDropdown = document.getElementById("model-dropdown");
2946
+ var modelSearchInput = document.getElementById("model-search");
2796
2947
  var modelList = document.getElementById("model-list");
2797
2948
  var sessionBtn = document.getElementById("session-btn");
2798
2949
  var sessionDropdown = document.getElementById("session-dropdown");
2799
2950
  var sessionList = document.getElementById("session-list");
2800
2951
  var sessionUpdateRow = document.getElementById("session-update-row");
2801
2952
  var sessionUpdateBtn = document.getElementById("session-update-btn");
2953
+ var sessionAutoSaveToggle = document.getElementById("session-autosave-toggle");
2802
2954
  var saveBtn = document.getElementById("save-btn");
2803
2955
  var saveLabelInput = document.getElementById("save-label");
2804
2956
  var contextFill = document.getElementById("context-fill");
@@ -2813,17 +2965,36 @@ var openRouterConnectCloseBtn = document.getElementById("openrouter-connect-clos
2813
2965
  var openRouterConnectCancelBtn = document.getElementById("openrouter-connect-cancel");
2814
2966
  var openRouterConnectContinueBtn = document.getElementById("openrouter-connect-continue");
2815
2967
  var openRouterPersistCheckbox = document.getElementById("openrouter-persist-checkbox");
2968
+ var openAiCompatibleConnectModal = document.getElementById("openai-compatible-connect-modal");
2969
+ var openAiCompatibleConnectBackdrop = document.getElementById("openai-compatible-connect-backdrop");
2970
+ var openAiCompatibleConnectCloseBtn = document.getElementById("openai-compatible-connect-close");
2971
+ var openAiCompatibleConnectCancelBtn = document.getElementById("openai-compatible-connect-cancel");
2972
+ var openAiCompatibleConnectContinueBtn = document.getElementById("openai-compatible-connect-continue");
2973
+ var openAiCompatiblePresetSelect = document.getElementById("openai-compatible-preset");
2974
+ var openAiCompatiblePresetHelp = document.getElementById("openai-compatible-preset-help");
2975
+ var openAiCompatibleBaseUrlInput = document.getElementById("openai-compatible-base-url");
2976
+ var openAiCompatibleApiKeyInput = document.getElementById("openai-compatible-api-key");
2977
+ var openAiCompatiblePersistCheckbox = document.getElementById("openai-compatible-persist-checkbox");
2978
+ var openAiCompatibleConnectStatus = document.getElementById("openai-compatible-connect-status");
2816
2979
  var settingsPath = document.getElementById("settings-path");
2817
2980
  var settingsList = document.getElementById("settings-list");
2818
2981
  var settingsBanner = document.getElementById("settings-banner");
2819
2982
  var settingsOpenRouterSession = document.getElementById("settings-openrouter-session");
2820
2983
  var settingsOpenRouterSessionMeta = document.getElementById("settings-openrouter-session-meta");
2984
+ var settingsOpenAiCompatibleSession = document.getElementById("settings-openai-compatible-session");
2985
+ var settingsOpenAiCompatibleSessionMeta = document.getElementById("settings-openai-compatible-session-meta");
2821
2986
  var settingsSaveBtn = document.getElementById("settings-save");
2822
2987
  var settingsResetBtn = document.getElementById("settings-reset");
2823
2988
  var disconnectOpenRouterBtn = document.getElementById("disconnect-openrouter-btn");
2989
+ var disconnectOpenAiCompatibleBtn = document.getElementById("disconnect-openai-compatible-btn");
2824
2990
  var connectOpenRouterButtons = Array.from(
2825
2991
  document.querySelectorAll("[data-openrouter-connect]")
2826
2992
  );
2993
+ var connectOpenAiCompatibleButtons = Array.from(
2994
+ document.querySelectorAll("[data-openai-compatible-connect]")
2995
+ );
2996
+ var GRAPH_REFRESH_TOOL_NAMES = /* @__PURE__ */ new Set(["write_file", "edit_file", "run_command"]);
2997
+ var configOverlayQuickConnects = document.getElementById("config-overlay-quick-connects");
2827
2998
  var configOverlaySpotlight = document.getElementById("config-overlay-spotlight");
2828
2999
  var configOverlayIntro = document.getElementById("config-overlay-intro");
2829
3000
  var configConnectStatus = document.getElementById("config-connect-status");
@@ -2835,10 +3006,40 @@ var settingsPayload = null;
2835
3006
  var activeSavedSession = null;
2836
3007
  var activeBaseUrl = "";
2837
3008
  var sessionOpenRouterConnected = false;
3009
+ var sessionOpenAiCompatibleConnected = false;
2838
3010
  var sessionRefreshTracker = createLatestRequestTracker();
2839
3011
  var TOOL_RESULT_MAX = 500;
2840
3012
  var OPENROUTER_PKCE_VERIFIER_KEY = "minicode:openrouter:pkce-verifier";
2841
3013
  var OPENROUTER_PERSIST_TO_ENV_KEY = "minicode:openrouter:persist-to-env";
3014
+ var SESSION_AUTOSAVE_KEY = "minicode:session:auto-save";
3015
+ var SESSION_AUTOSAVE_LABEL_PREFIX = "Autosave";
3016
+ var OPENAI_COMPATIBLE_PRESETS = {
3017
+ lmstudio: {
3018
+ baseUrl: "http://localhost:1234/v1",
3019
+ helpText: "LM Studio pre-fills the default local server endpoint at http://localhost:1234/v1.",
3020
+ apiKeyPlaceholder: "Leave blank for LM Studio unless local auth is enabled"
3021
+ },
3022
+ openai: {
3023
+ baseUrl: "https://api.openai.com/v1",
3024
+ helpText: "OpenAI uses https://api.openai.com/v1 and typically requires an API key.",
3025
+ apiKeyPlaceholder: "Enter your OpenAI API key"
3026
+ },
3027
+ ollama: {
3028
+ baseUrl: "http://localhost:11434/v1",
3029
+ helpText: "Ollama pre-fills the default local server endpoint at http://localhost:11434/v1.",
3030
+ apiKeyPlaceholder: "Leave blank for Ollama unless your proxy requires auth"
3031
+ },
3032
+ custom: {
3033
+ baseUrl: "",
3034
+ helpText: "Custom leaves the endpoint fully editable so you can point minicode at any OpenAI-compatible API.",
3035
+ apiKeyPlaceholder: "Add an API key only if this endpoint requires auth"
3036
+ }
3037
+ };
3038
+ var sessionAutoSaveEnabled = loadSessionAutoSavePreference();
3039
+ var pendingAutoSaveLabel = null;
3040
+ var autoSaveInFlight = null;
3041
+ var autoSaveQueued = false;
3042
+ sessionAutoSaveToggle.checked = sessionAutoSaveEnabled;
2842
3043
  function connect() {
2843
3044
  const protocol = location.protocol === "https:" ? "wss:" : "ws:";
2844
3045
  ws = new WebSocket(`${protocol}//${location.host}`);
@@ -2886,6 +3087,51 @@ function clearConfigConnectStatus() {
2886
3087
  configConnectStatus.textContent = "";
2887
3088
  configConnectStatus.className = "config-connect-status hidden";
2888
3089
  }
3090
+ function setOpenAiCompatibleConnectStatus(message, tone) {
3091
+ openAiCompatibleConnectStatus.textContent = message;
3092
+ openAiCompatibleConnectStatus.className = `config-connect-status ${tone}`;
3093
+ }
3094
+ function clearOpenAiCompatibleConnectStatus() {
3095
+ openAiCompatibleConnectStatus.textContent = "";
3096
+ openAiCompatibleConnectStatus.className = "config-connect-status hidden";
3097
+ }
3098
+ function normalizeBaseUrl(value) {
3099
+ return value.trim().replace(/\/+$/, "");
3100
+ }
3101
+ function inferOpenAiCompatiblePreset(baseUrl) {
3102
+ const normalizedBaseUrl = normalizeBaseUrl(baseUrl).toLowerCase();
3103
+ if (normalizedBaseUrl === OPENAI_COMPATIBLE_PRESETS.lmstudio.baseUrl) {
3104
+ return "lmstudio";
3105
+ }
3106
+ if (normalizedBaseUrl === OPENAI_COMPATIBLE_PRESETS.openai.baseUrl) {
3107
+ return "openai";
3108
+ }
3109
+ if (normalizedBaseUrl === OPENAI_COMPATIBLE_PRESETS.ollama.baseUrl) {
3110
+ return "ollama";
3111
+ }
3112
+ return "custom";
3113
+ }
3114
+ function applyOpenAiCompatiblePreset(preset, options = {}) {
3115
+ const presetConfig = OPENAI_COMPATIBLE_PRESETS[preset];
3116
+ openAiCompatiblePresetHelp.textContent = presetConfig.helpText;
3117
+ openAiCompatibleApiKeyInput.placeholder = presetConfig.apiKeyPlaceholder;
3118
+ if (preset === "custom" && options.preserveCustomValue) {
3119
+ return;
3120
+ }
3121
+ openAiCompatibleBaseUrlInput.value = presetConfig.baseUrl;
3122
+ }
3123
+ function isModalOpen(modal) {
3124
+ return !modal.classList.contains("hidden");
3125
+ }
3126
+ function isSettingsModalOpen() {
3127
+ return isModalOpen(settingsModal);
3128
+ }
3129
+ function isOpenRouterConnectModalOpen() {
3130
+ return isModalOpen(openRouterConnectModal);
3131
+ }
3132
+ function isOpenAiCompatibleConnectModalOpen() {
3133
+ return isModalOpen(openAiCompatibleConnectModal);
3134
+ }
2889
3135
  function encodeBase64Url(bytes) {
2890
3136
  let binary = "";
2891
3137
  for (const byte of bytes) {
@@ -2958,6 +3204,7 @@ async function maybeHandleOpenRouterCallback() {
2958
3204
  if (onlyModelMissing) {
2959
3205
  modelDropdown.classList.remove("hidden");
2960
3206
  sessionDropdown.classList.add("hidden");
3207
+ focusModelSearchInput();
2961
3208
  }
2962
3209
  } catch (error) {
2963
3210
  const message = error instanceof Error ? error.message : "Failed to connect OpenRouter";
@@ -2993,6 +3240,76 @@ async function disconnectOpenRouter() {
2993
3240
  disconnectOpenRouterBtn.disabled = false;
2994
3241
  }
2995
3242
  }
3243
+ async function connectOpenAiCompatible() {
3244
+ const baseUrl = normalizeBaseUrl(openAiCompatibleBaseUrlInput.value);
3245
+ const apiKey = openAiCompatibleApiKeyInput.value.trim();
3246
+ if (!baseUrl) {
3247
+ setOpenAiCompatibleConnectStatus("Endpoint is required.", "error");
3248
+ return;
3249
+ }
3250
+ clearOpenAiCompatibleConnectStatus();
3251
+ clearSettingsBanner();
3252
+ openAiCompatibleConnectContinueBtn.disabled = true;
3253
+ try {
3254
+ const res = await fetch("/api/openai-compatible/connect", {
3255
+ method: "POST",
3256
+ headers: { "Content-Type": "application/json" },
3257
+ body: JSON.stringify({
3258
+ baseUrl,
3259
+ apiKey,
3260
+ persistToEnv: openAiCompatiblePersistCheckbox.checked
3261
+ })
3262
+ });
3263
+ const body = await res.json();
3264
+ if (!res.ok) {
3265
+ throw new Error("error" in body ? body.error : `Failed to connect OpenAI-compatible provider (${res.status})`);
3266
+ }
3267
+ activeBaseUrl = body.baseUrl;
3268
+ addMessage(body.message, "thinking");
3269
+ const tone = body.persistWarning ? "info" : body.needsSetup ? "info" : "success";
3270
+ setConfigConnectStatus(body.message, tone);
3271
+ setSettingsBanner(body.message, tone === "success" ? "success" : "info");
3272
+ closeOpenAiCompatibleConnectModal();
3273
+ await fetchStatus();
3274
+ await refreshModelList();
3275
+ const onlyModelMissing = body.needsSetup && body.missing.length === 1 && body.missing[0]?.includes("MODEL");
3276
+ if (onlyModelMissing) {
3277
+ modelDropdown.classList.remove("hidden");
3278
+ sessionDropdown.classList.add("hidden");
3279
+ focusModelSearchInput();
3280
+ }
3281
+ } catch (error) {
3282
+ const message = error instanceof Error ? error.message : "Failed to connect OpenAI-compatible provider";
3283
+ setOpenAiCompatibleConnectStatus(message, "error");
3284
+ } finally {
3285
+ openAiCompatibleConnectContinueBtn.disabled = false;
3286
+ }
3287
+ }
3288
+ async function disconnectOpenAiCompatible() {
3289
+ disconnectOpenAiCompatibleBtn.disabled = true;
3290
+ clearSettingsBanner();
3291
+ try {
3292
+ const res = await fetch("/api/openai-compatible/disconnect", {
3293
+ method: "POST",
3294
+ headers: { "Content-Type": "application/json" }
3295
+ });
3296
+ const body = await res.json();
3297
+ if (!res.ok) {
3298
+ throw new Error("error" in body ? body.error : `Failed to disconnect OpenAI-compatible provider (${res.status})`);
3299
+ }
3300
+ activeBaseUrl = body.baseUrl;
3301
+ addMessage(body.message, "thinking");
3302
+ setSettingsBanner(body.message, body.disconnected ? "success" : "info");
3303
+ clearConfigConnectStatus();
3304
+ await fetchStatus();
3305
+ await refreshModelList();
3306
+ } catch (error) {
3307
+ const message = error instanceof Error ? error.message : "Failed to disconnect OpenAI-compatible provider";
3308
+ setSettingsBanner(message, "error");
3309
+ } finally {
3310
+ disconnectOpenAiCompatibleBtn.disabled = false;
3311
+ }
3312
+ }
2996
3313
  async function fetchStatus() {
2997
3314
  try {
2998
3315
  const res = await fetch("/api/status");
@@ -3002,22 +3319,32 @@ async function fetchStatus() {
3002
3319
  activeModel = data.model;
3003
3320
  activeBaseUrl = data.baseUrl ?? "";
3004
3321
  sessionOpenRouterConnected = data.sessionOpenRouterConnected ?? false;
3005
- renderOpenRouterSessionControls();
3322
+ sessionOpenAiCompatibleConnected = data.sessionOpenAiCompatibleConnected ?? false;
3323
+ renderSessionProviderControls();
3006
3324
  if (data.needsSetup) {
3007
3325
  configOverlay.classList.remove("hidden");
3008
3326
  chatInput.disabled = true;
3009
3327
  sendBtn.disabled = true;
3010
3328
  const missingEl = document.getElementById("config-missing");
3011
- if (missingEl && data.missing && data.missing.length > 0) {
3012
- const isOnlyModelMissing = data.missing.length === 1 && data.missing[0].includes("MODEL");
3013
- const hasPersistedOpenRouter = isOnlyModelMissing && (data.baseUrl ?? "").includes("openrouter");
3014
- if (configOverlayIntro) {
3015
- configOverlayIntro.textContent = hasPersistedOpenRouter ? "OpenRouter is already configured. Select a model to continue:" : "minicode needs a model provider to run. Configure one of the following:";
3329
+ const overlayState = deriveSetupOverlayState({
3330
+ configuredProvider: data.configuredProvider ?? null,
3331
+ missing: data.missing ?? []
3332
+ });
3333
+ if (configOverlayIntro) {
3334
+ configOverlayIntro.textContent = overlayState.introText;
3335
+ }
3336
+ configOverlayQuickConnects?.classList.toggle("hidden", overlayState.hideQuickConnects);
3337
+ configOverlaySpotlight?.classList.toggle("hidden", overlayState.hideOpenRouterSpotlight);
3338
+ if (missingEl) {
3339
+ if (overlayState.missingItems.length > 0) {
3340
+ const hint = overlayState.showModelSelectionHint ? ` \u2014 select one from the <strong>model dropdown</strong> above, or set it in config` : "";
3341
+ const note = overlayState.modelSelectionNote ? ` ${escapeHtml(overlayState.modelSelectionNote)}` : "";
3342
+ missingEl.innerHTML = `<strong>Missing:</strong> ${overlayState.missingItems.map(escapeHtml).join(", ")}${hint}${note}`;
3343
+ missingEl.classList.remove("hidden");
3344
+ } else {
3345
+ missingEl.classList.add("hidden");
3346
+ missingEl.innerHTML = "";
3016
3347
  }
3017
- configOverlaySpotlight?.classList.toggle("hidden", hasPersistedOpenRouter);
3018
- const hint = isOnlyModelMissing ? ` \u2014 select one from the <strong>model dropdown</strong> above, or set it in config` : "";
3019
- missingEl.innerHTML = `<strong>Missing:</strong> ${data.missing.map(escapeHtml).join(", ")}${hint}`;
3020
- missingEl.classList.remove("hidden");
3021
3348
  }
3022
3349
  } else {
3023
3350
  configOverlay.classList.add("hidden");
@@ -3029,8 +3356,9 @@ async function fetchStatus() {
3029
3356
  missingEl.innerHTML = "";
3030
3357
  }
3031
3358
  if (configOverlayIntro) {
3032
- configOverlayIntro.textContent = "minicode needs a model provider to run. Configure one of the following:";
3359
+ configOverlayIntro.textContent = DEFAULT_SETUP_INTRO;
3033
3360
  }
3361
+ configOverlayQuickConnects?.classList.remove("hidden");
3034
3362
  configOverlaySpotlight?.classList.remove("hidden");
3035
3363
  }
3036
3364
  } catch {
@@ -3079,6 +3407,9 @@ function handleServerMessage(msg) {
3079
3407
  break;
3080
3408
  case "tool_call_end":
3081
3409
  finalizeToolCall(msg.name || "", msg.result || "", msg.elapsedMs || 0);
3410
+ if (GRAPH_REFRESH_TOOL_NAMES.has(msg.name || "")) {
3411
+ scheduleGraphDataRefresh();
3412
+ }
3082
3413
  break;
3083
3414
  case "turn_end":
3084
3415
  if (hadToolCalls && msg.text) {
@@ -3099,6 +3430,7 @@ function handleServerMessage(msg) {
3099
3430
  if (msg.usage) {
3100
3431
  addUsageInfo(msg.usage);
3101
3432
  }
3433
+ queueSessionAutoSave();
3102
3434
  break;
3103
3435
  case "error":
3104
3436
  addMessage(`Error: ${msg.message || ""}`, "error");
@@ -3245,7 +3577,8 @@ function updateContextIndicator(contextTokens, maxContextTokens) {
3245
3577
  }
3246
3578
  contextLabel.textContent = pct + "%";
3247
3579
  const indicator = document.getElementById("context-indicator");
3248
- indicator.title = `Context: ~${contextTokens.toLocaleString()} / ${maxContextTokens.toLocaleString()} tokens (${pct}%)`;
3580
+ indicator.title = `Context: ~${contextTokens.toLocaleString()} / ${maxContextTokens.toLocaleString()} tokens (${pct}%)
3581
+ Adjust context size in Settings if you want it larger or smaller.`;
3249
3582
  }
3250
3583
  async function fetchContext() {
3251
3584
  try {
@@ -3264,8 +3597,12 @@ function addUsageInfo(usage) {
3264
3597
  function scrollToBottom() {
3265
3598
  messagesEl.scrollTop = messagesEl.scrollHeight;
3266
3599
  }
3267
- function closeHeaderMenus() {
3600
+ function closeModelDropdown() {
3268
3601
  modelDropdown.classList.add("hidden");
3602
+ modelSearchInput.value = "";
3603
+ }
3604
+ function closeHeaderMenus() {
3605
+ closeModelDropdown();
3269
3606
  sessionDropdown.classList.add("hidden");
3270
3607
  }
3271
3608
  function formatSettingsValue(value) {
@@ -3279,16 +3616,31 @@ function clearSettingsBanner() {
3279
3616
  settingsBanner.textContent = "";
3280
3617
  settingsBanner.className = "settings-banner hidden";
3281
3618
  }
3282
- function renderOpenRouterSessionControls() {
3619
+ function renderSessionProviderControls() {
3283
3620
  if (sessionOpenRouterConnected) {
3284
3621
  settingsOpenRouterSession.classList.remove("hidden");
3285
3622
  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.";
3623
+ settingsOpenAiCompatibleSession.classList.add("hidden");
3624
+ settingsOpenAiCompatibleSessionMeta.textContent = "";
3286
3625
  disconnectOpenRouterBtn.disabled = false;
3626
+ disconnectOpenAiCompatibleBtn.disabled = false;
3627
+ return;
3628
+ }
3629
+ if (sessionOpenAiCompatibleConnected) {
3630
+ settingsOpenAiCompatibleSession.classList.remove("hidden");
3631
+ settingsOpenAiCompatibleSessionMeta.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.";
3632
+ settingsOpenRouterSession.classList.add("hidden");
3633
+ settingsOpenRouterSessionMeta.textContent = "";
3634
+ disconnectOpenRouterBtn.disabled = false;
3635
+ disconnectOpenAiCompatibleBtn.disabled = false;
3287
3636
  return;
3288
3637
  }
3289
3638
  settingsOpenRouterSession.classList.add("hidden");
3290
3639
  settingsOpenRouterSessionMeta.textContent = "";
3640
+ settingsOpenAiCompatibleSession.classList.add("hidden");
3641
+ settingsOpenAiCompatibleSessionMeta.textContent = "";
3291
3642
  disconnectOpenRouterBtn.disabled = false;
3643
+ disconnectOpenAiCompatibleBtn.disabled = false;
3292
3644
  }
3293
3645
  function createSettingsControl(entry, inputId) {
3294
3646
  const value = entry.persistedValue;
@@ -3499,6 +3851,27 @@ function closeOpenRouterConnectModal() {
3499
3851
  closeModal(openRouterConnectModal);
3500
3852
  openRouterConnectContinueBtn.disabled = false;
3501
3853
  }
3854
+ function openOpenAiCompatibleConnectModal() {
3855
+ closeHeaderMenus();
3856
+ openModal(openAiCompatibleConnectModal);
3857
+ const preset = inferOpenAiCompatiblePreset(activeBaseUrl);
3858
+ openAiCompatiblePresetSelect.value = preset;
3859
+ openAiCompatibleBaseUrlInput.value = preset === "custom" ? activeBaseUrl : OPENAI_COMPATIBLE_PRESETS[preset].baseUrl;
3860
+ openAiCompatibleApiKeyInput.value = "";
3861
+ openAiCompatiblePersistCheckbox.checked = false;
3862
+ openAiCompatibleConnectContinueBtn.disabled = false;
3863
+ applyOpenAiCompatiblePreset(preset, { preserveCustomValue: preset === "custom" });
3864
+ clearOpenAiCompatibleConnectStatus();
3865
+ requestAnimationFrame(() => {
3866
+ openAiCompatibleBaseUrlInput.focus();
3867
+ openAiCompatibleBaseUrlInput.select();
3868
+ });
3869
+ }
3870
+ function closeOpenAiCompatibleConnectModal() {
3871
+ closeModal(openAiCompatibleConnectModal);
3872
+ openAiCompatibleConnectContinueBtn.disabled = false;
3873
+ clearOpenAiCompatibleConnectStatus();
3874
+ }
3502
3875
  chatForm.addEventListener("submit", (e) => {
3503
3876
  e.preventDefault();
3504
3877
  const message = chatInput.value.trim();
@@ -3522,30 +3895,99 @@ chatInput.addEventListener("keydown", (e) => {
3522
3895
  }
3523
3896
  });
3524
3897
  var activeModel = "";
3898
+ var availableModels = [];
3899
+ function focusModelSearchInput() {
3900
+ requestAnimationFrame(() => {
3901
+ modelSearchInput.focus();
3902
+ modelSearchInput.select();
3903
+ });
3904
+ }
3905
+ function renderModelList() {
3906
+ const filteredModels = filterModelsByQuery(availableModels, modelSearchInput.value);
3907
+ if (availableModels.length === 0) {
3908
+ modelList.innerHTML = '<div class="dropdown-empty">No models available</div>';
3909
+ return;
3910
+ }
3911
+ if (filteredModels.length === 0) {
3912
+ modelList.innerHTML = '<div class="dropdown-empty">No matching models</div>';
3913
+ return;
3914
+ }
3915
+ modelList.innerHTML = "";
3916
+ for (const model of filteredModels) {
3917
+ const el = document.createElement("button");
3918
+ el.type = "button";
3919
+ el.className = "model-item" + (model.id === activeModel ? " active" : "");
3920
+ el.title = model.id;
3921
+ const body = document.createElement("div");
3922
+ body.className = "model-item-body";
3923
+ const name = document.createElement("span");
3924
+ name.className = "model-item-name";
3925
+ name.textContent = getModelDisplayName(model);
3926
+ body.appendChild(name);
3927
+ if ((model.name ?? "").trim() && getModelDisplayName(model) !== model.id) {
3928
+ const subtitle = document.createElement("span");
3929
+ subtitle.className = "model-item-subtitle";
3930
+ subtitle.textContent = model.id;
3931
+ body.appendChild(subtitle);
3932
+ }
3933
+ el.appendChild(body);
3934
+ if (model.id === activeModel) {
3935
+ const badge = document.createElement("span");
3936
+ badge.className = "model-item-badge";
3937
+ badge.textContent = "Active";
3938
+ el.appendChild(badge);
3939
+ }
3940
+ el.addEventListener("click", () => {
3941
+ void switchModel(model.id);
3942
+ });
3943
+ modelList.appendChild(el);
3944
+ }
3945
+ }
3525
3946
  modelBtn.addEventListener("click", (e) => {
3526
3947
  e.stopPropagation();
3527
3948
  const isOpen = !modelDropdown.classList.contains("hidden");
3528
- modelDropdown.classList.toggle("hidden");
3529
- sessionDropdown.classList.add("hidden");
3530
- if (!isOpen) {
3531
- refreshModelList();
3949
+ if (isOpen) {
3950
+ closeModelDropdown();
3951
+ return;
3532
3952
  }
3953
+ closeHeaderMenus();
3954
+ modelDropdown.classList.remove("hidden");
3955
+ modelSearchInput.value = "";
3956
+ void refreshModelList({ focusSearch: true });
3533
3957
  });
3534
3958
  document.addEventListener("click", (e) => {
3535
3959
  if (!modelDropdown.contains(e.target) && e.target !== modelBtn) {
3536
- modelDropdown.classList.add("hidden");
3960
+ closeModelDropdown();
3961
+ }
3962
+ });
3963
+ modelSearchInput.addEventListener("input", () => {
3964
+ renderModelList();
3965
+ });
3966
+ modelSearchInput.addEventListener("keydown", (e) => {
3967
+ if (e.key === "Escape") {
3968
+ if (modelSearchInput.value) {
3969
+ modelSearchInput.value = "";
3970
+ renderModelList();
3971
+ return;
3972
+ }
3973
+ closeModelDropdown();
3537
3974
  }
3538
3975
  });
3539
- async function refreshModelList() {
3976
+ modelSearchInput.addEventListener("click", (e) => {
3977
+ e.stopPropagation();
3978
+ });
3979
+ async function refreshModelList(options = {}) {
3540
3980
  try {
3541
3981
  const res = await fetch("/api/models");
3542
3982
  const data = await res.json();
3543
3983
  activeModel = data.activeModel;
3984
+ availableModels = data.models ?? [];
3544
3985
  const hasActiveModel = !!activeModel && data.models.some((model) => model.id === activeModel);
3545
3986
  if (!data.models || data.models.length === 0) {
3546
3987
  modelInfo.textContent = "Select model";
3547
3988
  modelInfo.classList.add("placeholder");
3548
- modelList.innerHTML = '<div class="dropdown-empty">No models available</div>';
3989
+ availableModels = [];
3990
+ renderModelList();
3549
3991
  return;
3550
3992
  }
3551
3993
  if (hasActiveModel) {
@@ -3555,18 +3997,12 @@ async function refreshModelList() {
3555
3997
  modelInfo.textContent = "Select model";
3556
3998
  modelInfo.classList.add("placeholder");
3557
3999
  }
3558
- modelList.innerHTML = "";
3559
- for (const m2 of data.models) {
3560
- const el = document.createElement("div");
3561
- el.className = "model-item" + (m2.id === activeModel ? " active" : "");
3562
- el.textContent = m2.name ?? m2.id;
3563
- el.title = m2.id;
3564
- el.addEventListener("click", () => {
3565
- void switchModel(m2.id);
3566
- });
3567
- modelList.appendChild(el);
4000
+ renderModelList();
4001
+ if (options.focusSearch && !modelDropdown.classList.contains("hidden")) {
4002
+ focusModelSearchInput();
3568
4003
  }
3569
4004
  } catch {
4005
+ availableModels = [];
3570
4006
  modelList.innerHTML = '<div class="dropdown-empty">Failed to load models</div>';
3571
4007
  }
3572
4008
  }
@@ -3587,7 +4023,8 @@ async function switchModel(modelId) {
3587
4023
  modelInfo.textContent = modelId || "Select model";
3588
4024
  modelInfo.classList.toggle("placeholder", !modelId);
3589
4025
  activeModel = modelId;
3590
- modelDropdown.classList.add("hidden");
4026
+ renderModelList();
4027
+ closeModelDropdown();
3591
4028
  if (body.persistedToEnv) {
3592
4029
  addMessage(`Model switched to: ${modelId}. Saved as the default in ~/.minicode/.env.`, "thinking");
3593
4030
  } else {
@@ -3603,7 +4040,7 @@ sessionBtn.addEventListener("click", (e) => {
3603
4040
  e.stopPropagation();
3604
4041
  const isOpen = !sessionDropdown.classList.contains("hidden");
3605
4042
  sessionDropdown.classList.toggle("hidden");
3606
- modelDropdown.classList.add("hidden");
4043
+ closeModelDropdown();
3607
4044
  if (!isOpen) {
3608
4045
  refreshSessionList();
3609
4046
  }
@@ -3613,27 +4050,130 @@ document.addEventListener("click", (e) => {
3613
4050
  sessionDropdown.classList.add("hidden");
3614
4051
  }
3615
4052
  });
4053
+ function loadSessionAutoSavePreference() {
4054
+ try {
4055
+ return localStorage.getItem(SESSION_AUTOSAVE_KEY) === "1";
4056
+ } catch {
4057
+ return false;
4058
+ }
4059
+ }
4060
+ function persistSessionAutoSavePreference(enabled) {
4061
+ try {
4062
+ if (enabled) {
4063
+ localStorage.setItem(SESSION_AUTOSAVE_KEY, "1");
4064
+ } else {
4065
+ localStorage.removeItem(SESSION_AUTOSAVE_KEY);
4066
+ }
4067
+ } catch {
4068
+ }
4069
+ }
4070
+ function buildAutoSaveLabel() {
4071
+ return `${SESSION_AUTOSAVE_LABEL_PREFIX} ${(/* @__PURE__ */ new Date()).toLocaleString()}`;
4072
+ }
4073
+ async function persistCurrentSession(label) {
4074
+ const res = await fetch("/api/sessions/save", {
4075
+ method: "POST",
4076
+ headers: { "Content-Type": "application/json" },
4077
+ body: JSON.stringify({ label })
4078
+ });
4079
+ const body = await res.json();
4080
+ if (!res.ok) {
4081
+ throw new Error("error" in body ? body.error : `Failed to save session (${res.status})`);
4082
+ }
4083
+ return body;
4084
+ }
4085
+ async function deleteSavedSession(session) {
4086
+ const isCurrentSavedSession = activeSavedSession?.id === session.id;
4087
+ const confirmed = window.confirm(`Delete saved session "${session.label}"?`);
4088
+ if (!confirmed) {
4089
+ return;
4090
+ }
4091
+ try {
4092
+ const res = await fetch(`/api/sessions/${encodeURIComponent(session.id)}`, {
4093
+ method: "DELETE"
4094
+ });
4095
+ const body = await res.json();
4096
+ if (!res.ok) {
4097
+ throw new Error("error" in body ? body.error : `Failed to delete session (${res.status})`);
4098
+ }
4099
+ if (isCurrentSavedSession) {
4100
+ activeSavedSession = null;
4101
+ }
4102
+ if (pendingAutoSaveLabel === session.label) {
4103
+ pendingAutoSaveLabel = null;
4104
+ }
4105
+ addMessage(
4106
+ isCurrentSavedSession ? `Deleted saved session "${session.label}". The current chat stays open until you load another session or refresh.` : `Session deleted: "${session.label}"`,
4107
+ "thinking"
4108
+ );
4109
+ await refreshSessionList();
4110
+ } catch (error) {
4111
+ const message = error instanceof Error ? error.message : "Failed to delete session";
4112
+ addMessage(message, "error");
4113
+ }
4114
+ }
4115
+ async function maybeAutoSaveSession() {
4116
+ if (!sessionAutoSaveEnabled) {
4117
+ return;
4118
+ }
4119
+ const label = activeSavedSession?.label ?? pendingAutoSaveLabel ?? buildAutoSaveLabel();
4120
+ try {
4121
+ const data = await persistCurrentSession(label);
4122
+ pendingAutoSaveLabel = data.label;
4123
+ await refreshSessionList();
4124
+ } catch (error) {
4125
+ const message = error instanceof Error ? error.message : "Failed to auto-save session";
4126
+ addMessage(message, "error");
4127
+ }
4128
+ }
4129
+ function queueSessionAutoSave() {
4130
+ if (!sessionAutoSaveEnabled) {
4131
+ return;
4132
+ }
4133
+ if (autoSaveInFlight) {
4134
+ autoSaveQueued = true;
4135
+ return;
4136
+ }
4137
+ autoSaveInFlight = (async () => {
4138
+ await maybeAutoSaveSession();
4139
+ })();
4140
+ void autoSaveInFlight.finally(() => {
4141
+ autoSaveInFlight = null;
4142
+ if (autoSaveQueued) {
4143
+ autoSaveQueued = false;
4144
+ queueSessionAutoSave();
4145
+ }
4146
+ });
4147
+ }
4148
+ sessionAutoSaveToggle.addEventListener("change", () => {
4149
+ sessionAutoSaveEnabled = sessionAutoSaveToggle.checked;
4150
+ persistSessionAutoSavePreference(sessionAutoSaveEnabled);
4151
+ if (sessionAutoSaveEnabled) {
4152
+ addMessage(
4153
+ activeSavedSession ? `Auto-save enabled. minicode will update "${activeSavedSession.label}" after each completed turn.` : "Auto-save enabled. minicode will save this chat after the next completed turn.",
4154
+ "thinking"
4155
+ );
4156
+ } else {
4157
+ addMessage("Auto-save disabled.", "thinking");
4158
+ }
4159
+ });
3616
4160
  saveBtn.addEventListener("click", async () => {
3617
4161
  const requestedLabel = saveLabelInput.value.trim();
3618
4162
  const label = requestedLabel || activeSavedSession?.label || void 0;
3619
4163
  const isUpdatingCurrentSession = !!activeSavedSession && (requestedLabel.length === 0 || requestedLabel === activeSavedSession.label);
3620
4164
  try {
3621
4165
  saveBtn.setAttribute("disabled", "true");
3622
- const res = await fetch("/api/sessions/save", {
3623
- method: "POST",
3624
- headers: { "Content-Type": "application/json" },
3625
- body: JSON.stringify({ label })
3626
- });
3627
- if (res.ok) {
3628
- const data = await res.json();
3629
- saveLabelInput.value = "";
3630
- addMessage(
3631
- `${isUpdatingCurrentSession ? "Session updated" : "Session saved"}: "${data.label}"`,
3632
- "thinking"
3633
- );
3634
- await refreshSessionList();
3635
- }
3636
- } catch {
4166
+ const data = await persistCurrentSession(label);
4167
+ saveLabelInput.value = "";
4168
+ pendingAutoSaveLabel = data.label;
4169
+ addMessage(
4170
+ `${isUpdatingCurrentSession ? "Session updated" : "Session saved"}: "${data.label}"`,
4171
+ "thinking"
4172
+ );
4173
+ await refreshSessionList();
4174
+ } catch (error) {
4175
+ const message = error instanceof Error ? error.message : "Failed to save session";
4176
+ addMessage(message, "error");
3637
4177
  } finally {
3638
4178
  saveBtn.removeAttribute("disabled");
3639
4179
  }
@@ -3655,6 +4195,7 @@ async function refreshSessionList() {
3655
4195
  const sessions = data.sessions;
3656
4196
  activeSavedSession = sessions.find((session) => session.id === data.currentSessionId) ?? null;
3657
4197
  if (activeSavedSession) {
4198
+ pendingAutoSaveLabel = activeSavedSession.label;
3658
4199
  sessionUpdateRow.classList.remove("hidden");
3659
4200
  sessionUpdateBtn.textContent = `Update "${activeSavedSession.label}"`;
3660
4201
  sessionUpdateBtn.title = `Save changes back to "${activeSavedSession.label}"`;
@@ -3672,8 +4213,22 @@ async function refreshSessionList() {
3672
4213
  const el = document.createElement("div");
3673
4214
  const isActive = activeSavedSession?.id === s.id;
3674
4215
  el.className = "session-item" + (isActive ? " active" : "");
3675
- el.innerHTML = `<span class="session-label">${escapeHtml(s.label)}</span><span class="session-meta">${s.messageCount} msgs${isActive ? ' <span class="session-active-badge">\u2022 active</span>' : ""}</span>`;
3676
- el.addEventListener("click", () => loadSession(s.label));
4216
+ const loadBtn = document.createElement("button");
4217
+ loadBtn.type = "button";
4218
+ loadBtn.className = "session-load-btn";
4219
+ loadBtn.innerHTML = `<span class="session-label">${escapeHtml(s.label)}</span><span class="session-meta">${s.messageCount} msgs${isActive ? ' <span class="session-active-badge">\u2022 active</span>' : ""}</span>`;
4220
+ loadBtn.addEventListener("click", () => loadSession(s.label));
4221
+ const deleteBtn = document.createElement("button");
4222
+ deleteBtn.type = "button";
4223
+ deleteBtn.className = "session-delete-btn";
4224
+ deleteBtn.textContent = "Delete";
4225
+ deleteBtn.title = `Delete "${s.label}"`;
4226
+ deleteBtn.addEventListener("click", (event) => {
4227
+ event.stopPropagation();
4228
+ void deleteSavedSession(s);
4229
+ });
4230
+ el.appendChild(loadBtn);
4231
+ el.appendChild(deleteBtn);
3677
4232
  sessionList.appendChild(el);
3678
4233
  }
3679
4234
  } catch {
@@ -3695,6 +4250,7 @@ async function loadSession(label) {
3695
4250
  if (res.ok) {
3696
4251
  const body = await res.json();
3697
4252
  sessionDropdown.classList.add("hidden");
4253
+ pendingAutoSaveLabel = body.label;
3698
4254
  renderLoadedSessionMessages(body.messages);
3699
4255
  if (body.messages.length === 0) {
3700
4256
  addMessage(`Session "${body.label}" restored`, "thinking");
@@ -3710,17 +4266,13 @@ sessionUpdateBtn.addEventListener("click", async () => {
3710
4266
  }
3711
4267
  try {
3712
4268
  sessionUpdateBtn.disabled = true;
3713
- const res = await fetch("/api/sessions/save", {
3714
- method: "POST",
3715
- headers: { "Content-Type": "application/json" },
3716
- body: JSON.stringify({ label: activeSavedSession.label })
3717
- });
3718
- if (res.ok) {
3719
- const data = await res.json();
3720
- addMessage(`Session updated: "${data.label}"`, "thinking");
3721
- await refreshSessionList();
3722
- }
3723
- } catch {
4269
+ const data = await persistCurrentSession(activeSavedSession.label);
4270
+ pendingAutoSaveLabel = data.label;
4271
+ addMessage(`Session updated: "${data.label}"`, "thinking");
4272
+ await refreshSessionList();
4273
+ } catch (error) {
4274
+ const message = error instanceof Error ? error.message : "Failed to update session";
4275
+ addMessage(message, "error");
3724
4276
  } finally {
3725
4277
  sessionUpdateBtn.disabled = false;
3726
4278
  }
@@ -3743,10 +4295,19 @@ openRouterConnectCloseBtn.addEventListener("click", () => {
3743
4295
  openRouterConnectCancelBtn.addEventListener("click", () => {
3744
4296
  closeOpenRouterConnectModal();
3745
4297
  });
4298
+ openAiCompatibleConnectBackdrop.addEventListener("click", () => {
4299
+ closeOpenAiCompatibleConnectModal();
4300
+ });
4301
+ openAiCompatibleConnectCloseBtn.addEventListener("click", () => {
4302
+ closeOpenAiCompatibleConnectModal();
4303
+ });
4304
+ openAiCompatibleConnectCancelBtn.addEventListener("click", () => {
4305
+ closeOpenAiCompatibleConnectModal();
4306
+ });
3746
4307
  settingsResetBtn.addEventListener("click", () => {
3747
4308
  clearSettingsBanner();
3748
4309
  renderSettings();
3749
- renderOpenRouterSessionControls();
4310
+ renderSessionProviderControls();
3750
4311
  });
3751
4312
  settingsSaveBtn.addEventListener("click", async () => {
3752
4313
  if (!settingsPayload) {
@@ -3798,6 +4359,9 @@ settingsList.addEventListener("change", () => {
3798
4359
  disconnectOpenRouterBtn.addEventListener("click", () => {
3799
4360
  void disconnectOpenRouter();
3800
4361
  });
4362
+ disconnectOpenAiCompatibleBtn.addEventListener("click", () => {
4363
+ void disconnectOpenAiCompatible();
4364
+ });
3801
4365
  document.addEventListener("keydown", (event) => {
3802
4366
  if (event.key !== "Escape") {
3803
4367
  return;
@@ -3806,6 +4370,10 @@ document.addEventListener("keydown", (event) => {
3806
4370
  closeOpenRouterConnectModal();
3807
4371
  return;
3808
4372
  }
4373
+ if (isOpenAiCompatibleConnectModalOpen()) {
4374
+ closeOpenAiCompatibleConnectModal();
4375
+ return;
4376
+ }
3809
4377
  if (isSettingsModalOpen()) {
3810
4378
  closeSettings();
3811
4379
  }
@@ -3815,11 +4383,29 @@ for (const button of connectOpenRouterButtons) {
3815
4383
  openOpenRouterConnectModal();
3816
4384
  });
3817
4385
  }
4386
+ for (const button of connectOpenAiCompatibleButtons) {
4387
+ button.addEventListener("click", () => {
4388
+ openOpenAiCompatibleConnectModal();
4389
+ });
4390
+ }
4391
+ openAiCompatiblePresetSelect.addEventListener("change", () => {
4392
+ applyOpenAiCompatiblePreset(openAiCompatiblePresetSelect.value);
4393
+ clearOpenAiCompatibleConnectStatus();
4394
+ });
4395
+ openAiCompatibleBaseUrlInput.addEventListener("input", () => {
4396
+ clearOpenAiCompatibleConnectStatus();
4397
+ });
4398
+ openAiCompatibleApiKeyInput.addEventListener("input", () => {
4399
+ clearOpenAiCompatibleConnectStatus();
4400
+ });
3818
4401
  openRouterConnectContinueBtn.addEventListener("click", () => {
3819
4402
  openRouterConnectContinueBtn.disabled = true;
3820
4403
  closeOpenRouterConnectModal();
3821
4404
  void startOpenRouterConnect(openRouterPersistCheckbox.checked);
3822
4405
  });
4406
+ openAiCompatibleConnectContinueBtn.addEventListener("click", () => {
4407
+ void connectOpenAiCompatible();
4408
+ });
3823
4409
  var chatPane = document.getElementById("chat-pane");
3824
4410
  var divider = document.getElementById("pane-divider");
3825
4411
  divider.addEventListener("mousedown", (e) => {