Browse Source

Merge pull request #70 from colbymchenry/feat/improved-search-tokenization

feat: Improve search tokenization with camelCase splitting
Colby Mchenry 2 months ago
parent
commit
6647d1c827
5 changed files with 2622 additions and 8 deletions
  1. 1 1
      package.json
  2. 64 0
      src/bin/codegraph.ts
  3. 33 7
      src/search/query-utils.ts
  4. 1994 0
      src/visualizer/public/index.html
  5. 530 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]
  *

+ 33 - 7
src/search/query-utils.ts

@@ -8,9 +8,11 @@ import * as path from 'path';
 import { Node } from '../types';
 
 /**
- * Common stop words to filter from search queries
+ * Common stop words to filter from search queries.
+ * Includes generic English + code-specific noise words.
  */
 export const STOP_WORDS = new Set([
+  // English
   'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
   'of', 'with', 'by', 'from', 'is', 'it', 'that', 'this', 'are', 'was',
   'be', 'has', 'had', 'have', 'do', 'does', 'did', 'will', 'would', 'could',
@@ -18,17 +20,41 @@ export const STOP_WORDS = new Set([
   'every', 'how', 'what', 'where', 'when', 'who', 'which', 'why',
   'i', 'me', 'my', 'we', 'our', 'you', 'your', 'he', 'she', 'they',
   'find', 'show', 'get', 'list', 'give', 'tell',
+  'been', 'done', 'made', 'used', 'using', 'work', 'works', 'found',
+  'also', 'into', 'then', 'than', 'just', 'more', 'some', 'such',
+  'over', 'only', 'new', 'out', 'its', 'so', 'up', 'as', 'if',
+  // Code-specific noise
+  'code', 'file', 'files', 'function', 'method', 'class', 'type',
+  'build', 'run', 'test', 'fix', 'bug', 'call', 'called', 'set', 'add',
 ]);
 
 /**
- * Extract meaningful search terms from a natural language query
+ * Extract meaningful search terms from a natural language query.
+ * Splits camelCase, PascalCase, snake_case, SCREAMING_SNAKE, and dot.notation
+ * into individual tokens before filtering.
  */
 export function extractSearchTerms(query: string): string[] {
-  return query
-    .toLowerCase()
-    .replace(/[^\w\s-]/g, ' ')
-    .split(/\s+/)
-    .filter(term => term.length > 1 && !STOP_WORDS.has(term));
+  const tokens = new Set<string>();
+
+  // Split camelCase / PascalCase: "getUserName" → "get User Name"
+  const camelSplit = query
+    .replace(/([a-z])([A-Z])/g, '$1 $2')
+    .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
+
+  // Replace underscores and dots with spaces (snake_case, dot.notation)
+  const normalised = camelSplit.replace(/[_.]+/g, ' ');
+
+  // Split on any non-alphanumeric character
+  const words = normalised.split(/[^a-zA-Z0-9]+/).filter(Boolean);
+
+  for (const word of words) {
+    const lower = word.toLowerCase();
+    if (lower.length < 3) continue;
+    if (STOP_WORDS.has(lower)) continue;
+    tokens.add(lower);
+  }
+
+  return [...tokens];
 }
 
 /**

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

@@ -0,0 +1,1994 @@
+<!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: 460px;
+      --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="Search symbols... (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">Search for a starting point</div>
+          <div class="overlay-subtitle">Type a symbol name, pick it, and trace its call chain</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) => api.get(`explore?q=${encodeURIComponent(q)}`),
+      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': 'wrap',
+              'text-max-width': '180px',
+              '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
+    // ====================================================================
+    const kindLabels = {
+      'function': 'fn', 'method': 'method', 'class': 'class', 'interface': 'iface',
+      'component': 'comp', 'route': 'route', 'enum': 'enum', 'type_alias': 'type',
+      'struct': 'struct', 'trait': 'trait', 'variable': 'var', 'constant': 'const',
+      'property': 'prop', 'field': 'field', 'file': 'file', 'module': 'mod',
+    };
+
+    function addNodeToGraph(node) {
+      if (cy.getElementById(node.id).length > 0) return;
+      const color = kindColors[node.kind] || '#8b949e';
+      const shape = kindShapes[node.kind] || 'round-rectangle';
+      const kindLabel = kindLabels[node.kind] || node.kind;
+      cy.add({
+        group: 'nodes',
+        data: {
+          id: node.id,
+          nodeId: node.id,
+          label: `${node.name}\n${kindLabel}`,
+          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('Finding entry point...');
+
+      try {
+        const data = await api.explore(question);
+        if (data.nodes.length === 0) {
+          showToast('No relevant code found. Try searching for a specific symbol.');
+          hideOverlay(false);
+          return;
+        }
+        addSubgraph(data.nodes, data.edges);
+        runLayout();
+
+        // Center on entry point
+        if (data.entryPoint) {
+          const entryEle = cy.getElementById(data.entryPoint);
+          if (entryEle.length > 0) {
+            entryEle.select();
+            entryEle.addClass('highlighted');
+            setTimeout(() => {
+              cy.animate({ center: { eles: entryEle } }, { duration: 400 });
+              showNodeDetails(data.entryPoint);
+            }, 350);
+          }
+        }
+
+        const source = data.usedClaude ? ' (via Claude)' : '';
+        showToast(`Traced ${data.nodes.length} symbols from entry point${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) return;
+
+        // If dropdown is visible and has results, select the first one
+        const dropdown = document.getElementById('search-results-dropdown');
+        const firstItem = dropdown.querySelector('.search-result-item');
+        if (dropdown.classList.contains('visible') && firstItem) {
+          firstItem.click();
+        } else {
+          // Trigger a search and auto-select first result
+          (async () => {
+            try {
+              const data = await api.search(query, null, 10);
+              if (data.results.length > 0) {
+                selectSearchResult(data.results[0].node.id);
+              } else {
+                showToast('No symbols found. Try a different search.');
+              }
+            } catch (err) {
+              showToast('Search error: ' + err.message);
+            }
+          })();
+        }
+      }
+    }
+
+    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();
+      clearGraph();
+      hideOverlay();
+
+      showToast('Tracing call chain...');
+
+      try {
+        // Load the call graph from this entry point (depth 3 forward)
+        const data = await api.callgraph(nodeId, 3);
+        if (data.nodes.length === 0) {
+          // Fallback: just show the node
+          const nodeData = await api.node(nodeId);
+          if (nodeData.node) addNodeToGraph(nodeData.node);
+        } else {
+          addSubgraph(data.nodes, data.edges);
+        }
+
+        runLayout();
+
+        // Select and center on the entry point
+        const ele = cy.getElementById(nodeId);
+        if (ele.length > 0) {
+          ele.select();
+          ele.addClass('highlighted');
+          setTimeout(() => {
+            cy.animate({ center: { eles: ele } }, { duration: 300 });
+          }, 350);
+        }
+
+        showNodeDetails(nodeId);
+        showToast(`Traced ${data.nodes.length} symbols from entry point`);
+      } 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 = '';
+
+        // Quick actions
+        html += `<div class="detail-section" style="padding:8px 16px;">
+          <div style="display:flex;gap:6px;flex-wrap:wrap;">
+            <button class="toolbar-btn" onclick="expandCallees('${escapeAttr(node.id)}')" style="font-size:12px;">Expand Callees &rarr;</button>
+            <button class="toolbar-btn" onclick="expandCallers('${escapeAttr(node.id)}')" style="font-size:12px;">&larr; Expand Callers</button>
+            <button class="toolbar-btn" onclick="loadCallGraph('${escapeAttr(node.id)}')" style="font-size:12px;">Full Call Graph</button>
+            <button class="toolbar-btn" onclick="loadImpact('${escapeAttr(node.id)}')" style="font-size:12px;">Impact Analysis</button>
+          </div>
+        </div>`;
+
+        // 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>

+ 530 - 0
src/visualizer/server.ts

@@ -0,0 +1,530 @@
+/**
+ * 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 = `Given the question and codebase symbol index below, identify the single best ENTRY POINT symbol — the one function, component, or route handler where this flow starts.
+
+Rules:
+- Pick ONE symbol that is the starting point a user or request would hit first
+- Prefer page components, route handlers, or top-level functions
+- Do NOT pick utility functions, helpers, or middleware
+
+Return ONLY this JSON, nothing else:
+{"entry": "symbolName"}
+
+Question: "${question}"
+
+Symbol index:
+${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 Claude's response — try object format first, then array fallback
+        try {
+          const text = stdout.trim();
+          // Try to extract JSON object {"entry": ..., "flow": [...]}
+          const objMatch = text.match(/\{[\s\S]*\}/);
+          if (objMatch) {
+            const parsed = JSON.parse(objMatch[0]) as { entry?: string; flow?: string[] };
+            if (parsed.flow && Array.isArray(parsed.flow) && parsed.flow.length > 0) {
+              // Return flow with entry first
+              const names = parsed.flow.map(String);
+              if (parsed.entry && !names.includes(parsed.entry)) {
+                names.unshift(String(parsed.entry));
+              }
+              resolve(names);
+              return;
+            }
+          }
+          // Fallback: try JSON array
+          const arrMatch = text.match(/\[[\s\S]*\]/);
+          if (arrMatch) {
+            const names = JSON.parse(arrMatch[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=...
+      // Find the best entry point, then return its call graph
+      if (pathname === '/api/explore') {
+        const q = query.q || '';
+        if (!q) {
+          json({ nodes: [], edges: [], roots: [], entryPoint: null });
+          return;
+        }
+
+        let entryNodeId: string | null = null;
+        let usedClaude = false;
+        const validKinds: NodeKind[] = ['function', 'method', 'class', 'interface', 'component', 'route'];
+
+        // Try Claude CLI to find the best entry point
+        const claudeNames = await this.askClaude(q);
+        if (claudeNames && claudeNames.length > 0) {
+          usedClaude = true;
+          // Find the entry point in the graph
+          for (const name of claudeNames) {
+            if (entryNodeId) break;
+            const results = this.cg.searchNodes(name, { kinds: validKinds, limit: 3 });
+            for (const r of results) {
+              if (r.node.name.toLowerCase() === name.toLowerCase() ||
+                  r.node.name.toLowerCase().includes(name.toLowerCase()) ||
+                  name.toLowerCase().includes(r.node.name.toLowerCase())) {
+                entryNodeId = r.node.id;
+                break;
+              }
+            }
+          }
+        }
+
+        // Keyword fallback: find best match from query keywords
+        if (!entryNodeId) {
+          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', 'show', 'me']);
+          const keywords = q.toLowerCase().split(/\s+/)
+            .map(w => w.replace(/[^a-z0-9]/g, ''))
+            .filter(w => w.length >= 2 && !stopWords.has(w));
+
+          for (const kw of keywords) {
+            if (entryNodeId) break;
+            const results = this.cg.searchNodes(kw, { kinds: validKinds, limit: 5 });
+            if (results.length > 0) {
+              entryNodeId = results[0]!.node.id;
+            }
+          }
+        }
+
+        if (!entryNodeId) {
+          json({ nodes: [], edges: [], roots: [], entryPoint: null });
+          return;
+        }
+
+        // Get the call graph from this entry point (depth 3)
+        const callGraph = this.cg.getCallGraph(entryNodeId, 3);
+        const result = serializeSubgraph(callGraph);
+
+        json({
+          nodes: result.nodes,
+          edges: result.edges,
+          roots: [entryNodeId],
+          entryPoint: entryNodeId,
+          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');
+    }
+  }
+}