# cqs serve — v1: Call Graph + Chunk Detail

**Status:** Proposed
**Author:** opus 4.7 + jjohnson
**Date:** 2026-04-21
**Supersedes:** v1 scope only — `docs/plans/graph-visualization.md` parked-spec retains the full 6-view design as the long-term target.

## Problem

cqs has built a substantial code-intelligence index (16k chunks, 70k call edges, 12k type edges, per-function test coverage, embedding clusters, dead-code detection, cross-project graphs). All of this is accessible through the CLI today (`callers`, `callees`, `impact`, `trace`, `dead`, `gather`, `onboard`). For agents, that's the right surface — JSON in, JSON out, programmatic.

For human exploration the CLI is the wrong shape. "What does this codebase look like?" doesn't terminate as a query — it's a back-and-forth of zoom-in, follow-the-call, see-what-touches-this. Each step in the CLI is a separate invocation with a separate JSON dump. The mental model never assembles.

The parked `graph-visualization.md` proposed a full 6-view web UI: call graph, impact, file/module hierarchy, dead code, embedding clusters, cross-project. Six views is an "architect everything before shipping anything" trap. v1 ships **one real view** with the architecture deliberately open for the others to slot in later.

## Goal

Ship `cqs serve [--port 8080] [--open] [--bind 127.0.0.1]` with a single interactive view: **the call graph for the current project, with a chunk-detail sidebar on click and a search box for highlighting nodes.**

**Success criterion:** running `cqs serve --open` on the cqs corpus produces a browser tab where:
1. A force-directed graph of all 16k chunks renders in <5s and pans/zooms at >5 FPS.
2. Hovering a node shows a tooltip with name + chunk_type + file:line.
3. Clicking a node opens a sidebar with source preview + callers + callees + tests-that-cover.
4. Typing in the search box highlights matching nodes by name.
5. Closing the browser tab kills the server cleanly.

**Implicit success:** the architecture lets a follow-on PR ship the "impact" view in <3 days of work, not a from-scratch redesign.

## Non-goals (v1)

Explicit, with the architectural commitment to NOT close them off:

- **Auth or multi-user** — bind to 127.0.0.1 only. No login, no session. Single human exploration.
- **Editing code from the UI** — read-only.
- **Real-time updates** — manual refresh. No WebSocket. (`cqs watch --serve` daemon stays separate; this is a different process.)
- **Embedding-cluster view** — defer; no UMAP/t-SNE precompute in v1.
- **Cross-project view** — single store only in v1; cross-project comes when the call graph view stabilizes.
- **Dead-code view** — same data is reachable through the call graph (zero-caller nodes get flagged via existing `cqs dead`); a dedicated view is a nice-to-have, not v1.
- **Impact / "what breaks" view** — deferred to v2 (probably 1-2 days of additional work after v1 lands).
- **File/module hierarchical view** — also v2.

## Decision: ship v1 = call graph + sidebar

The full parked spec lists six views. Picking call graph + chunk detail as the v1 unit:

1. **Call graph is the cqs-distinctive view.** Every other tool gives you file trees and grep. Few give you "this function's web of dependencies, navigable by click." Even just shipping the call graph is a unique surface.
2. **Chunk detail is forced regardless** — a graph node without click-through is a dead picture. Chunk detail isn't a separable view, it's the thing that makes any view useful.
3. **Search highlight is cheap to add and unblocks discovery.** Without search, the user can't find "the function I was trying to explore" in a 16k-node graph.
4. **Other views all reuse the same node + edge data.** Once the graph rendering, sidebar, search, and click handling are wired, adding "impact" is a new endpoint + a new view-mode toggle. Adding "dead code" is a CSS filter on the existing graph.

## Architecture

### Server

```
src/serve/
├── mod.rs           — public entrypoint (run_server) + axum router setup
├── handlers.rs      — per-endpoint async handlers
├── data.rs          — Node + Edge structs + builders that pull from Store
├── assets.rs        — embedded HTML + CSS + JS via include_str! / include_bytes!
└── tests.rs         — integration tests against an in-memory store

src/cli/commands/serve.rs   — CLI dispatch (mirrors cli/commands/index/build.rs pattern)
src/cli/args.rs             — add Serve { port, open, bind } variant to Commands enum
```

Built on `axum` (added as a new direct dep — it's small and the de-facto modern Rust web framework). `tower-http` is already transitive via sqlx, so we reuse it for `ServeDir` if we ever want to serve unembedded assets in dev mode.

axum is chosen over actix-web (heavier, separate runtime concerns) and warp (less ergonomic builder API). axum integrates cleanly with the existing `tokio` runtime cqs uses.

### Frontend

```
src/serve/assets/
├── index.html       — single-page app shell, embedded
├── app.css          — embedded styles
├── app.js           — main app logic (~300 LOC), embedded
├── views/
│   └── callgraph.js — call graph view module, embedded
└── vendor/
    ├── cytoscape.min.js          — Cytoscape.js bundle (~460 KB), embedded
    └── cytoscape-dagre.min.js    — dagre layout extension (~30 KB), embedded
```

All bundled into the binary via `include_bytes!` at compile time. Adds ~500 KB to the binary size; the cqs binary is already ~150 MB so this is noise.

Why embed instead of CDN: cqs is local-first, agent-and-air-gapped use cases work without internet. CDN versions also rot — embedding ensures `cqs serve` from a 2-year-old install still works.

The frontend is intentionally one HTML file + one CSS file + a small JS app loading view modules. No build step, no npm, no bundler. Edit `app.js` in your editor, recompile cqs (`cargo build`), reload browser. Iteration speed matters for the inevitable v2/v3 views.

### Data flow

```
Browser GET /                       → axum returns embedded index.html
Browser GET /api/graph              → axum handler queries Store → JSON nodes + edges
Browser GET /api/chunk/:id          → axum handler queries Store → JSON chunk detail
Browser GET /api/search?q=foo       → axum handler queries Store::search_by_name → JSON [chunk_id]
Browser GET /api/stats              → axum handler queries Store::stats → JSON

Browser-side:
  app.js bootstraps Cytoscape.js with #cy container
  fetch('/api/graph') → cy.add(elements)
  cytoscape.dagre.layout({...}) → render
  cy.on('mouseover', 'node', show_tooltip)
  cy.on('tap', 'node', fetch chunk detail → render sidebar)
  search input → debounced fetch('/api/search?q=...') → cy.elements().removeClass('highlighted'); cy.$('#'+id).addClass('highlighted')
```

## v1 endpoints

| Endpoint | Purpose | Response shape |
|---|---|---|
| `GET /` | App shell HTML | `text/html` |
| `GET /static/:asset` | CSS / JS / vendor (embedded) | per asset content-type |
| `GET /api/graph` | Full node + edge graph | `{nodes: [...], edges: [...]}` |
| `GET /api/graph?file=path` | Subgraph filtered to one file | same shape, fewer rows |
| `GET /api/graph?type=function` | Subgraph filtered to chunk type | same shape |
| `GET /api/chunk/:id` | Chunk detail | `{chunk: {...}, callers: [...], callees: [...], tests: [...], notes: [...]}` |
| `GET /api/search?q=foo&limit=20` | Search result chunk IDs | `{matches: [chunk_id]}` |
| `GET /api/stats` | Quick stats for header bar | `{chunks, edges, dead, untested, ...}` |

The `?file=` and `?type=` query params on `/api/graph` are the **extensibility seam** — future "file/module view" reuses the same endpoint with the same handler. The handler builds a SQL query with optional WHERE clauses; the v1 frontend just doesn't pass the filters.

## Data model

### Node JSON

```json
{
  "id": "chunk_id_string",
  "name": "search_filtered",
  "type": "function",
  "language": "rust",
  "file": "src/search/query.rs",
  "line_start": 245,
  "line_end": 312,
  "tested": true,
  "n_callers": 8,
  "n_callees": 3,
  "dead": false
}
```

`type` drives node color via CSS class (`.node-function`, `.node-struct`, etc.).
`n_callers` drives node size (sqrt scaling: more callers = bigger).
`tested` is a binary flag — tested nodes get a green ring.
`dead` triggers a red ring + opacity drop.

### Edge JSON

```json
{
  "source": "caller_chunk_id",
  "target": "callee_chunk_id",
  "kind": "call",
  "cross_project": false
}
```

`kind` ∈ `{call, type_dep}`. Call edges are arrows; type_dep edges are dashed lines (different visual weight). v1 only includes `call` edges to keep the graph readable; `type_dep` is wired in the data layer but rendered only when explicitly toggled (a checkbox in v1 if cheap, or v2 if not).

## UX flow

```
$ cqs serve --open
[INFO] cqs serve listening on http://127.0.0.1:8080
[INFO] opening browser...
```

Browser opens to:

```
┌───────────────────────────────────────────────────────────────────┐
│ cqs · src/Projects/cqs                              16,150 chunks │
│ [search: ___________________]  [□ tests] [□ dead] [□ untested]    │
├──────────────────────────────────────────────┬────────────────────┤
│                                              │  Sidebar           │
│         (interactive call graph)             │  (chunk detail     │
│                                              │   when clicked)    │
│                                              │                    │
│                                              │                    │
└──────────────────────────────────────────────┴────────────────────┘
```

- **Left:** Cytoscape canvas, full pan/zoom, dagre force-directed initial layout.
- **Right sidebar:** empty until first click. On click: name, signature, file:line, source preview (first 30 lines), callers list (clickable), callees list (clickable), tests-that-cover list, attached notes from `notes.toml`.
- **Search box (top-left):** type, debounced 200ms, /api/search → highlight matching nodes by adding `.highlighted` class. Matched nodes pulse with a CSS animation.
- **Filter checkboxes (top):** toggle visibility of node subsets. CSS-driven (use Cytoscape's selector API to add `.hidden` class).
- **Double-click node:** opens `$EDITOR +line file` if `$EDITOR` is set. Cosmetic feature; deferrable.

## Performance plan

The cqs corpus is 16k nodes + ~70k edges. That's at the edge of Cytoscape's comfort zone (the parked spec quoted 3-10 FPS WebGL at 11k nodes). Mitigations layered:

1. **Initial render uses dagre layout pre-computed server-side, sent as `layout: 'preset'` data** — Cytoscape skips its own layout pass (which is the slow part on big graphs). Node positions baked into the JSON. Server-side compute is a one-shot ~500ms; client just paints.
2. **WebGL renderer enabled** (Cytoscape v3.31+ has an experimental WebGL renderer). 3-10x faster than canvas for large graphs.
3. **`hideEdgesOnViewport: true`** — edges hidden during pan/zoom, redrawn on idle. Most of the FPS cost on large graphs is edge rendering.
4. **Lazy-load chunk detail.** `/api/chunk/:id` is only called on click; not all 16k chunks shipped on initial load.
5. **Edge type filtering in v1.** Default to call edges only (type_dep edges add ~12k more, doubling edge count). Toggle to enable.
6. **Initial filter to top N by degree.** If 16k turns out to be too slow even with the above, add `?max_nodes=5000` query param defaulting to "top 5k by caller-count." Spec'd as fallback, not built unless needed.

If all else fails: server-side cluster collapse — render at the file level by default (one node per file with chunk count), expand on click. That moves us into the v2 "file/module view" early — also fine.

## Extensibility seams

Each parked-spec view maps to a small additive change on the v1 architecture:

| Future view | What it adds | Architectural fit |
|---|---|---|
| **Impact** | new endpoint `/api/impact/:fn` returning BFS subgraph; new view-mode toggle in UI | endpoint slots in alongside `/api/graph`; UI adds a button; reuses node/edge rendering |
| **Dead code** | new endpoint `/api/dead` returning chunk IDs; CSS class `.dead-only` on container | already partially in v1 via `dead` field on node — toggle just filters |
| **File/module** | reuse `/api/graph?file=` (already in v1 endpoint surface); UI adds a tree sidebar | tree component is the only new piece |
| **Embedding clusters** | new endpoint `/api/embed/2d` returning UMAP coords; new layout `'preset'` from coords | new view module, no architecture change |
| **Cross-project** | extend `/api/graph` with `?store=` param; node color includes project tag | data model already has `cross_project` flag |

Each is roughly 1-3 days of focused work post-v1. None requires re-architecting v1.

## v1 implementation order

1. **Stub axum server + CLI command.** `cqs serve` boots, listens, returns "Hello cqs" on `/`. Verifies axum integration with the cqs CommandContext + Store. ~half day.
2. **`/api/graph` endpoint.** Pulls all chunks + call edges from Store, builds Node + Edge JSON, sends. No layout yet. Validate the data shape against expected counts. ~half day.
3. **Embedded HTML + Cytoscape boot.** index.html loads embedded cytoscape.js, fetches /api/graph, calls `cy.add(elements)` + dagre layout. Renders the graph. ~half day.
4. **Click handler + sidebar.** `cy.on('tap', 'node')` triggers fetch /api/chunk/:id, populates sidebar. ~half day.
5. **Search + filter UI.** Debounced search box, CSS-driven filter checkboxes. ~half day.
6. **Performance pass.** Server-side pre-computed layout, WebGL renderer, `hideEdgesOnViewport`. Profile against the cqs corpus. ~half day.
7. **Polish + integration tests.** Tests against an in-memory store; smoke test against real cqs corpus. ~half day.

## Validation

**Functional:**
- [ ] `cqs serve` boots without panic on a real cqs corpus.
- [ ] `cqs serve --open` opens the browser to the right URL.
- [ ] `cqs serve --bind 0.0.0.0` binds non-localhost (with a stderr warning about no auth).
- [ ] Browser shows >0 nodes within 5 seconds of page load.
- [ ] Hover shows tooltip with name + type + file:line.
- [ ] Click triggers sidebar populate.
- [ ] Search of "search_filtered" highlights at least one node.
- [ ] Search of nonexistent string highlights zero nodes (no crash).
- [ ] Sidebar caller/callee links navigate to the linked node (highlight + center viewport).

**Performance:**
- [ ] Initial render <5 seconds on the cqs corpus (16k nodes, 70k edges).
- [ ] Pan/zoom >5 FPS sustained.
- [ ] Search response <500ms.
- [ ] Click-to-sidebar-render <300ms.

**Architectural:**
- [ ] `cargo build --release` adds <2MB to binary size.
- [ ] No new runtime dependencies beyond axum + axum-static.
- [ ] No `unsafe` in any new code.
- [ ] Integration test: spin up `run_server()` against an in-memory store, hit `/api/graph` with reqwest, assert response shape.

## Open questions

1. **Embedded Cytoscape vs CDN.** Spec defaults to embedded (~500KB binary growth) per cqs's local-first ethos. Alternative: CDN with a `--cdn` flag for offline workflows. Defer to embed; revisit if binary size becomes a complaint.

2. **Server-side dagre layout vs client-side.** Spec defaults to server-side (Rust port of dagre? or shell out to a node binary? or precompute once via the existing `cqs index` pipeline?). Easier first cut: client-side dagre, accept the 500ms cost. Server-side layout is a v1.x optimization if needed.

3. **`$EDITOR` integration security.** Double-clicking a node spawns `$EDITOR +line file` server-side, which is dangerous if the binary is ever exposed beyond localhost. Gate behind explicit `--allow-editor` flag, default off. Or skip in v1 entirely.

4. **Search backend.** v1 uses `Store::search_by_name` which is the existing FTS5 prefix matcher. Good for "find this function by identifier." Doesn't help with semantic search (which is the cqs unique value). Future: add a `/api/semantic-search?q=...` endpoint that runs the full BGE pipeline and highlights top-K. Defer to v1.x.

5. **What to do when the project is huge.** The parked spec mentions "progressive disclosure: start with module-level, drill down" for >50k node corpora. Out of scope for v1; spec'd performance plan handles 16k. If someone runs `cqs serve` on the Linux kernel and the browser melts, we'll have a real signal to design against.

6. **Multi-tab / multi-process.** What happens if the user runs `cqs serve` twice on different ports? Both bind their own port, no shared state, both work. What if they run on the SAME port? Second invocation fails to bind, prints clear error. v1: trust `EADDRINUSE` to surface the problem; no shared-port coordination.

7. **Daemon coexistence.** `cqs watch --serve` (the existing daemon) listens on a unix socket. `cqs serve` (this) listens on TCP. They don't conflict. But both reading the same SQLite store is a question — SQLite supports concurrent readers, so it's fine. v1 just opens the store read-only; daemon stays in its lane.

## Future work (v2+)

These are all backed by the parked `graph-visualization.md` spec. Each maps cleanly onto the v1 architecture:

**v2 (next ~1 week):**
- **Impact view** — `/api/impact/:fn` + view-mode toggle. Highlight transitive callers in red, tests in yellow. The "what breaks if I change this" answer.
- **Dead code view** — leverages the `dead` flag already in v1 node JSON; just adds a "show only dead" toggle.

**v3 (later):**
- **File/module hierarchical view** — uses `/api/graph?file=` already in v1's endpoint design. New tree sidebar component.
- **Embedding cluster view** — requires UMAP/t-SNE precompute step in `cqs index`. Schema change for storing 2D coords.
- **Cross-project view** — extends `/api/graph` with `?store=`. Multi-color rendering.

**v4 (research-tier):**
- **Semantic search highlight** — run BGE on the search box query, RRF fusion, highlight top-K with score-weighted opacity.
- **Timeline view** — overlay git history (last-modified, churn heatmap).
- **Live updates** — WebSocket from `cqs watch` daemon, push graph deltas as files change.

**Out of scope indefinitely** (correctly excluded by the parked spec):
- Editing code from the UI.
- Multi-user / authentication / hosted deployment.
- Replacing the CLI surface — agents stay on the CLI; this serves humans.
