@sean.holung/minicode 0.3.5 → 0.3.7

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 (47) hide show
  1. package/README.md +22 -45
  2. package/dist/scripts/run-benchmarks.js +1 -0
  3. package/dist/src/agent/config.js +53 -66
  4. package/dist/src/agent/editable-config.js +56 -58
  5. package/dist/src/agent/home-env.js +74 -0
  6. package/dist/src/cli/config-slash-command.js +15 -13
  7. package/dist/src/serve/agent-bridge.js +87 -28
  8. package/dist/src/serve/mcp-server.js +19 -13
  9. package/dist/src/serve/server.js +190 -4
  10. package/dist/src/session/session-preview.js +14 -0
  11. package/dist/src/shared/graph-search.js +80 -0
  12. package/dist/src/shared/graph-selection.js +40 -0
  13. package/dist/src/shared/symbol-search.js +156 -0
  14. package/dist/src/tools/search-code-map.js +27 -35
  15. package/dist/src/web/app.js +582 -64
  16. package/dist/src/web/index.html +84 -6
  17. package/dist/src/web/style.css +256 -1
  18. package/dist/tests/config-api.test.js +10 -5
  19. package/dist/tests/config-integration.test.js +130 -56
  20. package/dist/tests/config-slash-command.test.js +12 -11
  21. package/dist/tests/config.test.js +21 -4
  22. package/dist/tests/editable-config.test.js +15 -12
  23. package/dist/tests/graph-onboarding.test.js +22 -1
  24. package/dist/tests/graph-search.test.js +66 -0
  25. package/dist/tests/graph-selection.test.js +58 -0
  26. package/dist/tests/home-env.test.js +56 -0
  27. package/dist/tests/mcp-and-plugin.test.js +3 -0
  28. package/dist/tests/search-code-map.test.js +9 -0
  29. package/dist/tests/serve.integration.test.js +255 -6
  30. package/dist/tests/session-preview.test.js +56 -0
  31. package/dist/tests/session-ui.test.js +2 -0
  32. package/dist/tests/settings-ui.test.js +18 -0
  33. package/dist/tests/system-prompt.test.js +1 -0
  34. package/dist/tests/test-utils.js +1 -0
  35. package/node_modules/@minicode/agent-sdk/dist/src/agent/types.d.ts +1 -0
  36. package/node_modules/@minicode/agent-sdk/dist/src/agent/types.d.ts.map +1 -1
  37. package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts +8 -1
  38. package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts.map +1 -1
  39. package/node_modules/@minicode/agent-sdk/dist/src/model/client.js +143 -27
  40. package/node_modules/@minicode/agent-sdk/dist/src/model/client.js.map +1 -1
  41. package/node_modules/@minicode/agent-sdk/dist/tests/model-client-openai.test.js +87 -0
  42. package/node_modules/@minicode/agent-sdk/dist/tests/model-client-openai.test.js.map +1 -1
  43. package/node_modules/@minicode/agent-sdk/dist/tests/test-utils.d.ts.map +1 -1
  44. package/node_modules/@minicode/agent-sdk/dist/tests/test-utils.js +1 -0
  45. package/node_modules/@minicode/agent-sdk/dist/tests/test-utils.js.map +1 -1
  46. package/node_modules/@minicode/agent-sdk/dist/tsconfig.tsbuildinfo +1 -1
  47. package/package.json +1 -1
@@ -2,6 +2,22 @@ var __defProp = Object.defineProperty;
2
2
  var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
3
3
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
4
4
 
5
+ // src/web/modal-state.ts
6
+ function syncBodyModalOpenState() {
7
+ const anyModalOpen = [...document.querySelectorAll(".modal")].some((modal) => !modal.classList.contains("hidden"));
8
+ document.body.classList.toggle("modal-open", anyModalOpen);
9
+ }
10
+ function openModal(modal) {
11
+ modal.classList.remove("hidden");
12
+ modal.setAttribute("aria-hidden", "false");
13
+ syncBodyModalOpenState();
14
+ }
15
+ function closeModal(modal) {
16
+ modal.classList.add("hidden");
17
+ modal.setAttribute("aria-hidden", "true");
18
+ syncBodyModalOpenState();
19
+ }
20
+
5
21
  // node_modules/marked/lib/marked.esm.js
6
22
  function M() {
7
23
  return { async: false, breaks: false, extensions: null, gfm: true, hooks: null, pedantic: false, renderer: null, silent: false, tokenizer: null, walkTokens: null };
@@ -1523,10 +1539,135 @@ function resolveGraphNodeIds(nodes, symbolName) {
1523
1539
  return [...nodes.entries()].filter(([id, node]) => matchesGraphNodeQuery(query, node, id)).map(([id]) => id).sort((a, b2) => compareGraphNodeIds(a, b2, nodes));
1524
1540
  }
1525
1541
 
1542
+ // src/shared/graph-search.ts
1543
+ function getGraphNodeFilePath(node) {
1544
+ return node.filePath || node.file || "";
1545
+ }
1546
+ function buildGraphFileIndex(nodes) {
1547
+ const files = /* @__PURE__ */ new Map();
1548
+ for (const [id, node] of nodes) {
1549
+ const filePath = getGraphNodeFilePath(node);
1550
+ if (!filePath) continue;
1551
+ const existing = files.get(filePath);
1552
+ if (existing) {
1553
+ existing.push(id);
1554
+ } else {
1555
+ files.set(filePath, [id]);
1556
+ }
1557
+ }
1558
+ for (const symbolIds of files.values()) {
1559
+ symbolIds.sort((a, b2) => compareGraphNodeIds(a, b2, nodes));
1560
+ }
1561
+ return files;
1562
+ }
1563
+ function compareGraphFilePaths(a, b2, fileIndex) {
1564
+ const countDifference = (fileIndex.get(b2)?.length ?? 0) - (fileIndex.get(a)?.length ?? 0);
1565
+ if (countDifference !== 0) {
1566
+ return countDifference;
1567
+ }
1568
+ return a.localeCompare(b2, void 0, { sensitivity: "base" });
1569
+ }
1570
+ function matchesGraphFileQuery(query, filePath) {
1571
+ const normalizedQuery = query.trim().toLowerCase();
1572
+ if (normalizedQuery.length === 0) {
1573
+ return false;
1574
+ }
1575
+ return filePath.toLowerCase().includes(normalizedQuery);
1576
+ }
1577
+ function buildGraphSearchResults({
1578
+ query,
1579
+ symbolIds,
1580
+ nodes,
1581
+ fileIndex,
1582
+ symbolLimit = 12,
1583
+ fileLimit = 8
1584
+ }) {
1585
+ const normalizedQuery = query.trim().toLowerCase();
1586
+ const showDefaultResults = normalizedQuery.length < 2;
1587
+ const rankedFiles = [...fileIndex.keys()].sort((a, b2) => compareGraphFilePaths(a, b2, fileIndex));
1588
+ const symbolResults = symbolIds.filter((id) => {
1589
+ if (showDefaultResults) {
1590
+ return true;
1591
+ }
1592
+ return matchesGraphNodeQuery(normalizedQuery, nodes.get(id) || {}, id);
1593
+ }).slice(0, symbolLimit).map((id) => {
1594
+ const node = nodes.get(id) || {};
1595
+ return {
1596
+ type: "symbol",
1597
+ id,
1598
+ label: getGraphNodeLabel(node, id),
1599
+ subtitle: getGraphNodeFilePath(node),
1600
+ kind: (node.kind || "symbol").toLowerCase()
1601
+ };
1602
+ });
1603
+ const fileResults = rankedFiles.filter((filePath) => {
1604
+ if (showDefaultResults) {
1605
+ return true;
1606
+ }
1607
+ return matchesGraphFileQuery(normalizedQuery, filePath);
1608
+ }).slice(0, fileLimit).map((filePath) => {
1609
+ const symbolCount = fileIndex.get(filePath)?.length ?? 0;
1610
+ return {
1611
+ type: "file",
1612
+ id: filePath,
1613
+ label: filePath,
1614
+ subtitle: `${symbolCount} symbol${symbolCount === 1 ? "" : "s"}`,
1615
+ kind: "file",
1616
+ symbolCount
1617
+ };
1618
+ });
1619
+ return [...symbolResults, ...fileResults];
1620
+ }
1621
+
1622
+ // src/shared/graph-selection.ts
1623
+ function buildGraphEdgeId(edge) {
1624
+ return `${edge.source}->${edge.target}:${edge.kind}`;
1625
+ }
1626
+ function buildGraphEdgeIndex(edges) {
1627
+ const edgeIndex2 = /* @__PURE__ */ new Map();
1628
+ for (const edge of edges) {
1629
+ const sourceEdges = edgeIndex2.get(edge.source);
1630
+ if (sourceEdges) {
1631
+ sourceEdges.push(edge);
1632
+ } else {
1633
+ edgeIndex2.set(edge.source, [edge]);
1634
+ }
1635
+ const targetEdges = edgeIndex2.get(edge.target);
1636
+ if (targetEdges) {
1637
+ targetEdges.push(edge);
1638
+ } else {
1639
+ edgeIndex2.set(edge.target, [edge]);
1640
+ }
1641
+ }
1642
+ return edgeIndex2;
1643
+ }
1644
+ function buildFileFocusedSelection({
1645
+ filePath,
1646
+ fileIndex,
1647
+ edgeIndex: edgeIndex2
1648
+ }) {
1649
+ const fileSymbolIds = fileIndex.get(filePath) || [];
1650
+ const nodeIds = /* @__PURE__ */ new Set();
1651
+ const edges = /* @__PURE__ */ new Map();
1652
+ for (const symbolId of fileSymbolIds) {
1653
+ nodeIds.add(symbolId);
1654
+ for (const edge of edgeIndex2.get(symbolId) || []) {
1655
+ nodeIds.add(edge.source);
1656
+ nodeIds.add(edge.target);
1657
+ edges.set(buildGraphEdgeId(edge), edge);
1658
+ }
1659
+ }
1660
+ return {
1661
+ nodeIds: [...nodeIds],
1662
+ edges: [...edges.values()]
1663
+ };
1664
+ }
1665
+
1526
1666
  // src/web/graph.ts
1527
1667
  var cy = null;
1528
1668
  var graphNodes = /* @__PURE__ */ new Map();
1529
1669
  var graphEdges = [];
1670
+ var fileToSymbolIds = /* @__PURE__ */ new Map();
1530
1671
  var edgeIndex = /* @__PURE__ */ new Map();
1531
1672
  var pinnedNames = /* @__PURE__ */ new Set();
1532
1673
  var allSymbolNames = [];
@@ -1535,6 +1676,8 @@ var analysisReport = null;
1535
1676
  var activeAnalysisFindingId = null;
1536
1677
  var activeAnalysisFilter = "all";
1537
1678
  var analysisExplanationCache = /* @__PURE__ */ new Map();
1679
+ var filePreviewModalInitialized = false;
1680
+ var latestFilePreviewRequestId = 0;
1538
1681
  var LAYOUT_OPTIONS = {
1539
1682
  name: "cose",
1540
1683
  nodeRepulsion: function() {
@@ -1560,6 +1703,7 @@ async function initGraph() {
1560
1703
  initialized = true;
1561
1704
  const cyEl = document.getElementById("cy");
1562
1705
  const detailEl = document.getElementById("symbol-detail");
1706
+ setupFilePreviewModal();
1563
1707
  try {
1564
1708
  const [graphRes, symbolsRes, focusRes] = await Promise.all([
1565
1709
  fetch("/api/graph"),
@@ -1585,7 +1729,8 @@ async function initGraph() {
1585
1729
  target: e.target || e.to || "",
1586
1730
  kind: (e.kind || e.type || "references").toLowerCase()
1587
1731
  }));
1588
- buildEdgeIndex();
1732
+ edgeIndex = buildGraphEdgeIndex(graphEdges);
1733
+ fileToSymbolIds = buildGraphFileIndex(graphNodes);
1589
1734
  allSymbolNames = Array.from(graphNodes.keys()).sort();
1590
1735
  const pinned = focusData.pinned || [];
1591
1736
  for (const f of pinned) {
@@ -1632,30 +1777,13 @@ function showOnboardingHint(container) {
1632
1777
  if (container.querySelector(".graph-onboarding")) return;
1633
1778
  const hint = document.createElement("div");
1634
1779
  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>';
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>';
1636
1781
  container.appendChild(hint);
1637
1782
  }
1638
1783
  function removeOnboardingHint() {
1639
1784
  const hint = document.querySelector(".graph-onboarding");
1640
1785
  if (hint) hint.remove();
1641
1786
  }
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
1787
  function addNodeNeighborhood(symbolId, maxDegrees = 1) {
1660
1788
  const visited = /* @__PURE__ */ new Set();
1661
1789
  let frontier = /* @__PURE__ */ new Set([symbolId]);
@@ -1690,6 +1818,102 @@ function renderNodeNeighborhoodAndLayout(symbolId, maxDegrees = 1) {
1690
1818
  runLayout();
1691
1819
  }
1692
1820
  }
1821
+ function focusFileInGraph(filePath) {
1822
+ if (!cy) return;
1823
+ const selection = buildFileFocusedSelection({
1824
+ filePath,
1825
+ fileIndex: fileToSymbolIds,
1826
+ edgeIndex
1827
+ });
1828
+ cy.elements().remove();
1829
+ document.getElementById("symbol-detail")?.classList.add("hidden");
1830
+ if (selection.nodeIds.length === 0) {
1831
+ const cyEl = document.getElementById("cy");
1832
+ if (cyEl) showOnboardingHint(cyEl);
1833
+ refreshAnalysisGraphState();
1834
+ return;
1835
+ }
1836
+ for (const symbolId of selection.nodeIds) {
1837
+ addNodeToGraph(symbolId);
1838
+ }
1839
+ for (const edge of selection.edges) {
1840
+ addEdgeToGraph(edge);
1841
+ }
1842
+ refreshAnalysisGraphState();
1843
+ runLayout();
1844
+ }
1845
+ function getFilePreviewLanguage(filePath) {
1846
+ const ext = filePath.split(".").pop()?.toLowerCase() || "";
1847
+ const langMap = {
1848
+ ts: "typescript",
1849
+ tsx: "typescript",
1850
+ js: "javascript",
1851
+ jsx: "javascript",
1852
+ json: "json",
1853
+ md: "markdown",
1854
+ css: "css",
1855
+ html: "xml"
1856
+ };
1857
+ return langMap[ext] || "plaintext";
1858
+ }
1859
+ function closeFilePreview() {
1860
+ const modal = document.getElementById("file-preview-modal");
1861
+ if (!modal) return;
1862
+ closeModal(modal);
1863
+ }
1864
+ function setupFilePreviewModal() {
1865
+ if (filePreviewModalInitialized) return;
1866
+ filePreviewModalInitialized = true;
1867
+ const modal = document.getElementById("file-preview-modal");
1868
+ const backdrop = document.getElementById("file-preview-backdrop");
1869
+ const closeBtn = document.getElementById("file-preview-close");
1870
+ if (!modal || !backdrop || !closeBtn) {
1871
+ return;
1872
+ }
1873
+ backdrop.addEventListener("click", () => closeFilePreview());
1874
+ closeBtn.addEventListener("click", () => closeFilePreview());
1875
+ document.addEventListener("keydown", (event) => {
1876
+ if (event.key === "Escape" && !modal.classList.contains("hidden")) {
1877
+ closeFilePreview();
1878
+ }
1879
+ });
1880
+ }
1881
+ async function openFilePreview(filePath) {
1882
+ const modal = document.getElementById("file-preview-modal");
1883
+ const pathEl = document.getElementById("file-preview-path");
1884
+ const codeEl = document.getElementById("file-preview-code");
1885
+ if (!modal || !pathEl || !codeEl) {
1886
+ return;
1887
+ }
1888
+ latestFilePreviewRequestId += 1;
1889
+ const requestId = latestFilePreviewRequestId;
1890
+ pathEl.textContent = filePath;
1891
+ codeEl.className = "file-preview-code";
1892
+ codeEl.textContent = "Loading...";
1893
+ openModal(modal);
1894
+ try {
1895
+ const res = await fetch(`/api/file-source?path=${encodeURIComponent(filePath)}`);
1896
+ if (!res.ok) {
1897
+ codeEl.textContent = "(file unavailable)";
1898
+ return;
1899
+ }
1900
+ const data = await res.json();
1901
+ if (requestId !== latestFilePreviewRequestId) {
1902
+ return;
1903
+ }
1904
+ pathEl.textContent = data.filePath;
1905
+ codeEl.className = `file-preview-code language-${getFilePreviewLanguage(data.filePath)}`;
1906
+ codeEl.textContent = data.source;
1907
+ if (typeof hljs !== "undefined") {
1908
+ hljs.highlightElement(codeEl);
1909
+ }
1910
+ } catch {
1911
+ if (requestId !== latestFilePreviewRequestId) {
1912
+ return;
1913
+ }
1914
+ codeEl.textContent = "(file unavailable)";
1915
+ }
1916
+ }
1693
1917
  async function focusSymbolInGraph(symbolId, options = {}) {
1694
1918
  await focusSymbolsInGraph([symbolId], options);
1695
1919
  }
@@ -2216,7 +2440,7 @@ async function showDetail(node, detailEl) {
2216
2440
  <span class="detail-name">${escapeHtml(data.label)}</span>
2217
2441
  <span class="detail-kind-badge" style="background:${kindColor}20;color:${kindColor}">${kind}</span>
2218
2442
  </div>
2219
- <div class="detail-file">${escapeHtml(data.file || "unknown")}${data.startLine ? ":" + data.startLine : ""}</div>
2443
+ <div class="detail-file">${data.file ? `<button type="button" class="detail-file-link" data-file="${escapeHtml(data.file)}">${escapeHtml(data.file)}${data.startLine ? ":" + data.startLine : ""}</button>` : "unknown"}</div>
2220
2444
  `;
2221
2445
  html += `<div class="detail-actions">`;
2222
2446
  html += `<button class="detail-pin header-btn" data-name="${escapeHtml(data.qualifiedName)}">${isPinned ? "Unpin" : "Pin to focus"}</button>`;
@@ -2258,6 +2482,12 @@ async function showDetail(node, detailEl) {
2258
2482
  const name = pinBtn.dataset.name || "";
2259
2483
  await togglePin(name, node, pinBtn);
2260
2484
  });
2485
+ const fileLink = detailEl.querySelector(".detail-file-link");
2486
+ fileLink?.addEventListener("click", () => {
2487
+ const filePath = fileLink.dataset.file || "";
2488
+ if (!filePath) return;
2489
+ void openFilePreview(filePath);
2490
+ });
2261
2491
  const explainBtn = detailEl.querySelector(".detail-explain-btn");
2262
2492
  explainBtn.addEventListener("click", () => {
2263
2493
  const name = explainBtn.dataset.name || "";
@@ -2442,27 +2672,33 @@ function setupToolbar() {
2442
2672
  searchInput.parentNode.style.position = "relative";
2443
2673
  searchInput.parentNode.appendChild(dropdown);
2444
2674
  const rankedSymbols = allSymbolNames.slice().sort((a, b2) => compareGraphNodeIds(a, b2, graphNodes));
2445
- function showDropdownResults(matches) {
2446
- if (matches.length === 0) {
2675
+ function showDropdownResults(results) {
2676
+ if (results.length === 0) {
2447
2677
  dropdown.classList.add("hidden");
2448
2678
  return;
2449
2679
  }
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>
2680
+ dropdown.innerHTML = results.map((result) => {
2681
+ const kindColor = result.type === "file" ? "var(--accent)" : KIND_COLORS[result.kind] ? KIND_COLORS[result.kind].border : "#565f89";
2682
+ return `<div class="search-result" data-id="${escapeHtml(result.id)}" data-type="${result.type}">
2683
+ <div class="search-result-body">
2684
+ <span class="search-result-name">${escapeHtml(result.label)}</span>
2685
+ <span class="search-result-subtitle">${escapeHtml(result.subtitle)}</span>
2686
+ </div>
2687
+ <span class="search-result-kind${result.type === "file" ? " file" : ""}" style="color:${kindColor}">${escapeHtml(result.kind)}</span>
2458
2688
  </div>`;
2459
2689
  }).join("");
2460
2690
  dropdown.classList.remove("hidden");
2461
2691
  dropdown.querySelectorAll(".search-result").forEach((el) => {
2462
2692
  el.addEventListener("click", () => {
2463
2693
  const id = el.dataset.id || "";
2694
+ const type = el.dataset.type || "symbol";
2464
2695
  searchInput.value = "";
2465
2696
  dropdown.classList.add("hidden");
2697
+ if (type === "file") {
2698
+ focusFileInGraph(id);
2699
+ void openFilePreview(id);
2700
+ return;
2701
+ }
2466
2702
  void focusSymbolInGraph(id, {
2467
2703
  maxDegrees: 1,
2468
2704
  animate: true,
@@ -2473,23 +2709,22 @@ function setupToolbar() {
2473
2709
  });
2474
2710
  });
2475
2711
  }
2712
+ function updateDropdownResults() {
2713
+ const results = buildGraphSearchResults({
2714
+ query: searchInput.value,
2715
+ symbolIds: rankedSymbols,
2716
+ nodes: graphNodes,
2717
+ fileIndex: fileToSymbolIds
2718
+ });
2719
+ showDropdownResults(results);
2720
+ }
2476
2721
  searchInput.addEventListener("focus", () => {
2477
- if (searchInput.value.trim().length < 2) {
2478
- showDropdownResults(rankedSymbols.slice(0, 20));
2479
- }
2722
+ updateDropdownResults();
2480
2723
  });
2481
2724
  searchInput.addEventListener("input", () => {
2482
2725
  clearTimeout(searchTimeout);
2483
2726
  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);
2727
+ updateDropdownResults();
2493
2728
  }, 150);
2494
2729
  });
2495
2730
  searchInput.addEventListener("keydown", (e) => {
@@ -2572,25 +2807,45 @@ var settingsBtn = document.getElementById("settings-btn");
2572
2807
  var settingsModal = document.getElementById("settings-modal");
2573
2808
  var settingsBackdrop = document.getElementById("settings-backdrop");
2574
2809
  var settingsCloseBtn = document.getElementById("settings-close");
2810
+ var openRouterConnectModal = document.getElementById("openrouter-connect-modal");
2811
+ var openRouterConnectBackdrop = document.getElementById("openrouter-connect-backdrop");
2812
+ var openRouterConnectCloseBtn = document.getElementById("openrouter-connect-close");
2813
+ var openRouterConnectCancelBtn = document.getElementById("openrouter-connect-cancel");
2814
+ var openRouterConnectContinueBtn = document.getElementById("openrouter-connect-continue");
2815
+ var openRouterPersistCheckbox = document.getElementById("openrouter-persist-checkbox");
2575
2816
  var settingsPath = document.getElementById("settings-path");
2576
2817
  var settingsList = document.getElementById("settings-list");
2577
2818
  var settingsBanner = document.getElementById("settings-banner");
2819
+ var settingsOpenRouterSession = document.getElementById("settings-openrouter-session");
2820
+ var settingsOpenRouterSessionMeta = document.getElementById("settings-openrouter-session-meta");
2578
2821
  var settingsSaveBtn = document.getElementById("settings-save");
2579
2822
  var settingsResetBtn = document.getElementById("settings-reset");
2823
+ var disconnectOpenRouterBtn = document.getElementById("disconnect-openrouter-btn");
2824
+ var connectOpenRouterButtons = Array.from(
2825
+ document.querySelectorAll("[data-openrouter-connect]")
2826
+ );
2827
+ var configOverlaySpotlight = document.getElementById("config-overlay-spotlight");
2828
+ var configOverlayIntro = document.getElementById("config-overlay-intro");
2829
+ var configConnectStatus = document.getElementById("config-connect-status");
2580
2830
  var ws;
2581
2831
  var currentAssistantEl = null;
2582
2832
  var assistantText = "";
2583
2833
  var hadToolCalls = false;
2584
2834
  var settingsPayload = null;
2585
2835
  var activeSavedSession = null;
2836
+ var activeBaseUrl = "";
2837
+ var sessionOpenRouterConnected = false;
2586
2838
  var sessionRefreshTracker = createLatestRequestTracker();
2587
2839
  var TOOL_RESULT_MAX = 500;
2840
+ var OPENROUTER_PKCE_VERIFIER_KEY = "minicode:openrouter:pkce-verifier";
2841
+ var OPENROUTER_PERSIST_TO_ENV_KEY = "minicode:openrouter:persist-to-env";
2588
2842
  function connect() {
2589
2843
  const protocol = location.protocol === "https:" ? "wss:" : "ws:";
2590
2844
  ws = new WebSocket(`${protocol}//${location.host}`);
2591
2845
  ws.onopen = () => {
2592
2846
  setStatus("ready");
2593
2847
  fetchStatus();
2848
+ refreshModelList();
2594
2849
  fetchContext();
2595
2850
  };
2596
2851
  ws.onclose = () => {
@@ -2617,6 +2872,127 @@ function setBusy(busy) {
2617
2872
  }
2618
2873
  }
2619
2874
  var configOverlay = document.getElementById("config-overlay");
2875
+ function setConfigConnectStatus(message, tone) {
2876
+ if (!configConnectStatus) {
2877
+ return;
2878
+ }
2879
+ configConnectStatus.textContent = message;
2880
+ configConnectStatus.className = `config-connect-status ${tone}`;
2881
+ }
2882
+ function clearConfigConnectStatus() {
2883
+ if (!configConnectStatus) {
2884
+ return;
2885
+ }
2886
+ configConnectStatus.textContent = "";
2887
+ configConnectStatus.className = "config-connect-status hidden";
2888
+ }
2889
+ function encodeBase64Url(bytes) {
2890
+ let binary = "";
2891
+ for (const byte of bytes) {
2892
+ binary += String.fromCharCode(byte);
2893
+ }
2894
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
2895
+ }
2896
+ function createPkceVerifier() {
2897
+ const bytes = new Uint8Array(32);
2898
+ crypto.getRandomValues(bytes);
2899
+ return encodeBase64Url(bytes);
2900
+ }
2901
+ async function createPkceChallenge(verifier) {
2902
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
2903
+ return encodeBase64Url(new Uint8Array(digest));
2904
+ }
2905
+ async function startOpenRouterConnect(persistToEnv) {
2906
+ const verifier = createPkceVerifier();
2907
+ const challenge = await createPkceChallenge(verifier);
2908
+ sessionStorage.setItem(OPENROUTER_PKCE_VERIFIER_KEY, verifier);
2909
+ sessionStorage.setItem(OPENROUTER_PERSIST_TO_ENV_KEY, persistToEnv ? "1" : "0");
2910
+ setConfigConnectStatus("Redirecting to OpenRouter\u2026", "info");
2911
+ const callbackUrl = new URL(location.pathname, location.origin).toString();
2912
+ const authUrl = new URL("https://openrouter.ai/auth");
2913
+ authUrl.searchParams.set("callback_url", callbackUrl);
2914
+ authUrl.searchParams.set("code_challenge", challenge);
2915
+ authUrl.searchParams.set("code_challenge_method", "S256");
2916
+ location.assign(authUrl.toString());
2917
+ }
2918
+ async function maybeHandleOpenRouterCallback() {
2919
+ const url = new URL(location.href);
2920
+ const code = url.searchParams.get("code");
2921
+ if (!code) {
2922
+ return;
2923
+ }
2924
+ const cleanedUrl = `${url.pathname}${url.hash}`;
2925
+ history.replaceState({}, document.title, cleanedUrl);
2926
+ const codeVerifier = sessionStorage.getItem(OPENROUTER_PKCE_VERIFIER_KEY);
2927
+ const persistToEnv = sessionStorage.getItem(OPENROUTER_PERSIST_TO_ENV_KEY) === "1";
2928
+ sessionStorage.removeItem(OPENROUTER_PKCE_VERIFIER_KEY);
2929
+ sessionStorage.removeItem(OPENROUTER_PERSIST_TO_ENV_KEY);
2930
+ if (!codeVerifier) {
2931
+ setConfigConnectStatus(
2932
+ "OpenRouter sign-in could not be completed because the local PKCE verifier was missing. Start the connect flow again.",
2933
+ "error"
2934
+ );
2935
+ return;
2936
+ }
2937
+ for (const button of connectOpenRouterButtons) {
2938
+ button.disabled = true;
2939
+ }
2940
+ setConfigConnectStatus("Connecting OpenRouter to this serve session\u2026", "info");
2941
+ try {
2942
+ const res = await fetch("/api/openrouter/connect", {
2943
+ method: "POST",
2944
+ headers: { "Content-Type": "application/json" },
2945
+ body: JSON.stringify({ code, codeVerifier, persistToEnv })
2946
+ });
2947
+ const body = await res.json();
2948
+ if (!res.ok) {
2949
+ throw new Error("error" in body ? body.error : `Failed to connect OpenRouter (${res.status})`);
2950
+ }
2951
+ activeBaseUrl = body.baseUrl;
2952
+ addMessage(body.message, "thinking");
2953
+ const statusTone = body.persistWarning ? "info" : body.needsSetup ? "info" : "success";
2954
+ setConfigConnectStatus(body.message, statusTone);
2955
+ await fetchStatus();
2956
+ await refreshModelList();
2957
+ const onlyModelMissing = body.needsSetup && body.missing.length === 1 && body.missing[0]?.includes("MODEL");
2958
+ if (onlyModelMissing) {
2959
+ modelDropdown.classList.remove("hidden");
2960
+ sessionDropdown.classList.add("hidden");
2961
+ }
2962
+ } catch (error) {
2963
+ const message = error instanceof Error ? error.message : "Failed to connect OpenRouter";
2964
+ setConfigConnectStatus(message, "error");
2965
+ } finally {
2966
+ for (const button of connectOpenRouterButtons) {
2967
+ button.disabled = false;
2968
+ }
2969
+ }
2970
+ }
2971
+ async function disconnectOpenRouter() {
2972
+ disconnectOpenRouterBtn.disabled = true;
2973
+ clearSettingsBanner();
2974
+ try {
2975
+ const res = await fetch("/api/openrouter/disconnect", {
2976
+ method: "POST",
2977
+ headers: { "Content-Type": "application/json" }
2978
+ });
2979
+ const body = await res.json();
2980
+ if (!res.ok) {
2981
+ throw new Error("error" in body ? body.error : `Failed to disconnect OpenRouter (${res.status})`);
2982
+ }
2983
+ activeBaseUrl = body.baseUrl;
2984
+ addMessage(body.message, "thinking");
2985
+ setSettingsBanner(body.message, body.disconnected ? "success" : "info");
2986
+ clearConfigConnectStatus();
2987
+ await fetchStatus();
2988
+ await refreshModelList();
2989
+ } catch (error) {
2990
+ const message = error instanceof Error ? error.message : "Failed to disconnect OpenRouter";
2991
+ setSettingsBanner(message, "error");
2992
+ } finally {
2993
+ disconnectOpenRouterBtn.disabled = false;
2994
+ }
2995
+ }
2620
2996
  async function fetchStatus() {
2621
2997
  try {
2622
2998
  const res = await fetch("/api/status");
@@ -2624,6 +3000,9 @@ async function fetchStatus() {
2624
3000
  modelInfo.textContent = data.model || "Select model";
2625
3001
  modelInfo.classList.toggle("placeholder", !data.model);
2626
3002
  activeModel = data.model;
3003
+ activeBaseUrl = data.baseUrl ?? "";
3004
+ sessionOpenRouterConnected = data.sessionOpenRouterConnected ?? false;
3005
+ renderOpenRouterSessionControls();
2627
3006
  if (data.needsSetup) {
2628
3007
  configOverlay.classList.remove("hidden");
2629
3008
  chatInput.disabled = true;
@@ -2631,6 +3010,11 @@ async function fetchStatus() {
2631
3010
  const missingEl = document.getElementById("config-missing");
2632
3011
  if (missingEl && data.missing && data.missing.length > 0) {
2633
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:";
3016
+ }
3017
+ configOverlaySpotlight?.classList.toggle("hidden", hasPersistedOpenRouter);
2634
3018
  const hint = isOnlyModelMissing ? ` \u2014 select one from the <strong>model dropdown</strong> above, or set it in config` : "";
2635
3019
  missingEl.innerHTML = `<strong>Missing:</strong> ${data.missing.map(escapeHtml).join(", ")}${hint}`;
2636
3020
  missingEl.classList.remove("hidden");
@@ -2638,6 +3022,16 @@ async function fetchStatus() {
2638
3022
  } else {
2639
3023
  configOverlay.classList.add("hidden");
2640
3024
  chatInput.disabled = false;
3025
+ sendBtn.disabled = false;
3026
+ const missingEl = document.getElementById("config-missing");
3027
+ if (missingEl) {
3028
+ missingEl.classList.add("hidden");
3029
+ missingEl.innerHTML = "";
3030
+ }
3031
+ if (configOverlayIntro) {
3032
+ configOverlayIntro.textContent = "minicode needs a model provider to run. Configure one of the following:";
3033
+ }
3034
+ configOverlaySpotlight?.classList.remove("hidden");
2641
3035
  }
2642
3036
  } catch {
2643
3037
  }
@@ -2746,6 +3140,50 @@ function addMessage(text, type, markdown = false) {
2746
3140
  scrollToBottom();
2747
3141
  return el;
2748
3142
  }
3143
+ function clearChatTranscript() {
3144
+ messagesEl.innerHTML = "";
3145
+ currentAssistantEl = null;
3146
+ assistantText = "";
3147
+ hadToolCalls = false;
3148
+ }
3149
+ function stringifyToolInput(input) {
3150
+ const entries = Object.entries(input).flatMap(([key, value]) => {
3151
+ if (value === void 0 || value === null) {
3152
+ return [];
3153
+ }
3154
+ if (typeof value === "string") {
3155
+ return [[key, value]];
3156
+ }
3157
+ return [[key, JSON.stringify(value)]];
3158
+ });
3159
+ return Object.fromEntries(entries);
3160
+ }
3161
+ function addToolResultPreview(name, result) {
3162
+ const toolEls = messagesEl.querySelectorAll(`.tool-call[data-tool-name="${name}"]`);
3163
+ if (toolEls.length === 0) {
3164
+ addToolCall(name, {});
3165
+ }
3166
+ finalizeToolCall(name, result);
3167
+ }
3168
+ function renderLoadedSessionMessages(messages) {
3169
+ clearChatTranscript();
3170
+ for (const message of messages) {
3171
+ if (message.role === "user") {
3172
+ addMessage(message.content, "user");
3173
+ continue;
3174
+ }
3175
+ if (message.role === "assistant") {
3176
+ if (message.content.trim().length > 0) {
3177
+ addMessage(message.content, "assistant", true);
3178
+ }
3179
+ for (const toolCall of message.toolCalls ?? []) {
3180
+ addToolCall(toolCall.name, stringifyToolInput(toolCall.input));
3181
+ }
3182
+ continue;
3183
+ }
3184
+ addToolResultPreview(message.toolName, message.content);
3185
+ }
3186
+ }
2749
3187
  function summarizeToolInput(name, input) {
2750
3188
  const key = input.path ?? input.file_path ?? input.command ?? input.query ?? input.pattern ?? input.name ?? input.old_string;
2751
3189
  if (typeof key === "string") {
@@ -2786,7 +3224,7 @@ function finalizeToolCall(name, result, elapsedMs) {
2786
3224
  if (!el) return;
2787
3225
  const timeEl = el.querySelector(".tool-time");
2788
3226
  if (timeEl) {
2789
- timeEl.textContent = `${elapsedMs}ms`;
3227
+ timeEl.textContent = elapsedMs && elapsedMs > 0 ? `${elapsedMs}ms` : "";
2790
3228
  }
2791
3229
  const resultEl = el.querySelector(".tool-result");
2792
3230
  if (resultEl && result) {
@@ -2830,9 +3268,6 @@ function closeHeaderMenus() {
2830
3268
  modelDropdown.classList.add("hidden");
2831
3269
  sessionDropdown.classList.add("hidden");
2832
3270
  }
2833
- function isSettingsModalOpen() {
2834
- return !settingsModal.classList.contains("hidden");
2835
- }
2836
3271
  function formatSettingsValue(value) {
2837
3272
  return value === null ? "(unset)" : String(value);
2838
3273
  }
@@ -2844,6 +3279,17 @@ function clearSettingsBanner() {
2844
3279
  settingsBanner.textContent = "";
2845
3280
  settingsBanner.className = "settings-banner hidden";
2846
3281
  }
3282
+ function renderOpenRouterSessionControls() {
3283
+ if (sessionOpenRouterConnected) {
3284
+ settingsOpenRouterSession.classList.remove("hidden");
3285
+ 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.";
3286
+ disconnectOpenRouterBtn.disabled = false;
3287
+ return;
3288
+ }
3289
+ settingsOpenRouterSession.classList.add("hidden");
3290
+ settingsOpenRouterSessionMeta.textContent = "";
3291
+ disconnectOpenRouterBtn.disabled = false;
3292
+ }
2847
3293
  function createSettingsControl(entry, inputId) {
2848
3294
  const value = entry.persistedValue;
2849
3295
  if (entry.type === "boolean") {
@@ -3036,17 +3482,23 @@ function updateSettingsActions() {
3036
3482
  }
3037
3483
  function openSettings() {
3038
3484
  closeHeaderMenus();
3039
- settingsModal.classList.remove("hidden");
3040
- settingsModal.setAttribute("aria-hidden", "false");
3041
- document.body.classList.add("modal-open");
3485
+ openModal(settingsModal);
3042
3486
  void loadSettings();
3043
3487
  }
3044
3488
  function closeSettings() {
3045
- settingsModal.classList.add("hidden");
3046
- settingsModal.setAttribute("aria-hidden", "true");
3047
- document.body.classList.remove("modal-open");
3489
+ closeModal(settingsModal);
3048
3490
  clearSettingsBanner();
3049
3491
  }
3492
+ function openOpenRouterConnectModal() {
3493
+ closeHeaderMenus();
3494
+ openModal(openRouterConnectModal);
3495
+ openRouterPersistCheckbox.checked = false;
3496
+ openRouterConnectContinueBtn.disabled = false;
3497
+ }
3498
+ function closeOpenRouterConnectModal() {
3499
+ closeModal(openRouterConnectModal);
3500
+ openRouterConnectContinueBtn.disabled = false;
3501
+ }
3050
3502
  chatForm.addEventListener("submit", (e) => {
3051
3503
  e.preventDefault();
3052
3504
  const message = chatInput.value.trim();
@@ -3089,31 +3541,63 @@ async function refreshModelList() {
3089
3541
  const res = await fetch("/api/models");
3090
3542
  const data = await res.json();
3091
3543
  activeModel = data.activeModel;
3544
+ const hasActiveModel = !!activeModel && data.models.some((model) => model.id === activeModel);
3092
3545
  if (!data.models || data.models.length === 0) {
3546
+ modelInfo.textContent = "Select model";
3547
+ modelInfo.classList.add("placeholder");
3093
3548
  modelList.innerHTML = '<div class="dropdown-empty">No models available</div>';
3094
3549
  return;
3095
3550
  }
3551
+ if (hasActiveModel) {
3552
+ modelInfo.textContent = activeModel;
3553
+ modelInfo.classList.remove("placeholder");
3554
+ } else {
3555
+ modelInfo.textContent = "Select model";
3556
+ modelInfo.classList.add("placeholder");
3557
+ }
3096
3558
  modelList.innerHTML = "";
3097
3559
  for (const m2 of data.models) {
3098
3560
  const el = document.createElement("div");
3099
3561
  el.className = "model-item" + (m2.id === activeModel ? " active" : "");
3100
3562
  el.textContent = m2.name ?? m2.id;
3101
3563
  el.title = m2.id;
3102
- el.addEventListener("click", () => switchModel(m2.id));
3564
+ el.addEventListener("click", () => {
3565
+ void switchModel(m2.id);
3566
+ });
3103
3567
  modelList.appendChild(el);
3104
3568
  }
3105
3569
  } catch {
3106
3570
  modelList.innerHTML = '<div class="dropdown-empty">Failed to load models</div>';
3107
3571
  }
3108
3572
  }
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();
3573
+ async function switchModel(modelId) {
3574
+ try {
3575
+ const res = await fetch("/api/model", {
3576
+ method: "POST",
3577
+ headers: { "Content-Type": "application/json" },
3578
+ body: JSON.stringify({
3579
+ model: modelId,
3580
+ persistToHomeEnv: true
3581
+ })
3582
+ });
3583
+ const body = await res.json();
3584
+ if (!res.ok) {
3585
+ throw new Error("error" in body ? body.error : `Failed to switch model (${res.status})`);
3586
+ }
3587
+ modelInfo.textContent = modelId || "Select model";
3588
+ modelInfo.classList.toggle("placeholder", !modelId);
3589
+ activeModel = modelId;
3590
+ modelDropdown.classList.add("hidden");
3591
+ if (body.persistedToEnv) {
3592
+ addMessage(`Model switched to: ${modelId}. Saved as the default in ~/.minicode/.env.`, "thinking");
3593
+ } else {
3594
+ addMessage(`Model switched to: ${modelId}`, "thinking");
3595
+ }
3596
+ await fetchStatus();
3597
+ } catch (error) {
3598
+ const message = error instanceof Error ? error.message : "Failed to switch model";
3599
+ addMessage(message, "error");
3600
+ }
3117
3601
  }
3118
3602
  sessionBtn.addEventListener("click", (e) => {
3119
3603
  e.stopPropagation();
@@ -3209,9 +3693,12 @@ async function loadSession(label) {
3209
3693
  body: JSON.stringify({ label })
3210
3694
  });
3211
3695
  if (res.ok) {
3696
+ const body = await res.json();
3212
3697
  sessionDropdown.classList.add("hidden");
3213
- messagesEl.innerHTML = "";
3214
- addMessage(`Session "${label}" restored`, "thinking");
3698
+ renderLoadedSessionMessages(body.messages);
3699
+ if (body.messages.length === 0) {
3700
+ addMessage(`Session "${body.label}" restored`, "thinking");
3701
+ }
3215
3702
  void refreshSessionList();
3216
3703
  }
3217
3704
  } catch {
@@ -3247,9 +3734,19 @@ settingsCloseBtn.addEventListener("click", () => {
3247
3734
  settingsBackdrop.addEventListener("click", () => {
3248
3735
  closeSettings();
3249
3736
  });
3737
+ openRouterConnectBackdrop.addEventListener("click", () => {
3738
+ closeOpenRouterConnectModal();
3739
+ });
3740
+ openRouterConnectCloseBtn.addEventListener("click", () => {
3741
+ closeOpenRouterConnectModal();
3742
+ });
3743
+ openRouterConnectCancelBtn.addEventListener("click", () => {
3744
+ closeOpenRouterConnectModal();
3745
+ });
3250
3746
  settingsResetBtn.addEventListener("click", () => {
3251
3747
  clearSettingsBanner();
3252
3748
  renderSettings();
3749
+ renderOpenRouterSessionControls();
3253
3750
  });
3254
3751
  settingsSaveBtn.addEventListener("click", async () => {
3255
3752
  if (!settingsPayload) {
@@ -3298,11 +3795,31 @@ settingsList.addEventListener("change", () => {
3298
3795
  clearSettingsBanner();
3299
3796
  updateSettingsActions();
3300
3797
  });
3798
+ disconnectOpenRouterBtn.addEventListener("click", () => {
3799
+ void disconnectOpenRouter();
3800
+ });
3301
3801
  document.addEventListener("keydown", (event) => {
3302
- if (event.key === "Escape" && isSettingsModalOpen()) {
3802
+ if (event.key !== "Escape") {
3803
+ return;
3804
+ }
3805
+ if (isOpenRouterConnectModalOpen()) {
3806
+ closeOpenRouterConnectModal();
3807
+ return;
3808
+ }
3809
+ if (isSettingsModalOpen()) {
3303
3810
  closeSettings();
3304
3811
  }
3305
3812
  });
3813
+ for (const button of connectOpenRouterButtons) {
3814
+ button.addEventListener("click", () => {
3815
+ openOpenRouterConnectModal();
3816
+ });
3817
+ }
3818
+ openRouterConnectContinueBtn.addEventListener("click", () => {
3819
+ openRouterConnectContinueBtn.disabled = true;
3820
+ closeOpenRouterConnectModal();
3821
+ void startOpenRouterConnect(openRouterPersistCheckbox.checked);
3822
+ });
3306
3823
  var chatPane = document.getElementById("chat-pane");
3307
3824
  var divider = document.getElementById("pane-divider");
3308
3825
  divider.addEventListener("mousedown", (e) => {
@@ -3337,3 +3854,4 @@ graphToggle.addEventListener("click", () => {
3337
3854
  });
3338
3855
  connect();
3339
3856
  initGraph();
3857
+ void maybeHandleOpenRouterCallback();