@gmickel/gno 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +194 -53
  2. package/assets/badges/license.svg +12 -0
  3. package/assets/badges/npm.svg +13 -0
  4. package/assets/badges/twitter.svg +22 -0
  5. package/assets/badges/website.svg +22 -0
  6. package/package.json +30 -1
  7. package/src/cli/commands/ask.ts +11 -186
  8. package/src/cli/commands/models/pull.ts +9 -4
  9. package/src/cli/commands/serve.ts +19 -0
  10. package/src/cli/program.ts +28 -0
  11. package/src/llm/registry.ts +3 -1
  12. package/src/pipeline/answer.ts +191 -0
  13. package/src/serve/CLAUDE.md +91 -0
  14. package/src/serve/bunfig.toml +2 -0
  15. package/src/serve/context.ts +181 -0
  16. package/src/serve/index.ts +7 -0
  17. package/src/serve/public/app.tsx +56 -0
  18. package/src/serve/public/components/ai-elements/code-block.tsx +176 -0
  19. package/src/serve/public/components/ai-elements/conversation.tsx +98 -0
  20. package/src/serve/public/components/ai-elements/inline-citation.tsx +285 -0
  21. package/src/serve/public/components/ai-elements/loader.tsx +96 -0
  22. package/src/serve/public/components/ai-elements/message.tsx +443 -0
  23. package/src/serve/public/components/ai-elements/prompt-input.tsx +1421 -0
  24. package/src/serve/public/components/ai-elements/sources.tsx +75 -0
  25. package/src/serve/public/components/ai-elements/suggestion.tsx +51 -0
  26. package/src/serve/public/components/preset-selector.tsx +403 -0
  27. package/src/serve/public/components/ui/badge.tsx +46 -0
  28. package/src/serve/public/components/ui/button-group.tsx +82 -0
  29. package/src/serve/public/components/ui/button.tsx +62 -0
  30. package/src/serve/public/components/ui/card.tsx +92 -0
  31. package/src/serve/public/components/ui/carousel.tsx +244 -0
  32. package/src/serve/public/components/ui/collapsible.tsx +31 -0
  33. package/src/serve/public/components/ui/command.tsx +181 -0
  34. package/src/serve/public/components/ui/dialog.tsx +141 -0
  35. package/src/serve/public/components/ui/dropdown-menu.tsx +255 -0
  36. package/src/serve/public/components/ui/hover-card.tsx +42 -0
  37. package/src/serve/public/components/ui/input-group.tsx +167 -0
  38. package/src/serve/public/components/ui/input.tsx +21 -0
  39. package/src/serve/public/components/ui/progress.tsx +28 -0
  40. package/src/serve/public/components/ui/scroll-area.tsx +56 -0
  41. package/src/serve/public/components/ui/select.tsx +188 -0
  42. package/src/serve/public/components/ui/separator.tsx +26 -0
  43. package/src/serve/public/components/ui/table.tsx +114 -0
  44. package/src/serve/public/components/ui/textarea.tsx +18 -0
  45. package/src/serve/public/components/ui/tooltip.tsx +59 -0
  46. package/src/serve/public/globals.css +226 -0
  47. package/src/serve/public/hooks/use-api.ts +112 -0
  48. package/src/serve/public/index.html +13 -0
  49. package/src/serve/public/pages/Ask.tsx +442 -0
  50. package/src/serve/public/pages/Browse.tsx +270 -0
  51. package/src/serve/public/pages/Dashboard.tsx +202 -0
  52. package/src/serve/public/pages/DocView.tsx +302 -0
  53. package/src/serve/public/pages/Search.tsx +335 -0
  54. package/src/serve/routes/api.ts +763 -0
  55. package/src/serve/server.ts +249 -0
  56. package/src/store/sqlite/adapter.ts +47 -0
  57. package/src/store/types.ts +10 -0
@@ -0,0 +1,302 @@
1
+ import {
2
+ ArrowLeft,
3
+ Calendar,
4
+ FileText,
5
+ FolderOpen,
6
+ HardDrive,
7
+ } from 'lucide-react';
8
+ import { useEffect, useState } from 'react';
9
+ import {
10
+ CodeBlock,
11
+ CodeBlockCopyButton,
12
+ } from '../components/ai-elements/code-block';
13
+ import { Loader } from '../components/ai-elements/loader';
14
+ import { Badge } from '../components/ui/badge';
15
+ import { Button } from '../components/ui/button';
16
+ import {
17
+ Card,
18
+ CardContent,
19
+ CardHeader,
20
+ CardTitle,
21
+ } from '../components/ui/card';
22
+ import { Separator } from '../components/ui/separator';
23
+ import { apiFetch } from '../hooks/use-api';
24
+
25
+ interface PageProps {
26
+ navigate: (to: string | number) => void;
27
+ }
28
+
29
+ interface DocData {
30
+ docid: string;
31
+ uri: string;
32
+ title: string | null;
33
+ content: string | null;
34
+ contentAvailable: boolean;
35
+ collection: string;
36
+ relPath: string;
37
+ source: {
38
+ mime: string;
39
+ ext: string;
40
+ modifiedAt?: string;
41
+ sizeBytes?: number;
42
+ };
43
+ }
44
+
45
+ function formatBytes(bytes: number): string {
46
+ if (bytes === 0) {
47
+ return '0 B';
48
+ }
49
+ const k = 1024;
50
+ const sizes = ['B', 'KB', 'MB', 'GB'];
51
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
52
+ return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;
53
+ }
54
+
55
+ function formatDate(dateStr: string): string {
56
+ try {
57
+ return new Date(dateStr).toLocaleDateString('en-US', {
58
+ year: 'numeric',
59
+ month: 'short',
60
+ day: 'numeric',
61
+ hour: '2-digit',
62
+ minute: '2-digit',
63
+ });
64
+ } catch {
65
+ return dateStr;
66
+ }
67
+ }
68
+
69
+ // Shiki BundledLanguage subset we actually use
70
+ type SupportedLanguage =
71
+ | 'markdown'
72
+ | 'javascript'
73
+ | 'jsx'
74
+ | 'typescript'
75
+ | 'tsx'
76
+ | 'python'
77
+ | 'rust'
78
+ | 'go'
79
+ | 'json'
80
+ | 'yaml'
81
+ | 'html'
82
+ | 'css'
83
+ | 'sql'
84
+ | 'bash'
85
+ | 'text';
86
+
87
+ function getLanguageFromExt(ext: string): SupportedLanguage {
88
+ const map: Record<string, SupportedLanguage> = {
89
+ '.md': 'markdown',
90
+ '.markdown': 'markdown',
91
+ '.js': 'javascript',
92
+ '.jsx': 'jsx',
93
+ '.ts': 'typescript',
94
+ '.tsx': 'tsx',
95
+ '.py': 'python',
96
+ '.rs': 'rust',
97
+ '.go': 'go',
98
+ '.json': 'json',
99
+ '.yaml': 'yaml',
100
+ '.yml': 'yaml',
101
+ '.html': 'html',
102
+ '.css': 'css',
103
+ '.sql': 'sql',
104
+ '.sh': 'bash',
105
+ '.bash': 'bash',
106
+ };
107
+ return map[ext.toLowerCase()] || 'text';
108
+ }
109
+
110
+ export default function DocView({ navigate }: PageProps) {
111
+ const [doc, setDoc] = useState<DocData | null>(null);
112
+ const [error, setError] = useState<string | null>(null);
113
+ const [loading, setLoading] = useState(true);
114
+
115
+ useEffect(() => {
116
+ const params = new URLSearchParams(window.location.search);
117
+ const uri = params.get('uri');
118
+
119
+ if (!uri) {
120
+ setError('No document URI provided');
121
+ setLoading(false);
122
+ return;
123
+ }
124
+
125
+ apiFetch<DocData>(`/api/doc?uri=${encodeURIComponent(uri)}`).then(
126
+ ({ data, error }) => {
127
+ setLoading(false);
128
+ if (error) {
129
+ setError(error);
130
+ } else if (data) {
131
+ setDoc(data);
132
+ }
133
+ }
134
+ );
135
+ }, []);
136
+
137
+ const isCodeFile =
138
+ doc?.source.ext &&
139
+ [
140
+ '.md',
141
+ '.js',
142
+ '.jsx',
143
+ '.ts',
144
+ '.tsx',
145
+ '.py',
146
+ '.rs',
147
+ '.go',
148
+ '.json',
149
+ '.yaml',
150
+ '.yml',
151
+ '.html',
152
+ '.css',
153
+ '.sql',
154
+ '.sh',
155
+ '.bash',
156
+ ].includes(doc.source.ext.toLowerCase());
157
+
158
+ return (
159
+ <div className="min-h-screen">
160
+ {/* Header */}
161
+ <header className="glass sticky top-0 z-10 border-border/50 border-b">
162
+ <div className="flex items-center gap-4 px-8 py-4">
163
+ <Button
164
+ className="gap-2"
165
+ onClick={() => navigate(-1)}
166
+ size="sm"
167
+ variant="ghost"
168
+ >
169
+ <ArrowLeft className="size-4" />
170
+ Back
171
+ </Button>
172
+ <Separator className="h-6" orientation="vertical" />
173
+ <div className="flex min-w-0 flex-1 items-center gap-2">
174
+ <FileText className="size-4 shrink-0 text-muted-foreground" />
175
+ <h1 className="truncate font-semibold text-xl">
176
+ {doc?.title || 'Document'}
177
+ </h1>
178
+ </div>
179
+ {doc?.source.ext && (
180
+ <Badge className="shrink-0 font-mono" variant="outline">
181
+ {doc.source.ext}
182
+ </Badge>
183
+ )}
184
+ </div>
185
+ </header>
186
+
187
+ <main className="mx-auto max-w-5xl p-8">
188
+ {/* Loading */}
189
+ {loading && (
190
+ <div className="flex flex-col items-center justify-center gap-4 py-20">
191
+ <Loader className="text-primary" size={32} />
192
+ <p className="text-muted-foreground">Loading document...</p>
193
+ </div>
194
+ )}
195
+
196
+ {/* Error */}
197
+ {error && (
198
+ <Card className="border-destructive bg-destructive/10">
199
+ <CardContent className="py-6 text-center">
200
+ <FileText className="mx-auto mb-4 size-12 text-destructive" />
201
+ <h3 className="mb-2 font-medium text-destructive text-lg">
202
+ Failed to load document
203
+ </h3>
204
+ <p className="text-muted-foreground">{error}</p>
205
+ </CardContent>
206
+ </Card>
207
+ )}
208
+
209
+ {/* Document */}
210
+ {doc && (
211
+ <div className="animate-fade-in space-y-6 opacity-0">
212
+ {/* Metadata */}
213
+ <Card>
214
+ <CardContent className="py-4">
215
+ <div className="grid gap-4 sm:grid-cols-3">
216
+ <div className="flex items-center gap-3">
217
+ <FolderOpen className="size-4 text-muted-foreground" />
218
+ <div>
219
+ <div className="text-muted-foreground text-xs">
220
+ Collection
221
+ </div>
222
+ <div className="font-medium">
223
+ {doc.collection || 'Unknown'}
224
+ </div>
225
+ </div>
226
+ </div>
227
+ {doc.source.sizeBytes !== undefined && (
228
+ <div className="flex items-center gap-3">
229
+ <HardDrive className="size-4 text-muted-foreground" />
230
+ <div>
231
+ <div className="text-muted-foreground text-xs">
232
+ Size
233
+ </div>
234
+ <div className="font-medium">
235
+ {formatBytes(doc.source.sizeBytes)}
236
+ </div>
237
+ </div>
238
+ </div>
239
+ )}
240
+ {doc.source.modifiedAt && (
241
+ <div className="flex items-center gap-3">
242
+ <Calendar className="size-4 text-muted-foreground" />
243
+ <div>
244
+ <div className="text-muted-foreground text-xs">
245
+ Modified
246
+ </div>
247
+ <div className="font-medium">
248
+ {formatDate(doc.source.modifiedAt)}
249
+ </div>
250
+ </div>
251
+ </div>
252
+ )}
253
+ </div>
254
+ <div className="mt-4 border-border/50 border-t pt-4">
255
+ <div className="mb-1 text-muted-foreground text-xs">Path</div>
256
+ <code className="break-all font-mono text-muted-foreground text-sm">
257
+ {doc.uri}
258
+ </code>
259
+ </div>
260
+ </CardContent>
261
+ </Card>
262
+
263
+ {/* Content */}
264
+ <Card>
265
+ <CardHeader className="pb-0">
266
+ <CardTitle className="flex items-center gap-2 text-lg">
267
+ <FileText className="size-4" />
268
+ Content
269
+ </CardTitle>
270
+ </CardHeader>
271
+ <CardContent className="pt-4">
272
+ {!doc.contentAvailable && (
273
+ <div className="rounded-lg border border-border/50 bg-muted/30 p-6 text-center">
274
+ <p className="text-muted-foreground">
275
+ Content not available (document may need re-indexing)
276
+ </p>
277
+ </div>
278
+ )}
279
+ {doc.contentAvailable && isCodeFile && (
280
+ <CodeBlock
281
+ code={doc.content ?? ''}
282
+ language={getLanguageFromExt(doc.source.ext)}
283
+ showLineNumbers
284
+ >
285
+ <CodeBlockCopyButton />
286
+ </CodeBlock>
287
+ )}
288
+ {doc.contentAvailable && !isCodeFile && (
289
+ <div className="rounded-lg border border-border/50 bg-muted/30 p-6">
290
+ <pre className="whitespace-pre-wrap font-mono text-sm leading-relaxed">
291
+ {doc.content}
292
+ </pre>
293
+ </div>
294
+ )}
295
+ </CardContent>
296
+ </Card>
297
+ </div>
298
+ )}
299
+ </main>
300
+ </div>
301
+ );
302
+ }
@@ -0,0 +1,335 @@
1
+ import { ArrowLeft, FileText, Search as SearchIcon, Zap } from 'lucide-react';
2
+ import { useEffect, useState } from 'react';
3
+ import { Loader } from '../components/ai-elements/loader';
4
+ import { Badge } from '../components/ui/badge';
5
+ import { Button } from '../components/ui/button';
6
+ import { ButtonGroup } from '../components/ui/button-group';
7
+ import { Card, CardContent } from '../components/ui/card';
8
+ import { Input } from '../components/ui/input';
9
+ import { apiFetch } from '../hooks/use-api';
10
+
11
+ /**
12
+ * Render snippet with <mark> tags as highlighted spans.
13
+ * Only allows mark tags - strips all other HTML for safety.
14
+ */
15
+ function renderSnippet(snippet: string): React.ReactNode {
16
+ const parts: React.ReactNode[] = [];
17
+ let remaining = snippet;
18
+ let key = 0;
19
+
20
+ while (remaining.length > 0) {
21
+ const markStart = remaining.indexOf('<mark>');
22
+ if (markStart === -1) {
23
+ parts.push(remaining);
24
+ break;
25
+ }
26
+
27
+ if (markStart > 0) {
28
+ parts.push(remaining.slice(0, markStart));
29
+ }
30
+
31
+ const markEnd = remaining.indexOf('</mark>', markStart);
32
+ if (markEnd === -1) {
33
+ parts.push(remaining.slice(markStart));
34
+ break;
35
+ }
36
+
37
+ const highlighted = remaining.slice(markStart + 6, markEnd);
38
+ parts.push(
39
+ <mark
40
+ className="rounded bg-primary/20 px-0.5 font-medium text-primary"
41
+ key={key++}
42
+ >
43
+ {highlighted}
44
+ </mark>
45
+ );
46
+ remaining = remaining.slice(markEnd + 7);
47
+ }
48
+
49
+ return parts;
50
+ }
51
+
52
+ interface PageProps {
53
+ navigate: (to: string | number) => void;
54
+ }
55
+
56
+ interface SearchResult {
57
+ docid: string;
58
+ uri: string;
59
+ title?: string;
60
+ snippet: string;
61
+ score: number;
62
+ snippetRange?: {
63
+ startLine: number;
64
+ endLine: number;
65
+ };
66
+ }
67
+
68
+ interface SearchResponse {
69
+ results: SearchResult[];
70
+ meta: {
71
+ query: string;
72
+ mode: string;
73
+ totalResults: number;
74
+ expanded?: boolean;
75
+ reranked?: boolean;
76
+ vectorsUsed?: boolean;
77
+ };
78
+ }
79
+
80
+ interface Capabilities {
81
+ bm25: boolean;
82
+ vector: boolean;
83
+ hybrid: boolean;
84
+ answer: boolean;
85
+ }
86
+
87
+ type SearchMode = 'bm25' | 'hybrid';
88
+
89
+ export default function Search({ navigate }: PageProps) {
90
+ const [query, setQuery] = useState('');
91
+ const [mode, setMode] = useState<SearchMode>('bm25');
92
+ const [results, setResults] = useState<SearchResult[]>([]);
93
+ const [meta, setMeta] = useState<SearchResponse['meta'] | null>(null);
94
+ const [loading, setLoading] = useState(false);
95
+ const [error, setError] = useState<string | null>(null);
96
+ const [searched, setSearched] = useState(false);
97
+ const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
98
+
99
+ // Fetch capabilities on mount
100
+ useEffect(() => {
101
+ async function fetchCapabilities() {
102
+ const { data } = await apiFetch<Capabilities>('/api/capabilities');
103
+ if (data) {
104
+ setCapabilities(data);
105
+ // Auto-select hybrid if available
106
+ if (data.hybrid) {
107
+ setMode('hybrid');
108
+ }
109
+ }
110
+ }
111
+ fetchCapabilities();
112
+ }, []);
113
+
114
+ const handleSearch = async (e: React.FormEvent) => {
115
+ e.preventDefault();
116
+ if (!query.trim()) {
117
+ return;
118
+ }
119
+
120
+ setLoading(true);
121
+ setError(null);
122
+ setSearched(true);
123
+
124
+ // Use /api/query for hybrid, /api/search for bm25
125
+ const endpoint = mode === 'hybrid' ? '/api/query' : '/api/search';
126
+
127
+ const { data, error } = await apiFetch<SearchResponse>(endpoint, {
128
+ method: 'POST',
129
+ body: JSON.stringify({ query, limit: 20 }),
130
+ });
131
+
132
+ setLoading(false);
133
+ if (error) {
134
+ setError(error);
135
+ setResults([]);
136
+ setMeta(null);
137
+ } else if (data) {
138
+ setResults(data.results);
139
+ setMeta(data.meta);
140
+ }
141
+ };
142
+
143
+ const hybridAvailable = capabilities?.hybrid ?? false;
144
+
145
+ return (
146
+ <div className="min-h-screen">
147
+ {/* Header */}
148
+ <header className="glass sticky top-0 z-10 border-border/50 border-b">
149
+ <div className="flex items-center gap-4 px-8 py-4">
150
+ <Button
151
+ className="gap-2"
152
+ onClick={() => navigate(-1)}
153
+ size="sm"
154
+ variant="ghost"
155
+ >
156
+ <ArrowLeft className="size-4" />
157
+ Back
158
+ </Button>
159
+ <h1 className="font-semibold text-xl">Search</h1>
160
+ </div>
161
+ </header>
162
+
163
+ <main className="mx-auto max-w-4xl p-8">
164
+ {/* Search Form */}
165
+ <form className="mb-8" onSubmit={handleSearch}>
166
+ <div className="relative">
167
+ <SearchIcon className="absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
168
+ <Input
169
+ className="border-border bg-card py-6 pr-4 pl-12 text-lg focus:border-primary"
170
+ onChange={(e) => setQuery(e.target.value)}
171
+ placeholder="Search your documents..."
172
+ type="text"
173
+ value={query}
174
+ />
175
+ <Button
176
+ className="absolute top-1/2 right-2 -translate-y-1/2"
177
+ disabled={loading || !query.trim()}
178
+ size="sm"
179
+ type="submit"
180
+ >
181
+ {loading ? <Loader size={16} /> : 'Search'}
182
+ </Button>
183
+ </div>
184
+
185
+ {/* Mode selector */}
186
+ <div className="mt-4 flex items-center gap-4">
187
+ <span className="text-muted-foreground text-sm">Mode:</span>
188
+ <ButtonGroup>
189
+ <Button
190
+ className={
191
+ mode === 'bm25'
192
+ ? 'bg-primary text-primary-foreground hover:bg-primary/90'
193
+ : ''
194
+ }
195
+ onClick={() => setMode('bm25')}
196
+ size="sm"
197
+ type="button"
198
+ variant={mode === 'bm25' ? 'default' : 'outline'}
199
+ >
200
+ BM25
201
+ </Button>
202
+ <Button
203
+ className={
204
+ mode === 'hybrid'
205
+ ? 'bg-primary text-primary-foreground hover:bg-primary/90'
206
+ : ''
207
+ }
208
+ disabled={!hybridAvailable}
209
+ onClick={() => setMode('hybrid')}
210
+ size="sm"
211
+ title={
212
+ hybridAvailable
213
+ ? 'Hybrid search with vector + reranking'
214
+ : 'Hybrid search not available (no embedding model)'
215
+ }
216
+ type="button"
217
+ variant={mode === 'hybrid' ? 'default' : 'outline'}
218
+ >
219
+ <Zap className="mr-1 size-3" />
220
+ Hybrid
221
+ </Button>
222
+ </ButtonGroup>
223
+ <span className="text-muted-foreground/70 text-xs">
224
+ {mode === 'bm25'
225
+ ? 'Keyword-based full-text search'
226
+ : 'BM25 + vector + query expansion + reranking'}
227
+ </span>
228
+ </div>
229
+ </form>
230
+
231
+ {/* Error */}
232
+ {error && (
233
+ <Card className="mb-6 border-destructive bg-destructive/10">
234
+ <CardContent className="py-4 text-destructive">{error}</CardContent>
235
+ </Card>
236
+ )}
237
+
238
+ {/* Loading */}
239
+ {loading && (
240
+ <div className="flex flex-col items-center justify-center gap-4 py-20">
241
+ <Loader className="text-primary" size={32} />
242
+ <p className="text-muted-foreground">Searching...</p>
243
+ </div>
244
+ )}
245
+
246
+ {/* Empty state */}
247
+ {!loading && searched && results.length === 0 && !error && (
248
+ <div className="py-20 text-center">
249
+ <FileText className="mx-auto mb-4 size-12 text-muted-foreground" />
250
+ <h3 className="mb-2 font-medium text-lg">No results found</h3>
251
+ <p className="text-muted-foreground">
252
+ Try adjusting your search terms
253
+ </p>
254
+ </div>
255
+ )}
256
+
257
+ {/* Results */}
258
+ {!loading && results.length > 0 && (
259
+ <div className="space-y-4">
260
+ <div className="mb-6 flex items-center justify-between">
261
+ <p className="text-muted-foreground text-sm">
262
+ {results.length} result{results.length !== 1 ? 's' : ''}
263
+ </p>
264
+ {meta && (
265
+ <div className="flex items-center gap-2">
266
+ {meta.vectorsUsed && (
267
+ <Badge
268
+ className="font-mono text-[10px]"
269
+ variant="secondary"
270
+ >
271
+ vectors
272
+ </Badge>
273
+ )}
274
+ {meta.expanded && (
275
+ <Badge
276
+ className="font-mono text-[10px]"
277
+ variant="secondary"
278
+ >
279
+ expanded
280
+ </Badge>
281
+ )}
282
+ {meta.reranked && (
283
+ <Badge
284
+ className="font-mono text-[10px]"
285
+ variant="secondary"
286
+ >
287
+ reranked
288
+ </Badge>
289
+ )}
290
+ </div>
291
+ )}
292
+ </div>
293
+ {results.map((r, i) => (
294
+ <Card
295
+ className="group animate-fade-in cursor-pointer opacity-0 transition-all hover:border-primary/50 hover:bg-card/80"
296
+ key={`${r.docid}-${i}`}
297
+ onClick={() =>
298
+ navigate(`/doc?uri=${encodeURIComponent(r.uri)}`)
299
+ }
300
+ style={{ animationDelay: `${i * 0.05}s` }}
301
+ >
302
+ <CardContent className="py-4">
303
+ <div className="mb-2 flex items-start justify-between gap-4">
304
+ <h3 className="font-medium text-primary underline-offset-2 group-hover:underline">
305
+ {r.title || r.uri.split('/').pop()}
306
+ </h3>
307
+ <Badge
308
+ className="shrink-0 font-mono text-xs"
309
+ variant="secondary"
310
+ >
311
+ {(r.score * 100).toFixed(0)}%
312
+ </Badge>
313
+ </div>
314
+ <p className="line-clamp-3 text-muted-foreground text-sm leading-relaxed">
315
+ {renderSnippet(r.snippet)}
316
+ </p>
317
+ <div className="mt-2 flex items-center gap-2">
318
+ <p className="truncate font-mono text-muted-foreground/60 text-xs">
319
+ {r.uri}
320
+ </p>
321
+ {r.snippetRange && (
322
+ <span className="shrink-0 font-mono text-[10px] text-muted-foreground/40">
323
+ L{r.snippetRange.startLine}-{r.snippetRange.endLine}
324
+ </span>
325
+ )}
326
+ </div>
327
+ </CardContent>
328
+ </Card>
329
+ ))}
330
+ </div>
331
+ )}
332
+ </main>
333
+ </div>
334
+ );
335
+ }