@sean.holung/minicode 0.3.2 → 0.3.3

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 (107) hide show
  1. package/README.md +48 -43
  2. package/dist/scripts/run-benchmarks.js +147 -0
  3. package/dist/src/agent/config.js +149 -40
  4. package/dist/src/agent/editable-config.js +314 -0
  5. package/dist/src/analysis/structural-analysis.js +379 -0
  6. package/dist/src/benchmark/evaluator.js +79 -0
  7. package/dist/src/benchmark/index.js +4 -0
  8. package/dist/src/benchmark/reporter.js +177 -0
  9. package/dist/src/benchmark/runner.js +100 -0
  10. package/dist/src/benchmark/task-loader.js +78 -0
  11. package/dist/src/benchmark/types.js +5 -0
  12. package/dist/src/cli/args.js +10 -0
  13. package/dist/src/cli/config-slash-command.js +135 -0
  14. package/dist/src/cli/plugin-install.js +69 -0
  15. package/dist/src/index.js +76 -6
  16. package/dist/src/indexer/cache.js +6 -4
  17. package/dist/src/indexer/code-map.js +41 -13
  18. package/dist/src/indexer/plugins/typescript.js +70 -23
  19. package/dist/src/indexer/project-index.js +175 -36
  20. package/dist/src/indexer/symbol-names.js +92 -0
  21. package/dist/src/model-utils.js +18 -0
  22. package/dist/src/serve/agent-bridge.js +203 -24
  23. package/dist/src/serve/mcp-server.js +405 -0
  24. package/dist/src/serve/server.js +165 -10
  25. package/dist/src/serve/websocket.js +8 -0
  26. package/dist/src/shared/graph-styles.js +119 -0
  27. package/dist/src/tools/find-path.js +75 -0
  28. package/dist/src/tools/find-references.js +7 -2
  29. package/dist/src/tools/get-dependencies.js +3 -2
  30. package/dist/src/tools/read-symbol.js +12 -5
  31. package/dist/src/tools/registry.js +3 -1
  32. package/dist/src/tools/search-code-map.js +4 -2
  33. package/dist/src/ui/app.js +1 -1
  34. package/dist/src/ui/cli-ink.js +79 -4
  35. package/dist/src/ui/components/header-bar.js +6 -2
  36. package/dist/src/ui/state/ui-store.js +5 -0
  37. package/dist/src/web/app.js +1124 -176
  38. package/dist/src/web/index.html +113 -3
  39. package/dist/src/web/style.css +973 -55
  40. package/dist/tests/agent.test.js +31 -0
  41. package/dist/tests/analysis-helpers.test.js +89 -0
  42. package/dist/tests/analysis-ui.test.js +29 -0
  43. package/dist/tests/benchmark-harness.test.js +527 -0
  44. package/dist/tests/config-api.test.js +143 -0
  45. package/dist/tests/config-integration.test.js +751 -0
  46. package/dist/tests/config-slash-command.test.js +106 -0
  47. package/dist/tests/config.test.js +42 -1
  48. package/dist/tests/context-indicator.test.js +220 -0
  49. package/dist/tests/editable-config.test.js +109 -0
  50. package/dist/tests/find-path.test.js +183 -0
  51. package/dist/tests/focus-tracker.test.js +62 -0
  52. package/dist/tests/graph-onboarding.test.js +55 -0
  53. package/dist/tests/graph-styles.test.js +65 -0
  54. package/dist/tests/indexer.test.js +137 -0
  55. package/dist/tests/mcp-and-plugin.test.js +186 -0
  56. package/dist/tests/model-client-openai.test.js +29 -0
  57. package/dist/tests/model-selection.test.js +136 -0
  58. package/dist/tests/model-utils.test.js +22 -0
  59. package/dist/tests/reasoning-effort.test.js +264 -0
  60. package/dist/tests/run-benchmarks.test.js +161 -0
  61. package/dist/tests/search-code-map.test.js +18 -0
  62. package/dist/tests/serve.integration.test.js +218 -2
  63. package/dist/tests/session-ui.test.js +21 -0
  64. package/dist/tests/session.test.js +50 -0
  65. package/dist/tests/settings-ui.test.js +30 -0
  66. package/dist/tests/structural-analysis.test.js +218 -0
  67. package/node_modules/@minicode/agent-sdk/README.md +80 -51
  68. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.d.ts +16 -5
  69. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.d.ts.map +1 -1
  70. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js +51 -33
  71. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js.map +1 -1
  72. package/node_modules/@minicode/agent-sdk/dist/src/agent/types.d.ts +14 -0
  73. package/node_modules/@minicode/agent-sdk/dist/src/agent/types.d.ts.map +1 -1
  74. package/node_modules/@minicode/agent-sdk/dist/src/index.d.ts +3 -2
  75. package/node_modules/@minicode/agent-sdk/dist/src/index.d.ts.map +1 -1
  76. package/node_modules/@minicode/agent-sdk/dist/src/index.js +2 -0
  77. package/node_modules/@minicode/agent-sdk/dist/src/index.js.map +1 -1
  78. package/node_modules/@minicode/agent-sdk/dist/src/indexer/focus-tracker.d.ts +35 -0
  79. package/node_modules/@minicode/agent-sdk/dist/src/indexer/focus-tracker.d.ts.map +1 -0
  80. package/node_modules/@minicode/agent-sdk/dist/src/indexer/focus-tracker.js +64 -0
  81. package/node_modules/@minicode/agent-sdk/dist/src/indexer/focus-tracker.js.map +1 -0
  82. package/node_modules/@minicode/agent-sdk/dist/src/indexer/types.d.ts +7 -0
  83. package/node_modules/@minicode/agent-sdk/dist/src/indexer/types.d.ts.map +1 -1
  84. package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts +5 -1
  85. package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts.map +1 -1
  86. package/node_modules/@minicode/agent-sdk/dist/src/model/client.js +83 -11
  87. package/node_modules/@minicode/agent-sdk/dist/src/model/client.js.map +1 -1
  88. package/node_modules/@minicode/agent-sdk/dist/src/safety/guardrails.d.ts +1 -0
  89. package/node_modules/@minicode/agent-sdk/dist/src/safety/guardrails.d.ts.map +1 -1
  90. package/node_modules/@minicode/agent-sdk/dist/src/safety/guardrails.js +8 -1
  91. package/node_modules/@minicode/agent-sdk/dist/src/safety/guardrails.js.map +1 -1
  92. package/node_modules/@minicode/agent-sdk/dist/src/session/session.d.ts.map +1 -1
  93. package/node_modules/@minicode/agent-sdk/dist/src/session/session.js +4 -1
  94. package/node_modules/@minicode/agent-sdk/dist/src/session/session.js.map +1 -1
  95. package/node_modules/@minicode/agent-sdk/dist/tests/agent.test.js +3 -1
  96. package/node_modules/@minicode/agent-sdk/dist/tests/agent.test.js.map +1 -1
  97. package/node_modules/@minicode/agent-sdk/dist/tests/guardrails.test.js +8 -2
  98. package/node_modules/@minicode/agent-sdk/dist/tests/guardrails.test.js.map +1 -1
  99. package/node_modules/@minicode/agent-sdk/dist/tsconfig.tsbuildinfo +1 -1
  100. package/package.json +9 -5
  101. package/plugin/.claude-plugin/plugin.json +12 -0
  102. package/plugin/.mcp.json +8 -0
  103. package/plugin/CLAUDE.md +26 -0
  104. package/plugin/skills/analyze/SKILL.md +12 -0
  105. package/plugin/skills/focus/SKILL.md +20 -0
  106. package/plugin/skills/graph/SKILL.md +13 -0
  107. package/plugin/skills/symbols/SKILL.md +13 -0
@@ -1231,14 +1231,100 @@ function renderMarkdownInto(el, text) {
1231
1231
  }
1232
1232
  }
1233
1233
 
1234
- // src/web/graph.ts
1235
- var cy = null;
1236
- var graphNodes = /* @__PURE__ */ new Map();
1237
- var graphEdges = [];
1238
- var edgeIndex = /* @__PURE__ */ new Map();
1239
- var pinnedNames = /* @__PURE__ */ new Set();
1240
- var allSymbolNames = [];
1241
- var initialized = false;
1234
+ // src/web/analysis-helpers.ts
1235
+ function findingTypeLabel(type) {
1236
+ switch (type) {
1237
+ case "cycle":
1238
+ return "Cycle";
1239
+ case "fanInOutlier":
1240
+ return "High fan-in";
1241
+ case "fanOutOutlier":
1242
+ return "High fan-out";
1243
+ case "hotspot":
1244
+ return "Hotspot";
1245
+ case "fileCoupling":
1246
+ return "File coupling";
1247
+ }
1248
+ }
1249
+ function findingSeverityLabel(severity) {
1250
+ switch (severity) {
1251
+ case "high":
1252
+ return "High";
1253
+ case "warning":
1254
+ return "Warning";
1255
+ case "info":
1256
+ return "Info";
1257
+ }
1258
+ }
1259
+ function buildFindingMetricChips(finding) {
1260
+ switch (finding.type) {
1261
+ case "cycle":
1262
+ return [
1263
+ `${Number(finding.metrics.cycleSize ?? finding.symbols.length)} symbols`,
1264
+ `${Number(finding.metrics.edgeCount ?? 0)} edges`,
1265
+ `${Number(finding.metrics.fileCount ?? finding.files.length)} files`
1266
+ ];
1267
+ case "fanInOutlier":
1268
+ return [
1269
+ `fan-in ${Number(finding.metrics.fanIn ?? 0)}`,
1270
+ `threshold ${Number(finding.metrics.threshold ?? 0)}`
1271
+ ];
1272
+ case "fanOutOutlier":
1273
+ return [
1274
+ `fan-out ${Number(finding.metrics.fanOut ?? 0)}`,
1275
+ `threshold ${Number(finding.metrics.threshold ?? 0)}`
1276
+ ];
1277
+ case "hotspot":
1278
+ return [
1279
+ `degree ${Number(finding.metrics.totalDegree ?? 0)}`,
1280
+ `${Number(finding.metrics.fanIn ?? 0)} in`,
1281
+ `${Number(finding.metrics.fanOut ?? 0)} out`
1282
+ ];
1283
+ case "fileCoupling":
1284
+ return [
1285
+ `coupling ${Number(finding.metrics.totalCoupling ?? 0)}`,
1286
+ `instability ${Number(finding.metrics.instability ?? 0)}`
1287
+ ];
1288
+ }
1289
+ }
1290
+ function buildFindingGraphContext(finding, edges) {
1291
+ const selectedSymbols = new Set(finding.symbols);
1292
+ const edgeIds = /* @__PURE__ */ new Set();
1293
+ for (const edge of edges) {
1294
+ const touchesSelected = selectedSymbols.has(edge.source) || selectedSymbols.has(edge.target);
1295
+ const internalToSelection = selectedSymbols.has(edge.source) && selectedSymbols.has(edge.target);
1296
+ if (finding.type === "cycle" && internalToSelection || finding.type !== "cycle" && touchesSelected) {
1297
+ edgeIds.add(`${edge.source}->${edge.target}:${edge.kind}`);
1298
+ }
1299
+ }
1300
+ return {
1301
+ nodes: [...selectedSymbols].sort((a, b2) => a.localeCompare(b2)),
1302
+ edgeIds: [...edgeIds].sort((a, b2) => a.localeCompare(b2))
1303
+ };
1304
+ }
1305
+ function countFindingsByType(findings) {
1306
+ return findings.reduce(
1307
+ (counts, finding) => {
1308
+ counts[finding.type] += 1;
1309
+ return counts;
1310
+ },
1311
+ {
1312
+ cycle: 0,
1313
+ fanInOutlier: 0,
1314
+ fanOutOutlier: 0,
1315
+ hotspot: 0,
1316
+ fileCoupling: 0
1317
+ }
1318
+ );
1319
+ }
1320
+ function filterFindings(findings, filter) {
1321
+ if (filter === "all") {
1322
+ return findings;
1323
+ }
1324
+ return findings.filter((finding) => finding.type === filter);
1325
+ }
1326
+
1327
+ // src/shared/graph-styles.ts
1242
1328
  var KIND_COLORS = {
1243
1329
  function: { border: "#7aa2f7", bg: "rgba(122,162,247,0.15)" },
1244
1330
  class: { border: "#bb9af7", bg: "rgba(187,154,247,0.15)" },
@@ -1254,6 +1340,121 @@ var EDGE_STYLES = {
1254
1340
  implements: { lineStyle: "dashed", opacity: 0.6, color: "#2ac3de", width: 1.5 },
1255
1341
  references: { lineStyle: "dotted", opacity: 0.3, color: "#565f89", width: 1 }
1256
1342
  };
1343
+ function buildStylesheet() {
1344
+ const styles = [
1345
+ {
1346
+ selector: "node",
1347
+ style: {
1348
+ "label": "data(label)",
1349
+ "font-size": 11,
1350
+ "color": "#c0caf5",
1351
+ "text-valign": "bottom",
1352
+ "text-halign": "center",
1353
+ "text-margin-y": 5,
1354
+ "width": 20,
1355
+ "height": 20,
1356
+ "shape": "roundrectangle",
1357
+ "border-width": 1.5,
1358
+ "border-color": "#565f89",
1359
+ "background-color": "rgba(34,35,54,0.8)",
1360
+ "font-family": "'JetBrains Mono', monospace",
1361
+ "text-wrap": "none"
1362
+ }
1363
+ },
1364
+ {
1365
+ selector: "edge",
1366
+ style: {
1367
+ "width": 1,
1368
+ "line-color": "#565f89",
1369
+ "target-arrow-color": "#565f89",
1370
+ "target-arrow-shape": "triangle",
1371
+ "arrow-scale": 0.6,
1372
+ "curve-style": "bezier",
1373
+ "opacity": 0.4
1374
+ }
1375
+ }
1376
+ ];
1377
+ for (const [kind, colors] of Object.entries(KIND_COLORS)) {
1378
+ styles.push({
1379
+ selector: `node.${kind}`,
1380
+ style: {
1381
+ "color": colors.border,
1382
+ "border-color": colors.border,
1383
+ "background-color": colors.bg
1384
+ }
1385
+ });
1386
+ }
1387
+ for (const [kind, s] of Object.entries(EDGE_STYLES)) {
1388
+ styles.push({
1389
+ selector: `edge.${kind}`,
1390
+ style: {
1391
+ "line-style": s.lineStyle,
1392
+ "line-color": s.color,
1393
+ "target-arrow-color": s.color,
1394
+ "opacity": s.opacity,
1395
+ "width": s.width
1396
+ }
1397
+ });
1398
+ }
1399
+ styles.push({ selector: "node.pinned", style: { "border-color": "#e0af68", "border-width": 3 } });
1400
+ styles.push({ selector: "node.faded", style: { "opacity": 0.15 } });
1401
+ styles.push({ selector: "edge.faded", style: { "opacity": 0.05 } });
1402
+ styles.push({ selector: "node.highlighted", style: { "border-width": 2.5, "z-index": 10 } });
1403
+ styles.push({ selector: "edge.highlighted", style: { "opacity": 0.9, "width": 2, "z-index": 10 } });
1404
+ styles.push({
1405
+ selector: "node.analysis-flagged",
1406
+ style: {
1407
+ "border-style": "double",
1408
+ "border-width": 3,
1409
+ "overlay-color": "#7dcfff",
1410
+ "overlay-opacity": 0.05
1411
+ }
1412
+ });
1413
+ styles.push({
1414
+ selector: "node.analysis-selected",
1415
+ style: {
1416
+ "border-color": "#f7768e",
1417
+ "border-width": 4,
1418
+ "background-color": "rgba(247,118,142,0.18)",
1419
+ "z-index": 30
1420
+ }
1421
+ });
1422
+ styles.push({
1423
+ selector: "edge.analysis-flagged",
1424
+ style: {
1425
+ "opacity": 0.55,
1426
+ "width": 1.75
1427
+ }
1428
+ });
1429
+ styles.push({
1430
+ selector: "edge.analysis-selected",
1431
+ style: {
1432
+ "line-color": "#f7768e",
1433
+ "target-arrow-color": "#f7768e",
1434
+ "opacity": 0.95,
1435
+ "width": 3,
1436
+ "z-index": 30
1437
+ }
1438
+ });
1439
+ styles.push({ selector: "node.agent-pulse", style: { "border-color": "#ff9e64", "border-width": 4, "background-color": "rgba(255,158,100,0.25)" } });
1440
+ styles.push({ selector: "node.search-match", style: { "border-color": "#e0af68", "border-width": 2.5 } });
1441
+ styles.push({ selector: "node.expanded", style: { "border-width": 2.5 } });
1442
+ styles.push({ selector: "node.hover-target", style: { "border-width": 3, "width": 24, "height": 24, "overlay-color": "#c0caf5", "overlay-opacity": 0.08, "z-index": 20 } });
1443
+ return styles;
1444
+ }
1445
+
1446
+ // src/web/graph.ts
1447
+ var cy = null;
1448
+ var graphNodes = /* @__PURE__ */ new Map();
1449
+ var graphEdges = [];
1450
+ var edgeIndex = /* @__PURE__ */ new Map();
1451
+ var pinnedNames = /* @__PURE__ */ new Set();
1452
+ var allSymbolNames = [];
1453
+ var initialized = false;
1454
+ var analysisReport = null;
1455
+ var activeAnalysisFindingId = null;
1456
+ var activeAnalysisFilter = "all";
1457
+ var analysisExplanationCache = /* @__PURE__ */ new Map();
1257
1458
  var LAYOUT_OPTIONS = {
1258
1459
  name: "cose",
1259
1460
  nodeRepulsion: function() {
@@ -1322,9 +1523,11 @@ async function initGraph() {
1322
1523
  setupToolbar();
1323
1524
  if (pinnedNames.size > 0) {
1324
1525
  for (const name of pinnedNames) {
1325
- addNodeAndNeighbors(name);
1526
+ addNodeNeighborhood(name, 1);
1326
1527
  }
1327
1528
  runLayout();
1529
+ } else {
1530
+ showOnboardingHint(cyEl);
1328
1531
  }
1329
1532
  } catch (err) {
1330
1533
  console.error("Graph init failed:", err);
@@ -1334,22 +1537,28 @@ async function initGraph() {
1334
1537
  }
1335
1538
  function highlightAgentActivity(symbolName) {
1336
1539
  if (!cy) return;
1337
- const node = findNode(symbolName);
1338
- if (node) {
1339
- node.addClass("agent-pulse");
1340
- setTimeout(() => node.removeClass("agent-pulse"), 2e3);
1341
- return;
1342
- }
1343
- expandNodeAndLayout(symbolName);
1344
- const added = findNode(symbolName);
1345
- if (added) {
1346
- added.addClass("agent-pulse");
1347
- setTimeout(() => added.removeClass("agent-pulse"), 2e3);
1348
- }
1540
+ void focusSymbolInGraph(symbolName, {
1541
+ maxDegrees: 0,
1542
+ pulse: true,
1543
+ pulseDuration: 2e3,
1544
+ animate: false,
1545
+ openDetail: true
1546
+ });
1349
1547
  }
1350
1548
  function resizeGraph() {
1351
1549
  if (cy) cy.resize();
1352
1550
  }
1551
+ function showOnboardingHint(container) {
1552
+ if (container.querySelector(".graph-onboarding")) return;
1553
+ const hint = document.createElement("div");
1554
+ 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>';
1556
+ container.appendChild(hint);
1557
+ }
1558
+ function removeOnboardingHint() {
1559
+ const hint = document.querySelector(".graph-onboarding");
1560
+ if (hint) hint.remove();
1561
+ }
1353
1562
  function buildEdgeIndex() {
1354
1563
  edgeIndex.clear();
1355
1564
  for (const edge of graphEdges) {
@@ -1367,23 +1576,77 @@ function buildEdgeIndex() {
1367
1576
  tgtList.push(edge);
1368
1577
  }
1369
1578
  }
1370
- function addNodeAndNeighbors(symbolId) {
1371
- addNodeToGraph(symbolId);
1372
- const edges = edgeIndex.get(symbolId) || [];
1373
- for (const edge of edges) {
1374
- const neighbor = edge.source === symbolId ? edge.target : edge.source;
1375
- addNodeToGraph(neighbor);
1376
- addEdgeToGraph(edge);
1579
+ function addNodeNeighborhood(symbolId, maxDegrees = 1) {
1580
+ const visited = /* @__PURE__ */ new Set();
1581
+ let frontier = /* @__PURE__ */ new Set([symbolId]);
1582
+ for (let degree = 0; degree <= maxDegrees; degree += 1) {
1583
+ const next = /* @__PURE__ */ new Set();
1584
+ for (const currentId of frontier) {
1585
+ if (visited.has(currentId)) continue;
1586
+ visited.add(currentId);
1587
+ addNodeToGraph(currentId);
1588
+ if (degree === maxDegrees) continue;
1589
+ const edges = edgeIndex.get(currentId) || [];
1590
+ for (const edge of edges) {
1591
+ const neighbor = edge.source === currentId ? edge.target : edge.source;
1592
+ addNodeToGraph(neighbor);
1593
+ addEdgeToGraph(edge);
1594
+ if (!visited.has(neighbor)) {
1595
+ next.add(neighbor);
1596
+ }
1597
+ }
1598
+ }
1599
+ frontier = next;
1600
+ if (frontier.size === 0) break;
1377
1601
  }
1378
1602
  }
1379
- function expandNodeAndLayout(symbolId) {
1380
- addNodeAndNeighbors(symbolId);
1603
+ function renderNodeNeighborhoodAndLayout(symbolId, maxDegrees = 1) {
1604
+ if (!cy) return;
1605
+ const beforeNodeCount = cy.nodes().length;
1606
+ addNodeNeighborhood(symbolId, maxDegrees);
1381
1607
  connectExistingNodes();
1382
- runLayout();
1608
+ refreshAnalysisGraphState();
1609
+ if (cy.nodes().length > beforeNodeCount) {
1610
+ runLayout();
1611
+ }
1612
+ }
1613
+ async function focusSymbolInGraph(symbolId, options = {}) {
1614
+ if (!cy) return;
1615
+ const {
1616
+ maxDegrees = 0,
1617
+ pulse = false,
1618
+ pulseDuration = 1500,
1619
+ animate = false,
1620
+ zoom = 1.2,
1621
+ flashDuration = 1200,
1622
+ openDetail = false
1623
+ } = 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);
1635
+ }
1636
+ if (openDetail) {
1637
+ const detailEl = document.getElementById("symbol-detail");
1638
+ if (detailEl) {
1639
+ await showDetail(node, detailEl);
1640
+ }
1641
+ }
1642
+ }
1643
+ function expandNodeAndLayout(symbolId) {
1644
+ renderNodeNeighborhoodAndLayout(symbolId, 1);
1383
1645
  }
1384
1646
  function addNodeToGraph(id) {
1385
1647
  if (!cy) return;
1386
1648
  if (cy.getElementById(id).length > 0) return;
1649
+ removeOnboardingHint();
1387
1650
  const nodeData = graphNodes.get(id);
1388
1651
  if (!nodeData) return;
1389
1652
  const kind = (nodeData.kind || "function").toLowerCase();
@@ -1435,71 +1698,348 @@ function findNode(name) {
1435
1698
  });
1436
1699
  return match.length > 0 ? match : null;
1437
1700
  }
1438
- function buildStylesheet() {
1439
- const styles = [
1440
- {
1441
- selector: "node",
1442
- style: {
1443
- "label": "data(label)",
1444
- "font-size": 11,
1445
- "color": "#c0caf5",
1446
- "text-valign": "bottom",
1447
- "text-halign": "center",
1448
- "text-margin-y": 5,
1449
- "width": 20,
1450
- "height": 20,
1451
- "shape": "roundrectangle",
1452
- "border-width": 1.5,
1453
- "border-color": "#565f89",
1454
- "background-color": "rgba(34,35,54,0.8)",
1455
- "font-family": "'JetBrains Mono', monospace",
1456
- "text-wrap": "none"
1457
- }
1458
- },
1459
- {
1460
- selector: "edge",
1461
- style: {
1462
- "width": 1,
1463
- "line-color": "#565f89",
1464
- "target-arrow-color": "#565f89",
1465
- "target-arrow-shape": "triangle",
1466
- "arrow-scale": 0.6,
1467
- "curve-style": "bezier",
1468
- "opacity": 0.4
1469
- }
1470
- }
1701
+ function getAnalysisPanelEls() {
1702
+ return {
1703
+ panel: document.getElementById("analysis-panel"),
1704
+ status: document.getElementById("analysis-status"),
1705
+ summary: document.getElementById("analysis-summary"),
1706
+ findings: document.getElementById("analysis-findings")
1707
+ };
1708
+ }
1709
+ function setAnalysisStatus(message, tone = "info") {
1710
+ const { status } = getAnalysisPanelEls();
1711
+ status.textContent = message;
1712
+ status.classList.remove("hidden", "error");
1713
+ if (tone === "error") {
1714
+ status.classList.add("error");
1715
+ }
1716
+ }
1717
+ function clearAnalysisStatus() {
1718
+ const { status } = getAnalysisPanelEls();
1719
+ status.textContent = "";
1720
+ status.classList.add("hidden");
1721
+ status.classList.remove("error");
1722
+ }
1723
+ function clearAnalysisGraphClasses() {
1724
+ if (!cy) return;
1725
+ cy.elements().removeClass("analysis-flagged");
1726
+ cy.elements().removeClass("analysis-selected");
1727
+ }
1728
+ function refreshAnalysisGraphState() {
1729
+ const { panel } = getAnalysisPanelEls();
1730
+ if (!cy || !analysisReport || panel.classList.contains("hidden")) {
1731
+ clearAnalysisGraphClasses();
1732
+ return;
1733
+ }
1734
+ clearAnalysisGraphClasses();
1735
+ const candidateNodeIds = /* @__PURE__ */ new Set();
1736
+ const candidateEdgeIds = /* @__PURE__ */ new Set();
1737
+ for (const finding of getVisibleAnalysisFindings()) {
1738
+ const context = buildFindingGraphContext(finding, graphEdges);
1739
+ for (const nodeId of context.nodes) candidateNodeIds.add(nodeId);
1740
+ for (const edgeId of context.edgeIds) candidateEdgeIds.add(edgeId);
1741
+ }
1742
+ for (const nodeId of candidateNodeIds) {
1743
+ cy.getElementById(nodeId).addClass("analysis-flagged");
1744
+ }
1745
+ for (const edgeId of candidateEdgeIds) {
1746
+ cy.getElementById(edgeId).addClass("analysis-flagged");
1747
+ }
1748
+ if (!activeAnalysisFindingId) return;
1749
+ const activeFinding = getVisibleAnalysisFindings().find((finding) => finding.id === activeAnalysisFindingId);
1750
+ if (!activeFinding) return;
1751
+ const activeContext = buildFindingGraphContext(activeFinding, graphEdges);
1752
+ for (const nodeId of activeContext.nodes) {
1753
+ cy.getElementById(nodeId).addClass("analysis-selected");
1754
+ }
1755
+ for (const edgeId of activeContext.edgeIds) {
1756
+ cy.getElementById(edgeId).addClass("analysis-selected");
1757
+ }
1758
+ }
1759
+ function getVisibleAnalysisFindings() {
1760
+ return analysisReport ? filterFindings(analysisReport.findings, activeAnalysisFilter) : [];
1761
+ }
1762
+ function renderAnalysisSummary(report) {
1763
+ const { summary } = getAnalysisPanelEls();
1764
+ const counts = countFindingsByType(report.findings);
1765
+ const cards = [
1766
+ { label: "Findings", value: report.summary.findingCount, filter: "all" },
1767
+ { label: "Cycles", value: counts.cycle, filter: "cycle" },
1768
+ { label: "Hotspots", value: counts.hotspot, filter: "hotspot" },
1769
+ { label: "Coupling", value: counts.fileCoupling, filter: "fileCoupling" }
1471
1770
  ];
1472
- for (const [kind, colors] of Object.entries(KIND_COLORS)) {
1473
- styles.push({
1474
- selector: `node.${kind}`,
1475
- style: {
1476
- "color": colors.border,
1477
- "border-color": colors.border,
1478
- "background-color": colors.bg
1479
- }
1771
+ summary.innerHTML = cards.map((card) => `
1772
+ <button
1773
+ class="analysis-summary-card ${card.filter === activeAnalysisFilter ? "active" : ""}"
1774
+ data-filter="${card.filter}"
1775
+ type="button"
1776
+ >
1777
+ <span class="analysis-summary-value">${card.value}</span>
1778
+ <span class="analysis-summary-label">${escapeHtml(card.label)}</span>
1779
+ </button>
1780
+ `).join("");
1781
+ summary.querySelectorAll(".analysis-summary-card").forEach((el) => {
1782
+ el.addEventListener("click", () => {
1783
+ const filter = el.dataset.filter || "all";
1784
+ setAnalysisFilter(filter);
1480
1785
  });
1786
+ });
1787
+ }
1788
+ function renderAnalysisFindings(report) {
1789
+ const { findings } = getAnalysisPanelEls();
1790
+ const visibleFindings = filterFindings(report.findings, activeAnalysisFilter);
1791
+ if (visibleFindings.length === 0) {
1792
+ findings.innerHTML = `
1793
+ <div class="analysis-empty">
1794
+ No ${escapeHtml(activeAnalysisFilter === "all" ? "structural outliers" : findingTypeLabel(activeAnalysisFilter).toLowerCase())} cleared the current thresholds for this graph snapshot.
1795
+ </div>
1796
+ `;
1797
+ return;
1481
1798
  }
1482
- for (const [kind, s] of Object.entries(EDGE_STYLES)) {
1483
- styles.push({
1484
- selector: `edge.${kind}`,
1485
- style: {
1486
- "line-style": s.lineStyle,
1487
- "line-color": s.color,
1488
- "target-arrow-color": s.color,
1489
- "opacity": s.opacity,
1490
- "width": s.width
1491
- }
1799
+ findings.innerHTML = visibleFindings.map((finding) => {
1800
+ const metricChips = buildFindingMetricChips(finding).map((chip) => `<span class="analysis-metric-chip">${escapeHtml(chip)}</span>`).join("");
1801
+ const fileBadges = finding.files.slice(0, 3).map((file) => `<span class="analysis-file-badge">${escapeHtml(file)}</span>`).join("");
1802
+ const rationale = finding.rationale.slice(0, 2).map((item) => `<li>${escapeHtml(item)}</li>`).join("");
1803
+ return `
1804
+ <article class="analysis-finding" data-finding-id="${escapeHtml(finding.id)}">
1805
+ <div class="analysis-finding-header">
1806
+ <div>
1807
+ <h3 class="analysis-finding-title">${escapeHtml(finding.title)}</h3>
1808
+ </div>
1809
+ <div class="analysis-finding-meta">
1810
+ <span class="analysis-type-badge">${escapeHtml(findingTypeLabel(finding.type))}</span>
1811
+ <span class="analysis-severity-badge ${escapeHtml(finding.severity)}">${escapeHtml(findingSeverityLabel(finding.severity))}</span>
1812
+ </div>
1813
+ </div>
1814
+ <p class="analysis-finding-summary">${escapeHtml(finding.summary)}</p>
1815
+ <div class="analysis-chip-row">${metricChips}</div>
1816
+ ${fileBadges ? `<div class="analysis-file-row">${fileBadges}</div>` : ""}
1817
+ ${rationale ? `<ul class="analysis-rationale">${rationale}</ul>` : ""}
1818
+ <div class="analysis-finding-actions">
1819
+ <button class="header-btn analysis-select-btn" type="button">Inspect in graph</button>
1820
+ <button class="header-btn analysis-explain-btn" type="button">Explain this finding</button>
1821
+ </div>
1822
+ <div class="analysis-explanation hidden">
1823
+ <div class="analysis-explanation-label">AI interpretation</div>
1824
+ <div class="analysis-explanation-note">Advisory explanation grounded in the deterministic finding and affected symbols.</div>
1825
+ <div class="detail-explain-content analysis-explanation-content"></div>
1826
+ </div>
1827
+ </article>
1828
+ `;
1829
+ }).join("");
1830
+ findings.querySelectorAll(".analysis-finding").forEach((el) => {
1831
+ el.addEventListener("click", () => {
1832
+ const id = el.dataset.findingId || "";
1833
+ void selectAnalysisFinding(id);
1492
1834
  });
1835
+ });
1836
+ findings.querySelectorAll(".analysis-select-btn").forEach((button) => {
1837
+ button.addEventListener("click", (event) => {
1838
+ event.stopPropagation();
1839
+ const parent = button.closest(".analysis-finding");
1840
+ const id = parent?.dataset.findingId || "";
1841
+ void selectAnalysisFinding(id);
1842
+ });
1843
+ });
1844
+ findings.querySelectorAll(".analysis-explain-btn").forEach((button) => {
1845
+ button.addEventListener("click", (event) => {
1846
+ event.stopPropagation();
1847
+ const parent = button.closest(".analysis-finding");
1848
+ if (!parent) return;
1849
+ const id = parent.dataset.findingId || "";
1850
+ void explainAnalysisFinding(id, parent);
1851
+ });
1852
+ });
1853
+ }
1854
+ function setActiveFindingCard(findingId) {
1855
+ const { findings } = getAnalysisPanelEls();
1856
+ findings.querySelectorAll(".analysis-finding").forEach((el) => {
1857
+ el.classList.toggle("active", el.dataset.findingId === findingId);
1858
+ });
1859
+ }
1860
+ function setAnalysisFilter(filter) {
1861
+ const nextFilter = activeAnalysisFilter === filter ? "all" : filter;
1862
+ activeAnalysisFilter = nextFilter;
1863
+ if (!analysisReport) {
1864
+ return;
1865
+ }
1866
+ const visibleFindings = filterFindings(analysisReport.findings, activeAnalysisFilter);
1867
+ if (activeAnalysisFindingId && !visibleFindings.some((finding) => finding.id === activeAnalysisFindingId)) {
1868
+ activeAnalysisFindingId = null;
1869
+ }
1870
+ renderAnalysisSummary(analysisReport);
1871
+ renderAnalysisFindings(analysisReport);
1872
+ refreshAnalysisGraphState();
1873
+ setActiveFindingCard(activeAnalysisFindingId);
1874
+ }
1875
+ async function runStructuralAnalysis() {
1876
+ const { panel, findings } = getAnalysisPanelEls();
1877
+ panel.classList.remove("hidden");
1878
+ findings.innerHTML = '<div class="analysis-empty">Running structural analysis on the current dependency graph...</div>';
1879
+ setAnalysisStatus("Analyzing the current graph snapshot...");
1880
+ try {
1881
+ const res = await fetch("/api/analysis");
1882
+ if (!res.ok) {
1883
+ throw new Error(res.status === 404 ? "No project index available for analysis yet." : `Analysis request failed (${res.status})`);
1884
+ }
1885
+ const report = await res.json();
1886
+ analysisReport = report;
1887
+ activeAnalysisFindingId = null;
1888
+ activeAnalysisFilter = "all";
1889
+ analysisExplanationCache.clear();
1890
+ clearAnalysisStatus();
1891
+ setAnalysisStatus(
1892
+ `Analyzed ${report.summary.symbolCount} symbols across ${report.summary.fileCount} files. These are graph-derived structural signals.`
1893
+ );
1894
+ renderAnalysisSummary(report);
1895
+ renderAnalysisFindings(report);
1896
+ refreshAnalysisGraphState();
1897
+ } catch (error) {
1898
+ analysisReport = null;
1899
+ activeAnalysisFindingId = null;
1900
+ activeAnalysisFilter = "all";
1901
+ analysisExplanationCache.clear();
1902
+ clearAnalysisGraphClasses();
1903
+ getAnalysisPanelEls().summary.innerHTML = "";
1904
+ const message = error instanceof Error ? error.message : "Failed to run structural analysis.";
1905
+ findings.innerHTML = `<div class="analysis-empty">${escapeHtml(message)}</div>`;
1906
+ setAnalysisStatus(message, "error");
1907
+ }
1908
+ }
1909
+ async function streamExplanationInto(request, content, loadingLabel, unavailableText, failureText, onComplete) {
1910
+ content.innerHTML = `<span class="explain-status"><span class="explain-spinner"></span> ${escapeHtml(loadingLabel)}</span>`;
1911
+ let streaming = false;
1912
+ let rawText = "";
1913
+ function showToolStatus(toolName, input) {
1914
+ if (streaming) return;
1915
+ const arg = input.name || input.path || input.symbol || input.query || "";
1916
+ content.innerHTML = `<span class="explain-status"><span class="explain-spinner"></span> ${escapeHtml(toolName)}(${escapeHtml(String(arg))})</span>`;
1917
+ }
1918
+ function startStreaming() {
1919
+ if (streaming) return;
1920
+ streaming = true;
1921
+ rawText = "";
1922
+ content.textContent = "";
1923
+ }
1924
+ function finalize() {
1925
+ if (!rawText) return;
1926
+ onComplete?.(rawText);
1927
+ renderMarkdownInto(content, rawText);
1928
+ }
1929
+ try {
1930
+ const res = await request();
1931
+ if (!res.ok || !res.body) {
1932
+ content.textContent = unavailableText;
1933
+ return;
1934
+ }
1935
+ const reader = res.body.getReader();
1936
+ const decoder = new TextDecoder();
1937
+ let buffer = "";
1938
+ while (true) {
1939
+ const { done, value } = await reader.read();
1940
+ if (done) break;
1941
+ buffer += decoder.decode(value, { stream: true });
1942
+ const lines = buffer.split("\n");
1943
+ buffer = lines.pop() || "";
1944
+ for (const line of lines) {
1945
+ if (!line.startsWith("data: ")) continue;
1946
+ const payload = line.slice(6);
1947
+ if (payload === "[DONE]") {
1948
+ finalize();
1949
+ return;
1950
+ }
1951
+ try {
1952
+ const event = JSON.parse(payload);
1953
+ if (event.type === "tool_call_start") {
1954
+ showToolStatus(event.name, event.input || {});
1955
+ } else if (event.type === "streaming_chunk" && event.content) {
1956
+ startStreaming();
1957
+ rawText += event.content;
1958
+ content.textContent = rawText;
1959
+ content.scrollTop = content.scrollHeight;
1960
+ } else if (event.type === "error") {
1961
+ startStreaming();
1962
+ rawText += `
1963
+ [Error: ${event.message}]`;
1964
+ content.textContent = rawText;
1965
+ }
1966
+ } catch {
1967
+ }
1968
+ }
1969
+ }
1970
+ finalize();
1971
+ } catch {
1972
+ if (!streaming) {
1973
+ content.textContent = failureText;
1974
+ } else {
1975
+ finalize();
1976
+ }
1977
+ }
1978
+ }
1979
+ async function selectAnalysisFinding(findingId) {
1980
+ if (!analysisReport || !cy) return;
1981
+ const finding = analysisReport.findings.find((item) => item.id === findingId);
1982
+ if (!finding) return;
1983
+ const beforeNodeCount = cy.nodes().length;
1984
+ for (const symbol of finding.symbols) {
1985
+ addNodeNeighborhood(symbol, 1);
1986
+ }
1987
+ connectExistingNodes();
1988
+ activeAnalysisFindingId = findingId;
1989
+ refreshAnalysisGraphState();
1990
+ setActiveFindingCard(findingId);
1991
+ const afterNodeCount = cy.nodes().length;
1992
+ if (afterNodeCount > beforeNodeCount) {
1993
+ runLayout();
1994
+ }
1995
+ const primarySymbol = finding.symbols[0];
1996
+ if (!primarySymbol) return;
1997
+ const primaryNode = cy.getElementById(primarySymbol);
1998
+ if (primaryNode.length > 0) {
1999
+ if (finding.symbols.length > 1) {
2000
+ cy.fit(80);
2001
+ } else {
2002
+ cy.animate({ center: { eles: primaryNode }, zoom: 1.35 }, { duration: 300 });
2003
+ }
2004
+ primaryNode.flashClass("highlighted", 1200);
2005
+ await showDetail(primaryNode, document.getElementById("symbol-detail"));
2006
+ }
2007
+ }
2008
+ async function explainAnalysisFinding(findingId, findingEl) {
2009
+ await selectAnalysisFinding(findingId);
2010
+ const explanationEl = findingEl.querySelector(".analysis-explanation");
2011
+ const contentEl = findingEl.querySelector(".analysis-explanation-content");
2012
+ if (!explanationEl || !contentEl) return;
2013
+ explanationEl.classList.remove("hidden");
2014
+ const cached = analysisExplanationCache.get(findingId);
2015
+ if (cached) {
2016
+ renderMarkdownInto(contentEl, cached);
2017
+ return;
2018
+ }
2019
+ if (findingEl.dataset.explaining === "true") {
2020
+ return;
2021
+ }
2022
+ findingEl.dataset.explaining = "true";
2023
+ try {
2024
+ await streamExplanationInto(
2025
+ () => fetch("/api/analysis/explain", {
2026
+ method: "POST",
2027
+ headers: { "Content-Type": "application/json" },
2028
+ body: JSON.stringify({ findingId })
2029
+ }),
2030
+ contentEl,
2031
+ "Interpreting deterministic finding...",
2032
+ "(analysis explanation unavailable)",
2033
+ "(analysis explanation failed)",
2034
+ (markdown) => {
2035
+ if (!markdown.includes("[Error:")) {
2036
+ analysisExplanationCache.set(findingId, markdown);
2037
+ }
2038
+ }
2039
+ );
2040
+ } finally {
2041
+ delete findingEl.dataset.explaining;
1493
2042
  }
1494
- styles.push({ selector: "node.pinned", style: { "border-color": "#e0af68", "border-width": 3 } });
1495
- styles.push({ selector: "node.faded", style: { "opacity": 0.15 } });
1496
- styles.push({ selector: "edge.faded", style: { "opacity": 0.05 } });
1497
- styles.push({ selector: "node.highlighted", style: { "border-width": 2.5, "z-index": 10 } });
1498
- styles.push({ selector: "edge.highlighted", style: { "opacity": 0.9, "width": 2, "z-index": 10 } });
1499
- styles.push({ selector: "node.agent-pulse", style: { "border-color": "#ff9e64", "border-width": 4, "background-color": "rgba(255,158,100,0.25)" } });
1500
- styles.push({ selector: "node.search-match", style: { "border-color": "#e0af68", "border-width": 2.5 } });
1501
- styles.push({ selector: "node.expanded", style: { "border-width": 2.5 } });
1502
- return styles;
1503
2043
  }
1504
2044
  function setupInteractions(cyInst, detailEl) {
1505
2045
  cyInst.on("tap", "node", function(evt) {
@@ -1514,16 +2054,22 @@ function setupInteractions(cyInst, detailEl) {
1514
2054
  expandNodeAndLayout(id);
1515
2055
  }
1516
2056
  });
2057
+ const containerEl = cyInst.container();
1517
2058
  cyInst.on("mouseover", "node", function(evt) {
1518
2059
  if (!cy) return;
1519
2060
  const node = evt.target;
1520
2061
  const neighborhood = node.closedNeighborhood();
1521
2062
  cy.elements().not(neighborhood).addClass("faded");
1522
2063
  neighborhood.addClass("highlighted");
2064
+ node.addClass("hover-target");
2065
+ containerEl.style.cursor = "pointer";
1523
2066
  });
1524
- cyInst.on("mouseout", "node", function() {
2067
+ cyInst.on("mouseout", "node", function(evt) {
1525
2068
  if (!cy) return;
2069
+ const node = evt.target;
2070
+ node.removeClass("hover-target");
1526
2071
  cy.elements().removeClass("faded highlighted");
2072
+ containerEl.style.cursor = "";
1527
2073
  });
1528
2074
  cyInst.on("tap", function(evt) {
1529
2075
  if (evt.target === cyInst) {
@@ -1678,7 +2224,7 @@ async function loadDepsAndRefs(name, detailEl) {
1678
2224
  const items = refs.references || refs || [];
1679
2225
  refsEl.innerHTML = items.length ? items.map((r) => {
1680
2226
  const id = typeof r === "string" ? r : r.from || r.qualifiedName || r.name || "";
1681
- const label = id.split(".").pop() || id;
2227
+ const label = typeof r === "string" ? id : r.name || id.split(".").pop() || id;
1682
2228
  const kind = typeof r === "string" ? "" : r.kind || "";
1683
2229
  return `<a class="detail-link" data-target="${escapeHtml(id)}">${escapeHtml(label)} <span style="opacity:0.5">${escapeHtml(kind)}</span></a>`;
1684
2230
  }).join("") : '<span class="detail-empty">None</span>';
@@ -1744,74 +2290,13 @@ async function explainSymbol(name, detailEl) {
1744
2290
  const section = detailEl.querySelector("#detail-explain");
1745
2291
  const content = section.querySelector(".detail-explain-content");
1746
2292
  section.classList.remove("hidden");
1747
- content.innerHTML = '<span class="explain-status"><span class="explain-spinner"></span> Researching...</span>';
1748
- let streaming = false;
1749
- let rawText = "";
1750
- function showToolStatus(toolName, input) {
1751
- if (streaming) return;
1752
- const arg = input.name || input.path || input.symbol || input.query || "";
1753
- content.innerHTML = `<span class="explain-status"><span class="explain-spinner"></span> ${escapeHtml(toolName)}(${escapeHtml(String(arg))})</span>`;
1754
- }
1755
- function startStreaming() {
1756
- if (streaming) return;
1757
- streaming = true;
1758
- rawText = "";
1759
- content.textContent = "";
1760
- }
1761
- function finalize() {
1762
- if (rawText) {
1763
- renderMarkdownInto(content, rawText);
1764
- }
1765
- }
1766
- try {
1767
- const res = await fetch(`/api/symbols/${encodeURIComponent(name)}/explain`);
1768
- if (!res.ok || !res.body) {
1769
- content.textContent = "(explain unavailable)";
1770
- return;
1771
- }
1772
- const reader = res.body.getReader();
1773
- const decoder = new TextDecoder();
1774
- let buffer = "";
1775
- while (true) {
1776
- const { done, value } = await reader.read();
1777
- if (done) break;
1778
- buffer += decoder.decode(value, { stream: true });
1779
- const lines = buffer.split("\n");
1780
- buffer = lines.pop() || "";
1781
- for (const line of lines) {
1782
- if (!line.startsWith("data: ")) continue;
1783
- const payload = line.slice(6);
1784
- if (payload === "[DONE]") {
1785
- finalize();
1786
- return;
1787
- }
1788
- try {
1789
- const event = JSON.parse(payload);
1790
- if (event.type === "tool_call_start") {
1791
- showToolStatus(event.name, event.input || {});
1792
- } else if (event.type === "streaming_chunk" && event.content) {
1793
- startStreaming();
1794
- rawText += event.content;
1795
- content.textContent = rawText;
1796
- content.scrollTop = content.scrollHeight;
1797
- } else if (event.type === "error") {
1798
- startStreaming();
1799
- rawText += `
1800
- [Error: ${event.message}]`;
1801
- content.textContent = rawText;
1802
- }
1803
- } catch {
1804
- }
1805
- }
1806
- }
1807
- finalize();
1808
- } catch {
1809
- if (!streaming) {
1810
- content.textContent = "(explain failed)";
1811
- } else {
1812
- finalize();
1813
- }
1814
- }
2293
+ await streamExplanationInto(
2294
+ () => fetch(`/api/symbols/${encodeURIComponent(name)}/explain`),
2295
+ content,
2296
+ "Researching...",
2297
+ "(explain unavailable)",
2298
+ "(explain failed)"
2299
+ );
1815
2300
  }
1816
2301
  async function togglePin(name, node, btnEl) {
1817
2302
  const wasPinned = pinnedNames.has(name);
@@ -1838,9 +2323,13 @@ async function togglePin(name, node, btnEl) {
1838
2323
  }
1839
2324
  function setupToolbar() {
1840
2325
  const searchInput = document.getElementById("graph-search");
2326
+ const analyzeBtn = document.getElementById("graph-analyze");
1841
2327
  const fitBtn = document.getElementById("graph-fit");
1842
2328
  const relayoutBtn = document.getElementById("graph-relayout");
1843
2329
  const clearBtn = document.getElementById("graph-clear");
2330
+ const analysisCloseBtn = document.getElementById("analysis-close");
2331
+ const analysisRerunBtn = document.getElementById("analysis-rerun");
2332
+ const analysisPanel = document.getElementById("analysis-panel");
1844
2333
  let searchTimeout;
1845
2334
  const dropdown = document.createElement("div");
1846
2335
  dropdown.className = "search-dropdown hidden";
@@ -1875,17 +2364,15 @@ function setupToolbar() {
1875
2364
  dropdown.querySelectorAll(".search-result").forEach((el) => {
1876
2365
  el.addEventListener("click", () => {
1877
2366
  const id = el.dataset.id || "";
1878
- expandNodeAndLayout(id);
1879
2367
  searchInput.value = "";
1880
2368
  dropdown.classList.add("hidden");
1881
- if (!cy) return;
1882
- const targetNode = cy.getElementById(id);
1883
- if (targetNode.length) {
1884
- setTimeout(() => {
1885
- cy.animate({ center: { eles: targetNode }, zoom: 1.2 }, { duration: 300 });
1886
- targetNode.flashClass("highlighted", 1500);
1887
- }, 350);
1888
- }
2369
+ void focusSymbolInGraph(id, {
2370
+ maxDegrees: 1,
2371
+ animate: true,
2372
+ zoom: 1.2,
2373
+ flashDuration: 1500,
2374
+ openDetail: true
2375
+ });
1889
2376
  });
1890
2377
  });
1891
2378
  }
@@ -1925,6 +2412,19 @@ function setupToolbar() {
1925
2412
  cy.fit(40);
1926
2413
  }
1927
2414
  });
2415
+ analyzeBtn.addEventListener("click", () => {
2416
+ void runStructuralAnalysis();
2417
+ });
2418
+ analysisRerunBtn.addEventListener("click", () => {
2419
+ void runStructuralAnalysis();
2420
+ });
2421
+ analysisCloseBtn.addEventListener("click", () => {
2422
+ analysisPanel.classList.add("hidden");
2423
+ activeAnalysisFindingId = null;
2424
+ clearAnalysisStatus();
2425
+ clearAnalysisGraphClasses();
2426
+ setActiveFindingCard(null);
2427
+ });
1928
2428
  relayoutBtn.addEventListener("click", () => {
1929
2429
  runLayout();
1930
2430
  });
@@ -1932,6 +2432,9 @@ function setupToolbar() {
1932
2432
  if (!cy) return;
1933
2433
  cy.elements().remove();
1934
2434
  document.getElementById("symbol-detail").classList.add("hidden");
2435
+ const cyEl = document.getElementById("cy");
2436
+ if (cyEl) showOnboardingHint(cyEl);
2437
+ refreshAnalysisGraphState();
1935
2438
  });
1936
2439
  }
1937
2440
 
@@ -1943,15 +2446,33 @@ var sendBtn = document.getElementById("send-btn");
1943
2446
  var cancelBtn = document.getElementById("cancel-btn");
1944
2447
  var statusBadge = document.getElementById("status-badge");
1945
2448
  var modelInfo = document.getElementById("model-info");
2449
+ var modelBtn = document.getElementById("model-btn");
2450
+ var modelDropdown = document.getElementById("model-dropdown");
2451
+ var modelList = document.getElementById("model-list");
1946
2452
  var sessionBtn = document.getElementById("session-btn");
1947
2453
  var sessionDropdown = document.getElementById("session-dropdown");
1948
2454
  var sessionList = document.getElementById("session-list");
2455
+ var sessionUpdateRow = document.getElementById("session-update-row");
2456
+ var sessionUpdateBtn = document.getElementById("session-update-btn");
1949
2457
  var saveBtn = document.getElementById("save-btn");
1950
2458
  var saveLabelInput = document.getElementById("save-label");
2459
+ var contextFill = document.getElementById("context-fill");
2460
+ var contextLabel = document.getElementById("context-label");
2461
+ var settingsBtn = document.getElementById("settings-btn");
2462
+ var settingsModal = document.getElementById("settings-modal");
2463
+ var settingsBackdrop = document.getElementById("settings-backdrop");
2464
+ var settingsCloseBtn = document.getElementById("settings-close");
2465
+ var settingsPath = document.getElementById("settings-path");
2466
+ var settingsList = document.getElementById("settings-list");
2467
+ var settingsBanner = document.getElementById("settings-banner");
2468
+ var settingsSaveBtn = document.getElementById("settings-save");
2469
+ var settingsResetBtn = document.getElementById("settings-reset");
1951
2470
  var ws;
1952
2471
  var currentAssistantEl = null;
1953
2472
  var assistantText = "";
1954
2473
  var hadToolCalls = false;
2474
+ var settingsPayload = null;
2475
+ var activeSavedSession = null;
1955
2476
  var TOOL_RESULT_MAX = 500;
1956
2477
  function connect() {
1957
2478
  const protocol = location.protocol === "https:" ? "wss:" : "ws:";
@@ -1959,6 +2480,7 @@ function connect() {
1959
2480
  ws.onopen = () => {
1960
2481
  setStatus("ready");
1961
2482
  fetchStatus();
2483
+ fetchContext();
1962
2484
  };
1963
2485
  ws.onclose = () => {
1964
2486
  setStatus("error");
@@ -1983,11 +2505,29 @@ function setBusy(busy) {
1983
2505
  setStatus("ready");
1984
2506
  }
1985
2507
  }
2508
+ var configOverlay = document.getElementById("config-overlay");
1986
2509
  async function fetchStatus() {
1987
2510
  try {
1988
2511
  const res = await fetch("/api/status");
1989
2512
  const data = await res.json();
1990
- modelInfo.textContent = `${data.model} \xB7 ${data.provider}`;
2513
+ modelInfo.textContent = data.model || "Select model";
2514
+ modelInfo.classList.toggle("placeholder", !data.model);
2515
+ activeModel = data.model;
2516
+ if (data.needsSetup) {
2517
+ configOverlay.classList.remove("hidden");
2518
+ chatInput.disabled = true;
2519
+ sendBtn.disabled = true;
2520
+ const missingEl = document.getElementById("config-missing");
2521
+ if (missingEl && data.missing && data.missing.length > 0) {
2522
+ const isOnlyModelMissing = data.missing.length === 1 && data.missing[0].includes("MODEL");
2523
+ const hint = isOnlyModelMissing ? ` \u2014 select one from the <strong>model dropdown</strong> above, or set it in config` : "";
2524
+ missingEl.innerHTML = `<strong>Missing:</strong> ${data.missing.map(escapeHtml).join(", ")}${hint}`;
2525
+ missingEl.classList.remove("hidden");
2526
+ }
2527
+ } else {
2528
+ configOverlay.classList.add("hidden");
2529
+ chatInput.disabled = false;
2530
+ }
1991
2531
  } catch {
1992
2532
  }
1993
2533
  }
@@ -2067,6 +2607,20 @@ function handleServerMessage(msg) {
2067
2607
  case "busy":
2068
2608
  addMessage("Agent is busy. Please wait for the current turn to finish.", "error");
2069
2609
  break;
2610
+ case "context_status":
2611
+ updateContextIndicator(
2612
+ msg.contextTokens,
2613
+ msg.maxContextTokens
2614
+ );
2615
+ break;
2616
+ case "model_changed": {
2617
+ const changedModel = msg.model;
2618
+ modelInfo.textContent = changedModel || "Select model";
2619
+ modelInfo.classList.toggle("placeholder", !changedModel);
2620
+ activeModel = changedModel;
2621
+ void fetchStatus();
2622
+ break;
2623
+ }
2070
2624
  }
2071
2625
  }
2072
2626
  function addMessage(text, type, markdown = false) {
@@ -2131,6 +2685,27 @@ function finalizeToolCall(name, result, elapsedMs) {
2131
2685
  resultEl.textContent = truncated;
2132
2686
  }
2133
2687
  }
2688
+ function updateContextIndicator(contextTokens, maxContextTokens) {
2689
+ const pct = maxContextTokens > 0 ? Math.min(100, Math.round(contextTokens / maxContextTokens * 100)) : 0;
2690
+ contextFill.style.width = pct + "%";
2691
+ contextFill.classList.remove("warn", "critical");
2692
+ if (pct >= 80) {
2693
+ contextFill.classList.add("critical");
2694
+ } else if (pct >= 60) {
2695
+ contextFill.classList.add("warn");
2696
+ }
2697
+ contextLabel.textContent = pct + "%";
2698
+ const indicator = document.getElementById("context-indicator");
2699
+ indicator.title = `Context: ~${contextTokens.toLocaleString()} / ${maxContextTokens.toLocaleString()} tokens (${pct}%)`;
2700
+ }
2701
+ async function fetchContext() {
2702
+ try {
2703
+ const res = await fetch("/api/context");
2704
+ const data = await res.json();
2705
+ updateContextIndicator(data.contextTokens, data.maxContextTokens);
2706
+ } catch {
2707
+ }
2708
+ }
2134
2709
  function addUsageInfo(usage) {
2135
2710
  const el = document.createElement("div");
2136
2711
  el.className = "usage-info";
@@ -2140,6 +2715,227 @@ function addUsageInfo(usage) {
2140
2715
  function scrollToBottom() {
2141
2716
  messagesEl.scrollTop = messagesEl.scrollHeight;
2142
2717
  }
2718
+ function closeHeaderMenus() {
2719
+ modelDropdown.classList.add("hidden");
2720
+ sessionDropdown.classList.add("hidden");
2721
+ }
2722
+ function isSettingsModalOpen() {
2723
+ return !settingsModal.classList.contains("hidden");
2724
+ }
2725
+ function formatSettingsValue(value) {
2726
+ return value === null ? "(unset)" : String(value);
2727
+ }
2728
+ function setSettingsBanner(message, tone) {
2729
+ settingsBanner.textContent = message;
2730
+ settingsBanner.className = `settings-banner ${tone}`;
2731
+ }
2732
+ function clearSettingsBanner() {
2733
+ settingsBanner.textContent = "";
2734
+ settingsBanner.className = "settings-banner hidden";
2735
+ }
2736
+ function createSettingsControl(entry, inputId) {
2737
+ const value = entry.persistedValue;
2738
+ if (entry.type === "boolean") {
2739
+ const select = document.createElement("select");
2740
+ select.id = inputId;
2741
+ select.className = "settings-select";
2742
+ select.dataset.settingKey = entry.key;
2743
+ select.innerHTML = `
2744
+ <option value="">Use default</option>
2745
+ <option value="true">True</option>
2746
+ <option value="false">False</option>
2747
+ `;
2748
+ select.value = value === null ? "" : String(value);
2749
+ select.disabled = entry.overriddenByEnv;
2750
+ return select;
2751
+ }
2752
+ if (entry.type === "enum") {
2753
+ const select = document.createElement("select");
2754
+ select.id = inputId;
2755
+ select.className = "settings-select";
2756
+ select.dataset.settingKey = entry.key;
2757
+ const unsetOption = document.createElement("option");
2758
+ unsetOption.value = "";
2759
+ unsetOption.textContent = "Use default";
2760
+ select.appendChild(unsetOption);
2761
+ for (const optionValue of entry.values ?? []) {
2762
+ const option = document.createElement("option");
2763
+ option.value = optionValue;
2764
+ option.textContent = optionValue;
2765
+ select.appendChild(option);
2766
+ }
2767
+ select.value = value === null ? "" : String(value);
2768
+ select.disabled = entry.overriddenByEnv;
2769
+ return select;
2770
+ }
2771
+ const input = document.createElement("input");
2772
+ input.id = inputId;
2773
+ input.className = "settings-control";
2774
+ input.dataset.settingKey = entry.key;
2775
+ input.type = entry.type === "number" ? "number" : "text";
2776
+ if (entry.type === "number") {
2777
+ input.step = "any";
2778
+ input.inputMode = "numeric";
2779
+ }
2780
+ input.placeholder = "Use default";
2781
+ input.value = value === null ? "" : String(value);
2782
+ input.disabled = entry.overriddenByEnv;
2783
+ return input;
2784
+ }
2785
+ function renderSettings() {
2786
+ if (!settingsPayload) {
2787
+ return;
2788
+ }
2789
+ settingsPath.textContent = settingsPayload.configPath;
2790
+ settingsList.innerHTML = "";
2791
+ for (const entry of settingsPayload.entries) {
2792
+ const item = document.createElement("section");
2793
+ item.className = "settings-item";
2794
+ if (entry.overriddenByEnv) {
2795
+ item.classList.add("is-disabled");
2796
+ }
2797
+ const header = document.createElement("div");
2798
+ header.className = "settings-item-header";
2799
+ const heading = document.createElement("div");
2800
+ const title = document.createElement("div");
2801
+ title.className = "settings-item-title";
2802
+ title.textContent = entry.key;
2803
+ const description = document.createElement("div");
2804
+ description.className = "settings-item-description";
2805
+ description.textContent = entry.description;
2806
+ heading.append(title, description);
2807
+ const badges = document.createElement("div");
2808
+ badges.className = "settings-item-badges";
2809
+ const envBadge = document.createElement("span");
2810
+ envBadge.className = "settings-badge env";
2811
+ envBadge.textContent = `Env: ${entry.envVar}`;
2812
+ badges.appendChild(envBadge);
2813
+ if (entry.overriddenByEnv) {
2814
+ const overrideBadge = document.createElement("span");
2815
+ overrideBadge.className = "settings-badge override";
2816
+ overrideBadge.textContent = "Env override active";
2817
+ badges.appendChild(overrideBadge);
2818
+ }
2819
+ header.append(heading, badges);
2820
+ const meta = document.createElement("div");
2821
+ meta.className = "settings-item-meta";
2822
+ meta.innerHTML = `
2823
+ <div class="settings-meta-block">
2824
+ <span class="settings-meta-label">Effective</span>
2825
+ <div class="settings-meta-value">${escapeHtml(formatSettingsValue(entry.effectiveValue))}</div>
2826
+ </div>
2827
+ <div class="settings-meta-block">
2828
+ <span class="settings-meta-label">Saved</span>
2829
+ <div class="settings-meta-value">${escapeHtml(formatSettingsValue(entry.persistedValue))}</div>
2830
+ </div>
2831
+ `;
2832
+ const controls = document.createElement("div");
2833
+ controls.className = "settings-item-controls";
2834
+ const inputId = `setting-${entry.key}`;
2835
+ const label = document.createElement("label");
2836
+ label.className = "settings-help";
2837
+ label.htmlFor = inputId;
2838
+ label.textContent = "Saved in your global minicode config";
2839
+ const control = createSettingsControl(entry, inputId);
2840
+ controls.append(label, control);
2841
+ if (entry.overriddenByEnv) {
2842
+ const overrideHelp = document.createElement("div");
2843
+ overrideHelp.className = "settings-help settings-help-warning";
2844
+ overrideHelp.textContent = entry.envSource === "home-dotenv" && entry.envSourcePath ? `Defined by ${entry.envVar} in ${entry.envSourcePath}. Update or remove that env var there to manage this setting here.` : `${entry.envVar} is currently defined by the running environment. Remove or update that env var to manage this setting here.`;
2845
+ controls.appendChild(overrideHelp);
2846
+ }
2847
+ item.append(header, meta, controls);
2848
+ settingsList.appendChild(item);
2849
+ }
2850
+ updateSettingsActions();
2851
+ }
2852
+ async function loadSettings() {
2853
+ settingsList.innerHTML = '<div class="dropdown-empty">Loading settings...</div>';
2854
+ settingsSaveBtn.disabled = true;
2855
+ settingsResetBtn.disabled = true;
2856
+ settingsSaveBtn.textContent = "Save settings";
2857
+ clearSettingsBanner();
2858
+ try {
2859
+ const res = await fetch("/api/config");
2860
+ if (!res.ok) {
2861
+ throw new Error(`Failed to load settings (${res.status})`);
2862
+ }
2863
+ const data = await res.json();
2864
+ settingsPayload = data.settings;
2865
+ renderSettings();
2866
+ } catch (error) {
2867
+ const message = error instanceof Error ? error.message : "Failed to load settings";
2868
+ settingsList.innerHTML = '<div class="dropdown-empty">Failed to load settings</div>';
2869
+ setSettingsBanner(message, "error");
2870
+ }
2871
+ }
2872
+ function readSettingsControlValue(entry) {
2873
+ const control = settingsList.querySelector(`[data-setting-key="${entry.key}"]`);
2874
+ if (!control) {
2875
+ throw new Error(`Missing control for ${entry.key}`);
2876
+ }
2877
+ if (entry.type === "boolean" || entry.type === "enum") {
2878
+ const value = control.value.trim();
2879
+ return value === "" ? null : entry.type === "boolean" ? value === "true" : value;
2880
+ }
2881
+ const rawValue = control.value.trim();
2882
+ if (rawValue === "") {
2883
+ return null;
2884
+ }
2885
+ if (entry.type === "number") {
2886
+ const parsed = Number(rawValue);
2887
+ if (!Number.isFinite(parsed)) {
2888
+ throw new Error(`Expected a number for "${entry.key}".`);
2889
+ }
2890
+ return parsed;
2891
+ }
2892
+ return rawValue;
2893
+ }
2894
+ function collectSettingsUpdates() {
2895
+ if (!settingsPayload) {
2896
+ return {};
2897
+ }
2898
+ const updates = {};
2899
+ for (const entry of settingsPayload.entries) {
2900
+ const nextValue = readSettingsControlValue(entry);
2901
+ const baseline = entry.persistedValue;
2902
+ if (nextValue !== baseline) {
2903
+ updates[entry.key] = nextValue;
2904
+ }
2905
+ }
2906
+ return updates;
2907
+ }
2908
+ function updateSettingsActions() {
2909
+ if (!settingsPayload) {
2910
+ settingsSaveBtn.disabled = true;
2911
+ settingsResetBtn.disabled = true;
2912
+ settingsSaveBtn.textContent = "Save settings";
2913
+ return;
2914
+ }
2915
+ try {
2916
+ const changeCount = Object.keys(collectSettingsUpdates()).length;
2917
+ settingsSaveBtn.disabled = changeCount === 0;
2918
+ settingsResetBtn.disabled = changeCount === 0;
2919
+ settingsSaveBtn.textContent = changeCount === 0 ? "Save settings" : `Save ${changeCount} change${changeCount === 1 ? "" : "s"}`;
2920
+ } catch {
2921
+ settingsSaveBtn.disabled = true;
2922
+ settingsResetBtn.disabled = false;
2923
+ settingsSaveBtn.textContent = "Save settings";
2924
+ }
2925
+ }
2926
+ function openSettings() {
2927
+ closeHeaderMenus();
2928
+ settingsModal.classList.remove("hidden");
2929
+ settingsModal.setAttribute("aria-hidden", "false");
2930
+ document.body.classList.add("modal-open");
2931
+ void loadSettings();
2932
+ }
2933
+ function closeSettings() {
2934
+ settingsModal.classList.add("hidden");
2935
+ settingsModal.setAttribute("aria-hidden", "true");
2936
+ document.body.classList.remove("modal-open");
2937
+ clearSettingsBanner();
2938
+ }
2143
2939
  chatForm.addEventListener("submit", (e) => {
2144
2940
  e.preventDefault();
2145
2941
  const message = chatInput.value.trim();
@@ -2162,10 +2958,57 @@ chatInput.addEventListener("keydown", (e) => {
2162
2958
  chatForm.dispatchEvent(new Event("submit"));
2163
2959
  }
2164
2960
  });
2961
+ var activeModel = "";
2962
+ modelBtn.addEventListener("click", (e) => {
2963
+ e.stopPropagation();
2964
+ const isOpen = !modelDropdown.classList.contains("hidden");
2965
+ modelDropdown.classList.toggle("hidden");
2966
+ sessionDropdown.classList.add("hidden");
2967
+ if (!isOpen) {
2968
+ refreshModelList();
2969
+ }
2970
+ });
2971
+ document.addEventListener("click", (e) => {
2972
+ if (!modelDropdown.contains(e.target) && e.target !== modelBtn) {
2973
+ modelDropdown.classList.add("hidden");
2974
+ }
2975
+ });
2976
+ async function refreshModelList() {
2977
+ try {
2978
+ const res = await fetch("/api/models");
2979
+ const data = await res.json();
2980
+ activeModel = data.activeModel;
2981
+ if (!data.models || data.models.length === 0) {
2982
+ modelList.innerHTML = '<div class="dropdown-empty">No models available</div>';
2983
+ return;
2984
+ }
2985
+ modelList.innerHTML = "";
2986
+ for (const m2 of data.models) {
2987
+ const el = document.createElement("div");
2988
+ el.className = "model-item" + (m2.id === activeModel ? " active" : "");
2989
+ el.textContent = m2.name ?? m2.id;
2990
+ el.title = m2.id;
2991
+ el.addEventListener("click", () => switchModel(m2.id));
2992
+ modelList.appendChild(el);
2993
+ }
2994
+ } catch {
2995
+ modelList.innerHTML = '<div class="dropdown-empty">Failed to load models</div>';
2996
+ }
2997
+ }
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();
3006
+ }
2165
3007
  sessionBtn.addEventListener("click", (e) => {
2166
3008
  e.stopPropagation();
2167
3009
  const isOpen = !sessionDropdown.classList.contains("hidden");
2168
3010
  sessionDropdown.classList.toggle("hidden");
3011
+ modelDropdown.classList.add("hidden");
2169
3012
  if (!isOpen) {
2170
3013
  refreshSessionList();
2171
3014
  }
@@ -2176,7 +3019,9 @@ document.addEventListener("click", (e) => {
2176
3019
  }
2177
3020
  });
2178
3021
  saveBtn.addEventListener("click", async () => {
2179
- const label = saveLabelInput.value.trim() || void 0;
3022
+ const requestedLabel = saveLabelInput.value.trim();
3023
+ const label = requestedLabel || activeSavedSession?.label || void 0;
3024
+ const isUpdatingCurrentSession = !!activeSavedSession && (requestedLabel.length === 0 || requestedLabel === activeSavedSession.label);
2180
3025
  try {
2181
3026
  const res = await fetch("/api/sessions/save", {
2182
3027
  method: "POST",
@@ -2186,8 +3031,11 @@ saveBtn.addEventListener("click", async () => {
2186
3031
  if (res.ok) {
2187
3032
  const data = await res.json();
2188
3033
  saveLabelInput.value = "";
2189
- addMessage(`Session saved: "${data.label}"`, "thinking");
2190
- refreshSessionList();
3034
+ addMessage(
3035
+ `${isUpdatingCurrentSession ? "Session updated" : "Session saved"}: "${data.label}"`,
3036
+ "thinking"
3037
+ );
3038
+ void refreshSessionList();
2191
3039
  }
2192
3040
  } catch {
2193
3041
  }
@@ -2203,6 +3051,16 @@ async function refreshSessionList() {
2203
3051
  const res = await fetch("/api/sessions");
2204
3052
  const data = await res.json();
2205
3053
  const sessions = data.sessions;
3054
+ activeSavedSession = sessions.find((session) => session.id === data.currentSessionId) ?? null;
3055
+ if (activeSavedSession) {
3056
+ sessionUpdateRow.classList.remove("hidden");
3057
+ sessionUpdateBtn.textContent = `Update "${activeSavedSession.label}"`;
3058
+ sessionUpdateBtn.title = `Save changes back to "${activeSavedSession.label}"`;
3059
+ } else {
3060
+ sessionUpdateRow.classList.add("hidden");
3061
+ sessionUpdateBtn.textContent = "Update current saved session";
3062
+ sessionUpdateBtn.title = "";
3063
+ }
2206
3064
  if (!sessions || sessions.length === 0) {
2207
3065
  sessionList.innerHTML = '<div class="dropdown-empty">No saved sessions</div>';
2208
3066
  return;
@@ -2210,12 +3068,15 @@ async function refreshSessionList() {
2210
3068
  sessionList.innerHTML = "";
2211
3069
  for (const s of sessions) {
2212
3070
  const el = document.createElement("div");
2213
- el.className = "session-item";
2214
- el.innerHTML = `<span class="session-label">${escapeHtml(s.label)}</span><span class="session-meta">${s.messageCount} msgs</span>`;
3071
+ const isActive = activeSavedSession?.id === s.id;
3072
+ el.className = "session-item" + (isActive ? " active" : "");
3073
+ 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>`;
2215
3074
  el.addEventListener("click", () => loadSession(s.label));
2216
3075
  sessionList.appendChild(el);
2217
3076
  }
2218
3077
  } catch {
3078
+ activeSavedSession = null;
3079
+ sessionUpdateRow.classList.add("hidden");
2219
3080
  sessionList.innerHTML = '<div class="dropdown-empty">Failed to load sessions</div>';
2220
3081
  }
2221
3082
  }
@@ -2230,10 +3091,97 @@ async function loadSession(label) {
2230
3091
  sessionDropdown.classList.add("hidden");
2231
3092
  messagesEl.innerHTML = "";
2232
3093
  addMessage(`Session "${label}" restored`, "thinking");
3094
+ void refreshSessionList();
2233
3095
  }
2234
3096
  } catch {
2235
3097
  }
2236
3098
  }
3099
+ sessionUpdateBtn.addEventListener("click", async () => {
3100
+ if (!activeSavedSession) {
3101
+ return;
3102
+ }
3103
+ try {
3104
+ sessionUpdateBtn.disabled = true;
3105
+ const res = await fetch("/api/sessions/save", {
3106
+ method: "POST",
3107
+ headers: { "Content-Type": "application/json" },
3108
+ body: JSON.stringify({ label: activeSavedSession.label })
3109
+ });
3110
+ if (res.ok) {
3111
+ const data = await res.json();
3112
+ addMessage(`Session updated: "${data.label}"`, "thinking");
3113
+ await refreshSessionList();
3114
+ }
3115
+ } catch {
3116
+ } finally {
3117
+ sessionUpdateBtn.disabled = false;
3118
+ }
3119
+ });
3120
+ settingsBtn.addEventListener("click", () => {
3121
+ openSettings();
3122
+ });
3123
+ settingsCloseBtn.addEventListener("click", () => {
3124
+ closeSettings();
3125
+ });
3126
+ settingsBackdrop.addEventListener("click", () => {
3127
+ closeSettings();
3128
+ });
3129
+ settingsResetBtn.addEventListener("click", () => {
3130
+ clearSettingsBanner();
3131
+ renderSettings();
3132
+ });
3133
+ settingsSaveBtn.addEventListener("click", async () => {
3134
+ if (!settingsPayload) {
3135
+ return;
3136
+ }
3137
+ let updates;
3138
+ try {
3139
+ updates = collectSettingsUpdates();
3140
+ } catch (error) {
3141
+ const message = error instanceof Error ? error.message : "Failed to read settings";
3142
+ setSettingsBanner(message, "error");
3143
+ return;
3144
+ }
3145
+ if (Object.keys(updates).length === 0) {
3146
+ setSettingsBanner("No changes to save.", "info");
3147
+ return;
3148
+ }
3149
+ settingsSaveBtn.disabled = true;
3150
+ settingsSaveBtn.textContent = "Saving...";
3151
+ try {
3152
+ const res = await fetch("/api/config", {
3153
+ method: "POST",
3154
+ headers: { "Content-Type": "application/json" },
3155
+ body: JSON.stringify({
3156
+ updates
3157
+ })
3158
+ });
3159
+ const body = await res.json();
3160
+ if (!res.ok) {
3161
+ throw new Error("error" in body ? body.error : `Failed to save settings (${res.status})`);
3162
+ }
3163
+ settingsPayload = body.settings;
3164
+ renderSettings();
3165
+ setSettingsBanner(body.message, "success");
3166
+ } catch (error) {
3167
+ const message = error instanceof Error ? error.message : "Failed to save settings";
3168
+ setSettingsBanner(message, "error");
3169
+ updateSettingsActions();
3170
+ }
3171
+ });
3172
+ settingsList.addEventListener("input", () => {
3173
+ clearSettingsBanner();
3174
+ updateSettingsActions();
3175
+ });
3176
+ settingsList.addEventListener("change", () => {
3177
+ clearSettingsBanner();
3178
+ updateSettingsActions();
3179
+ });
3180
+ document.addEventListener("keydown", (event) => {
3181
+ if (event.key === "Escape" && isSettingsModalOpen()) {
3182
+ closeSettings();
3183
+ }
3184
+ });
2237
3185
  var chatPane = document.getElementById("chat-pane");
2238
3186
  var divider = document.getElementById("pane-divider");
2239
3187
  divider.addEventListener("mousedown", (e) => {