@sean.holung/minicode 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +25 -47
  2. package/dist/scripts/run-benchmarks.js +73 -28
  3. package/dist/src/agent/config.js +51 -66
  4. package/dist/src/agent/editable-config.js +50 -58
  5. package/dist/src/agent/home-env.js +74 -0
  6. package/dist/src/benchmark/runner.js +142 -59
  7. package/dist/src/cli/config-slash-command.js +15 -13
  8. package/dist/src/indexer/project-index.js +49 -13
  9. package/dist/src/serve/agent-bridge.js +99 -31
  10. package/dist/src/serve/mcp-server.js +70 -21
  11. package/dist/src/serve/server.js +198 -8
  12. package/dist/src/session/session-preview.js +14 -0
  13. package/dist/src/shared/graph-search.js +80 -0
  14. package/dist/src/shared/graph-selection.js +40 -0
  15. package/dist/src/shared/graph-symbols.js +82 -0
  16. package/dist/src/shared/symbol-resolution.js +33 -0
  17. package/dist/src/tools/find-path.js +15 -6
  18. package/dist/src/tools/find-references.js +7 -2
  19. package/dist/src/tools/get-dependencies.js +8 -3
  20. package/dist/src/tools/read-symbol.js +9 -3
  21. package/dist/src/tools/registry.js +4 -1
  22. package/dist/src/tools/search-code-map.js +18 -3
  23. package/dist/src/web/app.js +646 -87
  24. package/dist/src/web/index.html +68 -6
  25. package/dist/src/web/style.css +208 -1
  26. package/dist/tests/benchmark-harness.test.js +100 -0
  27. package/dist/tests/config-api.test.js +5 -5
  28. package/dist/tests/config-integration.test.js +130 -56
  29. package/dist/tests/config-slash-command.test.js +12 -11
  30. package/dist/tests/config.test.js +12 -4
  31. package/dist/tests/editable-config.test.js +15 -12
  32. package/dist/tests/file-tools.test.js +34 -1
  33. package/dist/tests/find-path.test.js +43 -2
  34. package/dist/tests/find-references.test.js +49 -0
  35. package/dist/tests/get-dependencies.test.js +23 -0
  36. package/dist/tests/graph-onboarding.test.js +10 -1
  37. package/dist/tests/graph-search.test.js +66 -0
  38. package/dist/tests/graph-selection.test.js +58 -0
  39. package/dist/tests/graph-symbols.test.js +45 -0
  40. package/dist/tests/home-env.test.js +56 -0
  41. package/dist/tests/indexer.test.js +6 -0
  42. package/dist/tests/read-symbol.test.js +35 -0
  43. package/dist/tests/request-tracker.test.js +15 -0
  44. package/dist/tests/run-benchmarks.test.js +117 -33
  45. package/dist/tests/search-code-map.test.js +2 -0
  46. package/dist/tests/serve.integration.test.js +338 -9
  47. package/dist/tests/session-preview.test.js +56 -0
  48. package/dist/tests/session-ui.test.js +4 -0
  49. package/dist/tests/settings-ui.test.js +18 -0
  50. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.d.ts.map +1 -1
  51. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js +2 -1
  52. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js.map +1 -1
  53. package/node_modules/@minicode/agent-sdk/dist/src/index.d.ts +1 -1
  54. package/node_modules/@minicode/agent-sdk/dist/src/index.d.ts.map +1 -1
  55. package/node_modules/@minicode/agent-sdk/dist/src/index.js.map +1 -1
  56. package/node_modules/@minicode/agent-sdk/dist/src/indexer/types.d.ts +3 -0
  57. package/node_modules/@minicode/agent-sdk/dist/src/indexer/types.d.ts.map +1 -1
  58. package/node_modules/@minicode/agent-sdk/dist/src/tools/registry.d.ts +3 -0
  59. package/node_modules/@minicode/agent-sdk/dist/src/tools/registry.d.ts.map +1 -1
  60. package/node_modules/@minicode/agent-sdk/dist/src/tools/registry.js +4 -1
  61. package/node_modules/@minicode/agent-sdk/dist/src/tools/registry.js.map +1 -1
  62. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.d.ts +11 -1
  63. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.d.ts.map +1 -1
  64. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.js +4 -1
  65. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.js.map +1 -1
  66. package/node_modules/@minicode/agent-sdk/dist/src/tools/search.d.ts.map +1 -1
  67. package/node_modules/@minicode/agent-sdk/dist/src/tools/search.js +16 -8
  68. package/node_modules/@minicode/agent-sdk/dist/src/tools/search.js.map +1 -1
  69. package/node_modules/@minicode/agent-sdk/dist/tests/file-tools.test.js +19 -2
  70. package/node_modules/@minicode/agent-sdk/dist/tests/file-tools.test.js.map +1 -1
  71. package/node_modules/@minicode/agent-sdk/dist/tsconfig.tsbuildinfo +1 -1
  72. package/package.json +1 -1
@@ -1443,10 +1443,215 @@ function buildStylesheet() {
1443
1443
  return styles;
1444
1444
  }
1445
1445
 
1446
+ // src/shared/graph-symbols.ts
1447
+ function dedupe(values) {
1448
+ return [...new Set(values.filter((value) => value.length > 0))];
1449
+ }
1450
+ function stripCollisionSuffix(value) {
1451
+ const hashIndex = value.indexOf("#");
1452
+ return hashIndex >= 0 ? value.slice(0, hashIndex) : value;
1453
+ }
1454
+ function stripDisplayKindSuffix(value) {
1455
+ return value.replace(/\s+\([^()]+\)$/, "");
1456
+ }
1457
+ function getGraphNodeId(node, fallbackId = "") {
1458
+ return node.qualifiedName || node.id || fallbackId || node.name || "";
1459
+ }
1460
+ function getGraphNodeLabel(node, fallbackId = "") {
1461
+ const label = node.name?.trim();
1462
+ if (label && label.length > 0) {
1463
+ return label;
1464
+ }
1465
+ const id = getGraphNodeId(node, fallbackId);
1466
+ return id.split(".").pop() || id;
1467
+ }
1468
+ function getGraphNodeAliases(node, fallbackId = "") {
1469
+ const id = getGraphNodeId(node, fallbackId);
1470
+ const label = getGraphNodeLabel(node, fallbackId);
1471
+ const shortId = id.split(".").pop() || id;
1472
+ return dedupe([
1473
+ id,
1474
+ node.id ?? "",
1475
+ node.qualifiedName ?? "",
1476
+ label,
1477
+ stripDisplayKindSuffix(label),
1478
+ shortId,
1479
+ stripCollisionSuffix(shortId),
1480
+ stripCollisionSuffix(id)
1481
+ ]);
1482
+ }
1483
+ function compareLabels(a, b2) {
1484
+ return a.localeCompare(b2, void 0, { sensitivity: "base" });
1485
+ }
1486
+ function compareGraphNodeIds(a, b2, nodes) {
1487
+ const nodeA = nodes.get(a);
1488
+ const nodeB = nodes.get(b2);
1489
+ const exportedA = nodeA ? Number(!!nodeA.exported) : 0;
1490
+ const exportedB = nodeB ? Number(!!nodeB.exported) : 0;
1491
+ if (exportedA !== exportedB) {
1492
+ return exportedB - exportedA;
1493
+ }
1494
+ const labelA = getGraphNodeLabel(nodeA ?? {}, a);
1495
+ const labelB = getGraphNodeLabel(nodeB ?? {}, b2);
1496
+ const labelComparison = compareLabels(labelA, labelB);
1497
+ if (labelComparison !== 0) {
1498
+ return labelComparison;
1499
+ }
1500
+ return compareLabels(a, b2);
1501
+ }
1502
+ function matchesGraphNodeQuery(query, node, fallbackId = "") {
1503
+ const normalizedQuery = query.trim().toLowerCase();
1504
+ if (normalizedQuery.length === 0) {
1505
+ return false;
1506
+ }
1507
+ return getGraphNodeAliases(node, fallbackId).some(
1508
+ (alias) => alias.toLowerCase().includes(normalizedQuery)
1509
+ );
1510
+ }
1511
+ function resolveGraphNodeIds(nodes, symbolName) {
1512
+ const query = symbolName.trim();
1513
+ if (query.length === 0) {
1514
+ return [];
1515
+ }
1516
+ if (nodes.has(query)) {
1517
+ return [query];
1518
+ }
1519
+ const exactMatches = [...nodes.entries()].filter(([id, node]) => getGraphNodeAliases(node, id).includes(query)).map(([id]) => id).sort((a, b2) => compareGraphNodeIds(a, b2, nodes));
1520
+ if (exactMatches.length > 0) {
1521
+ return exactMatches;
1522
+ }
1523
+ return [...nodes.entries()].filter(([id, node]) => matchesGraphNodeQuery(query, node, id)).map(([id]) => id).sort((a, b2) => compareGraphNodeIds(a, b2, nodes));
1524
+ }
1525
+
1526
+ // src/shared/graph-search.ts
1527
+ function getGraphNodeFilePath(node) {
1528
+ return node.filePath || node.file || "";
1529
+ }
1530
+ function buildGraphFileIndex(nodes) {
1531
+ const files = /* @__PURE__ */ new Map();
1532
+ for (const [id, node] of nodes) {
1533
+ const filePath = getGraphNodeFilePath(node);
1534
+ if (!filePath) continue;
1535
+ const existing = files.get(filePath);
1536
+ if (existing) {
1537
+ existing.push(id);
1538
+ } else {
1539
+ files.set(filePath, [id]);
1540
+ }
1541
+ }
1542
+ for (const symbolIds of files.values()) {
1543
+ symbolIds.sort((a, b2) => compareGraphNodeIds(a, b2, nodes));
1544
+ }
1545
+ return files;
1546
+ }
1547
+ function compareGraphFilePaths(a, b2, fileIndex) {
1548
+ const countDifference = (fileIndex.get(b2)?.length ?? 0) - (fileIndex.get(a)?.length ?? 0);
1549
+ if (countDifference !== 0) {
1550
+ return countDifference;
1551
+ }
1552
+ return a.localeCompare(b2, void 0, { sensitivity: "base" });
1553
+ }
1554
+ function matchesGraphFileQuery(query, filePath) {
1555
+ const normalizedQuery = query.trim().toLowerCase();
1556
+ if (normalizedQuery.length === 0) {
1557
+ return false;
1558
+ }
1559
+ return filePath.toLowerCase().includes(normalizedQuery);
1560
+ }
1561
+ function buildGraphSearchResults({
1562
+ query,
1563
+ symbolIds,
1564
+ nodes,
1565
+ fileIndex,
1566
+ symbolLimit = 12,
1567
+ fileLimit = 8
1568
+ }) {
1569
+ const normalizedQuery = query.trim().toLowerCase();
1570
+ const showDefaultResults = normalizedQuery.length < 2;
1571
+ const rankedFiles = [...fileIndex.keys()].sort((a, b2) => compareGraphFilePaths(a, b2, fileIndex));
1572
+ const symbolResults = symbolIds.filter((id) => {
1573
+ if (showDefaultResults) {
1574
+ return true;
1575
+ }
1576
+ return matchesGraphNodeQuery(normalizedQuery, nodes.get(id) || {}, id);
1577
+ }).slice(0, symbolLimit).map((id) => {
1578
+ const node = nodes.get(id) || {};
1579
+ return {
1580
+ type: "symbol",
1581
+ id,
1582
+ label: getGraphNodeLabel(node, id),
1583
+ subtitle: getGraphNodeFilePath(node),
1584
+ kind: (node.kind || "symbol").toLowerCase()
1585
+ };
1586
+ });
1587
+ const fileResults = rankedFiles.filter((filePath) => {
1588
+ if (showDefaultResults) {
1589
+ return true;
1590
+ }
1591
+ return matchesGraphFileQuery(normalizedQuery, filePath);
1592
+ }).slice(0, fileLimit).map((filePath) => {
1593
+ const symbolCount = fileIndex.get(filePath)?.length ?? 0;
1594
+ return {
1595
+ type: "file",
1596
+ id: filePath,
1597
+ label: filePath,
1598
+ subtitle: `${symbolCount} symbol${symbolCount === 1 ? "" : "s"}`,
1599
+ kind: "file",
1600
+ symbolCount
1601
+ };
1602
+ });
1603
+ return [...symbolResults, ...fileResults];
1604
+ }
1605
+
1606
+ // src/shared/graph-selection.ts
1607
+ function buildGraphEdgeId(edge) {
1608
+ return `${edge.source}->${edge.target}:${edge.kind}`;
1609
+ }
1610
+ function buildGraphEdgeIndex(edges) {
1611
+ const edgeIndex2 = /* @__PURE__ */ new Map();
1612
+ for (const edge of edges) {
1613
+ const sourceEdges = edgeIndex2.get(edge.source);
1614
+ if (sourceEdges) {
1615
+ sourceEdges.push(edge);
1616
+ } else {
1617
+ edgeIndex2.set(edge.source, [edge]);
1618
+ }
1619
+ const targetEdges = edgeIndex2.get(edge.target);
1620
+ if (targetEdges) {
1621
+ targetEdges.push(edge);
1622
+ } else {
1623
+ edgeIndex2.set(edge.target, [edge]);
1624
+ }
1625
+ }
1626
+ return edgeIndex2;
1627
+ }
1628
+ function buildFileFocusedSelection({
1629
+ filePath,
1630
+ fileIndex,
1631
+ edgeIndex: edgeIndex2
1632
+ }) {
1633
+ const fileSymbolIds = fileIndex.get(filePath) || [];
1634
+ const nodeIds = /* @__PURE__ */ new Set();
1635
+ const edges = /* @__PURE__ */ new Map();
1636
+ for (const symbolId of fileSymbolIds) {
1637
+ nodeIds.add(symbolId);
1638
+ for (const edge of edgeIndex2.get(symbolId) || []) {
1639
+ nodeIds.add(edge.source);
1640
+ nodeIds.add(edge.target);
1641
+ edges.set(buildGraphEdgeId(edge), edge);
1642
+ }
1643
+ }
1644
+ return {
1645
+ nodeIds: [...nodeIds],
1646
+ edges: [...edges.values()]
1647
+ };
1648
+ }
1649
+
1446
1650
  // src/web/graph.ts
1447
1651
  var cy = null;
1448
1652
  var graphNodes = /* @__PURE__ */ new Map();
1449
1653
  var graphEdges = [];
1654
+ var fileToSymbolIds = /* @__PURE__ */ new Map();
1450
1655
  var edgeIndex = /* @__PURE__ */ new Map();
1451
1656
  var pinnedNames = /* @__PURE__ */ new Set();
1452
1657
  var allSymbolNames = [];
@@ -1505,7 +1710,8 @@ async function initGraph() {
1505
1710
  target: e.target || e.to || "",
1506
1711
  kind: (e.kind || e.type || "references").toLowerCase()
1507
1712
  }));
1508
- buildEdgeIndex();
1713
+ edgeIndex = buildGraphEdgeIndex(graphEdges);
1714
+ fileToSymbolIds = buildGraphFileIndex(graphNodes);
1509
1715
  allSymbolNames = Array.from(graphNodes.keys()).sort();
1510
1716
  const pinned = focusData.pinned || [];
1511
1717
  for (const f of pinned) {
@@ -1537,7 +1743,7 @@ async function initGraph() {
1537
1743
  }
1538
1744
  function highlightAgentActivity(symbolName) {
1539
1745
  if (!cy) return;
1540
- void focusSymbolInGraph(symbolName, {
1746
+ void focusResolvedSymbolsInGraph(symbolName, {
1541
1747
  maxDegrees: 0,
1542
1748
  pulse: true,
1543
1749
  pulseDuration: 2e3,
@@ -1552,30 +1758,13 @@ function showOnboardingHint(container) {
1552
1758
  if (container.querySelector(".graph-onboarding")) return;
1553
1759
  const hint = document.createElement("div");
1554
1760
  hint.className = "graph-onboarding";
1555
- hint.innerHTML = '<div class="graph-onboarding-icon">&#9670; &#8212; &#9670;</div><div class="graph-onboarding-title">Code dependency graph</div><div class="graph-onboarding-subtitle">Search for a symbol above to start exploring.<br/>Nodes expand on click to reveal connections.</div>';
1761
+ hint.innerHTML = '<div class="graph-onboarding-icon">&#9670; &#8212; &#9670;</div><div class="graph-onboarding-title">Code dependency graph</div><div class="graph-onboarding-subtitle">Search for a symbol or file above to start exploring.<br/>Nodes expand on click to reveal connections.</div>';
1556
1762
  container.appendChild(hint);
1557
1763
  }
1558
1764
  function removeOnboardingHint() {
1559
1765
  const hint = document.querySelector(".graph-onboarding");
1560
1766
  if (hint) hint.remove();
1561
1767
  }
1562
- function buildEdgeIndex() {
1563
- edgeIndex.clear();
1564
- for (const edge of graphEdges) {
1565
- let srcList = edgeIndex.get(edge.source);
1566
- if (!srcList) {
1567
- srcList = [];
1568
- edgeIndex.set(edge.source, srcList);
1569
- }
1570
- srcList.push(edge);
1571
- let tgtList = edgeIndex.get(edge.target);
1572
- if (!tgtList) {
1573
- tgtList = [];
1574
- edgeIndex.set(edge.target, tgtList);
1575
- }
1576
- tgtList.push(edge);
1577
- }
1578
- }
1579
1768
  function addNodeNeighborhood(symbolId, maxDegrees = 1) {
1580
1769
  const visited = /* @__PURE__ */ new Set();
1581
1770
  let frontier = /* @__PURE__ */ new Set([symbolId]);
@@ -1610,7 +1799,41 @@ function renderNodeNeighborhoodAndLayout(symbolId, maxDegrees = 1) {
1610
1799
  runLayout();
1611
1800
  }
1612
1801
  }
1802
+ function focusFileInGraph(filePath) {
1803
+ if (!cy) return;
1804
+ const selection = buildFileFocusedSelection({
1805
+ filePath,
1806
+ fileIndex: fileToSymbolIds,
1807
+ edgeIndex
1808
+ });
1809
+ cy.elements().remove();
1810
+ document.getElementById("symbol-detail")?.classList.add("hidden");
1811
+ if (selection.nodeIds.length === 0) {
1812
+ const cyEl = document.getElementById("cy");
1813
+ if (cyEl) showOnboardingHint(cyEl);
1814
+ refreshAnalysisGraphState();
1815
+ return;
1816
+ }
1817
+ for (const symbolId of selection.nodeIds) {
1818
+ addNodeToGraph(symbolId);
1819
+ }
1820
+ for (const edge of selection.edges) {
1821
+ addEdgeToGraph(edge);
1822
+ }
1823
+ refreshAnalysisGraphState();
1824
+ runLayout();
1825
+ }
1613
1826
  async function focusSymbolInGraph(symbolId, options = {}) {
1827
+ await focusSymbolsInGraph([symbolId], options);
1828
+ }
1829
+ async function focusResolvedSymbolsInGraph(symbolName, options = {}) {
1830
+ const matches = resolveGraphNodeIds(graphNodes, symbolName);
1831
+ if (matches.length === 0) {
1832
+ return;
1833
+ }
1834
+ await focusSymbolsInGraph(matches, options);
1835
+ }
1836
+ async function focusSymbolsInGraph(symbolIds, options = {}) {
1614
1837
  if (!cy) return;
1615
1838
  const {
1616
1839
  maxDegrees = 0,
@@ -1621,21 +1844,38 @@ async function focusSymbolInGraph(symbolId, options = {}) {
1621
1844
  flashDuration = 1200,
1622
1845
  openDetail = false
1623
1846
  } = options;
1624
- renderNodeNeighborhoodAndLayout(symbolId, maxDegrees);
1625
- const node = findNode(symbolId);
1626
- if (!node) return;
1627
- if (animate) {
1628
- cy.animate({ center: { eles: node }, zoom }, { duration: 300 });
1629
- }
1630
- if (pulse) {
1631
- node.addClass("agent-pulse");
1632
- setTimeout(() => node.removeClass("agent-pulse"), pulseDuration);
1633
- } else {
1634
- node.flashClass("highlighted", flashDuration);
1847
+ const uniqueIds = [...new Set(symbolIds)];
1848
+ let addedNodes = false;
1849
+ for (const symbolId of uniqueIds) {
1850
+ const beforeNodeCount = cy.nodes().length;
1851
+ addNodeNeighborhood(symbolId, maxDegrees);
1852
+ if (cy.nodes().length > beforeNodeCount) {
1853
+ addedNodes = true;
1854
+ }
1855
+ }
1856
+ connectExistingNodes();
1857
+ refreshAnalysisGraphState();
1858
+ if (addedNodes) {
1859
+ runLayout();
1635
1860
  }
1636
- if (openDetail) {
1861
+ const nodes = uniqueIds.map((symbolId) => findNode(symbolId)).filter((node) => node !== null);
1862
+ if (nodes.length === 0) return;
1863
+ const primaryNode = nodes[0];
1864
+ if (animate && primaryNode) {
1865
+ cy.animate({ center: { eles: primaryNode }, zoom }, { duration: 300 });
1866
+ }
1867
+ for (const node of nodes) {
1868
+ if (pulse) {
1869
+ node.addClass("agent-pulse");
1870
+ setTimeout(() => node.removeClass("agent-pulse"), pulseDuration);
1871
+ } else {
1872
+ node.flashClass("highlighted", flashDuration);
1873
+ }
1874
+ }
1875
+ if (openDetail && primaryNode && uniqueIds.length === 1) {
1637
1876
  const detailEl = document.getElementById("symbol-detail");
1638
1877
  if (detailEl) {
1878
+ const node = primaryNode;
1639
1879
  await showDetail(node, detailEl);
1640
1880
  }
1641
1881
  }
@@ -1650,7 +1890,7 @@ function addNodeToGraph(id) {
1650
1890
  const nodeData = graphNodes.get(id);
1651
1891
  if (!nodeData) return;
1652
1892
  const kind = (nodeData.kind || "function").toLowerCase();
1653
- const name = nodeData.name || id.split(".").pop() || id;
1893
+ const name = getGraphNodeLabel(nodeData, id);
1654
1894
  const file = nodeData.filePath || nodeData.file || "";
1655
1895
  cy.add({
1656
1896
  data: {
@@ -1691,11 +1931,10 @@ function findNode(name) {
1691
1931
  if (!cy) return null;
1692
1932
  const node = cy.getElementById(name);
1693
1933
  if (node.length > 0) return node;
1694
- const match = cy.nodes().filter((n) => {
1695
- const nName = n.data("name") || "";
1696
- const qName = n.data("qualifiedName") || "";
1697
- return nName === name || qName.endsWith("." + name);
1698
- });
1934
+ const matchingIds = new Set(resolveGraphNodeIds(graphNodes, name));
1935
+ const match = cy.nodes().filter(
1936
+ (n) => matchingIds.has(n.data("qualifiedName") || n.data("id") || "")
1937
+ );
1699
1938
  return match.length > 0 ? match : null;
1700
1939
  }
1701
1940
  function getAnalysisPanelEls() {
@@ -2335,37 +2574,33 @@ function setupToolbar() {
2335
2574
  dropdown.className = "search-dropdown hidden";
2336
2575
  searchInput.parentNode.style.position = "relative";
2337
2576
  searchInput.parentNode.appendChild(dropdown);
2338
- const rankedSymbols = allSymbolNames.slice().sort((a, b2) => {
2339
- const nodeA = graphNodes.get(a);
2340
- const nodeB = graphNodes.get(b2);
2341
- const expA = nodeA ? !!nodeA.exported : false;
2342
- const expB = nodeB ? !!nodeB.exported : false;
2343
- if (expA !== expB) return expA ? -1 : 1;
2344
- const nameA = (a.split(".").pop() || "").toLowerCase();
2345
- const nameB = (b2.split(".").pop() || "").toLowerCase();
2346
- return nameA.localeCompare(nameB);
2347
- });
2348
- function showDropdownResults(matches) {
2349
- if (matches.length === 0) {
2577
+ const rankedSymbols = allSymbolNames.slice().sort((a, b2) => compareGraphNodeIds(a, b2, graphNodes));
2578
+ function showDropdownResults(results) {
2579
+ if (results.length === 0) {
2350
2580
  dropdown.classList.add("hidden");
2351
2581
  return;
2352
2582
  }
2353
- dropdown.innerHTML = matches.map((name) => {
2354
- const node = graphNodes.get(name);
2355
- const kind = node ? (node.kind || "").toLowerCase() : "";
2356
- const shortName = name.split(".").pop() || name;
2357
- const kindColor = KIND_COLORS[kind] ? KIND_COLORS[kind].border : "#565f89";
2358
- return `<div class="search-result" data-id="${escapeHtml(name)}">
2359
- <span class="search-result-name">${escapeHtml(shortName)}</span>
2360
- <span class="search-result-kind" style="color:${kindColor}">${kind}</span>
2583
+ dropdown.innerHTML = results.map((result) => {
2584
+ const kindColor = result.type === "file" ? "var(--accent)" : KIND_COLORS[result.kind] ? KIND_COLORS[result.kind].border : "#565f89";
2585
+ return `<div class="search-result" data-id="${escapeHtml(result.id)}" data-type="${result.type}">
2586
+ <div class="search-result-body">
2587
+ <span class="search-result-name">${escapeHtml(result.label)}</span>
2588
+ <span class="search-result-subtitle">${escapeHtml(result.subtitle)}</span>
2589
+ </div>
2590
+ <span class="search-result-kind${result.type === "file" ? " file" : ""}" style="color:${kindColor}">${escapeHtml(result.kind)}</span>
2361
2591
  </div>`;
2362
2592
  }).join("");
2363
2593
  dropdown.classList.remove("hidden");
2364
2594
  dropdown.querySelectorAll(".search-result").forEach((el) => {
2365
2595
  el.addEventListener("click", () => {
2366
2596
  const id = el.dataset.id || "";
2597
+ const type = el.dataset.type || "symbol";
2367
2598
  searchInput.value = "";
2368
2599
  dropdown.classList.add("hidden");
2600
+ if (type === "file") {
2601
+ focusFileInGraph(id);
2602
+ return;
2603
+ }
2369
2604
  void focusSymbolInGraph(id, {
2370
2605
  maxDegrees: 1,
2371
2606
  animate: true,
@@ -2376,24 +2611,22 @@ function setupToolbar() {
2376
2611
  });
2377
2612
  });
2378
2613
  }
2614
+ function updateDropdownResults() {
2615
+ const results = buildGraphSearchResults({
2616
+ query: searchInput.value,
2617
+ symbolIds: rankedSymbols,
2618
+ nodes: graphNodes,
2619
+ fileIndex: fileToSymbolIds
2620
+ });
2621
+ showDropdownResults(results);
2622
+ }
2379
2623
  searchInput.addEventListener("focus", () => {
2380
- if (searchInput.value.trim().length < 2) {
2381
- showDropdownResults(rankedSymbols.slice(0, 20));
2382
- }
2624
+ updateDropdownResults();
2383
2625
  });
2384
2626
  searchInput.addEventListener("input", () => {
2385
2627
  clearTimeout(searchTimeout);
2386
2628
  searchTimeout = setTimeout(() => {
2387
- const query = searchInput.value.trim().toLowerCase();
2388
- if (query.length < 2) {
2389
- showDropdownResults(rankedSymbols.slice(0, 20));
2390
- return;
2391
- }
2392
- const matches = rankedSymbols.filter((name) => {
2393
- const shortName = (name.split(".").pop() || "").toLowerCase();
2394
- return shortName.includes(query) || name.toLowerCase().includes(query);
2395
- }).slice(0, 15);
2396
- showDropdownResults(matches);
2629
+ updateDropdownResults();
2397
2630
  }, 150);
2398
2631
  });
2399
2632
  searchInput.addEventListener("keydown", (e) => {
@@ -2438,6 +2671,20 @@ function setupToolbar() {
2438
2671
  });
2439
2672
  }
2440
2673
 
2674
+ // src/web/request-tracker.ts
2675
+ function createLatestRequestTracker() {
2676
+ let latestToken = 0;
2677
+ return {
2678
+ begin() {
2679
+ latestToken += 1;
2680
+ return latestToken;
2681
+ },
2682
+ isCurrent(token) {
2683
+ return token === latestToken;
2684
+ }
2685
+ };
2686
+ }
2687
+
2441
2688
  // src/web/app.ts
2442
2689
  var messagesEl = document.getElementById("messages");
2443
2690
  var chatForm = document.getElementById("chat-form");
@@ -2462,24 +2709,45 @@ var settingsBtn = document.getElementById("settings-btn");
2462
2709
  var settingsModal = document.getElementById("settings-modal");
2463
2710
  var settingsBackdrop = document.getElementById("settings-backdrop");
2464
2711
  var settingsCloseBtn = document.getElementById("settings-close");
2712
+ var openRouterConnectModal = document.getElementById("openrouter-connect-modal");
2713
+ var openRouterConnectBackdrop = document.getElementById("openrouter-connect-backdrop");
2714
+ var openRouterConnectCloseBtn = document.getElementById("openrouter-connect-close");
2715
+ var openRouterConnectCancelBtn = document.getElementById("openrouter-connect-cancel");
2716
+ var openRouterConnectContinueBtn = document.getElementById("openrouter-connect-continue");
2717
+ var openRouterPersistCheckbox = document.getElementById("openrouter-persist-checkbox");
2465
2718
  var settingsPath = document.getElementById("settings-path");
2466
2719
  var settingsList = document.getElementById("settings-list");
2467
2720
  var settingsBanner = document.getElementById("settings-banner");
2721
+ var settingsOpenRouterSession = document.getElementById("settings-openrouter-session");
2722
+ var settingsOpenRouterSessionMeta = document.getElementById("settings-openrouter-session-meta");
2468
2723
  var settingsSaveBtn = document.getElementById("settings-save");
2469
2724
  var settingsResetBtn = document.getElementById("settings-reset");
2725
+ var disconnectOpenRouterBtn = document.getElementById("disconnect-openrouter-btn");
2726
+ var connectOpenRouterButtons = Array.from(
2727
+ document.querySelectorAll("[data-openrouter-connect]")
2728
+ );
2729
+ var configOverlaySpotlight = document.getElementById("config-overlay-spotlight");
2730
+ var configOverlayIntro = document.getElementById("config-overlay-intro");
2731
+ var configConnectStatus = document.getElementById("config-connect-status");
2470
2732
  var ws;
2471
2733
  var currentAssistantEl = null;
2472
2734
  var assistantText = "";
2473
2735
  var hadToolCalls = false;
2474
2736
  var settingsPayload = null;
2475
2737
  var activeSavedSession = null;
2738
+ var activeBaseUrl = "";
2739
+ var sessionOpenRouterConnected = false;
2740
+ var sessionRefreshTracker = createLatestRequestTracker();
2476
2741
  var TOOL_RESULT_MAX = 500;
2742
+ var OPENROUTER_PKCE_VERIFIER_KEY = "minicode:openrouter:pkce-verifier";
2743
+ var OPENROUTER_PERSIST_TO_ENV_KEY = "minicode:openrouter:persist-to-env";
2477
2744
  function connect() {
2478
2745
  const protocol = location.protocol === "https:" ? "wss:" : "ws:";
2479
2746
  ws = new WebSocket(`${protocol}//${location.host}`);
2480
2747
  ws.onopen = () => {
2481
2748
  setStatus("ready");
2482
2749
  fetchStatus();
2750
+ refreshModelList();
2483
2751
  fetchContext();
2484
2752
  };
2485
2753
  ws.onclose = () => {
@@ -2506,6 +2774,127 @@ function setBusy(busy) {
2506
2774
  }
2507
2775
  }
2508
2776
  var configOverlay = document.getElementById("config-overlay");
2777
+ function setConfigConnectStatus(message, tone) {
2778
+ if (!configConnectStatus) {
2779
+ return;
2780
+ }
2781
+ configConnectStatus.textContent = message;
2782
+ configConnectStatus.className = `config-connect-status ${tone}`;
2783
+ }
2784
+ function clearConfigConnectStatus() {
2785
+ if (!configConnectStatus) {
2786
+ return;
2787
+ }
2788
+ configConnectStatus.textContent = "";
2789
+ configConnectStatus.className = "config-connect-status hidden";
2790
+ }
2791
+ function encodeBase64Url(bytes) {
2792
+ let binary = "";
2793
+ for (const byte of bytes) {
2794
+ binary += String.fromCharCode(byte);
2795
+ }
2796
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
2797
+ }
2798
+ function createPkceVerifier() {
2799
+ const bytes = new Uint8Array(32);
2800
+ crypto.getRandomValues(bytes);
2801
+ return encodeBase64Url(bytes);
2802
+ }
2803
+ async function createPkceChallenge(verifier) {
2804
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
2805
+ return encodeBase64Url(new Uint8Array(digest));
2806
+ }
2807
+ async function startOpenRouterConnect(persistToEnv) {
2808
+ const verifier = createPkceVerifier();
2809
+ const challenge = await createPkceChallenge(verifier);
2810
+ sessionStorage.setItem(OPENROUTER_PKCE_VERIFIER_KEY, verifier);
2811
+ sessionStorage.setItem(OPENROUTER_PERSIST_TO_ENV_KEY, persistToEnv ? "1" : "0");
2812
+ setConfigConnectStatus("Redirecting to OpenRouter\u2026", "info");
2813
+ const callbackUrl = new URL(location.pathname, location.origin).toString();
2814
+ const authUrl = new URL("https://openrouter.ai/auth");
2815
+ authUrl.searchParams.set("callback_url", callbackUrl);
2816
+ authUrl.searchParams.set("code_challenge", challenge);
2817
+ authUrl.searchParams.set("code_challenge_method", "S256");
2818
+ location.assign(authUrl.toString());
2819
+ }
2820
+ async function maybeHandleOpenRouterCallback() {
2821
+ const url = new URL(location.href);
2822
+ const code = url.searchParams.get("code");
2823
+ if (!code) {
2824
+ return;
2825
+ }
2826
+ const cleanedUrl = `${url.pathname}${url.hash}`;
2827
+ history.replaceState({}, document.title, cleanedUrl);
2828
+ const codeVerifier = sessionStorage.getItem(OPENROUTER_PKCE_VERIFIER_KEY);
2829
+ const persistToEnv = sessionStorage.getItem(OPENROUTER_PERSIST_TO_ENV_KEY) === "1";
2830
+ sessionStorage.removeItem(OPENROUTER_PKCE_VERIFIER_KEY);
2831
+ sessionStorage.removeItem(OPENROUTER_PERSIST_TO_ENV_KEY);
2832
+ if (!codeVerifier) {
2833
+ setConfigConnectStatus(
2834
+ "OpenRouter sign-in could not be completed because the local PKCE verifier was missing. Start the connect flow again.",
2835
+ "error"
2836
+ );
2837
+ return;
2838
+ }
2839
+ for (const button of connectOpenRouterButtons) {
2840
+ button.disabled = true;
2841
+ }
2842
+ setConfigConnectStatus("Connecting OpenRouter to this serve session\u2026", "info");
2843
+ try {
2844
+ const res = await fetch("/api/openrouter/connect", {
2845
+ method: "POST",
2846
+ headers: { "Content-Type": "application/json" },
2847
+ body: JSON.stringify({ code, codeVerifier, persistToEnv })
2848
+ });
2849
+ const body = await res.json();
2850
+ if (!res.ok) {
2851
+ throw new Error("error" in body ? body.error : `Failed to connect OpenRouter (${res.status})`);
2852
+ }
2853
+ activeBaseUrl = body.baseUrl;
2854
+ addMessage(body.message, "thinking");
2855
+ const statusTone = body.persistWarning ? "info" : body.needsSetup ? "info" : "success";
2856
+ setConfigConnectStatus(body.message, statusTone);
2857
+ await fetchStatus();
2858
+ await refreshModelList();
2859
+ const onlyModelMissing = body.needsSetup && body.missing.length === 1 && body.missing[0]?.includes("MODEL");
2860
+ if (onlyModelMissing) {
2861
+ modelDropdown.classList.remove("hidden");
2862
+ sessionDropdown.classList.add("hidden");
2863
+ }
2864
+ } catch (error) {
2865
+ const message = error instanceof Error ? error.message : "Failed to connect OpenRouter";
2866
+ setConfigConnectStatus(message, "error");
2867
+ } finally {
2868
+ for (const button of connectOpenRouterButtons) {
2869
+ button.disabled = false;
2870
+ }
2871
+ }
2872
+ }
2873
+ async function disconnectOpenRouter() {
2874
+ disconnectOpenRouterBtn.disabled = true;
2875
+ clearSettingsBanner();
2876
+ try {
2877
+ const res = await fetch("/api/openrouter/disconnect", {
2878
+ method: "POST",
2879
+ headers: { "Content-Type": "application/json" }
2880
+ });
2881
+ const body = await res.json();
2882
+ if (!res.ok) {
2883
+ throw new Error("error" in body ? body.error : `Failed to disconnect OpenRouter (${res.status})`);
2884
+ }
2885
+ activeBaseUrl = body.baseUrl;
2886
+ addMessage(body.message, "thinking");
2887
+ setSettingsBanner(body.message, body.disconnected ? "success" : "info");
2888
+ clearConfigConnectStatus();
2889
+ await fetchStatus();
2890
+ await refreshModelList();
2891
+ } catch (error) {
2892
+ const message = error instanceof Error ? error.message : "Failed to disconnect OpenRouter";
2893
+ setSettingsBanner(message, "error");
2894
+ } finally {
2895
+ disconnectOpenRouterBtn.disabled = false;
2896
+ }
2897
+ }
2509
2898
  async function fetchStatus() {
2510
2899
  try {
2511
2900
  const res = await fetch("/api/status");
@@ -2513,6 +2902,9 @@ async function fetchStatus() {
2513
2902
  modelInfo.textContent = data.model || "Select model";
2514
2903
  modelInfo.classList.toggle("placeholder", !data.model);
2515
2904
  activeModel = data.model;
2905
+ activeBaseUrl = data.baseUrl ?? "";
2906
+ sessionOpenRouterConnected = data.sessionOpenRouterConnected ?? false;
2907
+ renderOpenRouterSessionControls();
2516
2908
  if (data.needsSetup) {
2517
2909
  configOverlay.classList.remove("hidden");
2518
2910
  chatInput.disabled = true;
@@ -2520,6 +2912,11 @@ async function fetchStatus() {
2520
2912
  const missingEl = document.getElementById("config-missing");
2521
2913
  if (missingEl && data.missing && data.missing.length > 0) {
2522
2914
  const isOnlyModelMissing = data.missing.length === 1 && data.missing[0].includes("MODEL");
2915
+ const hasPersistedOpenRouter = isOnlyModelMissing && (data.baseUrl ?? "").includes("openrouter");
2916
+ if (configOverlayIntro) {
2917
+ configOverlayIntro.textContent = hasPersistedOpenRouter ? "OpenRouter is already configured. Select a model to continue:" : "minicode needs a model provider to run. Configure one of the following:";
2918
+ }
2919
+ configOverlaySpotlight?.classList.toggle("hidden", hasPersistedOpenRouter);
2523
2920
  const hint = isOnlyModelMissing ? ` \u2014 select one from the <strong>model dropdown</strong> above, or set it in config` : "";
2524
2921
  missingEl.innerHTML = `<strong>Missing:</strong> ${data.missing.map(escapeHtml).join(", ")}${hint}`;
2525
2922
  missingEl.classList.remove("hidden");
@@ -2527,6 +2924,16 @@ async function fetchStatus() {
2527
2924
  } else {
2528
2925
  configOverlay.classList.add("hidden");
2529
2926
  chatInput.disabled = false;
2927
+ sendBtn.disabled = false;
2928
+ const missingEl = document.getElementById("config-missing");
2929
+ if (missingEl) {
2930
+ missingEl.classList.add("hidden");
2931
+ missingEl.innerHTML = "";
2932
+ }
2933
+ if (configOverlayIntro) {
2934
+ configOverlayIntro.textContent = "minicode needs a model provider to run. Configure one of the following:";
2935
+ }
2936
+ configOverlaySpotlight?.classList.remove("hidden");
2530
2937
  }
2531
2938
  } catch {
2532
2939
  }
@@ -2635,6 +3042,50 @@ function addMessage(text, type, markdown = false) {
2635
3042
  scrollToBottom();
2636
3043
  return el;
2637
3044
  }
3045
+ function clearChatTranscript() {
3046
+ messagesEl.innerHTML = "";
3047
+ currentAssistantEl = null;
3048
+ assistantText = "";
3049
+ hadToolCalls = false;
3050
+ }
3051
+ function stringifyToolInput(input) {
3052
+ const entries = Object.entries(input).flatMap(([key, value]) => {
3053
+ if (value === void 0 || value === null) {
3054
+ return [];
3055
+ }
3056
+ if (typeof value === "string") {
3057
+ return [[key, value]];
3058
+ }
3059
+ return [[key, JSON.stringify(value)]];
3060
+ });
3061
+ return Object.fromEntries(entries);
3062
+ }
3063
+ function addToolResultPreview(name, result) {
3064
+ const toolEls = messagesEl.querySelectorAll(`.tool-call[data-tool-name="${name}"]`);
3065
+ if (toolEls.length === 0) {
3066
+ addToolCall(name, {});
3067
+ }
3068
+ finalizeToolCall(name, result);
3069
+ }
3070
+ function renderLoadedSessionMessages(messages) {
3071
+ clearChatTranscript();
3072
+ for (const message of messages) {
3073
+ if (message.role === "user") {
3074
+ addMessage(message.content, "user");
3075
+ continue;
3076
+ }
3077
+ if (message.role === "assistant") {
3078
+ if (message.content.trim().length > 0) {
3079
+ addMessage(message.content, "assistant", true);
3080
+ }
3081
+ for (const toolCall of message.toolCalls ?? []) {
3082
+ addToolCall(toolCall.name, stringifyToolInput(toolCall.input));
3083
+ }
3084
+ continue;
3085
+ }
3086
+ addToolResultPreview(message.toolName, message.content);
3087
+ }
3088
+ }
2638
3089
  function summarizeToolInput(name, input) {
2639
3090
  const key = input.path ?? input.file_path ?? input.command ?? input.query ?? input.pattern ?? input.name ?? input.old_string;
2640
3091
  if (typeof key === "string") {
@@ -2675,7 +3126,7 @@ function finalizeToolCall(name, result, elapsedMs) {
2675
3126
  if (!el) return;
2676
3127
  const timeEl = el.querySelector(".tool-time");
2677
3128
  if (timeEl) {
2678
- timeEl.textContent = `${elapsedMs}ms`;
3129
+ timeEl.textContent = elapsedMs && elapsedMs > 0 ? `${elapsedMs}ms` : "";
2679
3130
  }
2680
3131
  const resultEl = el.querySelector(".tool-result");
2681
3132
  if (resultEl && result) {
@@ -2722,6 +3173,13 @@ function closeHeaderMenus() {
2722
3173
  function isSettingsModalOpen() {
2723
3174
  return !settingsModal.classList.contains("hidden");
2724
3175
  }
3176
+ function isOpenRouterConnectModalOpen() {
3177
+ return !openRouterConnectModal.classList.contains("hidden");
3178
+ }
3179
+ function syncModalOpenState() {
3180
+ const anyModalOpen = isSettingsModalOpen() || isOpenRouterConnectModalOpen();
3181
+ document.body.classList.toggle("modal-open", anyModalOpen);
3182
+ }
2725
3183
  function formatSettingsValue(value) {
2726
3184
  return value === null ? "(unset)" : String(value);
2727
3185
  }
@@ -2733,6 +3191,17 @@ function clearSettingsBanner() {
2733
3191
  settingsBanner.textContent = "";
2734
3192
  settingsBanner.className = "settings-banner hidden";
2735
3193
  }
3194
+ function renderOpenRouterSessionControls() {
3195
+ if (sessionOpenRouterConnected) {
3196
+ settingsOpenRouterSession.classList.remove("hidden");
3197
+ settingsOpenRouterSessionMeta.textContent = activeBaseUrl ? `Endpoint: ${activeBaseUrl}. This session-only connection overrides your original provider settings until you disconnect or restart serve.` : "This session-only connection overrides your original provider settings until you disconnect or restart serve.";
3198
+ disconnectOpenRouterBtn.disabled = false;
3199
+ return;
3200
+ }
3201
+ settingsOpenRouterSession.classList.add("hidden");
3202
+ settingsOpenRouterSessionMeta.textContent = "";
3203
+ disconnectOpenRouterBtn.disabled = false;
3204
+ }
2736
3205
  function createSettingsControl(entry, inputId) {
2737
3206
  const value = entry.persistedValue;
2738
3207
  if (entry.type === "boolean") {
@@ -2927,15 +3396,29 @@ function openSettings() {
2927
3396
  closeHeaderMenus();
2928
3397
  settingsModal.classList.remove("hidden");
2929
3398
  settingsModal.setAttribute("aria-hidden", "false");
2930
- document.body.classList.add("modal-open");
3399
+ syncModalOpenState();
2931
3400
  void loadSettings();
2932
3401
  }
2933
3402
  function closeSettings() {
2934
3403
  settingsModal.classList.add("hidden");
2935
3404
  settingsModal.setAttribute("aria-hidden", "true");
2936
- document.body.classList.remove("modal-open");
3405
+ syncModalOpenState();
2937
3406
  clearSettingsBanner();
2938
3407
  }
3408
+ function openOpenRouterConnectModal() {
3409
+ closeHeaderMenus();
3410
+ openRouterConnectModal.classList.remove("hidden");
3411
+ openRouterConnectModal.setAttribute("aria-hidden", "false");
3412
+ openRouterPersistCheckbox.checked = false;
3413
+ openRouterConnectContinueBtn.disabled = false;
3414
+ syncModalOpenState();
3415
+ }
3416
+ function closeOpenRouterConnectModal() {
3417
+ openRouterConnectModal.classList.add("hidden");
3418
+ openRouterConnectModal.setAttribute("aria-hidden", "true");
3419
+ openRouterConnectContinueBtn.disabled = false;
3420
+ syncModalOpenState();
3421
+ }
2939
3422
  chatForm.addEventListener("submit", (e) => {
2940
3423
  e.preventDefault();
2941
3424
  const message = chatInput.value.trim();
@@ -2978,31 +3461,63 @@ async function refreshModelList() {
2978
3461
  const res = await fetch("/api/models");
2979
3462
  const data = await res.json();
2980
3463
  activeModel = data.activeModel;
3464
+ const hasActiveModel = !!activeModel && data.models.some((model) => model.id === activeModel);
2981
3465
  if (!data.models || data.models.length === 0) {
3466
+ modelInfo.textContent = "Select model";
3467
+ modelInfo.classList.add("placeholder");
2982
3468
  modelList.innerHTML = '<div class="dropdown-empty">No models available</div>';
2983
3469
  return;
2984
3470
  }
3471
+ if (hasActiveModel) {
3472
+ modelInfo.textContent = activeModel;
3473
+ modelInfo.classList.remove("placeholder");
3474
+ } else {
3475
+ modelInfo.textContent = "Select model";
3476
+ modelInfo.classList.add("placeholder");
3477
+ }
2985
3478
  modelList.innerHTML = "";
2986
3479
  for (const m2 of data.models) {
2987
3480
  const el = document.createElement("div");
2988
3481
  el.className = "model-item" + (m2.id === activeModel ? " active" : "");
2989
3482
  el.textContent = m2.name ?? m2.id;
2990
3483
  el.title = m2.id;
2991
- el.addEventListener("click", () => switchModel(m2.id));
3484
+ el.addEventListener("click", () => {
3485
+ void switchModel(m2.id);
3486
+ });
2992
3487
  modelList.appendChild(el);
2993
3488
  }
2994
3489
  } catch {
2995
3490
  modelList.innerHTML = '<div class="dropdown-empty">Failed to load models</div>';
2996
3491
  }
2997
3492
  }
2998
- function switchModel(modelId) {
2999
- ws.send(JSON.stringify({ type: "switch_model", model: modelId }));
3000
- modelInfo.textContent = modelId || "Select model";
3001
- modelInfo.classList.toggle("placeholder", !modelId);
3002
- activeModel = modelId;
3003
- modelDropdown.classList.add("hidden");
3004
- addMessage(`Model switched to: ${modelId}`, "thinking");
3005
- void fetchStatus();
3493
+ async function switchModel(modelId) {
3494
+ try {
3495
+ const res = await fetch("/api/model", {
3496
+ method: "POST",
3497
+ headers: { "Content-Type": "application/json" },
3498
+ body: JSON.stringify({
3499
+ model: modelId,
3500
+ persistToHomeEnv: true
3501
+ })
3502
+ });
3503
+ const body = await res.json();
3504
+ if (!res.ok) {
3505
+ throw new Error("error" in body ? body.error : `Failed to switch model (${res.status})`);
3506
+ }
3507
+ modelInfo.textContent = modelId || "Select model";
3508
+ modelInfo.classList.toggle("placeholder", !modelId);
3509
+ activeModel = modelId;
3510
+ modelDropdown.classList.add("hidden");
3511
+ if (body.persistedToEnv) {
3512
+ addMessage(`Model switched to: ${modelId}. Saved as the default in ~/.minicode/.env.`, "thinking");
3513
+ } else {
3514
+ addMessage(`Model switched to: ${modelId}`, "thinking");
3515
+ }
3516
+ await fetchStatus();
3517
+ } catch (error) {
3518
+ const message = error instanceof Error ? error.message : "Failed to switch model";
3519
+ addMessage(message, "error");
3520
+ }
3006
3521
  }
3007
3522
  sessionBtn.addEventListener("click", (e) => {
3008
3523
  e.stopPropagation();
@@ -3023,6 +3538,7 @@ saveBtn.addEventListener("click", async () => {
3023
3538
  const label = requestedLabel || activeSavedSession?.label || void 0;
3024
3539
  const isUpdatingCurrentSession = !!activeSavedSession && (requestedLabel.length === 0 || requestedLabel === activeSavedSession.label);
3025
3540
  try {
3541
+ saveBtn.setAttribute("disabled", "true");
3026
3542
  const res = await fetch("/api/sessions/save", {
3027
3543
  method: "POST",
3028
3544
  headers: { "Content-Type": "application/json" },
@@ -3035,9 +3551,11 @@ saveBtn.addEventListener("click", async () => {
3035
3551
  `${isUpdatingCurrentSession ? "Session updated" : "Session saved"}: "${data.label}"`,
3036
3552
  "thinking"
3037
3553
  );
3038
- void refreshSessionList();
3554
+ await refreshSessionList();
3039
3555
  }
3040
3556
  } catch {
3557
+ } finally {
3558
+ saveBtn.removeAttribute("disabled");
3041
3559
  }
3042
3560
  });
3043
3561
  saveLabelInput.addEventListener("keydown", (e) => {
@@ -3047,9 +3565,13 @@ saveLabelInput.addEventListener("keydown", (e) => {
3047
3565
  }
3048
3566
  });
3049
3567
  async function refreshSessionList() {
3568
+ const requestToken = sessionRefreshTracker.begin();
3050
3569
  try {
3051
3570
  const res = await fetch("/api/sessions");
3052
3571
  const data = await res.json();
3572
+ if (!sessionRefreshTracker.isCurrent(requestToken)) {
3573
+ return;
3574
+ }
3053
3575
  const sessions = data.sessions;
3054
3576
  activeSavedSession = sessions.find((session) => session.id === data.currentSessionId) ?? null;
3055
3577
  if (activeSavedSession) {
@@ -3075,6 +3597,9 @@ async function refreshSessionList() {
3075
3597
  sessionList.appendChild(el);
3076
3598
  }
3077
3599
  } catch {
3600
+ if (!sessionRefreshTracker.isCurrent(requestToken)) {
3601
+ return;
3602
+ }
3078
3603
  activeSavedSession = null;
3079
3604
  sessionUpdateRow.classList.add("hidden");
3080
3605
  sessionList.innerHTML = '<div class="dropdown-empty">Failed to load sessions</div>';
@@ -3088,9 +3613,12 @@ async function loadSession(label) {
3088
3613
  body: JSON.stringify({ label })
3089
3614
  });
3090
3615
  if (res.ok) {
3616
+ const body = await res.json();
3091
3617
  sessionDropdown.classList.add("hidden");
3092
- messagesEl.innerHTML = "";
3093
- addMessage(`Session "${label}" restored`, "thinking");
3618
+ renderLoadedSessionMessages(body.messages);
3619
+ if (body.messages.length === 0) {
3620
+ addMessage(`Session "${body.label}" restored`, "thinking");
3621
+ }
3094
3622
  void refreshSessionList();
3095
3623
  }
3096
3624
  } catch {
@@ -3126,9 +3654,19 @@ settingsCloseBtn.addEventListener("click", () => {
3126
3654
  settingsBackdrop.addEventListener("click", () => {
3127
3655
  closeSettings();
3128
3656
  });
3657
+ openRouterConnectBackdrop.addEventListener("click", () => {
3658
+ closeOpenRouterConnectModal();
3659
+ });
3660
+ openRouterConnectCloseBtn.addEventListener("click", () => {
3661
+ closeOpenRouterConnectModal();
3662
+ });
3663
+ openRouterConnectCancelBtn.addEventListener("click", () => {
3664
+ closeOpenRouterConnectModal();
3665
+ });
3129
3666
  settingsResetBtn.addEventListener("click", () => {
3130
3667
  clearSettingsBanner();
3131
3668
  renderSettings();
3669
+ renderOpenRouterSessionControls();
3132
3670
  });
3133
3671
  settingsSaveBtn.addEventListener("click", async () => {
3134
3672
  if (!settingsPayload) {
@@ -3177,11 +3715,31 @@ settingsList.addEventListener("change", () => {
3177
3715
  clearSettingsBanner();
3178
3716
  updateSettingsActions();
3179
3717
  });
3718
+ disconnectOpenRouterBtn.addEventListener("click", () => {
3719
+ void disconnectOpenRouter();
3720
+ });
3180
3721
  document.addEventListener("keydown", (event) => {
3181
- if (event.key === "Escape" && isSettingsModalOpen()) {
3722
+ if (event.key !== "Escape") {
3723
+ return;
3724
+ }
3725
+ if (isOpenRouterConnectModalOpen()) {
3726
+ closeOpenRouterConnectModal();
3727
+ return;
3728
+ }
3729
+ if (isSettingsModalOpen()) {
3182
3730
  closeSettings();
3183
3731
  }
3184
3732
  });
3733
+ for (const button of connectOpenRouterButtons) {
3734
+ button.addEventListener("click", () => {
3735
+ openOpenRouterConnectModal();
3736
+ });
3737
+ }
3738
+ openRouterConnectContinueBtn.addEventListener("click", () => {
3739
+ openRouterConnectContinueBtn.disabled = true;
3740
+ closeOpenRouterConnectModal();
3741
+ void startOpenRouterConnect(openRouterPersistCheckbox.checked);
3742
+ });
3185
3743
  var chatPane = document.getElementById("chat-pane");
3186
3744
  var divider = document.getElementById("pane-divider");
3187
3745
  divider.addEventListener("mousedown", (e) => {
@@ -3216,3 +3774,4 @@ graphToggle.addEventListener("click", () => {
3216
3774
  });
3217
3775
  connect();
3218
3776
  initGraph();
3777
+ void maybeHandleOpenRouterCallback();