Commit53c9249unknown_key
feat: semantic AI code search — ask in plain English, find code by meaning
feat: semantic AI code search — ask in plain English, find code by meaning Adds Claude-powered semantic search to the repo search page at /:owner/:repo/search. When ANTHROPIC_API_KEY is set, a mode toggle appears (Semantic AI / Keyword) and semantic is the default. Claude reads a compact file index and ranks files by relevance to the natural-language query; the top 5 results show the most relevant 20-line snippet with AI reasoning and a confidence badge. Large repos (>200 files) are pre-filtered with git grep before the Claude API call. Results are cached in-memory for 5 minutes and rate-limited to 20 AI searches/user/hour with automatic keyword fallback when the quota is exceeded or the API key is absent. https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
2 files changed+922−6553c9249fc44537332cb12595f8bb551856e57e1a
2 changed files+922−65
Addedsrc/lib/claude-semantic-search.ts+538−0View fileUnifiedSplit
@@ -0,0 +1,538 @@
1/**
2 * Claude-API-based semantic code search.
3 *
4 * Unlike the embedding-based search in semantic-search.ts (which requires
5 * Voyage AI + a pre-built index), this approach uses Claude to understand
6 * the query and rank files in natural language — no vector DB, no prior
7 * indexing. Works immediately on any repo.
8 *
9 * Algorithm:
10 * 1. List all files via `git ls-tree -r --name-only HEAD` (up to 1000 files).
11 * 2. For large repos (>200 files): pre-filter with `git grep -l keyword`.
12 * 3. Build a compact index: for each candidate file, read its first 50 lines.
13 * 4. Send index + query to Claude (haiku — fast + cheap) and ask for a
14 * JSON-ranked result: [{file, reason, confidence}].
15 * 5. For the top 5 results, read the actual file and extract the most
16 * relevant 20-line snippet (heuristic: find the block that contains the
17 * most query-adjacent tokens).
18 * 6. Cache results in-memory for 5 minutes (Map<`${repoId}:${query}`, ...>).
19 *
20 * Rate limit: 20 semantic searches per user (by userId or IP) per hour.
21 * After the limit is hit, falls back to `git grep` keyword search.
22 *
23 * Fallback: if ANTHROPIC_API_KEY is not set, falls back to `git grep`.
24 */
25
26import { getAnthropic, MODEL_HAIKU, parseJsonResponse } from "./ai-client";
27import { config } from "./config";
28import { getRepoPath } from "../git/repository";
29
30// ---------------------------------------------------------------------------
31// Types
32// ---------------------------------------------------------------------------
33
34export interface SemanticSearchResult {
35 file: string;
36 reason: string; // AI's explanation of why this file is relevant
37 confidence: number; // 0–1
38 snippet: string; // up to 20 lines of the most relevant content
39 lineNumber?: number; // best-guess start line for the snippet
40}
41
42// ---------------------------------------------------------------------------
43// In-memory cache (5-minute TTL)
44// ---------------------------------------------------------------------------
45
46interface CacheEntry {
47 results: SemanticSearchResult[];
48 expiresAt: number;
49}
50
51const resultCache = new Map<string, CacheEntry>();
52const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
53
54function cacheGet(key: string): SemanticSearchResult[] | null {
55 const entry = resultCache.get(key);
56 if (!entry) return null;
57 if (Date.now() > entry.expiresAt) {
58 resultCache.delete(key);
59 return null;
60 }
61 return entry.results;
62}
63
64function cacheSet(key: string, results: SemanticSearchResult[]): void {
65 // Evict old entries to keep memory bounded.
66 if (resultCache.size > 500) {
67 const now = Date.now();
68 for (const [k, v] of resultCache) {
69 if (v.expiresAt < now) resultCache.delete(k);
70 }
71 }
72 resultCache.set(key, { results, expiresAt: Date.now() + CACHE_TTL_MS });
73}
74
75// ---------------------------------------------------------------------------
76// Per-user/IP rate limiting (20 semantic searches per hour)
77// ---------------------------------------------------------------------------
78
79interface BucketEntry {
80 count: number;
81 resetAt: number;
82}
83
84const semanticSearchBuckets = new Map<string, BucketEntry>();
85const SEMANTIC_RATE_WINDOW_MS = 60 * 60 * 1000; // 1 hour
86const SEMANTIC_RATE_MAX = 20;
87
88/** Returns true if the caller is within quota, false if exceeded. */
89export function checkSemanticRateLimit(key: string): boolean {
90 const now = Date.now();
91 let bucket = semanticSearchBuckets.get(key);
92 if (!bucket || bucket.resetAt < now) {
93 bucket = { count: 0, resetAt: now + SEMANTIC_RATE_WINDOW_MS };
94 semanticSearchBuckets.set(key, bucket);
95 }
96 bucket.count++;
97 return bucket.count <= SEMANTIC_RATE_MAX;
98}
99
100/** Returns remaining semantic searches in the current window (never negative). */
101export function semanticRateLimitRemaining(key: string): number {
102 const now = Date.now();
103 const bucket = semanticSearchBuckets.get(key);
104 if (!bucket || bucket.resetAt < now) return SEMANTIC_RATE_MAX;
105 return Math.max(0, SEMANTIC_RATE_MAX - bucket.count);
106}
107
108// ---------------------------------------------------------------------------
109// Skip-list: directories we never look inside
110// ---------------------------------------------------------------------------
111
112const SKIP_DIRS = new Set([
113 "node_modules",
114 ".git",
115 "dist",
116 "build",
117 "vendor",
118 ".next",
119 ".turbo",
120 "target",
121 "__pycache__",
122]);
123
124const SKIP_FILES = new Set([
125 "package-lock.json",
126 "yarn.lock",
127 "pnpm-lock.yaml",
128 "bun.lockb",
129 "bun.lock",
130 "poetry.lock",
131 "cargo.lock",
132 "composer.lock",
133 "gemfile.lock",
134]);
135
136function shouldSkipPath(path: string): boolean {
137 const parts = path.split("/");
138 // Skip if any directory segment is in the skip list.
139 for (const part of parts.slice(0, -1)) {
140 if (SKIP_DIRS.has(part.toLowerCase())) return true;
141 }
142 const basename = parts[parts.length - 1].toLowerCase();
143 if (SKIP_FILES.has(basename)) return true;
144 return false;
145}
146
147// ---------------------------------------------------------------------------
148// Git helpers
149// ---------------------------------------------------------------------------
150
151async function gitExec(
152 cmd: string[],
153 cwd: string
154): Promise<{ stdout: string; exitCode: number }> {
155 const proc = Bun.spawn(cmd, {
156 cwd,
157 env: process.env as Record<string, string>,
158 stdout: "pipe",
159 stderr: "pipe",
160 });
161 const stdout = await new Response(proc.stdout).text();
162 const exitCode = await proc.exited;
163 return { stdout, exitCode };
164}
165
166async function lsFiles(owner: string, repo: string, branch: string): Promise<string[]> {
167 const repoDir = getRepoPath(owner, repo);
168 const { stdout, exitCode } = await gitExec(
169 ["git", "ls-tree", "-r", "--name-only", branch],
170 repoDir
171 );
172 if (exitCode !== 0) return [];
173 return stdout
174 .trim()
175 .split("\n")
176 .filter(Boolean)
177 .filter((p) => !shouldSkipPath(p));
178}
179
180async function grepFiles(
181 owner: string,
182 repo: string,
183 branch: string,
184 keyword: string
185): Promise<string[]> {
186 const repoDir = getRepoPath(owner, repo);
187 const { stdout } = await gitExec(
188 ["git", "grep", "-l", "-i", keyword, branch, "--"],
189 repoDir
190 );
191 return stdout
192 .trim()
193 .split("\n")
194 .filter(Boolean)
195 .map((line) => {
196 // git grep -l output: "<ref>:<path>"
197 const idx = line.indexOf(":");
198 return idx >= 0 ? line.slice(idx + 1) : line;
199 })
200 .filter((p) => !shouldSkipPath(p));
201}
202
203async function readFileHead(
204 owner: string,
205 repo: string,
206 branch: string,
207 filePath: string,
208 maxLines = 50
209): Promise<string> {
210 const repoDir = getRepoPath(owner, repo);
211 const { stdout, exitCode } = await gitExec(
212 ["git", "show", `${branch}:${filePath}`],
213 repoDir
214 );
215 if (exitCode !== 0) return "";
216 return stdout.split("\n").slice(0, maxLines).join("\n");
217}
218
219async function readFileFull(
220 owner: string,
221 repo: string,
222 branch: string,
223 filePath: string
224): Promise<string> {
225 const repoDir = getRepoPath(owner, repo);
226 const { stdout, exitCode } = await gitExec(
227 ["git", "show", `${branch}:${filePath}`],
228 repoDir
229 );
230 if (exitCode !== 0) return "";
231 return stdout;
232}
233
234// ---------------------------------------------------------------------------
235// Snippet extraction
236// ---------------------------------------------------------------------------
237
238/**
239 * Extract the most relevant 20-line window from a file for a given query.
240 * Strategy: split query into lowercase tokens, score each line by how many
241 * tokens it contains, then find the 20-line window with the highest total
242 * score. Falls back to the first 20 lines when no tokens match anything.
243 */
244function extractSnippet(
245 content: string,
246 query: string,
247 windowSize = 20
248): { snippet: string; lineNumber: number } {
249 const lines = content.split("\n");
250 if (lines.length <= windowSize) {
251 return { snippet: content, lineNumber: 1 };
252 }
253
254 const tokens = query
255 .toLowerCase()
256 .split(/[^a-z0-9]+/)
257 .filter((t) => t.length >= 3);
258
259 if (tokens.length === 0) {
260 return { snippet: lines.slice(0, windowSize).join("\n"), lineNumber: 1 };
261 }
262
263 const lineScores = lines.map((line) => {
264 const lower = line.toLowerCase();
265 return tokens.reduce((acc, tok) => acc + (lower.includes(tok) ? 1 : 0), 0);
266 });
267
268 // Sliding window: find the windowSize-line slice with the highest score sum.
269 let windowScore = lineScores.slice(0, windowSize).reduce((a, b) => a + b, 0);
270 let bestStart = 0;
271 let bestScore = windowScore;
272
273 for (let i = 1; i + windowSize <= lines.length; i++) {
274 windowScore = windowScore - lineScores[i - 1] + lineScores[i + windowSize - 1];
275 if (windowScore > bestScore) {
276 bestScore = windowScore;
277 bestStart = i;
278 }
279 }
280
281 const snippet = lines.slice(bestStart, bestStart + windowSize).join("\n");
282 return { snippet, lineNumber: bestStart + 1 };
283}
284
285// ---------------------------------------------------------------------------
286// Claude API call
287// ---------------------------------------------------------------------------
288
289interface ClaudeRankedFile {
290 file: string;
291 reason: string;
292 confidence: number;
293}
294
295const MAX_INDEX_CHARS = 80_000; // cap total index size sent to Claude
296
297async function rankFilesWithClaude(
298 query: string,
299 fileIndex: Array<{ path: string; head: string }>
300): Promise<ClaudeRankedFile[]> {
301 const client = getAnthropic();
302
303 // Build a compact text index, respecting the char cap.
304 let indexText = "";
305 for (const { path, head } of fileIndex) {
306 const entry = `\n--- ${path} ---\n${head.slice(0, 600)}\n`;
307 if (indexText.length + entry.length > MAX_INDEX_CHARS) break;
308 indexText += entry;
309 }
310
311 const prompt = `You are a code navigation assistant. Given a codebase file index and a search query, identify the most relevant files.
312
313QUERY: "${query}"
314
315CODEBASE INDEX (filename + first 50 lines per file):
316${indexText}
317
318Return ONLY a JSON array (no prose, no markdown) of up to 8 objects, ranked by relevance, with this shape:
319[{"file": "<path>", "reason": "<1-sentence explanation>", "confidence": <0.0–1.0>}]
320
321Only include files with confidence > 0.2. Return [] if nothing is relevant.`;
322
323 try {
324 const message = await client.messages.create({
325 model: MODEL_HAIKU,
326 max_tokens: 1000,
327 messages: [{ role: "user", content: prompt }],
328 });
329
330 const text =
331 message.content.find((b) => b.type === "text")?.text ?? "";
332 const parsed = parseJsonResponse<ClaudeRankedFile[]>(text);
333 if (!Array.isArray(parsed)) return [];
334 return parsed.filter(
335 (r): r is ClaudeRankedFile =>
336 typeof r.file === "string" &&
337 typeof r.reason === "string" &&
338 typeof r.confidence === "number"
339 );
340 } catch (err) {
341 console.error("[claude-semantic-search] Claude API error:", err);
342 return [];
343 }
344}
345
346// ---------------------------------------------------------------------------
347// Keyword fallback (git grep)
348// ---------------------------------------------------------------------------
349
350async function keywordFallback(
351 owner: string,
352 repo: string,
353 branch: string,
354 query: string
355): Promise<SemanticSearchResult[]> {
356 const repoDir = getRepoPath(owner, repo);
357 const keyword = query.split(/\s+/)[0] || query;
358 const { stdout, exitCode } = await gitExec(
359 ["git", "grep", "-n", "-i", "-m", "5", keyword, branch, "--"],
360 repoDir
361 );
362 if (exitCode !== 0) return [];
363
364 // Group by file, take top 5 files.
365 const byFile = new Map<string, { lineNum: number; line: string }[]>();
366 for (const raw of stdout.trim().split("\n").filter(Boolean)) {
367 // Format: <ref>:<file>:<lineNum>:<content>
368 const refPrefix = branch + ":";
369 const stripped = raw.startsWith(refPrefix) ? raw.slice(refPrefix.length) : raw;
370 const firstColon = stripped.indexOf(":");
371 if (firstColon < 0) continue;
372 const file = stripped.slice(0, firstColon);
373 const rest = stripped.slice(firstColon + 1);
374 const secondColon = rest.indexOf(":");
375 if (secondColon < 0) continue;
376 const lineNum = parseInt(rest.slice(0, secondColon), 10);
377 const line = rest.slice(secondColon + 1);
378 if (!byFile.has(file)) byFile.set(file, []);
379 byFile.get(file)!.push({ lineNum, line });
380 }
381
382 const results: SemanticSearchResult[] = [];
383 let count = 0;
384 for (const [file, matches] of byFile) {
385 if (count++ >= 5) break;
386 const snippet = matches
387 .slice(0, 5)
388 .map((m) => `${m.lineNum}: ${m.line}`)
389 .join("\n");
390 results.push({
391 file,
392 reason: `Keyword match for "${keyword}"`,
393 confidence: 0.5,
394 snippet,
395 lineNumber: matches[0]?.lineNum,
396 });
397 }
398 return results;
399}
400
401// ---------------------------------------------------------------------------
402// Main export
403// ---------------------------------------------------------------------------
404
405export interface SemanticSearchOptions {
406 maxFiles?: number; // max candidate files to index (default 200)
407 branch?: string; // branch/ref to search (default "HEAD")
408 rateLimitKey?: string; // userId or IP for rate limiting
409}
410
411/**
412 * Semantic code search powered by Claude.
413 *
414 * @param owner - repo owner username
415 * @param repo - repo name
416 * @param repoId - DB repository id (used as cache key)
417 * @param query - natural-language search query
418 * @param opts - optional config
419 * @returns ranked list of matching files with AI reasoning
420 */
421export async function claudeSemanticSearch(
422 owner: string,
423 repo: string,
424 repoId: string,
425 query: string,
426 opts: SemanticSearchOptions = {}
427): Promise<{ results: SemanticSearchResult[]; mode: "semantic" | "keyword"; quotaExceeded: boolean }> {
428 const q = query.trim();
429 if (!q) return { results: [], mode: "keyword", quotaExceeded: false };
430
431 const branch = opts.branch || "HEAD";
432 const maxFiles = opts.maxFiles ?? 200;
433 const cacheKey = `${repoId}:${branch}:${q}`;
434
435 // Cache hit
436 const cached = cacheGet(cacheKey);
437 if (cached) {
438 return { results: cached, mode: "semantic", quotaExceeded: false };
439 }
440
441 // Rate limit check (per-user or per-IP)
442 let quotaExceeded = false;
443 if (opts.rateLimitKey) {
444 const within = checkSemanticRateLimit(opts.rateLimitKey);
445 if (!within) {
446 quotaExceeded = true;
447 }
448 }
449
450 // No Claude API key or quota exceeded → keyword fallback
451 if (!config.anthropicApiKey || quotaExceeded) {
452 const results = await keywordFallback(owner, repo, branch, q);
453 return { results, mode: "keyword", quotaExceeded };
454 }
455
456 // Step 1: list all files
457 let allFiles: string[];
458 try {
459 allFiles = await lsFiles(owner, repo, branch);
460 } catch {
461 return { results: await keywordFallback(owner, repo, branch, q), mode: "keyword", quotaExceeded: false };
462 }
463
464 // Step 2: pre-filter if repo is large
465 let candidates: string[];
466 if (allFiles.length > maxFiles) {
467 const keyword = q.split(/\s+/)[0] || q;
468 try {
469 const grepHits = new Set(await grepFiles(owner, repo, branch, keyword));
470 candidates = allFiles.filter((f) => grepHits.has(f));
471 // If grep found nothing, fall back to first maxFiles files.
472 if (candidates.length === 0) {
473 candidates = allFiles.slice(0, maxFiles);
474 } else if (candidates.length > maxFiles) {
475 candidates = candidates.slice(0, maxFiles);
476 }
477 } catch {
478 candidates = allFiles.slice(0, maxFiles);
479 }
480 } else {
481 candidates = allFiles;
482 }
483
484 // Step 3: build file index (filename + first 50 lines)
485 const fileIndex: Array<{ path: string; head: string }> = [];
486 const HEAD_CONCURRENCY = 20;
487 for (let i = 0; i < candidates.length; i += HEAD_CONCURRENCY) {
488 const batch = candidates.slice(i, i + HEAD_CONCURRENCY);
489 const heads = await Promise.all(
490 batch.map((p) => readFileHead(owner, repo, branch, p, 50))
491 );
492 for (let j = 0; j < batch.length; j++) {
493 if (heads[j]) fileIndex.push({ path: batch[j], head: heads[j] });
494 }
495 }
496
497 if (fileIndex.length === 0) {
498 return { results: await keywordFallback(owner, repo, branch, q), mode: "keyword", quotaExceeded: false };
499 }
500
501 // Step 4: ask Claude to rank files
502 const ranked = await rankFilesWithClaude(q, fileIndex);
503
504 if (ranked.length === 0) {
505 // Claude found nothing — try keyword fallback
506 const kwResults = await keywordFallback(owner, repo, branch, q);
507 return { results: kwResults, mode: "keyword", quotaExceeded: false };
508 }
509
510 // Step 5: for each top result, read full file and extract relevant snippet
511 const TOP_N = 5;
512 const top = ranked.slice(0, TOP_N);
513 const results: SemanticSearchResult[] = [];
514
515 await Promise.all(
516 top.map(async (r) => {
517 const content = await readFileFull(owner, repo, branch, r.file).catch(() => "");
518 if (!content) {
519 results.push({ ...r, snippet: "", lineNumber: 1 });
520 return;
521 }
522 const { snippet, lineNumber } = extractSnippet(content, q, 20);
523 results.push({
524 file: r.file,
525 reason: r.reason,
526 confidence: Math.min(1, Math.max(0, r.confidence)),
527 snippet,
528 lineNumber,
529 });
530 })
531 );
532
533 // Sort by confidence desc (Promise.all doesn't preserve order after push).
534 results.sort((a, b) => b.confidence - a.confidence);
535
536 cacheSet(cacheKey, results);
537 return { results, mode: "semantic", quotaExceeded: false };
538}
Modifiedsrc/routes/web.tsx+384−65View fileUnifiedSplit
@@ -58,6 +58,8 @@ import { computeHealthScore } from "../lib/health-score";
5858import type { HealthScore } from "../lib/health-score";
5959import { softAuth, requireAuth } from "../middleware/auth";
6060import type { AuthEnv } from "../middleware/auth";
61import { claudeSemanticSearch } from "../lib/claude-semantic-search";
62import { isAiAvailable } from "../lib/ai-client";
6163import { trackByName } from "../lib/traffic";
6264import { LandingPage, type LandingLiveFeed } from "../views/landing";
6365import { Landing2030Page } from "../views/landing-2030";
@@ -3534,9 +3536,9 @@ web.get("/:owner/:repo", async (c) => {
35343536 <span class="repo-ai-cta-icon" aria-hidden="true">{"⛁"}</span>
35353537 Migrations
35363538 </a>
3537 <a class="repo-ai-cta" href={`/${owner}/${repo}/semantic-search`} title="Embedding-backed code search">
3539 <a class="repo-ai-cta" href={`/${owner}/${repo}/search?mode=semantic`} title="AI-powered semantic code search — ask in plain English">
35383540 <span class="repo-ai-cta-icon" aria-hidden="true">{"✨"}</span>
3539 Semantic search
3541 AI Search
35403542 </a>
35413543 <a class="repo-ai-cta" href={`/${owner}/${repo}/releases/new`} title="Draft release notes with AI">
35423544 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F3F7}"}</span>
@@ -5282,24 +5284,212 @@ web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
52825284 );
52835285});
52845286
5285// Search
5287// Search — keyword + optional Claude semantic mode
52865288web.get("/:owner/:repo/search", async (c) => {
52875289 const { owner, repo } = c.req.param();
52885290 const user = c.get("user");
52895291 const q = c.req.query("q") || "";
5292 const aiAvailable = isAiAvailable();
5293 // Default to semantic when Claude is available and no explicit mode set.
5294 const modeParam = c.req.query("mode");
5295 const mode: "semantic" | "keyword" =
5296 modeParam === "keyword"
5297 ? "keyword"
5298 : modeParam === "semantic"
5299 ? "semantic"
5300 : aiAvailable
5301 ? "semantic"
5302 : "keyword";
5303 const isSemantic = mode === "semantic" && aiAvailable;
52905304
52915305 if (!(await repoExists(owner, repo))) return c.notFound();
52925306
52935307 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
5294 let results: Array<{ file: string; lineNum: number; line: string }> = [];
5308
5309 // Keyword results (always available as fallback / when in keyword mode)
5310 let keywordResults: Array<{ file: string; lineNum: number; line: string }> = [];
5311 // Semantic results (Claude-powered)
5312 type SemanticHit = import("../lib/claude-semantic-search").SemanticSearchResult;
5313 let semanticHits: SemanticHit[] = [];
5314 let semanticMode: "semantic" | "keyword" = "semantic";
5315 let quotaExceeded = false;
52955316
52965317 if (q.trim()) {
5297 results = await searchCode(owner, repo, defaultBranch, q.trim());
5318 if (isSemantic) {
5319 // Resolve repo DB id for caching
5320 const [ownerRow] = await db
5321 .select({ id: users.id })
5322 .from(users)
5323 .where(eq(users.username, owner))
5324 .limit(1);
5325 const [repoRow] = ownerRow
5326 ? await db
5327 .select({ id: repositories.id })
5328 .from(repositories)
5329 .where(
5330 and(
5331 eq(repositories.ownerId, ownerRow.id),
5332 eq(repositories.name, repo)
5333 )
5334 )
5335 .limit(1)
5336 : [];
5337
5338 const rateLimitKey = user
5339 ? `user:${user.id}`
5340 : (c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "anon");
5341
5342 const searchResult = await claudeSemanticSearch(
5343 owner,
5344 repo,
5345 repoRow?.id ?? `${owner}/${repo}`,
5346 q.trim(),
5347 { branch: defaultBranch, rateLimitKey }
5348 );
5349 semanticHits = searchResult.results;
5350 semanticMode = searchResult.mode;
5351 quotaExceeded = searchResult.quotaExceeded;
5352 } else {
5353 keywordResults = await searchCode(owner, repo, defaultBranch, q.trim());
5354 }
52985355 }
52995356
5357 const aiSearchCss = `
5358 /* ─── Semantic search mode toggle ─── */
5359 .search-mode-bar {
5360 display: flex;
5361 align-items: center;
5362 gap: 8px;
5363 margin-bottom: 14px;
5364 flex-wrap: wrap;
5365 }
5366 .search-mode-label {
5367 font-size: 12.5px;
5368 color: var(--text-muted);
5369 font-weight: 500;
5370 }
5371 .search-mode-toggle {
5372 display: inline-flex;
5373 background: var(--bg-elevated);
5374 border: 1px solid var(--border);
5375 border-radius: 9999px;
5376 padding: 3px;
5377 gap: 2px;
5378 }
5379 .search-mode-btn {
5380 display: inline-flex;
5381 align-items: center;
5382 gap: 5px;
5383 padding: 5px 12px;
5384 border-radius: 9999px;
5385 font-size: 12.5px;
5386 font-weight: 500;
5387 color: var(--text-muted);
5388 text-decoration: none;
5389 transition: color 120ms ease, background 120ms ease;
5390 cursor: pointer;
5391 border: none;
5392 background: none;
5393 font: inherit;
5394 }
5395 .search-mode-btn:hover { color: var(--text-strong); text-decoration: none; }
5396 .search-mode-btn.active {
5397 background: rgba(140,109,255,0.15);
5398 color: var(--text-strong);
5399 }
5400 .search-mode-ai-badge {
5401 display: inline-flex;
5402 align-items: center;
5403 gap: 4px;
5404 padding: 2px 8px;
5405 border-radius: 9999px;
5406 font-size: 11px;
5407 font-weight: 600;
5408 background: linear-gradient(135deg, rgba(140,109,255,0.20), rgba(54,197,214,0.15));
5409 color: #c4b5fd;
5410 border: 1px solid rgba(140,109,255,0.30);
5411 margin-left: 2px;
5412 }
5413 .search-quota-warn {
5414 font-size: 12.5px;
5415 color: var(--text-muted);
5416 padding: 6px 12px;
5417 background: rgba(255,200,0,0.06);
5418 border: 1px solid rgba(255,200,0,0.20);
5419 border-radius: 8px;
5420 }
5421
5422 /* ─── Semantic result cards ─── */
5423 .sem-results { display: flex; flex-direction: column; gap: 10px; }
5424 .sem-result {
5425 padding: 14px 16px;
5426 background: var(--bg-elevated);
5427 border: 1px solid var(--border);
5428 border-radius: 12px;
5429 transition: border-color 120ms ease;
5430 }
5431 .sem-result:hover { border-color: var(--border-strong); }
5432 .sem-result-head {
5433 display: flex;
5434 align-items: flex-start;
5435 justify-content: space-between;
5436 gap: 10px;
5437 flex-wrap: wrap;
5438 margin-bottom: 6px;
5439 }
5440 .sem-result-path {
5441 font-family: var(--font-mono);
5442 font-size: 13px;
5443 font-weight: 600;
5444 color: var(--text-strong);
5445 text-decoration: none;
5446 word-break: break-all;
5447 }
5448 .sem-result-path:hover { color: #c4b5fd; text-decoration: none; }
5449 .sem-result-conf {
5450 display: inline-flex;
5451 align-items: center;
5452 gap: 4px;
5453 padding: 2px 9px;
5454 border-radius: 9999px;
5455 font-size: 11.5px;
5456 font-weight: 600;
5457 white-space: nowrap;
5458 flex-shrink: 0;
5459 }
5460 .sem-conf-strong { background: rgba(52,211,153,0.12); color: #34d399; border: 1px solid rgba(52,211,153,0.30); }
5461 .sem-conf-possible { background: rgba(140,109,255,0.12); color: #b69dff; border: 1px solid rgba(140,109,255,0.30); }
5462 .sem-conf-weak { background: rgba(255,255,255,0.04); color: var(--text-muted); border: 1px solid var(--border); }
5463 .sem-result-reason {
5464 font-size: 12.5px;
5465 color: var(--text-muted);
5466 margin-bottom: 8px;
5467 line-height: 1.5;
5468 }
5469 .sem-result-snippet {
5470 margin: 0;
5471 padding: 10px 12px;
5472 background: rgba(0,0,0,0.22);
5473 border: 1px solid var(--border);
5474 border-radius: 8px;
5475 font-family: var(--font-mono);
5476 font-size: 12px;
5477 line-height: 1.55;
5478 color: var(--text);
5479 overflow-x: auto;
5480 white-space: pre-wrap;
5481 word-break: break-word;
5482 max-height: 240px;
5483 overflow-y: auto;
5484 }
5485 `;
5486
5487 const semanticBaseUrl = `/${owner}/${repo}/search?mode=semantic`;
5488 const keywordBaseUrl = `/${owner}/${repo}/search?mode=keyword`;
5489
53005490 return c.html(
53015491 <Layout title={`Search — ${owner}/${repo}`} user={user}>
5302 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
5492 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss + aiSearchCss }} />
53035493 <RepoHeader owner={owner} repo={repo} />
53045494 <RepoNav owner={owner} repo={repo} active="code" />
53055495 <div class="search-hero">
@@ -5307,7 +5497,11 @@ web.get("/:owner/:repo/search", async (c) => {
53075497 <strong>Search</strong> · {owner}/{repo}
53085498 </div>
53095499 <h1 class="search-title">
5310 Find any line in <span class="gradient-text">{repo}</span>
5500 {isSemantic ? (
5501 <>{"Ask "}<span class="gradient-text">{repo}</span>{" anything."}</>
5502 ) : (
5503 <>{"Find any line in "}<span class="gradient-text">{repo}</span></>
5504 )}
53115505 </h1>
53125506 <form
53135507 method="get"
@@ -5315,13 +5509,18 @@ web.get("/:owner/:repo/search", async (c) => {
53155509 class="search-form"
53165510 role="search"
53175511 >
5512 <input type="hidden" name="mode" value={mode} />
53185513 <div class="search-input-wrap">
53195514 <span class="search-input-icon" aria-hidden="true">{"⌕"}</span>
53205515 <input
53215516 type="text"
53225517 name="q"
53235518 value={q}
5324 placeholder="Search code on the default branch…"
5519 placeholder={
5520 isSemantic
5521 ? "Ask a question or describe what you're looking for…"
5522 : "Search code on the default branch…"
5523 }
53255524 aria-label="Search code"
53265525 class="search-input"
53275526 autocomplete="off"
@@ -5333,67 +5532,187 @@ web.get("/:owner/:repo/search", async (c) => {
53335532 </button>
53345533 </form>
53355534 </div>
5336 {q && (
5337 <div class="search-results-head">
5338 <span class="search-results-count">
5339 <strong>{results.length}</strong> result
5340 {results.length !== 1 ? "s" : ""}
5535
5536 {/* Mode toggle */}
5537 <div class="search-mode-bar">
5538 <span class="search-mode-label">Mode:</span>
5539 <div class="search-mode-toggle" role="group" aria-label="Search mode">
5540 {aiAvailable ? (
5541 <a
5542 href={q ? `${semanticBaseUrl}&q=${encodeURIComponent(q)}` : semanticBaseUrl}
5543 class={`search-mode-btn${isSemantic ? " active" : ""}`}
5544 aria-pressed={isSemantic ? "true" : "false"}
5545 >
5546 {"✨"} Semantic AI
5547 <span class="search-mode-ai-badge">AI-powered</span>
5548 </a>
5549 ) : null}
5550 <a
5551 href={q ? `${keywordBaseUrl}&q=${encodeURIComponent(q)}` : keywordBaseUrl}
5552 class={`search-mode-btn${!isSemantic ? " active" : ""}`}
5553 aria-pressed={!isSemantic ? "true" : "false"}
5554 >
5555 {"⌕"} Keyword
5556 </a>
5557 </div>
5558 {isSemantic && aiAvailable && (
5559 <span style="font-size:12px;color:var(--text-muted)">
5560 Ask in plain English — finds code by meaning, not just exact words
53415561 </span>
5342 <span class="search-results-query">
5343 for <span class="search-results-q">"{q}"</span> on{" "}
5344 <code>{defaultBranch}</code>
5562 )}
5563 {quotaExceeded && (
5564 <span class="search-quota-warn">
5565 Semantic search quota reached — showing keyword results
53455566 </span>
5346 </div>
5567 )}
5568 </div>
5569
5570 {/* ─── Semantic results ─── */}
5571 {isSemantic && q && (
5572 <>
5573 {semanticMode === "keyword" && !quotaExceeded && semanticHits.length > 0 && (
5574 <div class="search-quota-warn" style="margin-bottom:10px">
5575 Falling back to keyword search — Claude found no relevant files.
5576 </div>
5577 )}
5578 {semanticHits.length === 0 ? (
5579 <div class="search-empty">
5580 <p>
5581 No matches for <strong>"{q}"</strong>.{" "}
5582 Try a different phrasing or{" "}
5583 <a href={`${keywordBaseUrl}&q=${encodeURIComponent(q)}`}>
5584 switch to keyword search
5585 </a>.
5586 </p>
5587 </div>
5588 ) : (
5589 <>
5590 <div class="search-results-head">
5591 <span class="search-results-count">
5592 <strong>{semanticHits.length}</strong> result
5593 {semanticHits.length !== 1 ? "s" : ""}
5594 </span>
5595 <span class="search-results-query">
5596 {semanticMode === "semantic" ? "AI-ranked files for" : "keyword matches for"}{" "}
5597 <span class="search-results-q">"{q}"</span>
5598 </span>
5599 </div>
5600 <div class="sem-results">
5601 {semanticHits.map((h) => {
5602 const confClass =
5603 h.confidence > 0.7
5604 ? "sem-conf-strong"
5605 : h.confidence > 0.4
5606 ? "sem-conf-possible"
5607 : "sem-conf-weak";
5608 const confLabel =
5609 h.confidence > 0.7
5610 ? "Strong match"
5611 : h.confidence > 0.4
5612 ? "Possible match"
5613 : "Weak match";
5614 const href = `/${owner}/${repo}/blob/${defaultBranch}/${h.file}${h.lineNumber ? `#L${h.lineNumber}` : ""}`;
5615 const preview =
5616 h.snippet.length > 800
5617 ? h.snippet.slice(0, 800) + "\n…"
5618 : h.snippet;
5619 return (
5620 <div class="sem-result">
5621 <div class="sem-result-head">
5622 <a href={href} class="sem-result-path">
5623 {h.file}
5624 {h.lineNumber ? (
5625 <span style="color:var(--text-muted);font-weight:500">
5626 :{h.lineNumber}
5627 </span>
5628 ) : null}
5629 </a>
5630 <span class={`sem-result-conf ${confClass}`}>
5631 {confLabel}
5632 </span>
5633 </div>
5634 {h.reason && (
5635 <p class="sem-result-reason">{h.reason}</p>
5636 )}
5637 {preview && (
5638 <pre class="sem-result-snippet">{preview}</pre>
5639 )}
5640 </div>
5641 );
5642 })}
5643 </div>
5644 </>
5645 )}
5646 </>
53475647 )}
5348 {q && results.length === 0 ? (
5349 <div class="search-empty">
5350 <p>
5351 No matches for <strong>"{q}"</strong>. Try a shorter query or check
5352 you're on the right branch.
5353 </p>
5354 </div>
5355 ) : results.length > 0 ? (
5356 <div class="search-results">
5357 {(() => {
5358 // Group by file
5359 const grouped: Record<
5360 string,
5361 Array<{ lineNum: number; line: string }>
5362 > = {};
5363 for (const r of results) {
5364 if (!grouped[r.file]) grouped[r.file] = [];
5365 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
5366 }
5367 return Object.entries(grouped).map(([file, matches]) => (
5368 <div class="search-file diff-file">
5369 <div class="search-file-head diff-file-header">
5370 <a
5371 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
5372 class="search-file-link"
5373 >
5374 {file}
5648
5649 {/* ─── Keyword results ─── */}
5650 {!isSemantic && q && (
5651 <>
5652 <div class="search-results-head">
5653 <span class="search-results-count">
5654 <strong>{keywordResults.length}</strong> result
5655 {keywordResults.length !== 1 ? "s" : ""}
5656 </span>
5657 <span class="search-results-query">
5658 for <span class="search-results-q">"{q}"</span> on{" "}
5659 <code>{defaultBranch}</code>
5660 </span>
5661 </div>
5662 {keywordResults.length === 0 ? (
5663 <div class="search-empty">
5664 <p>
5665 No matches for <strong>"{q}"</strong>. Try a shorter query or{" "}
5666 {aiAvailable && (
5667 <a href={`${semanticBaseUrl}&q=${encodeURIComponent(q)}`}>
5668 try AI semantic search
53755669 </a>
5376 <span class="search-file-count">
5377 {matches.length} match{matches.length === 1 ? "" : "es"}
5378 </span>
5379 </div>
5380 <div class="blob-code">
5381 <table>
5382 <tbody>
5383 {matches.map((m) => (
5384 <tr>
5385 <td class="line-num">{m.lineNum}</td>
5386 <td class="line-content">{m.line}</td>
5387 </tr>
5388 ))}
5389 </tbody>
5390 </table>
5391 </div>
5392 </div>
5393 ));
5394 })()}
5395 </div>
5396 ) : null}
5670 )}.
5671 </p>
5672 </div>
5673 ) : (
5674 <div class="search-results">
5675 {(() => {
5676 const grouped: Record<
5677 string,
5678 Array<{ lineNum: number; line: string }>
5679 > = {};
5680 for (const r of keywordResults) {
5681 if (!grouped[r.file]) grouped[r.file] = [];
5682 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
5683 }
5684 return Object.entries(grouped).map(([file, matches]) => (
5685 <div class="search-file diff-file">
5686 <div class="search-file-head diff-file-header">
5687 <a
5688 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
5689 class="search-file-link"
5690 >
5691 {file}
5692 </a>
5693 <span class="search-file-count">
5694 {matches.length} match{matches.length === 1 ? "" : "es"}
5695 </span>
5696 </div>
5697 <div class="blob-code">
5698 <table>
5699 <tbody>
5700 {matches.map((m) => (
5701 <tr>
5702 <td class="line-num">{m.lineNum}</td>
5703 <td class="line-content">{m.line}</td>
5704 </tr>
5705 ))}
5706 </tbody>
5707 </table>
5708 </div>
5709 </div>
5710 ));
5711 })()}
5712 </div>
5713 )}
5714 </>
5715 )}
53975716 </Layout>
53985717 );
53995718});
54005719