Procházet zdrojové kódy

feat: Add interactive graph visualization with Claude-powered exploration

Adds `codegraph visualize` command that launches a localhost web UI for
visually exploring code relationships. Users can ask natural language
questions like "how does authentication work?" and see the relevant code
flow rendered as an interactive graph.

Key components:
- Visualizer HTTP server (src/visualizer/server.ts) with REST API
- Single-page frontend with Cytoscape.js graph + highlight.js code preview
- Claude CLI integration for intelligent query interpretation
- Dark theme, right-click context menus, keyboard shortcuts
- Detail panel with source code, callers, callees, hierarchy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Colby McHenry před 3 měsíci
rodič
revize
43ea0a40ba
4 změnil soubory, kde provedl 2607 přidání a 1 odebrání
  1. 1 1
      package.json
  2. 64 0
      src/bin/codegraph.ts
  3. 1960 0
      src/visualizer/public/index.html
  4. 582 0
      src/visualizer/server.ts

+ 1 - 1
package.json

@@ -16,7 +16,7 @@
     "build": "tsc && npm run copy-assets",
     "postinstall": "node scripts/postinstall.js",
     "preuninstall": "node dist/bin/uninstall.js",
-    "copy-assets": "node -e \"const fs=require('fs');fs.mkdirSync('dist/db',{recursive:true});fs.copyFileSync('src/db/schema.sql','dist/db/schema.sql');fs.mkdirSync('dist/extraction/wasm',{recursive:true});fs.readdirSync('src/extraction/wasm').filter(f=>f.endsWith('.wasm')).forEach(f=>fs.copyFileSync('src/extraction/wasm/'+f,'dist/extraction/wasm/'+f))\"",
+    "copy-assets": "node -e \"const fs=require('fs');fs.mkdirSync('dist/db',{recursive:true});fs.copyFileSync('src/db/schema.sql','dist/db/schema.sql');fs.mkdirSync('dist/extraction/wasm',{recursive:true});fs.readdirSync('src/extraction/wasm').filter(f=>f.endsWith('.wasm')).forEach(f=>fs.copyFileSync('src/extraction/wasm/'+f,'dist/extraction/wasm/'+f));fs.mkdirSync('dist/visualizer/public',{recursive:true});fs.copyFileSync('src/visualizer/public/index.html','dist/visualizer/public/index.html')\"",
     "dev": "tsc --watch",
     "cli": "npm run build && node dist/bin/codegraph.js",
     "test": "vitest run",

+ 64 - 0
src/bin/codegraph.ts

@@ -955,6 +955,70 @@ program
     }
   });
 
+/**
+ * codegraph visualize [path]
+ */
+program
+  .command('visualize [path]')
+  .description('Open interactive graph visualization in your browser')
+  .option('-p, --port <port>', 'Port to listen on (default: auto)', parseInt)
+  .option('--no-open', 'Do not open browser automatically')
+  .action(async (pathArg: string | undefined, options: { port?: number; open?: boolean }) => {
+    const projectPath = resolveProjectPath(pathArg);
+
+    try {
+      if (!isInitialized(projectPath)) {
+        error(`CodeGraph not initialized in ${projectPath}`);
+        info('Run "codegraph init -i" first');
+        process.exit(1);
+      }
+
+      const { default: CodeGraph } = await loadCodeGraph();
+      const cg = await CodeGraph.open(projectPath);
+      const stats = cg.getStats();
+
+      console.log(chalk.bold('\n  CodeGraph Explorer\n'));
+      info(`Project: ${projectPath}`);
+      info(`Indexed: ${formatNumber(stats.nodeCount)} nodes, ${formatNumber(stats.edgeCount)} edges, ${formatNumber(stats.fileCount)} files\n`);
+
+      const { VisualizerServer } = await import('../visualizer/server');
+      const server = new VisualizerServer(cg);
+      const { url } = await server.start({ port: options.port, openBrowser: options.open !== false });
+
+      success(`Visualizer running at ${chalk.cyan(url)}`);
+      console.log(chalk.dim('  Press Ctrl+C to stop\n'));
+
+      // Open browser
+      if (options.open !== false) {
+        const openCmd = process.platform === 'darwin' ? 'open' :
+                        process.platform === 'win32' ? 'start' : 'xdg-open';
+        spawn(openCmd, [url], { detached: true, stdio: 'ignore' }).unref();
+      }
+
+      // Handle shutdown — force exit on second Ctrl+C
+      let shuttingDown = false;
+      const shutdown = () => {
+        if (shuttingDown) {
+          process.exit(1);
+        }
+        shuttingDown = true;
+        console.log(chalk.dim('\n  Shutting down...'));
+        server.stop().then(() => {
+          cg.close();
+          process.exit(0);
+        }).catch(() => process.exit(1));
+        // Force exit after 2s if graceful shutdown hangs
+        setTimeout(() => process.exit(1), 2000).unref();
+      };
+      process.on('SIGINT', shutdown);
+      process.on('SIGTERM', shutdown);
+    } catch (err) {
+      captureException(err);
+      error(`Failed to start visualizer: ${err instanceof Error ? err.message : String(err)}`);
+      process.exit(1);
+    }
+  });
+
 /**
  * codegraph mark-dirty [path]
  *

+ 1960 - 0
src/visualizer/public/index.html

@@ -0,0 +1,1960 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <title>CodeGraph Explorer</title>
+
+  <!-- Cytoscape.js + Dagre layout -->
+  <script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.30.4/cytoscape.min.js"></script>
+  <script src="https://cdn.jsdelivr.net/npm/dagre@0.8.5/dist/dagre.min.js"></script>
+  <script src="https://cdn.jsdelivr.net/npm/cytoscape-dagre@2.5.0/cytoscape-dagre.js"></script>
+
+  <!-- Highlight.js for code syntax highlighting -->
+  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" />
+  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
+
+  <style>
+    /* ====================================================================
+       CSS Reset & Base
+       ==================================================================== */
+    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+    :root {
+      --bg-primary: #0d1117;
+      --bg-secondary: #161b22;
+      --bg-tertiary: #1c2128;
+      --bg-hover: #1f2937;
+      --border: #30363d;
+      --border-light: #3d444d;
+      --text-primary: #e6edf3;
+      --text-secondary: #8b949e;
+      --text-muted: #656d76;
+      --accent: #58a6ff;
+      --accent-hover: #79c0ff;
+      --green: #3fb950;
+      --purple: #d2a8ff;
+      --orange: #ffa657;
+      --red: #ff7b72;
+      --yellow: #d29922;
+      --pink: #f778ba;
+      --cyan: #76e3ea;
+      --font-mono: 'SF Mono', 'Fira Code', 'JetBrains Mono', Consolas, monospace;
+      --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
+      --sidebar-width: 320px;
+      --panel-width: 420px;
+      --header-height: 52px;
+      --radius: 8px;
+      --radius-sm: 6px;
+    }
+
+    html, body {
+      height: 100%;
+      font-family: var(--font-sans);
+      background: var(--bg-primary);
+      color: var(--text-primary);
+      overflow: hidden;
+    }
+
+    /* ====================================================================
+       Layout
+       ==================================================================== */
+    #app {
+      display: flex;
+      flex-direction: column;
+      height: 100vh;
+    }
+
+    /* Header */
+    #header {
+      height: var(--header-height);
+      background: var(--bg-secondary);
+      border-bottom: 1px solid var(--border);
+      display: flex;
+      align-items: center;
+      padding: 0 16px;
+      gap: 16px;
+      flex-shrink: 0;
+      z-index: 10;
+    }
+
+    #header .logo {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      font-size: 16px;
+      font-weight: 600;
+      color: var(--text-primary);
+      white-space: nowrap;
+    }
+
+    #header .logo span.icon { font-size: 20px; }
+
+    #search-container {
+      flex: 1;
+      max-width: 560px;
+      position: relative;
+    }
+
+    #search-input {
+      width: 100%;
+      height: 34px;
+      background: var(--bg-primary);
+      border: 1px solid var(--border);
+      border-radius: var(--radius-sm);
+      color: var(--text-primary);
+      font-size: 13px;
+      padding: 0 12px 0 34px;
+      outline: none;
+      transition: border-color 0.15s;
+      font-family: var(--font-sans);
+    }
+
+    #search-input:focus { border-color: var(--accent); }
+
+    #search-container .search-icon {
+      position: absolute;
+      left: 10px;
+      top: 50%;
+      transform: translateY(-50%);
+      color: var(--text-muted);
+      font-size: 14px;
+      pointer-events: none;
+    }
+
+    #search-results-dropdown {
+      position: absolute;
+      top: 100%;
+      left: 0;
+      right: 0;
+      background: var(--bg-secondary);
+      border: 1px solid var(--border);
+      border-radius: var(--radius-sm);
+      margin-top: 4px;
+      max-height: 400px;
+      overflow-y: auto;
+      z-index: 100;
+      display: none;
+      box-shadow: 0 8px 24px rgba(0,0,0,0.4);
+    }
+
+    #search-results-dropdown.visible { display: block; }
+
+    .search-result-item {
+      padding: 8px 12px;
+      cursor: pointer;
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      border-bottom: 1px solid var(--border);
+      transition: background 0.1s;
+    }
+
+    .search-result-item:last-child { border-bottom: none; }
+    .search-result-item:hover { background: var(--bg-hover); }
+
+    .search-result-item .kind-badge {
+      font-size: 10px;
+      padding: 2px 6px;
+      border-radius: 4px;
+      font-weight: 600;
+      text-transform: uppercase;
+      white-space: nowrap;
+      flex-shrink: 0;
+    }
+
+    .search-result-item .name {
+      font-weight: 500;
+      font-size: 13px;
+      color: var(--text-primary);
+    }
+
+    .search-result-item .file-path {
+      font-size: 11px;
+      color: var(--text-muted);
+      margin-left: auto;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      max-width: 200px;
+    }
+
+    #header-stats {
+      display: flex;
+      gap: 12px;
+      font-size: 12px;
+      color: var(--text-muted);
+      white-space: nowrap;
+    }
+
+    #header-stats .stat { display: flex; align-items: center; gap: 4px; }
+    #header-stats .stat-value { color: var(--text-secondary); font-weight: 500; }
+
+    /* Main content */
+    #main {
+      display: flex;
+      flex: 1;
+      overflow: hidden;
+    }
+
+    /* Sidebar */
+    #sidebar {
+      width: var(--sidebar-width);
+      background: var(--bg-secondary);
+      border-right: 1px solid var(--border);
+      display: flex;
+      flex-direction: column;
+      flex-shrink: 0;
+      overflow: hidden;
+    }
+
+    .sidebar-section {
+      border-bottom: 1px solid var(--border);
+    }
+
+    .sidebar-header {
+      padding: 10px 14px;
+      font-size: 11px;
+      font-weight: 600;
+      text-transform: uppercase;
+      letter-spacing: 0.5px;
+      color: var(--text-muted);
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      cursor: pointer;
+      user-select: none;
+    }
+
+    .sidebar-header:hover { color: var(--text-secondary); }
+
+    .sidebar-content {
+      overflow-y: auto;
+      max-height: 300px;
+    }
+
+    #file-tree {
+      padding: 4px 0;
+    }
+
+    .file-item {
+      padding: 5px 14px;
+      font-size: 12px;
+      color: var(--text-secondary);
+      cursor: pointer;
+      display: flex;
+      align-items: center;
+      gap: 6px;
+      transition: background 0.1s;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+
+    .file-item:hover { background: var(--bg-hover); color: var(--text-primary); }
+    .file-item.active { background: var(--bg-hover); color: var(--accent); }
+
+    .file-item .file-icon { font-size: 12px; flex-shrink: 0; }
+
+    /* Graph legend */
+    #legend {
+      padding: 10px 14px;
+    }
+
+    .legend-item {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      padding: 3px 0;
+      font-size: 12px;
+      color: var(--text-secondary);
+    }
+
+    .legend-dot {
+      width: 10px;
+      height: 10px;
+      border-radius: 50%;
+      flex-shrink: 0;
+    }
+
+    /* Graph toolbar */
+    #graph-toolbar {
+      padding: 8px 14px;
+      display: flex;
+      flex-wrap: wrap;
+      gap: 6px;
+    }
+
+    .toolbar-btn {
+      padding: 4px 10px;
+      font-size: 11px;
+      background: var(--bg-primary);
+      border: 1px solid var(--border);
+      border-radius: 4px;
+      color: var(--text-secondary);
+      cursor: pointer;
+      transition: all 0.15s;
+      font-family: var(--font-sans);
+    }
+
+    .toolbar-btn:hover {
+      background: var(--bg-hover);
+      color: var(--text-primary);
+      border-color: var(--border-light);
+    }
+
+    .toolbar-btn.active {
+      background: var(--accent);
+      color: #fff;
+      border-color: var(--accent);
+    }
+
+    /* Graph canvas */
+    #graph-container {
+      flex: 1;
+      position: relative;
+      background: var(--bg-primary);
+      overflow: hidden;
+    }
+
+    #cy {
+      width: 100%;
+      height: 100%;
+    }
+
+    #graph-overlay {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translate(-50%, -50%);
+      text-align: center;
+      color: var(--text-muted);
+      pointer-events: none;
+    }
+
+    #graph-overlay .overlay-icon { font-size: 48px; margin-bottom: 12px; }
+    #graph-overlay .overlay-title { font-size: 18px; font-weight: 500; margin-bottom: 6px; }
+    #graph-overlay .overlay-subtitle { font-size: 13px; }
+
+    .example-btn {
+      background: var(--bg-secondary);
+      border: 1px solid var(--border);
+      border-radius: 20px;
+      color: var(--text-secondary);
+      padding: 6px 16px;
+      font-size: 12px;
+      cursor: pointer;
+      transition: all 0.15s;
+      font-family: var(--font-sans);
+      pointer-events: auto;
+    }
+
+    .example-btn:hover {
+      background: var(--bg-hover);
+      color: var(--accent);
+      border-color: var(--accent);
+    }
+
+    /* Breadcrumbs */
+    #breadcrumbs {
+      position: absolute;
+      top: 10px;
+      left: 10px;
+      display: flex;
+      align-items: center;
+      gap: 4px;
+      font-size: 12px;
+      z-index: 5;
+      background: var(--bg-secondary);
+      border: 1px solid var(--border);
+      border-radius: var(--radius-sm);
+      padding: 6px 10px;
+      opacity: 0;
+      transition: opacity 0.2s;
+      pointer-events: none;
+    }
+
+    #breadcrumbs.visible { opacity: 1; pointer-events: auto; }
+
+    .breadcrumb-item {
+      color: var(--accent);
+      cursor: pointer;
+    }
+
+    .breadcrumb-item:hover { text-decoration: underline; }
+    .breadcrumb-sep { color: var(--text-muted); }
+
+    /* Graph controls */
+    #graph-controls {
+      position: absolute;
+      bottom: 14px;
+      right: 14px;
+      display: flex;
+      gap: 4px;
+      z-index: 5;
+    }
+
+    .graph-ctrl-btn {
+      width: 32px;
+      height: 32px;
+      background: var(--bg-secondary);
+      border: 1px solid var(--border);
+      border-radius: var(--radius-sm);
+      color: var(--text-secondary);
+      cursor: pointer;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      font-size: 16px;
+      transition: all 0.15s;
+      font-family: var(--font-sans);
+    }
+
+    .graph-ctrl-btn:hover {
+      background: var(--bg-hover);
+      color: var(--text-primary);
+    }
+
+    /* Context menu */
+    #context-menu {
+      position: fixed;
+      background: var(--bg-secondary);
+      border: 1px solid var(--border);
+      border-radius: var(--radius-sm);
+      padding: 4px 0;
+      z-index: 200;
+      display: none;
+      box-shadow: 0 8px 24px rgba(0,0,0,0.5);
+      min-width: 180px;
+    }
+
+    #context-menu.visible { display: block; }
+
+    .ctx-item {
+      padding: 7px 14px;
+      font-size: 13px;
+      color: var(--text-primary);
+      cursor: pointer;
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      transition: background 0.1s;
+    }
+
+    .ctx-item:hover { background: var(--bg-hover); }
+    .ctx-item .ctx-icon { font-size: 14px; width: 18px; text-align: center; }
+    .ctx-sep { height: 1px; background: var(--border); margin: 4px 0; }
+
+    /* Detail panel */
+    #detail-panel {
+      width: 0;
+      background: var(--bg-secondary);
+      border-left: 1px solid var(--border);
+      flex-shrink: 0;
+      overflow: hidden;
+      transition: width 0.2s ease;
+      display: flex;
+      flex-direction: column;
+    }
+
+    #detail-panel.open { width: var(--panel-width); }
+
+    #detail-header {
+      padding: 12px 16px;
+      border-bottom: 1px solid var(--border);
+      display: flex;
+      align-items: flex-start;
+      justify-content: space-between;
+      gap: 8px;
+      flex-shrink: 0;
+    }
+
+    #detail-header .node-title {
+      font-size: 15px;
+      font-weight: 600;
+      word-break: break-all;
+    }
+
+    #detail-header .close-btn {
+      background: none;
+      border: none;
+      color: var(--text-muted);
+      cursor: pointer;
+      font-size: 18px;
+      padding: 0 4px;
+      line-height: 1;
+      flex-shrink: 0;
+    }
+
+    #detail-header .close-btn:hover { color: var(--text-primary); }
+
+    #detail-body {
+      flex: 1;
+      overflow-y: auto;
+      padding: 0;
+    }
+
+    .detail-section {
+      padding: 12px 16px;
+      border-bottom: 1px solid var(--border);
+    }
+
+    .detail-section-title {
+      font-size: 11px;
+      font-weight: 600;
+      text-transform: uppercase;
+      letter-spacing: 0.5px;
+      color: var(--text-muted);
+      margin-bottom: 8px;
+    }
+
+    .detail-meta {
+      display: grid;
+      grid-template-columns: auto 1fr;
+      gap: 4px 12px;
+      font-size: 12px;
+    }
+
+    .detail-meta .label { color: var(--text-muted); }
+    .detail-meta .value { color: var(--text-secondary); word-break: break-all; }
+    .detail-meta .value.accent { color: var(--accent); }
+
+    /* Code block */
+    .code-block {
+      background: var(--bg-primary);
+      border-radius: var(--radius-sm);
+      overflow-x: auto;
+      font-size: 12px;
+      line-height: 1.5;
+    }
+
+    .code-block pre {
+      margin: 0;
+      padding: 12px;
+    }
+
+    .code-block code {
+      font-family: var(--font-mono);
+    }
+
+    /* Relations list */
+    .relation-list { list-style: none; }
+
+    .relation-item {
+      padding: 5px 0;
+      font-size: 12px;
+      display: flex;
+      align-items: center;
+      gap: 6px;
+      cursor: pointer;
+      transition: color 0.1s;
+      color: var(--text-secondary);
+    }
+
+    .relation-item:hover { color: var(--accent); }
+
+    .relation-item .rel-badge {
+      font-size: 9px;
+      padding: 1px 5px;
+      border-radius: 3px;
+      font-weight: 600;
+      text-transform: uppercase;
+    }
+
+    /* Loading spinner */
+    .spinner {
+      display: inline-block;
+      width: 16px;
+      height: 16px;
+      border: 2px solid var(--border);
+      border-top-color: var(--accent);
+      border-radius: 50%;
+      animation: spin 0.6s linear infinite;
+    }
+
+    @keyframes spin { to { transform: rotate(360deg); } }
+
+    /* Scrollbar */
+    ::-webkit-scrollbar { width: 8px; height: 8px; }
+    ::-webkit-scrollbar-track { background: transparent; }
+    ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
+    ::-webkit-scrollbar-thumb:hover { background: var(--border-light); }
+
+    /* Tooltip */
+    .cy-tooltip {
+      position: fixed;
+      background: var(--bg-secondary);
+      border: 1px solid var(--border);
+      border-radius: var(--radius-sm);
+      padding: 6px 10px;
+      font-size: 12px;
+      color: var(--text-primary);
+      pointer-events: none;
+      z-index: 50;
+      box-shadow: 0 4px 12px rgba(0,0,0,0.3);
+      max-width: 300px;
+      display: none;
+    }
+
+    .cy-tooltip .tip-kind {
+      font-size: 10px;
+      color: var(--text-muted);
+      text-transform: uppercase;
+      margin-bottom: 2px;
+    }
+
+    .cy-tooltip .tip-name { font-weight: 500; }
+    .cy-tooltip .tip-file { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
+
+    /* Notification toast */
+    #toast {
+      position: fixed;
+      bottom: 20px;
+      left: 50%;
+      transform: translateX(-50%) translateY(80px);
+      background: var(--bg-secondary);
+      border: 1px solid var(--border);
+      border-radius: var(--radius-sm);
+      padding: 10px 20px;
+      font-size: 13px;
+      color: var(--text-primary);
+      z-index: 300;
+      box-shadow: 0 8px 24px rgba(0,0,0,0.4);
+      transition: transform 0.3s ease;
+    }
+
+    #toast.visible { transform: translateX(-50%) translateY(0); }
+
+    /* Embeddings setup dialog */
+    #dialog-overlay {
+      position: fixed;
+      inset: 0;
+      background: rgba(0,0,0,0.7);
+      z-index: 500;
+      display: none;
+      align-items: center;
+      justify-content: center;
+      backdrop-filter: blur(4px);
+    }
+
+    #dialog-overlay.visible { display: flex; }
+
+    #dialog {
+      background: var(--bg-secondary);
+      border: 1px solid var(--border);
+      border-radius: 12px;
+      padding: 32px;
+      max-width: 480px;
+      width: 90%;
+      box-shadow: 0 16px 48px rgba(0,0,0,0.5);
+    }
+
+    #dialog .dialog-icon { font-size: 36px; margin-bottom: 16px; }
+
+    #dialog .dialog-title {
+      font-size: 18px;
+      font-weight: 600;
+      margin-bottom: 8px;
+    }
+
+    #dialog .dialog-body {
+      font-size: 13px;
+      color: var(--text-secondary);
+      line-height: 1.6;
+      margin-bottom: 20px;
+    }
+
+    #dialog .dialog-body strong { color: var(--text-primary); }
+
+    #dialog-progress {
+      display: none;
+      margin-bottom: 20px;
+    }
+
+    #dialog-progress .progress-bar-track {
+      width: 100%;
+      height: 8px;
+      background: var(--bg-primary);
+      border-radius: 4px;
+      overflow: hidden;
+      margin-bottom: 8px;
+    }
+
+    #dialog-progress .progress-bar-fill {
+      height: 100%;
+      background: var(--accent);
+      border-radius: 4px;
+      width: 0%;
+      transition: width 0.2s ease;
+    }
+
+    #dialog-progress .progress-text {
+      font-size: 12px;
+      color: var(--text-muted);
+    }
+
+    #dialog-progress .progress-percent {
+      float: right;
+      color: var(--text-secondary);
+      font-weight: 500;
+    }
+
+    #dialog .dialog-actions {
+      display: flex;
+      gap: 10px;
+      justify-content: flex-end;
+    }
+
+    #dialog .btn {
+      padding: 8px 20px;
+      border-radius: var(--radius-sm);
+      font-size: 13px;
+      font-weight: 500;
+      cursor: pointer;
+      border: 1px solid var(--border);
+      transition: all 0.15s;
+      font-family: var(--font-sans);
+    }
+
+    #dialog .btn-primary {
+      background: var(--accent);
+      color: #fff;
+      border-color: var(--accent);
+    }
+
+    #dialog .btn-primary:hover { background: var(--accent-hover); }
+    #dialog .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
+
+    #dialog .btn-secondary {
+      background: var(--bg-primary);
+      color: var(--text-secondary);
+    }
+
+    #dialog .btn-secondary:hover { color: var(--text-primary); }
+  </style>
+</head>
+<body>
+  <div id="app">
+    <!-- Header -->
+    <div id="header">
+      <div class="logo">
+        <span class="icon">&#x1F52E;</span>
+        <span>CodeGraph</span>
+      </div>
+
+      <div id="search-container">
+        <span class="search-icon">&#x1F50D;</span>
+        <input id="search-input" type="text" placeholder="Ask about your code... (Ctrl+K)" autocomplete="off" spellcheck="false" />
+        <div id="search-results-dropdown"></div>
+      </div>
+
+      <div id="header-stats">
+        <div class="stat"><span>Nodes:</span> <span class="stat-value" id="stat-nodes">-</span></div>
+        <div class="stat"><span>Edges:</span> <span class="stat-value" id="stat-edges">-</span></div>
+        <div class="stat"><span>Files:</span> <span class="stat-value" id="stat-files">-</span></div>
+      </div>
+    </div>
+
+    <!-- Main content -->
+    <div id="main">
+      <!-- Sidebar -->
+      <div id="sidebar">
+        <!-- Graph controls section -->
+        <div class="sidebar-section">
+          <div class="sidebar-header">
+            <span>Graph Actions</span>
+          </div>
+          <div id="graph-toolbar">
+            <button class="toolbar-btn" onclick="loadOverview()" title="Show top-level symbols">Overview</button>
+            <button class="toolbar-btn" onclick="clearGraph()" title="Clear the graph">Clear</button>
+            <button class="toolbar-btn" onclick="runLayout()" title="Re-run layout">Layout</button>
+            <button class="toolbar-btn" onclick="fitGraph()" title="Fit graph to view">Fit</button>
+          </div>
+        </div>
+
+        <!-- Legend -->
+        <div class="sidebar-section">
+          <div class="sidebar-header" onclick="toggleSection(this)">
+            <span>Legend</span>
+            <span>&#x25B6;</span>
+          </div>
+          <div class="sidebar-content" id="legend" style="display:none;"></div>
+        </div>
+
+        <!-- Files -->
+        <div class="sidebar-section" style="flex:1; overflow:hidden; display:flex; flex-direction:column;">
+          <div class="sidebar-header" onclick="toggleSection(this)">
+            <span>Files</span>
+            <span>&#x25BC;</span>
+          </div>
+          <div class="sidebar-content" id="file-tree" style="flex:1; max-height:none;"></div>
+        </div>
+      </div>
+
+      <!-- Graph area -->
+      <div id="graph-container">
+        <div id="cy"></div>
+        <div id="graph-overlay">
+          <div class="overlay-icon">&#x1F52E;</div>
+          <div class="overlay-title">Ask about your code</div>
+          <div class="overlay-subtitle">Try: "How does authentication work?" or "What happens when a user signs in?"</div>
+          <div class="overlay-examples" style="margin-top:16px; display:flex; flex-wrap:wrap; gap:8px; justify-content:center;">
+            <button class="example-btn" onclick="exploreQuery('authentication and login')">Authentication flow</button>
+            <button class="example-btn" onclick="exploreQuery('API routes and endpoints')">API routes</button>
+            <button class="example-btn" onclick="exploreQuery('database and data storage')">Database layer</button>
+            <button class="example-btn" onclick="exploreQuery('error handling')">Error handling</button>
+          </div>
+        </div>
+        <div id="breadcrumbs"></div>
+        <div id="graph-controls">
+          <button class="graph-ctrl-btn" onclick="cy.zoom(cy.zoom() * 1.3); cy.center()" title="Zoom in">+</button>
+          <button class="graph-ctrl-btn" onclick="cy.zoom(cy.zoom() / 1.3); cy.center()" title="Zoom out">&minus;</button>
+          <button class="graph-ctrl-btn" onclick="fitGraph()" title="Fit to view">&#x2922;</button>
+        </div>
+      </div>
+
+      <!-- Detail panel -->
+      <div id="detail-panel">
+        <div id="detail-header">
+          <div>
+            <div class="node-title" id="detail-title">-</div>
+          </div>
+          <button class="close-btn" onclick="closeDetailPanel()">&times;</button>
+        </div>
+        <div id="detail-body"></div>
+      </div>
+    </div>
+  </div>
+
+  <!-- Embeddings setup dialog -->
+  <div id="dialog-overlay">
+    <div id="dialog">
+      <div class="dialog-icon">&#x1F9E0;</div>
+      <div class="dialog-title" id="dialog-title">Enable Semantic Search</div>
+      <div class="dialog-body" id="dialog-body">
+        CodeGraph Explorer uses <strong>semantic embeddings</strong> to understand your code by meaning, not just keywords.
+        This lets you ask questions like "how does authentication work?" and get accurate results.
+        <br><br>
+        This is a <strong>one-time setup</strong> that generates a local embedding model for this project. No data leaves your machine.
+      </div>
+      <div id="dialog-progress">
+        <div class="progress-bar-track">
+          <div class="progress-bar-fill" id="dialog-progress-fill"></div>
+        </div>
+        <div class="progress-text">
+          <span id="dialog-progress-text">Preparing...</span>
+          <span class="progress-percent" id="dialog-progress-percent">0%</span>
+        </div>
+      </div>
+      <div class="dialog-actions" id="dialog-actions">
+        <button class="btn btn-secondary" id="dialog-skip" onclick="closeDialog()">Skip for now</button>
+        <button class="btn btn-primary" id="dialog-enable" onclick="startEmbeddings()">Enable Semantic Search</button>
+      </div>
+    </div>
+  </div>
+
+  <!-- Context menu -->
+  <div id="context-menu">
+    <div class="ctx-item" onclick="ctxAction('expand-callees')"><span class="ctx-icon">&#x2192;</span> Expand Callees</div>
+    <div class="ctx-item" onclick="ctxAction('expand-callers')"><span class="ctx-icon">&#x2190;</span> Expand Callers</div>
+    <div class="ctx-sep"></div>
+    <div class="ctx-item" onclick="ctxAction('callgraph')"><span class="ctx-icon">&#x1F310;</span> Full Call Graph</div>
+    <div class="ctx-item" onclick="ctxAction('impact')"><span class="ctx-icon">&#x1F4A5;</span> Impact Analysis</div>
+    <div class="ctx-sep"></div>
+    <div class="ctx-item" onclick="ctxAction('children')"><span class="ctx-icon">&#x1F4C2;</span> Show Children</div>
+    <div class="ctx-item" onclick="ctxAction('details')"><span class="ctx-icon">&#x1F4CB;</span> View Details</div>
+    <div class="ctx-sep"></div>
+    <div class="ctx-item" onclick="ctxAction('remove')"><span class="ctx-icon">&#x2716;</span> Remove from Graph</div>
+  </div>
+
+  <!-- Tooltip -->
+  <div class="cy-tooltip" id="tooltip"></div>
+
+  <!-- Toast -->
+  <div id="toast"></div>
+
+  <script>
+    // ====================================================================
+    // State
+    // ====================================================================
+    let cy;
+    let ctxNodeId = null;
+    let searchDebounce = null;
+    const expandedSets = { callers: new Set(), callees: new Set() };
+
+    // Node kind → color mapping
+    const kindColors = {
+      'function':    '#79c0ff',
+      'method':      '#7ee787',
+      'class':       '#d2a8ff',
+      'interface':   '#ffa657',
+      'struct':      '#ffa657',
+      'trait':       '#ffa657',
+      'protocol':    '#ffa657',
+      'component':   '#f778ba',
+      'enum':        '#d29922',
+      'enum_member': '#d29922',
+      'type_alias':  '#d2a8ff',
+      'variable':    '#ff7b72',
+      'constant':    '#ff7b72',
+      'property':    '#76e3ea',
+      'field':       '#76e3ea',
+      'file':        '#8b949e',
+      'module':      '#8b949e',
+      'namespace':   '#8b949e',
+      'import':      '#f0883e',
+      'export':      '#3fb950',
+      'route':       '#f778ba',
+      'parameter':   '#8b949e',
+    };
+
+    const kindShapes = {
+      'class': 'round-rectangle',
+      'interface': 'round-diamond',
+      'struct': 'round-rectangle',
+      'trait': 'round-diamond',
+      'protocol': 'round-diamond',
+      'enum': 'round-hexagon',
+      'component': 'round-pentagon',
+      'file': 'round-rectangle',
+      'module': 'round-rectangle',
+      'namespace': 'round-rectangle',
+    };
+
+    const edgeColors = {
+      'calls':        '#58a6ff',
+      'imports':      '#f0883e',
+      'extends':      '#d2a8ff',
+      'implements':   '#ffa657',
+      'references':   '#8b949e',
+      'contains':     '#3d444d',
+      'type_of':      '#76e3ea',
+      'returns':      '#76e3ea',
+      'instantiates': '#f778ba',
+      'overrides':    '#d29922',
+      'decorates':    '#f778ba',
+      'exports':      '#3fb950',
+    };
+
+    // ====================================================================
+    // API Client
+    // ====================================================================
+    const api = {
+      async get(path) {
+        const res = await fetch('/api/' + path);
+        if (!res.ok) throw new Error(`API error: ${res.status}`);
+        return res.json();
+      },
+      embeddingsStatus: () => api.get('embeddings/status'),
+      status: ()          => api.get('status'),
+      search: (q, kind, limit) => api.get(`search?q=${encodeURIComponent(q)}${kind ? '&kind='+kind : ''}&limit=${limit||30}`),
+      explore: (q, maxNodes) => api.get(`explore?q=${encodeURIComponent(q)}&maxNodes=${maxNodes||30}`),
+      overview: (limit)   => api.get(`overview?limit=${limit||60}`),
+      files: ()           => api.get('files'),
+      fileNodes: (p)      => api.get(`file-nodes?path=${encodeURIComponent(p)}`),
+      node: (id)          => api.get(`node/${encodeURIComponent(id)}`),
+      callers: (id, d)    => api.get(`node/${encodeURIComponent(id)}/callers?depth=${d||1}`),
+      callees: (id, d)    => api.get(`node/${encodeURIComponent(id)}/callees?depth=${d||1}`),
+      children: (id)      => api.get(`node/${encodeURIComponent(id)}/children`),
+      impact: (id, d)     => api.get(`node/${encodeURIComponent(id)}/impact?depth=${d||2}`),
+      callgraph: (id, d)  => api.get(`node/${encodeURIComponent(id)}/callgraph?depth=${d||2}`),
+      context: (id)       => api.get(`node/${encodeURIComponent(id)}/context`),
+    };
+
+    // ====================================================================
+    // Cytoscape Initialization
+    // ====================================================================
+    function initCytoscape() {
+      cy = cytoscape({
+        container: document.getElementById('cy'),
+        style: [
+          // Nodes
+          {
+            selector: 'node',
+            style: {
+              'label': 'data(label)',
+              'text-valign': 'center',
+              'text-halign': 'center',
+              'font-size': '12px',
+              'font-weight': 'bold',
+              'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
+              'color': '#ffffff',
+              'text-outline-color': '#000000',
+              'text-outline-width': 2,
+              'text-outline-opacity': 0.6,
+              'background-color': 'data(color)',
+              'background-opacity': 0.85,
+              'border-width': 2,
+              'border-color': 'data(color)',
+              'border-opacity': 0.7,
+              'width': 'label',
+              'height': 'label',
+              'padding': '12px',
+              'shape': 'data(shape)',
+              'text-wrap': 'none',
+              'text-max-width': '160px',
+              'transition-property': 'background-opacity, border-color, border-opacity, opacity, text-opacity',
+              'transition-duration': '0.2s',
+            }
+          },
+          // Selected node
+          {
+            selector: 'node:selected',
+            style: {
+              'border-width': 3,
+              'border-color': '#ffffff',
+              'border-opacity': 1,
+              'background-opacity': 1,
+              'z-index': 10,
+            }
+          },
+          // Hovered node
+          {
+            selector: 'node.hover',
+            style: {
+              'border-width': 3,
+              'border-color': '#ffffff',
+              'border-opacity': 0.9,
+              'background-opacity': 1,
+            }
+          },
+          // Faded node — keep text readable
+          {
+            selector: 'node.faded',
+            style: {
+              'background-opacity': 0.3,
+              'border-opacity': 0.2,
+              'text-opacity': 0.7,
+            }
+          },
+          // Highlighted node
+          {
+            selector: 'node.highlighted',
+            style: {
+              'border-width': 3,
+              'border-color': '#f0e68c',
+              'border-opacity': 1,
+              'z-index': 10,
+            }
+          },
+          // Edges
+          {
+            selector: 'edge',
+            style: {
+              'width': 1.5,
+              'line-color': 'data(color)',
+              'target-arrow-color': 'data(color)',
+              'target-arrow-shape': 'triangle',
+              'arrow-scale': 0.8,
+              'curve-style': 'bezier',
+              'opacity': 0.6,
+              'label': 'data(label)',
+              'font-size': '9px',
+              'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
+              'color': '#656d76',
+              'text-rotation': 'autorotate',
+              'text-margin-y': -8,
+              'text-outline-color': '#0d1117',
+              'text-outline-width': 2,
+              'transition-property': 'opacity, line-color',
+              'transition-duration': '0.15s',
+            }
+          },
+          // Selected edge
+          {
+            selector: 'edge:selected',
+            style: { 'opacity': 1, 'width': 2.5 }
+          },
+          // Faded edge
+          {
+            selector: 'edge.faded',
+            style: { 'opacity': 0.15 }
+          },
+          // Highlighted edge
+          {
+            selector: 'edge.highlighted',
+            style: { 'opacity': 1, 'width': 2.5 }
+          },
+        ],
+        layout: { name: 'preset' },
+        minZoom: 0.1,
+        maxZoom: 4,
+        wheelSensitivity: 0.3,
+      });
+
+      // Event handlers
+      cy.on('tap', 'node', (e) => {
+        const nodeId = e.target.data('nodeId');
+        if (nodeId) showNodeDetails(nodeId);
+        highlightNeighborhood(e.target);
+      });
+
+      cy.on('cxttap', 'node', (e) => {
+        e.originalEvent.preventDefault();
+        ctxNodeId = e.target.data('nodeId');
+        showContextMenu(e.originalEvent.clientX, e.originalEvent.clientY);
+      });
+
+      cy.on('tap', (e) => {
+        if (e.target === cy) {
+          clearHighlights();
+          hideContextMenu();
+        }
+      });
+
+      cy.on('mouseover', 'node', (e) => {
+        e.target.addClass('hover');
+        showTooltip(e);
+      });
+
+      cy.on('mouseout', 'node', (e) => {
+        e.target.removeClass('hover');
+        hideTooltip();
+      });
+
+      cy.on('dblclick', 'node', (e) => {
+        const nodeId = e.target.data('nodeId');
+        if (nodeId) expandCallees(nodeId);
+      });
+
+      // Click outside to close context menu
+      document.addEventListener('click', (e) => {
+        if (!e.target.closest('#context-menu')) hideContextMenu();
+      });
+
+      document.addEventListener('contextmenu', (e) => {
+        if (e.target.closest('#cy')) e.preventDefault();
+      });
+    }
+
+    // ====================================================================
+    // Graph Operations
+    // ====================================================================
+    function addNodeToGraph(node) {
+      if (cy.getElementById(node.id).length > 0) return;
+      const color = kindColors[node.kind] || '#8b949e';
+      const shape = kindShapes[node.kind] || 'round-rectangle';
+      cy.add({
+        group: 'nodes',
+        data: {
+          id: node.id,
+          nodeId: node.id,
+          label: node.name,
+          color: color,
+          shape: shape,
+          kind: node.kind,
+          filePath: node.filePath,
+          signature: node.signature || '',
+        },
+      });
+    }
+
+    function addEdgeToGraph(edge) {
+      const edgeId = `${edge.source}-${edge.kind}-${edge.target}`;
+      if (cy.getElementById(edgeId).length > 0) return;
+      // Don't add edge if source or target not in graph
+      if (cy.getElementById(edge.source).length === 0 || cy.getElementById(edge.target).length === 0) return;
+      const color = edgeColors[edge.kind] || '#8b949e';
+      cy.add({
+        group: 'edges',
+        data: {
+          id: edgeId,
+          source: edge.source,
+          target: edge.target,
+          kind: edge.kind,
+          label: edge.kind,
+          color: color,
+        },
+      });
+    }
+
+    function addSubgraph(nodes, edges) {
+      const batchElements = [];
+      for (const node of nodes) {
+        if (cy.getElementById(node.id).length > 0) continue;
+        const color = kindColors[node.kind] || '#8b949e';
+        const shape = kindShapes[node.kind] || 'round-rectangle';
+        batchElements.push({
+          group: 'nodes',
+          data: {
+            id: node.id,
+            nodeId: node.id,
+            label: node.name,
+            color: color,
+            shape: shape,
+            kind: node.kind,
+            filePath: node.filePath,
+            signature: node.signature || '',
+          },
+        });
+      }
+      for (const edge of edges) {
+        const edgeId = `${edge.source}-${edge.kind}-${edge.target}`;
+        if (cy.getElementById(edgeId).length > 0) continue;
+        // Check source/target will exist
+        const srcExists = cy.getElementById(edge.source).length > 0 || batchElements.some(e => e.data.id === edge.source);
+        const tgtExists = cy.getElementById(edge.target).length > 0 || batchElements.some(e => e.data.id === edge.target);
+        if (!srcExists || !tgtExists) continue;
+        const color = edgeColors[edge.kind] || '#8b949e';
+        batchElements.push({
+          group: 'edges',
+          data: {
+            id: edgeId,
+            source: edge.source,
+            target: edge.target,
+            kind: edge.kind,
+            label: edge.kind,
+            color: color,
+          },
+        });
+      }
+      if (batchElements.length > 0) {
+        cy.add(batchElements);
+      }
+    }
+
+    function clearGraph() {
+      cy.elements().remove();
+      expandedSets.callers.clear();
+      expandedSets.callees.clear();
+      hideOverlay(false);
+      closeDetailPanel();
+    }
+
+    function runLayout() {
+      if (cy.nodes().length === 0) return;
+      const layout = cy.layout({
+        name: 'dagre',
+        rankDir: 'LR',
+        nodeSep: 50,
+        rankSep: 80,
+        edgeSep: 20,
+        animate: true,
+        animationDuration: 300,
+        fit: true,
+        padding: 40,
+      });
+      layout.run();
+    }
+
+    function fitGraph() {
+      if (cy.nodes().length > 0) {
+        cy.animate({ fit: { eles: cy.elements(), padding: 40 } }, { duration: 300 });
+      }
+    }
+
+    function highlightNeighborhood(node) {
+      clearHighlights();
+      const neighborhood = node.closedNeighborhood();
+      cy.elements().not(neighborhood).addClass('faded');
+      neighborhood.edges().addClass('highlighted');
+    }
+
+    function clearHighlights() {
+      cy.elements().removeClass('faded highlighted');
+    }
+
+    function hideOverlay(hide = true) {
+      const overlay = document.getElementById('graph-overlay');
+      overlay.style.display = hide ? 'none' : 'block';
+    }
+
+    // ====================================================================
+    // Data Loading
+    // ====================================================================
+    async function loadOverview() {
+      showToast('Loading overview...');
+      try {
+        const data = await api.overview(60);
+        if (data.nodes.length === 0) {
+          showToast('No symbols found. Is the project indexed?');
+          return;
+        }
+        clearGraph();
+        hideOverlay();
+        for (const node of data.nodes) addNodeToGraph(node);
+        runLayout();
+        showToast(`Loaded ${data.nodes.length} symbols`);
+      } catch (err) {
+        showToast('Error: ' + err.message);
+      }
+    }
+
+    async function expandCallers(nodeId) {
+      if (expandedSets.callers.has(nodeId)) return;
+      expandedSets.callers.add(nodeId);
+      try {
+        const data = await api.callers(nodeId, 1);
+        if (data.items.length === 0) {
+          showToast('No callers found');
+          return;
+        }
+        for (const item of data.items) {
+          addNodeToGraph(item.node);
+          addEdgeToGraph(item.edge);
+        }
+        runLayout();
+        showToast(`Found ${data.items.length} callers`);
+      } catch (err) {
+        showToast('Error: ' + err.message);
+      }
+    }
+
+    async function expandCallees(nodeId) {
+      if (expandedSets.callees.has(nodeId)) return;
+      expandedSets.callees.add(nodeId);
+      try {
+        const data = await api.callees(nodeId, 1);
+        if (data.items.length === 0) {
+          showToast('No callees found');
+          return;
+        }
+        for (const item of data.items) {
+          addNodeToGraph(item.node);
+          addEdgeToGraph(item.edge);
+        }
+        runLayout();
+        showToast(`Found ${data.items.length} callees`);
+      } catch (err) {
+        showToast('Error: ' + err.message);
+      }
+    }
+
+    async function loadCallGraph(nodeId) {
+      showToast('Loading call graph...');
+      try {
+        const data = await api.callgraph(nodeId, 2);
+        addSubgraph(data.nodes, data.edges);
+        runLayout();
+        showToast(`Loaded call graph: ${data.nodes.length} nodes`);
+      } catch (err) {
+        showToast('Error: ' + err.message);
+      }
+    }
+
+    async function loadImpact(nodeId) {
+      showToast('Analyzing impact...');
+      try {
+        const data = await api.impact(nodeId, 2);
+        addSubgraph(data.nodes, data.edges);
+        runLayout();
+        // Highlight the root
+        const rootEle = cy.getElementById(nodeId);
+        if (rootEle.length > 0) {
+          rootEle.addClass('highlighted');
+        }
+        showToast(`Impact: ${data.nodes.length} nodes potentially affected`);
+      } catch (err) {
+        showToast('Error: ' + err.message);
+      }
+    }
+
+    async function loadChildren(nodeId) {
+      try {
+        const data = await api.children(nodeId);
+        if (data.children.length === 0) {
+          showToast('No children found');
+          return;
+        }
+        for (const child of data.children) {
+          addNodeToGraph(child);
+          // Add contains edge
+          addEdgeToGraph({ source: nodeId, target: child.id, kind: 'contains' });
+        }
+        runLayout();
+        showToast(`Found ${data.children.length} children`);
+      } catch (err) {
+        showToast('Error: ' + err.message);
+      }
+    }
+
+    async function loadFileNodes(filePath) {
+      showToast('Loading file symbols...');
+      try {
+        const data = await api.fileNodes(filePath);
+        if (data.nodes.length === 0) {
+          showToast('No symbols in this file');
+          return;
+        }
+        clearGraph();
+        hideOverlay();
+        for (const node of data.nodes) addNodeToGraph(node);
+        runLayout();
+        showToast(`Loaded ${data.nodes.length} symbols from file`);
+      } catch (err) {
+        showToast('Error: ' + err.message);
+      }
+    }
+
+    // ====================================================================
+    // Explore — natural language question → graph
+    // ====================================================================
+    async function exploreQuery(question) {
+      hideSearchDropdown();
+      clearGraph();
+      hideOverlay();
+      document.getElementById('search-input').value = question;
+
+      showToast('Asking Claude...');
+
+      try {
+        const data = await api.explore(question, 30);
+        if (data.nodes.length === 0) {
+          showToast('No relevant code found. Try different keywords.');
+          hideOverlay(false);
+          return;
+        }
+        addSubgraph(data.nodes, data.edges);
+
+        // Highlight root/entry-point nodes
+        if (data.roots && data.roots.length > 0) {
+          for (const rootId of data.roots) {
+            const ele = cy.getElementById(rootId);
+            if (ele.length > 0) ele.addClass('highlighted');
+          }
+        }
+
+        runLayout();
+        const source = data.usedClaude ? ' (via Claude)' : '';
+        showToast(`Found ${data.nodes.length} related symbols${source}`);
+      } catch (err) {
+        showToast('Error: ' + err.message);
+      }
+    }
+
+    // ====================================================================
+    // Search
+    // ====================================================================
+    function onSearchInput(e) {
+      const query = e.target.value.trim();
+      clearTimeout(searchDebounce);
+      if (!query) {
+        hideSearchDropdown();
+        return;
+      }
+      searchDebounce = setTimeout(async () => {
+        try {
+          const data = await api.search(query, null, 20);
+          showSearchResults(data.results);
+        } catch (err) {
+          console.error('Search error:', err);
+        }
+      }, 200);
+    }
+
+    function onSearchKeydown(e) {
+      if (e.key === 'Enter') {
+        e.preventDefault();
+        const query = e.target.value.trim();
+        if (query) {
+          hideSearchDropdown();
+          exploreQuery(query);
+        }
+      }
+    }
+
+    function showSearchResults(results) {
+      const dropdown = document.getElementById('search-results-dropdown');
+      if (results.length === 0) {
+        dropdown.innerHTML = '<div style="padding:12px;color:var(--text-muted);font-size:13px;">No results found</div>';
+        dropdown.classList.add('visible');
+        return;
+      }
+      dropdown.innerHTML = results.map(r => `
+        <div class="search-result-item" onclick="selectSearchResult('${escapeAttr(r.node.id)}')">
+          <span class="kind-badge" style="background:${kindColors[r.node.kind] || '#8b949e'}22;color:${kindColors[r.node.kind] || '#8b949e'}">${r.node.kind}</span>
+          <span class="name">${escapeHtml(r.node.name)}</span>
+          <span class="file-path">${escapeHtml(r.node.filePath)}</span>
+        </div>
+      `).join('');
+      dropdown.classList.add('visible');
+    }
+
+    function hideSearchDropdown() {
+      document.getElementById('search-results-dropdown').classList.remove('visible');
+    }
+
+    async function selectSearchResult(nodeId) {
+      hideSearchDropdown();
+      document.getElementById('search-input').value = '';
+      hideOverlay();
+
+      // Add to graph if not present
+      try {
+        const data = await api.node(nodeId);
+        if (data.node) {
+          addNodeToGraph(data.node);
+          // Also load its immediate relations
+          const [callersData, calleesData] = await Promise.all([
+            api.callers(nodeId, 1),
+            api.callees(nodeId, 1),
+          ]);
+          for (const item of callersData.items) {
+            addNodeToGraph(item.node);
+            addEdgeToGraph(item.edge);
+          }
+          for (const item of calleesData.items) {
+            addNodeToGraph(item.node);
+            addEdgeToGraph(item.edge);
+          }
+          expandedSets.callers.add(nodeId);
+          expandedSets.callees.add(nodeId);
+          runLayout();
+          // Select and focus
+          const ele = cy.getElementById(nodeId);
+          if (ele.length > 0) {
+            cy.nodes().unselect();
+            ele.select();
+            cy.animate({ center: { eles: ele }, zoom: 1.5 }, { duration: 300 });
+          }
+          showNodeDetails(nodeId);
+        }
+      } catch (err) {
+        showToast('Error: ' + err.message);
+      }
+    }
+
+    // ====================================================================
+    // Detail Panel
+    // ====================================================================
+    async function showNodeDetails(nodeId) {
+      const panel = document.getElementById('detail-panel');
+      const body = document.getElementById('detail-body');
+      const title = document.getElementById('detail-title');
+
+      panel.classList.add('open');
+
+      body.innerHTML = '<div style="padding:20px;text-align:center;"><div class="spinner"></div></div>';
+
+      try {
+        const [nodeData, contextData] = await Promise.all([
+          api.node(nodeId),
+          api.context(nodeId),
+        ]);
+
+        const node = nodeData.node;
+        const code = nodeData.code;
+        const ancestors = nodeData.ancestors || [];
+        const ctx = contextData.context;
+
+        title.textContent = node.name;
+
+        let html = '';
+
+        // Meta info
+        html += `<div class="detail-section">
+          <div class="detail-section-title">Info</div>
+          <div class="detail-meta">
+            <span class="label">Kind</span>
+            <span class="value"><span class="kind-badge" style="background:${kindColors[node.kind] || '#8b949e'}22;color:${kindColors[node.kind] || '#8b949e'};font-size:10px;padding:1px 5px;border-radius:3px;">${node.kind}</span></span>
+            <span class="label">File</span>
+            <span class="value accent">${escapeHtml(node.filePath)}</span>
+            <span class="label">Lines</span>
+            <span class="value">${node.startLine} - ${node.endLine}</span>
+            ${node.signature ? `<span class="label">Signature</span><span class="value" style="font-family:var(--font-mono);font-size:11px;">${escapeHtml(node.signature)}</span>` : ''}
+            ${node.visibility ? `<span class="label">Visibility</span><span class="value">${node.visibility}</span>` : ''}
+            ${node.isExported ? `<span class="label">Exported</span><span class="value">Yes</span>` : ''}
+            ${node.isAsync ? `<span class="label">Async</span><span class="value">Yes</span>` : ''}
+            ${node.decorators && node.decorators.length ? `<span class="label">Decorators</span><span class="value">${escapeHtml(node.decorators.join(', '))}</span>` : ''}
+          </div>
+        </div>`;
+
+        // Breadcrumb ancestors
+        if (ancestors.length > 0) {
+          html += `<div class="detail-section">
+            <div class="detail-section-title">Hierarchy</div>
+            <div style="font-size:12px;color:var(--text-secondary);">
+              ${ancestors.map(a => `<span class="relation-item" onclick="selectSearchResult('${escapeAttr(a.id)}')" style="display:inline;cursor:pointer;color:var(--accent);">${escapeHtml(a.name)}</span>`).join(' <span style="color:var(--text-muted);">&#x203A;</span> ')} <span style="color:var(--text-muted);">&#x203A;</span> <strong>${escapeHtml(node.name)}</strong>
+            </div>
+          </div>`;
+        }
+
+        // Source code
+        if (code) {
+          const lang = langForHighlight(node.language);
+          html += `<div class="detail-section">
+            <div class="detail-section-title">Source Code</div>
+            <div class="code-block"><pre><code class="language-${lang}">${escapeHtml(code)}</code></pre></div>
+          </div>`;
+        }
+
+        // Callers
+        if (ctx.incomingRefs && ctx.incomingRefs.length > 0) {
+          html += `<div class="detail-section">
+            <div class="detail-section-title">Called By (${ctx.incomingRefs.length})</div>
+            <ul class="relation-list">
+              ${ctx.incomingRefs.slice(0, 20).map(r => `
+                <li class="relation-item" onclick="selectSearchResult('${escapeAttr(r.node.id)}')">
+                  <span class="rel-badge" style="background:${kindColors[r.node.kind] || '#8b949e'}22;color:${kindColors[r.node.kind] || '#8b949e'}">${r.node.kind}</span>
+                  ${escapeHtml(r.node.name)}
+                  <span style="margin-left:auto;color:var(--text-muted);font-size:11px;">${r.edge.kind}</span>
+                </li>
+              `).join('')}
+            </ul>
+          </div>`;
+        }
+
+        // Callees
+        if (ctx.outgoingRefs && ctx.outgoingRefs.length > 0) {
+          html += `<div class="detail-section">
+            <div class="detail-section-title">Calls (${ctx.outgoingRefs.length})</div>
+            <ul class="relation-list">
+              ${ctx.outgoingRefs.slice(0, 20).map(r => `
+                <li class="relation-item" onclick="selectSearchResult('${escapeAttr(r.node.id)}')">
+                  <span class="rel-badge" style="background:${kindColors[r.node.kind] || '#8b949e'}22;color:${kindColors[r.node.kind] || '#8b949e'}">${r.node.kind}</span>
+                  ${escapeHtml(r.node.name)}
+                  <span style="margin-left:auto;color:var(--text-muted);font-size:11px;">${r.edge.kind}</span>
+                </li>
+              `).join('')}
+            </ul>
+          </div>`;
+        }
+
+        // Children
+        if (ctx.children && ctx.children.length > 0) {
+          html += `<div class="detail-section">
+            <div class="detail-section-title">Contains (${ctx.children.length})</div>
+            <ul class="relation-list">
+              ${ctx.children.slice(0, 30).map(c => `
+                <li class="relation-item" onclick="selectSearchResult('${escapeAttr(c.id)}')">
+                  <span class="rel-badge" style="background:${kindColors[c.kind] || '#8b949e'}22;color:${kindColors[c.kind] || '#8b949e'}">${c.kind}</span>
+                  ${escapeHtml(c.name)}
+                </li>
+              `).join('')}
+            </ul>
+          </div>`;
+        }
+
+        // Docstring
+        if (node.docstring) {
+          html += `<div class="detail-section">
+            <div class="detail-section-title">Documentation</div>
+            <div style="font-size:12px;color:var(--text-secondary);white-space:pre-wrap;font-family:var(--font-mono);line-height:1.5;">${escapeHtml(node.docstring)}</div>
+          </div>`;
+        }
+
+        body.innerHTML = html;
+
+        // Apply syntax highlighting
+        body.querySelectorAll('pre code').forEach(block => hljs.highlightElement(block));
+
+      } catch (err) {
+        body.innerHTML = `<div style="padding:20px;color:var(--red);">Error loading details: ${escapeHtml(err.message)}</div>`;
+      }
+    }
+
+    function closeDetailPanel() {
+      document.getElementById('detail-panel').classList.remove('open');
+    }
+
+    // ====================================================================
+    // Context Menu
+    // ====================================================================
+    function showContextMenu(x, y) {
+      const menu = document.getElementById('context-menu');
+      menu.style.left = x + 'px';
+      menu.style.top = y + 'px';
+      menu.classList.add('visible');
+    }
+
+    function hideContextMenu() {
+      document.getElementById('context-menu').classList.remove('visible');
+    }
+
+    function ctxAction(action) {
+      hideContextMenu();
+      if (!ctxNodeId) return;
+      switch (action) {
+        case 'expand-callees': expandCallees(ctxNodeId); break;
+        case 'expand-callers': expandCallers(ctxNodeId); break;
+        case 'callgraph': loadCallGraph(ctxNodeId); break;
+        case 'impact': loadImpact(ctxNodeId); break;
+        case 'children': loadChildren(ctxNodeId); break;
+        case 'details': showNodeDetails(ctxNodeId); break;
+        case 'remove':
+          cy.getElementById(ctxNodeId).remove();
+          expandedSets.callers.delete(ctxNodeId);
+          expandedSets.callees.delete(ctxNodeId);
+          break;
+      }
+    }
+
+    // ====================================================================
+    // Tooltip
+    // ====================================================================
+    function showTooltip(e) {
+      const node = e.target;
+      const tip = document.getElementById('tooltip');
+      const pos = e.originalEvent;
+      tip.innerHTML = `
+        <div class="tip-kind">${node.data('kind')}</div>
+        <div class="tip-name">${escapeHtml(node.data('label'))}</div>
+        ${node.data('signature') ? `<div class="tip-file" style="font-family:var(--font-mono);">${escapeHtml(node.data('signature'))}</div>` : ''}
+        <div class="tip-file">${escapeHtml(node.data('filePath') || '')}</div>
+      `;
+      tip.style.left = (pos.clientX + 12) + 'px';
+      tip.style.top = (pos.clientY + 12) + 'px';
+      tip.style.display = 'block';
+    }
+
+    function hideTooltip() {
+      document.getElementById('tooltip').style.display = 'none';
+    }
+
+    // ====================================================================
+    // Toast notifications
+    // ====================================================================
+    function showToast(msg) {
+      const toast = document.getElementById('toast');
+      toast.textContent = msg;
+      toast.classList.add('visible');
+      clearTimeout(toast._timeout);
+      toast._timeout = setTimeout(() => toast.classList.remove('visible'), 2500);
+    }
+
+    // ====================================================================
+    // Sidebar
+    // ====================================================================
+    function toggleSection(header) {
+      const content = header.nextElementSibling;
+      const arrow = header.querySelector('span:last-child');
+      if (content.style.display === 'none') {
+        content.style.display = '';
+        arrow.innerHTML = '&#x25BC;';
+      } else {
+        content.style.display = 'none';
+        arrow.innerHTML = '&#x25B6;';
+      }
+    }
+
+    async function loadFileTree() {
+      try {
+        const data = await api.files();
+        const tree = document.getElementById('file-tree');
+        if (data.files.length === 0) {
+          tree.innerHTML = '<div style="padding:12px;color:var(--text-muted);font-size:12px;">No files indexed</div>';
+          return;
+        }
+        // Group by directory
+        const dirs = {};
+        for (const f of data.files) {
+          const dir = f.filePath.split('/').slice(0, -1).join('/') || '.';
+          if (!dirs[dir]) dirs[dir] = [];
+          dirs[dir].push(f);
+        }
+        let html = '';
+        const sortedDirs = Object.keys(dirs).sort();
+        for (const dir of sortedDirs) {
+          html += `<div class="file-item" style="color:var(--text-muted);font-weight:500;padding-top:8px;" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? '' : 'none'">
+            <span class="file-icon">&#x1F4C1;</span> ${escapeHtml(dir)}/
+          </div><div>`;
+          for (const f of dirs[dir].sort((a, b) => a.filePath.localeCompare(b.filePath))) {
+            const fileName = f.filePath.split('/').pop();
+            html += `<div class="file-item" style="padding-left:28px;" onclick="loadFileNodes('${escapeAttr(f.filePath)}')">
+              <span class="file-icon">&#x1F4C4;</span> ${escapeHtml(fileName)}
+            </div>`;
+          }
+          html += '</div>';
+        }
+        tree.innerHTML = html;
+      } catch (err) {
+        console.error('Failed to load file tree:', err);
+      }
+    }
+
+    function buildLegend() {
+      const legendEl = document.getElementById('legend');
+      const kinds = ['function', 'method', 'class', 'interface', 'component', 'enum', 'variable', 'constant', 'property', 'type_alias', 'import', 'export'];
+      legendEl.innerHTML = kinds.map(k => `
+        <div class="legend-item">
+          <div class="legend-dot" style="background:${kindColors[k]};"></div>
+          <span>${k.replace('_', ' ')}</span>
+        </div>
+      `).join('');
+    }
+
+    // ====================================================================
+    // Helpers
+    // ====================================================================
+    function escapeHtml(str) {
+      if (!str) return '';
+      return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
+    }
+
+    function escapeAttr(str) {
+      if (!str) return '';
+      return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
+    }
+
+    function langForHighlight(lang) {
+      const map = {
+        'typescript': 'typescript', 'javascript': 'javascript', 'tsx': 'typescript',
+        'jsx': 'javascript', 'python': 'python', 'go': 'go', 'rust': 'rust',
+        'java': 'java', 'c': 'c', 'cpp': 'cpp', 'csharp': 'csharp',
+        'php': 'php', 'ruby': 'ruby', 'swift': 'swift', 'kotlin': 'kotlin',
+        'dart': 'dart', 'svelte': 'xml', 'liquid': 'xml', 'pascal': 'delphi',
+      };
+      return map[lang] || 'plaintext';
+    }
+
+    // ====================================================================
+    // Keyboard Shortcuts
+    // ====================================================================
+    document.addEventListener('keydown', (e) => {
+      // Ctrl+K or Cmd+K → focus search
+      if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
+        e.preventDefault();
+        document.getElementById('search-input').focus();
+      }
+      // Escape → close things
+      if (e.key === 'Escape') {
+        hideSearchDropdown();
+        hideContextMenu();
+        closeDetailPanel();
+        clearHighlights();
+        document.getElementById('search-input').blur();
+      }
+      // Delete/Backspace → remove selected nodes
+      if ((e.key === 'Delete' || e.key === 'Backspace') && document.activeElement.tagName !== 'INPUT') {
+        const selected = cy.$(':selected');
+        if (selected.length > 0) {
+          selected.remove();
+        }
+      }
+    });
+
+    // ====================================================================
+    // Embeddings Setup Dialog
+    // ====================================================================
+    function showDialog() {
+      document.getElementById('dialog-overlay').classList.add('visible');
+    }
+
+    function closeDialog() {
+      document.getElementById('dialog-overlay').classList.remove('visible');
+    }
+
+    async function checkEmbeddings() {
+      try {
+        const data = await api.embeddingsStatus();
+        if (!data.isReady) {
+          showDialog();
+        }
+      } catch (err) {
+        console.error('Failed to check embeddings status:', err);
+      }
+    }
+
+    function startEmbeddings() {
+      const btnEnable = document.getElementById('dialog-enable');
+      const btnSkip = document.getElementById('dialog-skip');
+      const progress = document.getElementById('dialog-progress');
+      const progressFill = document.getElementById('dialog-progress-fill');
+      const progressText = document.getElementById('dialog-progress-text');
+      const progressPercent = document.getElementById('dialog-progress-percent');
+      const title = document.getElementById('dialog-title');
+      const body = document.getElementById('dialog-body');
+
+      // Update UI to progress mode
+      btnEnable.disabled = true;
+      btnEnable.textContent = 'Setting up...';
+      btnSkip.style.display = 'none';
+      progress.style.display = 'block';
+      body.innerHTML = 'Setting up semantic search for your project. This only needs to happen once.';
+
+      const evtSource = new EventSource('/api/embeddings/generate');
+
+      evtSource.addEventListener('status', (e) => {
+        const data = JSON.parse(e.data);
+        progressText.textContent = data.message;
+        title.textContent = data.phase === 'model' ? 'Downloading Model...' :
+                           data.phase === 'embedding' ? 'Generating Embeddings...' :
+                           'Setting Up...';
+      });
+
+      evtSource.addEventListener('progress', (e) => {
+        const data = JSON.parse(e.data);
+        progressFill.style.width = data.percent + '%';
+        progressPercent.textContent = data.percent + '%';
+        progressText.textContent = data.nodeName
+          ? `Embedding: ${data.nodeName} (${data.current}/${data.total})`
+          : `Processing ${data.current} of ${data.total}...`;
+      });
+
+      evtSource.addEventListener('complete', (e) => {
+        const data = JSON.parse(e.data);
+        evtSource.close();
+        title.textContent = 'Ready!';
+        body.innerHTML = `<strong>${data.message}</strong><br><br>Semantic search is now active. Your explore queries will understand code meaning, not just keywords.`;
+        progressFill.style.width = '100%';
+        progressPercent.textContent = '100%';
+        progressText.textContent = 'Complete';
+
+        // Change actions to just a close button
+        document.getElementById('dialog-actions').innerHTML =
+          '<button class="btn btn-primary" onclick="closeDialog()">Start Exploring</button>';
+      });
+
+      evtSource.addEventListener('error', (e) => {
+        let msg = 'An error occurred during setup.';
+        try {
+          const data = JSON.parse(e.data);
+          msg = data.message || msg;
+        } catch {}
+        evtSource.close();
+        title.textContent = 'Setup Error';
+        body.innerHTML = `<span style="color:var(--red);">${escapeHtml(msg)}</span><br><br>You can still use the explorer with keyword-based search.`;
+        document.getElementById('dialog-actions').innerHTML =
+          '<button class="btn btn-secondary" onclick="closeDialog()">Close</button>';
+      });
+
+      // SSE connection error (different from app error event)
+      evtSource.onerror = () => {
+        // EventSource reconnects automatically; if it closes, we handle via the error event above
+      };
+    }
+
+    // ====================================================================
+    // Init
+    // ====================================================================
+    async function init() {
+      initCytoscape();
+      buildLegend();
+
+      // Load stats
+      try {
+        const data = await api.status();
+        document.getElementById('stat-nodes').textContent = data.stats.nodeCount.toLocaleString();
+        document.getElementById('stat-edges').textContent = data.stats.edgeCount.toLocaleString();
+        document.getElementById('stat-files').textContent = data.stats.fileCount.toLocaleString();
+        document.title = `CodeGraph - ${data.projectName}`;
+      } catch (err) {
+        console.error('Failed to load status:', err);
+      }
+
+      // Load file tree
+      loadFileTree();
+
+      // Embeddings dialog available but not auto-shown
+      // (local embedding model quality is insufficient for natural language queries)
+
+      // Search input
+      document.getElementById('search-input').addEventListener('input', onSearchInput);
+      document.getElementById('search-input').addEventListener('keydown', onSearchKeydown);
+      document.getElementById('search-input').addEventListener('focus', () => {
+        if (document.getElementById('search-input').value.trim()) {
+          document.getElementById('search-results-dropdown').classList.add('visible');
+        }
+      });
+
+      // Click outside search dropdown to close
+      document.addEventListener('click', (e) => {
+        if (!e.target.closest('#search-container')) hideSearchDropdown();
+      });
+    }
+
+    // Start
+    init();
+  </script>
+</body>
+</html>

+ 582 - 0
src/visualizer/server.ts

@@ -0,0 +1,582 @@
+/**
+ * CodeGraph Visualizer Server
+ *
+ * Lightweight HTTP server that serves the graph visualization UI
+ * and exposes REST API endpoints for querying the CodeGraph database.
+ */
+
+import * as http from 'http';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as url from 'url';
+import { execFile } from 'child_process';
+import type CodeGraph from '../index';
+import type { Node, Edge, NodeKind } from '../types';
+
+export interface VisualizerOptions {
+  /** Port to listen on (0 = auto-assign) */
+  port?: number;
+  /** Whether to open browser automatically */
+  openBrowser?: boolean;
+  /** Host to bind to */
+  host?: string;
+}
+
+/**
+ * Serialize a Subgraph (which uses Map) to plain JSON
+ */
+function serializeSubgraph(subgraph: { nodes: Map<string, Node>; edges: Edge[]; roots: string[] }) {
+  return {
+    nodes: Array.from(subgraph.nodes.values()),
+    edges: subgraph.edges,
+    roots: subgraph.roots,
+  };
+}
+
+export class VisualizerServer {
+  private cg: CodeGraph;
+  private server: http.Server | null = null;
+  private projectRoot: string;
+  private symbolIndexCache: string | null = null;
+  private claudeAvailable: boolean | null = null;
+
+  constructor(cg: CodeGraph) {
+    this.cg = cg;
+    this.projectRoot = cg.getProjectRoot();
+  }
+
+  /**
+   * Build a compact symbol index string for Claude prompts
+   */
+  private buildSymbolIndex(): string {
+    if (this.symbolIndexCache) return this.symbolIndexCache;
+
+    const validKinds: NodeKind[] = ['function', 'method', 'class', 'interface', 'component', 'route', 'enum', 'type_alias'];
+    const byFile = new Map<string, string[]>();
+
+    for (const kind of validKinds) {
+      for (const node of this.cg.getNodesByKind(kind)) {
+        const symbols = byFile.get(node.filePath) || [];
+        symbols.push(`${node.kind}:${node.name}`);
+        byFile.set(node.filePath, symbols);
+      }
+    }
+
+    const lines: string[] = [];
+    for (const [file, symbols] of byFile) {
+      lines.push(`${file}: ${symbols.join(', ')}`);
+    }
+
+    this.symbolIndexCache = lines.join('\n');
+    return this.symbolIndexCache;
+  }
+
+  /**
+   * Ask Claude CLI to interpret a natural language question into relevant symbol names
+   */
+  private async askClaude(question: string): Promise<string[] | null> {
+    // Check if claude is available (cache result)
+    if (this.claudeAvailable === false) return null;
+
+    const symbolIndex = this.buildSymbolIndex();
+
+    const prompt = `You are analyzing a codebase to help a developer understand it visually. Given the question and symbol index below, identify the 8-12 most relevant symbols that would help answer the question.
+
+IMPORTANT: Return ONLY a JSON array of symbol names. No explanation, no markdown, no code fences. Just the array.
+Example: ["requireAuth", "LoginPage", "getSession", "UserService"]
+
+Question: "${question}"
+
+Symbol index (format: file: kind:name, ...):
+${symbolIndex}`;
+
+    return new Promise((resolve) => {
+      const timeout = setTimeout(() => {
+        resolve(null);
+      }, 30000);
+
+      execFile('claude', ['-p', prompt, '--output-format', 'text'], {
+        timeout: 30000,
+        maxBuffer: 1024 * 1024,
+      }, (err, stdout) => {
+        clearTimeout(timeout);
+
+        if (err) {
+          this.claudeAvailable = false;
+          resolve(null);
+          return;
+        }
+
+        this.claudeAvailable = true;
+
+        // Parse the JSON array from Claude's response
+        try {
+          const text = stdout.trim();
+          // Try to extract JSON array from response (Claude might wrap it)
+          const jsonMatch = text.match(/\[[\s\S]*\]/);
+          if (jsonMatch) {
+            const names = JSON.parse(jsonMatch[0]) as string[];
+            if (Array.isArray(names) && names.length > 0) {
+              resolve(names.map(String));
+              return;
+            }
+          }
+        } catch {
+          // Parse failed
+        }
+
+        resolve(null);
+      });
+    });
+  }
+
+  /**
+   * Start the visualizer server
+   */
+  async start(options: VisualizerOptions = {}): Promise<{ port: number; url: string }> {
+    const host = options.host || '127.0.0.1';
+    const port = options.port || 0;
+
+    this.server = http.createServer((req, res) => {
+      this.handleRequest(req, res).catch((err) => {
+        console.error('[Visualizer] Request error:', err);
+        res.writeHead(500, { 'Content-Type': 'application/json' });
+        res.end(JSON.stringify({ error: 'Internal server error' }));
+      });
+    });
+
+    return new Promise((resolve, reject) => {
+      this.server!.listen(port, host, () => {
+        const addr = this.server!.address();
+        if (!addr || typeof addr === 'string') {
+          reject(new Error('Failed to get server address'));
+          return;
+        }
+        const serverUrl = `http://${host}:${addr.port}`;
+        resolve({ port: addr.port, url: serverUrl });
+      });
+
+      this.server!.on('error', reject);
+    });
+  }
+
+  /**
+   * Stop the server
+   */
+  stop(): Promise<void> {
+    return new Promise((resolve) => {
+      if (this.server) {
+        this.server.close(() => resolve());
+      } else {
+        resolve();
+      }
+    });
+  }
+
+  private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
+    const parsedUrl = url.parse(req.url || '/', true);
+    const pathname = parsedUrl.pathname || '/';
+
+    // CORS headers for local development
+    res.setHeader('Access-Control-Allow-Origin', '*');
+    res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
+    res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
+
+    if (req.method === 'OPTIONS') {
+      res.writeHead(204);
+      res.end();
+      return;
+    }
+
+    // API routes
+    if (pathname.startsWith('/api/')) {
+      return this.handleAPI(pathname, parsedUrl.query as Record<string, string>, res);
+    }
+
+    // Static file serving
+    return this.serveStatic(pathname, res);
+  }
+
+  private async handleAPI(
+    pathname: string,
+    query: Record<string, string>,
+    res: http.ServerResponse
+  ): Promise<void> {
+    const json = (data: unknown, status = 200) => {
+      res.writeHead(status, { 'Content-Type': 'application/json' });
+      res.end(JSON.stringify(data));
+    };
+
+    try {
+      // GET /api/status
+      if (pathname === '/api/status') {
+        const stats = this.cg.getStats();
+        json({ stats, projectRoot: this.projectRoot, projectName: path.basename(this.projectRoot) });
+        return;
+      }
+
+      // GET /api/embeddings/status
+      if (pathname === '/api/embeddings/status') {
+        const config = this.cg.getConfig();
+        const embeddingStats = this.cg.getEmbeddingStats();
+        const isEnabled = config.enableEmbeddings === true;
+        const isInitialized = this.cg.isEmbeddingsInitialized();
+        const totalVectors = embeddingStats?.totalVectors ?? 0;
+        const stats = this.cg.getStats();
+        // Consider ready if we have vectors for at least half the eligible nodes
+        const eligibleNodes = stats.nodeCount - (stats.nodesByKind.file ?? 0) - (stats.nodesByKind.import ?? 0);
+        const isReady = isEnabled && totalVectors > 0 && totalVectors >= eligibleNodes * 0.5;
+        json({ isEnabled, isInitialized, isReady, totalVectors, eligibleNodes });
+        return;
+      }
+
+      // GET /api/embeddings/generate — SSE stream that enables, initializes, and generates embeddings
+      if (pathname === '/api/embeddings/generate') {
+        res.writeHead(200, {
+          'Content-Type': 'text/event-stream',
+          'Cache-Control': 'no-cache',
+          'Connection': 'keep-alive',
+        });
+
+        const send = (event: string, data: unknown) => {
+          res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
+        };
+
+        try {
+          // Step 1: Enable embeddings in config
+          send('status', { phase: 'config', message: 'Enabling embeddings...' });
+          const config = this.cg.getConfig();
+          if (!config.enableEmbeddings) {
+            this.cg.updateConfig({ enableEmbeddings: true });
+          }
+
+          // Step 2: Initialize embedding model (downloads on first use)
+          send('status', { phase: 'model', message: 'Loading embedding model (first time may download ~30MB)...' });
+          await this.cg.initializeEmbeddings();
+          send('status', { phase: 'model', message: 'Embedding model ready' });
+
+          // Step 3: Generate embeddings with progress
+          send('status', { phase: 'embedding', message: 'Generating embeddings...' });
+          const count = await this.cg.generateEmbeddings((progress) => {
+            send('progress', {
+              current: progress.current,
+              total: progress.total,
+              nodeName: progress.nodeName,
+              percent: progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0,
+            });
+          });
+
+          send('complete', { totalEmbedded: count, message: `Generated ${count} embeddings` });
+        } catch (err) {
+          const message = err instanceof Error ? err.message : String(err);
+          send('error', { message });
+        }
+
+        res.end();
+        return;
+      }
+
+      // GET /api/search?q=...&kind=...&limit=...
+      if (pathname === '/api/search') {
+        const q = query.q || '';
+        const kind = query.kind as NodeKind | undefined;
+        const limit = parseInt(query.limit || '30', 10);
+        if (!q) {
+          json({ results: [] });
+          return;
+        }
+        const results = this.cg.searchNodes(q, { kinds: kind ? [kind] : undefined, limit });
+        json({ results });
+        return;
+      }
+
+      // GET /api/explore?q=...&maxNodes=...
+      // Natural language question → semantic or keyword-based subgraph
+      if (pathname === '/api/explore') {
+        const q = query.q || '';
+        const maxNodes = parseInt(query.maxNodes || '30', 10);
+        if (!q) {
+          json({ nodes: [], edges: [], roots: [] });
+          return;
+        }
+
+        // Extract keywords and stems for relevance scoring (used by all paths)
+        const stopWords = new Set(['how', 'does', 'what', 'the', 'is', 'a', 'an', 'and', 'or', 'in', 'to', 'for', 'of', 'with', 'when', 'do', 'it', 'my', 'work', 'works', 'about']);
+        const keywords = q.toLowerCase()
+          .split(/\s+/)
+          .map(w => w.replace(/[^a-z0-9]/g, ''))
+          .filter(w => w.length >= 2 && !stopWords.has(w));
+
+        const stems = keywords.map(kw => kw.length > 5 ? kw.slice(0, Math.max(4, Math.ceil(kw.length * 0.5))) : kw);
+        const uniqueStems = [...new Set(stems)];
+
+        const isRelevant = (node: Node): boolean => {
+          const haystack = `${node.name} ${node.filePath} ${node.qualifiedName}`.toLowerCase();
+          return uniqueStems.some(stem => haystack.includes(stem));
+        };
+
+        // Step 1: Find seed nodes
+        const seedMap = new Map<string, Node>();
+        const validKinds: NodeKind[] = ['function', 'method', 'class', 'interface', 'component', 'route'];
+        let usedClaude = false;
+
+        // Try Claude CLI first for intelligent query interpretation
+        const claudeNames = await this.askClaude(q);
+        if (claudeNames && claudeNames.length > 0) {
+          usedClaude = true;
+          for (const name of claudeNames) {
+            const results = this.cg.searchNodes(name, { kinds: validKinds, limit: 3 });
+            for (const r of results) {
+              // Only add if the name is a close match
+              if (r.node.name.toLowerCase().includes(name.toLowerCase()) ||
+                  name.toLowerCase().includes(r.node.name.toLowerCase())) {
+                seedMap.set(r.node.id, r.node);
+              }
+            }
+          }
+        }
+
+        // Keyword fallback if Claude unavailable or returned nothing useful
+        if (seedMap.size < 3) {
+          for (const kw of keywords) {
+            const kwResults = this.cg.searchNodes(kw, { kinds: validKinds, limit: 10 });
+            for (const r of kwResults) {
+              seedMap.set(r.node.id, r.node);
+            }
+          }
+          const fullResults = this.cg.searchNodes(q, { kinds: validKinds, limit: 10 });
+          for (const r of fullResults) {
+            seedMap.set(r.node.id, r.node);
+          }
+        }
+
+        if (seedMap.size === 0) {
+          const broad = this.cg.searchNodes(q, { limit: 10 });
+          for (const r of broad) seedMap.set(r.node.id, r.node);
+        }
+
+        if (seedMap.size === 0) {
+          json({ nodes: [], edges: [], roots: [] });
+          return;
+        }
+
+        const rootIds = Array.from(seedMap.keys());
+        const nodeMap = new Map<string, Node>(seedMap);
+        const edgeList: Edge[] = [];
+        const edgeSet = new Set<string>();
+
+        const addEdge = (edge: Edge) => {
+          const ek = `${edge.source}-${edge.kind}-${edge.target}`;
+          if (!edgeSet.has(ek)) { edgeSet.add(ek); edgeList.push(edge); }
+        };
+
+        // Step 2: For each seed, get callers/callees (depth 1)
+        // Only keep neighbors that are relevant or connect to other seeds
+        // Fall back to top-3 non-relevant only if seed has NO relevant neighbors
+        for (const [seedId] of seedMap) {
+          if (nodeMap.size >= maxNodes) break;
+          const callers = this.cg.getCallers(seedId, 1);
+          const callees = this.cg.getCallees(seedId, 1);
+          const neighbors = [...callers, ...callees];
+
+          const relevant: typeof neighbors = [];
+          const irrelevant: typeof neighbors = [];
+
+          for (const item of neighbors) {
+            if (seedMap.has(item.node.id) || isRelevant(item.node)) {
+              relevant.push(item);
+            } else {
+              irrelevant.push(item);
+            }
+          }
+
+          // Always add relevant neighbors
+          for (const item of relevant) {
+            if (nodeMap.size >= maxNodes && !nodeMap.has(item.node.id)) continue;
+            nodeMap.set(item.node.id, item.node);
+            addEdge(item.edge);
+          }
+
+          // Seeds with no relevant neighbors stay isolated — user can
+          // right-click → expand to explore manually. No noise added.
+        }
+
+        // Step 3: Cross-connection pass — find edges between all result nodes
+        for (const [nodeId] of nodeMap) {
+          const callers = this.cg.getCallers(nodeId, 1);
+          const callees = this.cg.getCallees(nodeId, 1);
+          for (const item of [...callers, ...callees]) {
+            if (nodeMap.has(item.node.id)) {
+              addEdge(item.edge);
+            }
+          }
+        }
+
+        // Step 4: Filter edges and remove isolated non-root nodes
+        const finalEdges = edgeList.filter(e => nodeMap.has(e.source) && nodeMap.has(e.target));
+
+        const connectedIds = new Set<string>();
+        for (const e of finalEdges) {
+          connectedIds.add(e.source);
+          connectedIds.add(e.target);
+        }
+        for (const id of rootIds) connectedIds.add(id);
+
+        const finalNodes = Array.from(nodeMap.values()).filter(n => connectedIds.has(n.id));
+
+        json({ nodes: finalNodes, edges: finalEdges, roots: rootIds, usedClaude });
+        return;
+      }
+
+      // GET /api/overview?limit=...
+      if (pathname === '/api/overview') {
+        const limit = parseInt(query.limit || '50', 10);
+        // Get top-level exported classes, functions, components
+        const kinds: NodeKind[] = ['class', 'function', 'interface', 'component', 'enum', 'type_alias'];
+        const nodes: Node[] = [];
+        for (const kind of kinds) {
+          const kindNodes = this.cg.getNodesByKind(kind);
+          for (const n of kindNodes) {
+            if (n.isExported || n.kind === 'class' || n.kind === 'component') {
+              nodes.push(n);
+            }
+            if (nodes.length >= limit) break;
+          }
+          if (nodes.length >= limit) break;
+        }
+        json({ nodes });
+        return;
+      }
+
+      // GET /api/files
+      if (pathname === '/api/files') {
+        const files = this.cg.getFiles();
+        json({ files });
+        return;
+      }
+
+      // Routes with node ID: /api/node/<id>/...
+      const nodeMatch = pathname.match(/^\/api\/node\/([^/]+)(\/.*)?$/);
+      if (nodeMatch) {
+        const nodeId = decodeURIComponent(nodeMatch[1]!);
+        const sub = nodeMatch[2] || '';
+
+        // GET /api/node/<id>
+        if (!sub || sub === '/') {
+          const node = this.cg.getNode(nodeId);
+          if (!node) {
+            json({ error: 'Node not found' }, 404);
+            return;
+          }
+          const code = await this.cg.getCode(nodeId);
+          const ancestors = this.cg.getAncestors(nodeId);
+          json({ node, code, ancestors });
+          return;
+        }
+
+        // GET /api/node/<id>/callers?depth=...
+        if (sub === '/callers') {
+          const depth = parseInt(query.depth || '1', 10);
+          const items = this.cg.getCallers(nodeId, depth);
+          json({ items });
+          return;
+        }
+
+        // GET /api/node/<id>/callees?depth=...
+        if (sub === '/callees') {
+          const depth = parseInt(query.depth || '1', 10);
+          const items = this.cg.getCallees(nodeId, depth);
+          json({ items });
+          return;
+        }
+
+        // GET /api/node/<id>/children
+        if (sub === '/children') {
+          const children = this.cg.getChildren(nodeId);
+          json({ children });
+          return;
+        }
+
+        // GET /api/node/<id>/impact?depth=...
+        if (sub === '/impact') {
+          const depth = parseInt(query.depth || '2', 10);
+          const subgraph = this.cg.getImpactRadius(nodeId, depth);
+          json(serializeSubgraph(subgraph));
+          return;
+        }
+
+        // GET /api/node/<id>/callgraph?depth=...
+        if (sub === '/callgraph') {
+          const depth = parseInt(query.depth || '2', 10);
+          const subgraph = this.cg.getCallGraph(nodeId, depth);
+          json(serializeSubgraph(subgraph));
+          return;
+        }
+
+        // GET /api/node/<id>/context
+        if (sub === '/context') {
+          const context = this.cg.getContext(nodeId);
+          json({ context });
+          return;
+        }
+
+        json({ error: 'Unknown endpoint' }, 404);
+        return;
+      }
+
+      // GET /api/file-nodes?path=...
+      if (pathname === '/api/file-nodes') {
+        const filePath = query.path || '';
+        if (!filePath) {
+          json({ error: 'path parameter required' }, 400);
+          return;
+        }
+        const nodes = this.cg.getNodesInFile(filePath);
+        json({ nodes });
+        return;
+      }
+
+      json({ error: 'Unknown API endpoint' }, 404);
+    } catch (err) {
+      const message = err instanceof Error ? err.message : String(err);
+      json({ error: message }, 500);
+    }
+  }
+
+  private serveStatic(pathname: string, res: http.ServerResponse): void {
+    if (pathname === '/' || pathname === '/index.html') {
+      pathname = '/index.html';
+    }
+
+    // Resolve from the public directory next to this file
+    const publicDir = path.join(__dirname, 'public');
+    const filePath = path.join(publicDir, pathname);
+
+    // Security: prevent directory traversal
+    if (!filePath.startsWith(publicDir)) {
+      res.writeHead(403);
+      res.end('Forbidden');
+      return;
+    }
+
+    const ext = path.extname(filePath).toLowerCase();
+    const mimeTypes: Record<string, string> = {
+      '.html': 'text/html',
+      '.css': 'text/css',
+      '.js': 'application/javascript',
+      '.json': 'application/json',
+      '.png': 'image/png',
+      '.svg': 'image/svg+xml',
+      '.ico': 'image/x-icon',
+    };
+
+    try {
+      const content = fs.readFileSync(filePath);
+      res.writeHead(200, { 'Content-Type': mimeTypes[ext] || 'application/octet-stream' });
+      res.end(content);
+    } catch {
+      res.writeHead(404, { 'Content-Type': 'text/plain' });
+      res.end('Not found');
+    }
+  }
+}