@gmickel/gno 0.16.0 → 0.18.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 (43) hide show
  1. package/README.md +55 -2
  2. package/package.json +4 -1
  3. package/src/cli/commands/ask.ts +13 -0
  4. package/src/cli/commands/models/use.ts +1 -0
  5. package/src/cli/commands/query.ts +3 -2
  6. package/src/cli/pager.ts +1 -1
  7. package/src/cli/program.ts +107 -0
  8. package/src/config/types.ts +2 -0
  9. package/src/core/links.ts +92 -20
  10. package/src/ingestion/sync.ts +267 -23
  11. package/src/ingestion/types.ts +2 -0
  12. package/src/ingestion/walker.ts +2 -1
  13. package/src/llm/nodeLlamaCpp/generation.ts +3 -1
  14. package/src/llm/registry.ts +1 -0
  15. package/src/llm/types.ts +2 -0
  16. package/src/mcp/tools/index.ts +34 -1
  17. package/src/mcp/tools/query.ts +26 -2
  18. package/src/mcp/tools/search.ts +10 -0
  19. package/src/mcp/tools/vsearch.ts +10 -0
  20. package/src/pipeline/answer.ts +324 -7
  21. package/src/pipeline/expansion.ts +282 -11
  22. package/src/pipeline/explain.ts +93 -5
  23. package/src/pipeline/hybrid.ts +273 -70
  24. package/src/pipeline/intent.ts +152 -0
  25. package/src/pipeline/query-modes.ts +125 -0
  26. package/src/pipeline/rerank.ts +109 -51
  27. package/src/pipeline/search.ts +58 -4
  28. package/src/pipeline/temporal.ts +257 -0
  29. package/src/pipeline/types.ts +67 -0
  30. package/src/pipeline/vsearch.ts +121 -10
  31. package/src/serve/public/app.tsx +1 -3
  32. package/src/serve/public/globals.built.css +2 -2
  33. package/src/serve/public/lib/retrieval-filters.ts +174 -0
  34. package/src/serve/public/pages/Ask.tsx +378 -109
  35. package/src/serve/public/pages/Browse.tsx +71 -5
  36. package/src/serve/public/pages/DocView.tsx +2 -21
  37. package/src/serve/public/pages/Search.tsx +561 -120
  38. package/src/serve/routes/api.ts +247 -2
  39. package/src/store/migrations/006-document-metadata.ts +104 -0
  40. package/src/store/migrations/007-document-date-fields.ts +24 -0
  41. package/src/store/migrations/index.ts +3 -1
  42. package/src/store/sqlite/adapter.ts +218 -5
  43. package/src/store/types.ts +46 -0
@@ -1,4 +1,11 @@
1
- import { ArrowLeft, FileText, Search as SearchIcon, XIcon } from "lucide-react";
1
+ import {
2
+ ArrowLeft,
3
+ ChevronDown,
4
+ FileText,
5
+ Search as SearchIcon,
6
+ SlidersHorizontal,
7
+ XIcon,
8
+ } from "lucide-react";
2
9
  import { useCallback, useEffect, useMemo, useState } from "react";
3
10
 
4
11
  import { Loader } from "../components/ai-elements/loader";
@@ -10,9 +17,28 @@ import {
10
17
  import { Badge } from "../components/ui/badge";
11
18
  import { Button } from "../components/ui/button";
12
19
  import { Card, CardContent } from "../components/ui/card";
20
+ import {
21
+ Collapsible,
22
+ CollapsibleContent,
23
+ CollapsibleTrigger,
24
+ } from "../components/ui/collapsible";
13
25
  import { Input } from "../components/ui/input";
26
+ import {
27
+ Select,
28
+ SelectContent,
29
+ SelectItem,
30
+ SelectTrigger,
31
+ SelectValue,
32
+ } from "../components/ui/select";
14
33
  import { apiFetch } from "../hooks/use-api";
15
34
  import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
35
+ import {
36
+ applyFiltersToUrl,
37
+ parseFiltersFromSearch,
38
+ type QueryModeEntry,
39
+ type QueryModeType,
40
+ type TagMode,
41
+ } from "../lib/retrieval-filters";
16
42
  import { cn } from "../lib/utils";
17
43
 
18
44
  /**
@@ -56,49 +82,6 @@ function renderSnippet(snippet: string): React.ReactNode {
56
82
  return parts;
57
83
  }
58
84
 
59
- /**
60
- * Tag grammar validation (matches src/core/tags.ts).
61
- * Validates that a tag follows the grammar for filtering.
62
- */
63
- const TAG_SEGMENT_REGEX = /^[\p{Ll}\p{Lo}\p{N}][\p{Ll}\p{Lo}\p{N}\-.]*$/u;
64
-
65
- function normalizeTag(tag: string): string {
66
- return tag.trim().normalize("NFC").toLowerCase();
67
- }
68
-
69
- function isValidTag(tag: string): boolean {
70
- if (tag.length === 0) return false;
71
- if (tag.startsWith("/") || tag.endsWith("/")) return false;
72
- const segments = tag.split("/");
73
- for (const segment of segments) {
74
- if (segment.length === 0) return false;
75
- if (!TAG_SEGMENT_REGEX.test(segment)) return false;
76
- }
77
- return true;
78
- }
79
-
80
- /** Parse and validate tags from URL search params */
81
- function parseTagsFromUrl(): string[] {
82
- const params = new URLSearchParams(window.location.search);
83
- const tagsAny = params.get("tagsAny");
84
- if (!tagsAny) return [];
85
- return tagsAny
86
- .split(",")
87
- .map((t) => normalizeTag(t))
88
- .filter((t) => t.length > 0 && isValidTag(t));
89
- }
90
-
91
- /** Update URL with tag filters */
92
- function updateUrlWithTags(tags: string[]): void {
93
- const url = new URL(window.location.href);
94
- if (tags.length > 0) {
95
- url.searchParams.set("tagsAny", tags.join(","));
96
- } else {
97
- url.searchParams.delete("tagsAny");
98
- }
99
- window.history.replaceState({}, "", url.toString());
100
- }
101
-
102
85
  interface PageProps {
103
86
  navigate: (to: string | number) => void;
104
87
  }
@@ -124,6 +107,11 @@ interface SearchResponse {
124
107
  expanded?: boolean;
125
108
  reranked?: boolean;
126
109
  vectorsUsed?: boolean;
110
+ queryModes?: {
111
+ term: number;
112
+ intent: number;
113
+ hyde: boolean;
114
+ };
127
115
  };
128
116
  }
129
117
 
@@ -134,9 +122,24 @@ interface Capabilities {
134
122
  answer: boolean;
135
123
  }
136
124
 
125
+ interface Collection {
126
+ name: string;
127
+ }
128
+
137
129
  const THOROUGHNESS_ORDER: Thoroughness[] = ["fast", "balanced", "thorough"];
138
130
 
131
+ const QUERY_MODE_LABEL: Record<QueryModeType, string> = {
132
+ term: "Term",
133
+ intent: "Intent",
134
+ hyde: "HyDE",
135
+ };
136
+
139
137
  export default function Search({ navigate }: PageProps) {
138
+ const initialFilters = useMemo(
139
+ () => parseFiltersFromSearch(window.location.search),
140
+ []
141
+ );
142
+
140
143
  const [query, setQuery] = useState("");
141
144
  const [thoroughness, setThoroughness] = useState<Thoroughness>("balanced");
142
145
  const [results, setResults] = useState<SearchResult[]>([]);
@@ -145,18 +148,76 @@ export default function Search({ navigate }: PageProps) {
145
148
  const [error, setError] = useState<string | null>(null);
146
149
  const [searched, setSearched] = useState(false);
147
150
  const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
151
+ const [collections, setCollections] = useState<Collection[]>([]);
152
+
153
+ const [showAdvanced, setShowAdvanced] = useState(
154
+ Boolean(
155
+ initialFilters.collection ||
156
+ initialFilters.intent ||
157
+ initialFilters.candidateLimit ||
158
+ initialFilters.since ||
159
+ initialFilters.until ||
160
+ initialFilters.category ||
161
+ initialFilters.author ||
162
+ initialFilters.queryModes.length > 0
163
+ )
164
+ );
148
165
 
149
- // Tag filter state - initialized from URL
150
- const [activeTags, setActiveTags] = useState<string[]>(() =>
151
- parseTagsFromUrl()
166
+ const [activeTags, setActiveTags] = useState<string[]>(initialFilters.tags);
167
+ const [tagMode, setTagMode] = useState<TagMode>(initialFilters.tagMode);
168
+ const [selectedCollection, setSelectedCollection] = useState(
169
+ initialFilters.collection
152
170
  );
171
+ const [intent, setIntent] = useState(initialFilters.intent);
172
+ const [candidateLimit, setCandidateLimit] = useState(
173
+ initialFilters.candidateLimit
174
+ );
175
+ const [since, setSince] = useState(initialFilters.since);
176
+ const [until, setUntil] = useState(initialFilters.until);
177
+ const [category, setCategory] = useState(initialFilters.category);
178
+ const [author, setAuthor] = useState(initialFilters.author);
179
+ const [queryModes, setQueryModes] = useState<QueryModeEntry[]>(
180
+ initialFilters.queryModes
181
+ );
182
+ const [queryModeDraft, setQueryModeDraft] = useState<QueryModeType>("term");
183
+ const [queryModeText, setQueryModeText] = useState("");
184
+ const [queryModeError, setQueryModeError] = useState<string | null>(null);
185
+ const [showMobileTags, setShowMobileTags] = useState(false);
186
+
187
+ const hybridAvailable = capabilities?.hybrid ?? false;
188
+ const forceHybridForModes =
189
+ thoroughness === "fast" &&
190
+ (queryModes.length > 0 || intent.trim().length > 0);
153
191
 
154
- // Sync URL when tags change
192
+ // Sync URL as filter state changes.
155
193
  useEffect(() => {
156
- updateUrlWithTags(activeTags);
157
- }, [activeTags]);
194
+ const url = new URL(window.location.href);
195
+ applyFiltersToUrl(url, {
196
+ collection: selectedCollection,
197
+ intent,
198
+ candidateLimit,
199
+ since,
200
+ until,
201
+ category,
202
+ author,
203
+ tagMode,
204
+ tags: activeTags,
205
+ queryModes,
206
+ });
207
+ window.history.replaceState({}, "", url.toString());
208
+ }, [
209
+ activeTags,
210
+ author,
211
+ candidateLimit,
212
+ category,
213
+ intent,
214
+ queryModes,
215
+ selectedCollection,
216
+ since,
217
+ tagMode,
218
+ until,
219
+ ]);
158
220
 
159
- // Tag filter handlers
160
221
  const handleTagSelect = useCallback((tag: string) => {
161
222
  setActiveTags((prev) => (prev.includes(tag) ? prev : [...prev, tag]));
162
223
  }, []);
@@ -165,28 +226,29 @@ export default function Search({ navigate }: PageProps) {
165
226
  setActiveTags((prev) => prev.filter((t) => t !== tag));
166
227
  }, []);
167
228
 
168
- // Fetch capabilities on mount
169
229
  useEffect(() => {
170
- async function fetchCapabilities() {
171
- const { data } = await apiFetch<Capabilities>("/api/capabilities");
172
- if (data) {
173
- setCapabilities(data);
174
- // Auto-select balanced if hybrid available, otherwise fast (BM25)
175
- if (data.hybrid) {
176
- setThoroughness("balanced");
177
- } else {
178
- setThoroughness("fast");
179
- }
230
+ async function bootstrap(): Promise<void> {
231
+ const [capabilitiesResult, collectionsResult] = await Promise.all([
232
+ apiFetch<Capabilities>("/api/capabilities"),
233
+ apiFetch<Collection[]>("/api/collections"),
234
+ ]);
235
+
236
+ if (capabilitiesResult.data) {
237
+ const caps = capabilitiesResult.data;
238
+ setCapabilities(caps);
239
+ setThoroughness(caps.hybrid ? "balanced" : "fast");
240
+ }
241
+
242
+ if (collectionsResult.data) {
243
+ setCollections(collectionsResult.data);
180
244
  }
181
245
  }
182
- void fetchCapabilities();
246
+
247
+ void bootstrap();
183
248
  }, []);
184
249
 
185
- // Cycle thoroughness with 't' key (only cycles to supported modes)
186
- const hybridAvailable = capabilities?.hybrid ?? false;
187
250
  const cycleThoroughness = useCallback(() => {
188
251
  setThoroughness((current) => {
189
- // If hybrid not available, stay on fast
190
252
  if (!hybridAvailable) return "fast";
191
253
  const currentIdx = THOROUGHNESS_ORDER.indexOf(current);
192
254
  const nextIdx = (currentIdx + 1) % THOROUGHNESS_ORDER.length;
@@ -198,9 +260,30 @@ export default function Search({ navigate }: PageProps) {
198
260
  () => [{ key: "t", action: cycleThoroughness }],
199
261
  [cycleThoroughness]
200
262
  );
201
-
202
263
  useKeyboardShortcuts(shortcuts);
203
264
 
265
+ const handleAddQueryMode = useCallback(() => {
266
+ const text = queryModeText.trim();
267
+ if (!text) {
268
+ return;
269
+ }
270
+ if (
271
+ queryModeDraft === "hyde" &&
272
+ queryModes.some((queryMode) => queryMode.mode === "hyde")
273
+ ) {
274
+ setQueryModeError("Only one HyDE mode is allowed.");
275
+ return;
276
+ }
277
+ setQueryModes((prev) => [...prev, { mode: queryModeDraft, text }]);
278
+ setQueryModeText("");
279
+ setQueryModeError(null);
280
+ }, [queryModeDraft, queryModeText, queryModes]);
281
+
282
+ const handleRemoveQueryMode = useCallback((index: number) => {
283
+ setQueryModes((prev) => prev.filter((_, i) => i !== index));
284
+ setQueryModeError(null);
285
+ }, []);
286
+
204
287
  const handleSearch = useCallback(
205
288
  async (e?: React.FormEvent) => {
206
289
  e?.preventDefault();
@@ -212,30 +295,59 @@ export default function Search({ navigate }: PageProps) {
212
295
  setError(null);
213
296
  setSearched(true);
214
297
 
215
- // Fast uses BM25 (/api/search), balanced/thorough use hybrid (/api/query)
216
- const useBm25 = thoroughness === "fast";
298
+ const useBm25 =
299
+ thoroughness === "fast" &&
300
+ queryModes.length === 0 &&
301
+ intent.trim().length === 0;
217
302
  const endpoint = useBm25 ? "/api/search" : "/api/query";
303
+ const body: Record<string, unknown> = {
304
+ query,
305
+ limit: 20,
306
+ };
218
307
 
219
- // Build request body
220
- const body: Record<string, unknown> = { query, limit: 20 };
221
-
222
- // Add tag filters if present
308
+ if (selectedCollection) {
309
+ body.collection = selectedCollection;
310
+ }
311
+ if (intent.trim()) {
312
+ body.intent = intent.trim();
313
+ }
314
+ if (candidateLimit.trim()) {
315
+ body.candidateLimit = Number(candidateLimit);
316
+ }
317
+ if (since) {
318
+ body.since = since;
319
+ }
320
+ if (until) {
321
+ body.until = until;
322
+ }
323
+ if (category.trim()) {
324
+ body.category = category.trim();
325
+ }
326
+ if (author.trim()) {
327
+ body.author = author.trim();
328
+ }
223
329
  if (activeTags.length > 0) {
224
- body.tagsAny = activeTags.join(",");
330
+ if (tagMode === "all") {
331
+ body.tagsAll = activeTags.join(",");
332
+ } else {
333
+ body.tagsAny = activeTags.join(",");
334
+ }
225
335
  }
226
336
 
227
337
  if (!useBm25) {
228
- // Map thoroughness to noExpand/noRerank flags
229
- // balanced: with reranking, no expansion (~2-3s)
230
- // thorough: full pipeline (~5-8s)
231
- if (thoroughness === "balanced") {
338
+ if (thoroughness === "fast") {
339
+ body.noExpand = true;
340
+ body.noRerank = true;
341
+ } else if (thoroughness === "balanced") {
232
342
  body.noExpand = true;
233
343
  body.noRerank = false;
234
344
  } else {
235
- // thorough
236
345
  body.noExpand = false;
237
346
  body.noRerank = false;
238
347
  }
348
+ if (queryModes.length > 0) {
349
+ body.queryModes = queryModes;
350
+ }
239
351
  }
240
352
 
241
353
  const { data, error: fetchError } = await apiFetch<SearchResponse>(
@@ -256,18 +368,41 @@ export default function Search({ navigate }: PageProps) {
256
368
  setMeta(data.meta);
257
369
  }
258
370
  },
259
- [query, thoroughness, activeTags]
371
+ [
372
+ activeTags,
373
+ author,
374
+ candidateLimit,
375
+ category,
376
+ intent,
377
+ query,
378
+ queryModes,
379
+ selectedCollection,
380
+ since,
381
+ tagMode,
382
+ thoroughness,
383
+ until,
384
+ ]
260
385
  );
261
386
 
262
- // Re-search when tags change (if we've already searched)
387
+ // Re-search when filters change after an initial search.
263
388
  useEffect(() => {
264
389
  if (searched && query.trim()) {
265
390
  void handleSearch();
266
391
  }
267
392
  // eslint-disable-next-line react-hooks/exhaustive-deps
268
- }, [activeTags]);
393
+ }, [
394
+ activeTags,
395
+ author,
396
+ candidateLimit,
397
+ category,
398
+ intent,
399
+ queryModes,
400
+ selectedCollection,
401
+ since,
402
+ tagMode,
403
+ until,
404
+ ]);
269
405
 
270
- // Description for current thoroughness
271
406
  const thoroughnessDesc =
272
407
  thoroughness === "fast"
273
408
  ? "Keyword search (BM25)"
@@ -275,9 +410,35 @@ export default function Search({ navigate }: PageProps) {
275
410
  ? "Hybrid + reranking"
276
411
  : "Full pipeline with expansion";
277
412
 
413
+ const activeFilterPills = [
414
+ selectedCollection ? `collection:${selectedCollection}` : null,
415
+ intent.trim() ? `intent:${intent.trim()}` : null,
416
+ candidateLimit.trim() ? `candidates:${candidateLimit.trim()}` : null,
417
+ since ? `since:${since}` : null,
418
+ until ? `until:${until}` : null,
419
+ category.trim() ? `category:${category.trim()}` : null,
420
+ author.trim() ? `author:${author.trim()}` : null,
421
+ queryModes.length > 0 ? `${queryModes.length} query mode(s)` : null,
422
+ ].filter((pill): pill is string => Boolean(pill));
423
+
424
+ const hasActiveFilters =
425
+ activeFilterPills.length > 0 || activeTags.length > 0;
426
+
427
+ const clearAdvancedFilters = () => {
428
+ setSelectedCollection("");
429
+ setIntent("");
430
+ setCandidateLimit("");
431
+ setSince("");
432
+ setUntil("");
433
+ setCategory("");
434
+ setAuthor("");
435
+ setQueryModes([]);
436
+ setQueryModeText("");
437
+ setQueryModeError(null);
438
+ };
439
+
278
440
  return (
279
441
  <div className="min-h-screen">
280
- {/* Header */}
281
442
  <header className="glass sticky top-0 z-10 border-border/50 border-b">
282
443
  <div className="flex items-center gap-4 px-8 py-4">
283
444
  <Button
@@ -293,9 +454,7 @@ export default function Search({ navigate }: PageProps) {
293
454
  </div>
294
455
  </header>
295
456
 
296
- {/* Main content with sidebar */}
297
457
  <div className="flex">
298
- {/* Sidebar - Tag Facets */}
299
458
  <aside className="sticky top-[65px] hidden h-[calc(100vh-65px)] w-64 shrink-0 overflow-y-auto border-border/30 border-r lg:block">
300
459
  <TagFacets
301
460
  activeTags={activeTags}
@@ -305,13 +464,10 @@ export default function Search({ navigate }: PageProps) {
305
464
  />
306
465
  </aside>
307
466
 
308
- {/* Main content */}
309
467
  <main className="min-w-0 flex-1 p-8">
310
468
  <div className="mx-auto max-w-3xl">
311
- {/* Search Form */}
312
- <form className="mb-6" onSubmit={handleSearch}>
469
+ <form className="mb-6 space-y-4" onSubmit={handleSearch}>
313
470
  <div className="group relative">
314
- {/* Gradient border effect on focus */}
315
471
  <div className="pointer-events-none absolute -inset-[1px] rounded-lg bg-gradient-to-r from-primary/50 via-primary to-primary/50 opacity-0 blur-sm transition-opacity duration-300 group-focus-within:opacity-100" />
316
472
  <div className="relative">
317
473
  <SearchIcon className="absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground transition-colors duration-200 group-focus-within:text-primary" />
@@ -334,8 +490,7 @@ export default function Search({ navigate }: PageProps) {
334
490
  </div>
335
491
  </div>
336
492
 
337
- {/* Thoroughness selector */}
338
- <div className="mt-4 flex flex-wrap items-center gap-4">
493
+ <div className="flex flex-wrap items-center gap-4">
339
494
  <ThoroughnessSelector
340
495
  disabled={
341
496
  loading || (!hybridAvailable && thoroughness !== "fast")
@@ -348,40 +503,317 @@ export default function Search({ navigate }: PageProps) {
348
503
  {thoroughnessDesc}
349
504
  </span>
350
505
 
506
+ {forceHybridForModes && (
507
+ <span className="text-amber-500/80 text-xs">
508
+ query modes active: using hybrid endpoint
509
+ </span>
510
+ )}
511
+
351
512
  {!hybridAvailable && thoroughness !== "fast" && (
352
513
  <span className="text-amber-500/70 text-xs">
353
514
  (vectors not available)
354
515
  </span>
355
516
  )}
356
517
  </div>
518
+
519
+ <Collapsible onOpenChange={setShowAdvanced} open={showAdvanced}>
520
+ <CollapsibleTrigger asChild>
521
+ <Button
522
+ className="gap-2"
523
+ size="sm"
524
+ type="button"
525
+ variant="outline"
526
+ >
527
+ <SlidersHorizontal className="size-4" />
528
+ Advanced Retrieval
529
+ <ChevronDown
530
+ className={cn(
531
+ "size-4 transition-transform",
532
+ showAdvanced && "rotate-180"
533
+ )}
534
+ />
535
+ </Button>
536
+ </CollapsibleTrigger>
537
+ <CollapsibleContent className="pt-3">
538
+ <Card className="border-border/50 bg-card/50">
539
+ <CardContent className="space-y-4 pt-4">
540
+ <div className="grid gap-3 md:grid-cols-2">
541
+ <div>
542
+ <p className="mb-1 text-muted-foreground text-xs">
543
+ Collection
544
+ </p>
545
+ <Select
546
+ onValueChange={(value) =>
547
+ setSelectedCollection(
548
+ value === "all" ? "" : value
549
+ )
550
+ }
551
+ value={selectedCollection || "all"}
552
+ >
553
+ <SelectTrigger className="w-full">
554
+ <SelectValue placeholder="All collections" />
555
+ </SelectTrigger>
556
+ <SelectContent>
557
+ <SelectItem value="all">
558
+ All collections
559
+ </SelectItem>
560
+ {collections.map((collection) => (
561
+ <SelectItem
562
+ key={collection.name}
563
+ value={collection.name}
564
+ >
565
+ {collection.name}
566
+ </SelectItem>
567
+ ))}
568
+ </SelectContent>
569
+ </Select>
570
+ </div>
571
+
572
+ <div>
573
+ <p className="mb-1 text-muted-foreground text-xs">
574
+ Author
575
+ </p>
576
+ <Input
577
+ onChange={(e) => setAuthor(e.target.value)}
578
+ placeholder="gordon"
579
+ value={author}
580
+ />
581
+ </div>
582
+
583
+ <div className="md:col-span-2">
584
+ <p className="mb-1 text-muted-foreground text-xs">
585
+ Intent
586
+ </p>
587
+ <Input
588
+ onChange={(e) => setIntent(e.target.value)}
589
+ placeholder="Disambiguate ambiguous queries without searching on this text"
590
+ value={intent}
591
+ />
592
+ </div>
593
+
594
+ <div>
595
+ <p className="mb-1 text-muted-foreground text-xs">
596
+ Category
597
+ </p>
598
+ <Input
599
+ onChange={(e) => setCategory(e.target.value)}
600
+ placeholder="engineering, research"
601
+ value={category}
602
+ />
603
+ </div>
604
+
605
+ <div className="grid gap-3 sm:grid-cols-2">
606
+ <div>
607
+ <p className="mb-1 text-muted-foreground text-xs">
608
+ Since
609
+ </p>
610
+ <Input
611
+ onChange={(e) => setSince(e.target.value)}
612
+ type="date"
613
+ value={since}
614
+ />
615
+ </div>
616
+ <div>
617
+ <p className="mb-1 text-muted-foreground text-xs">
618
+ Until
619
+ </p>
620
+ <Input
621
+ onChange={(e) => setUntil(e.target.value)}
622
+ type="date"
623
+ value={until}
624
+ />
625
+ </div>
626
+ </div>
627
+
628
+ <div>
629
+ <p className="mb-1 text-muted-foreground text-xs">
630
+ Candidate limit
631
+ </p>
632
+ <Input
633
+ inputMode="numeric"
634
+ min="1"
635
+ onChange={(e) => setCandidateLimit(e.target.value)}
636
+ placeholder="20"
637
+ type="number"
638
+ value={candidateLimit}
639
+ />
640
+ </div>
641
+ </div>
642
+
643
+ <div className="flex flex-wrap items-center gap-2">
644
+ <span className="text-muted-foreground text-xs">
645
+ Tag match:
646
+ </span>
647
+ <Button
648
+ onClick={() => setTagMode("any")}
649
+ size="sm"
650
+ type="button"
651
+ variant={tagMode === "any" ? "default" : "outline"}
652
+ >
653
+ Any
654
+ </Button>
655
+ <Button
656
+ onClick={() => setTagMode("all")}
657
+ size="sm"
658
+ type="button"
659
+ variant={tagMode === "all" ? "default" : "outline"}
660
+ >
661
+ All
662
+ </Button>
663
+
664
+ <Button
665
+ className="ml-auto"
666
+ onClick={clearAdvancedFilters}
667
+ size="sm"
668
+ type="button"
669
+ variant="ghost"
670
+ >
671
+ Clear advanced
672
+ </Button>
673
+ </div>
674
+
675
+ <div className="space-y-2">
676
+ <p className="text-muted-foreground text-xs">
677
+ Query modes (term, intent, hyde)
678
+ </p>
679
+ <div className="flex flex-wrap items-center gap-2">
680
+ <Select
681
+ onValueChange={(value) =>
682
+ setQueryModeDraft(value as QueryModeType)
683
+ }
684
+ value={queryModeDraft}
685
+ >
686
+ <SelectTrigger className="w-[120px]">
687
+ <SelectValue />
688
+ </SelectTrigger>
689
+ <SelectContent>
690
+ <SelectItem value="term">Term</SelectItem>
691
+ <SelectItem value="intent">Intent</SelectItem>
692
+ <SelectItem value="hyde">HyDE</SelectItem>
693
+ </SelectContent>
694
+ </Select>
695
+ <Input
696
+ className="min-w-[220px] flex-1"
697
+ onChange={(e) => setQueryModeText(e.target.value)}
698
+ onKeyDown={(e) => {
699
+ if (e.key === "Enter") {
700
+ e.preventDefault();
701
+ handleAddQueryMode();
702
+ }
703
+ }}
704
+ placeholder="Add query mode text"
705
+ value={queryModeText}
706
+ />
707
+ <Button
708
+ onClick={handleAddQueryMode}
709
+ size="sm"
710
+ type="button"
711
+ variant="outline"
712
+ >
713
+ Add mode
714
+ </Button>
715
+ </div>
716
+
717
+ {queryModeError && (
718
+ <p className="text-destructive text-xs">
719
+ {queryModeError}
720
+ </p>
721
+ )}
722
+
723
+ {queryModes.length > 0 && (
724
+ <div className="flex flex-wrap gap-2">
725
+ {queryModes.map((queryMode, index) => (
726
+ <button
727
+ className={cn(
728
+ "group inline-flex items-center gap-1 rounded-full border border-primary/30 bg-primary/10",
729
+ "px-2.5 py-1 font-mono text-[11px] text-primary transition-all duration-150",
730
+ "hover:border-primary/50 hover:bg-primary/20"
731
+ )}
732
+ key={`${queryMode.mode}:${queryMode.text}:${index}`}
733
+ onClick={() => handleRemoveQueryMode(index)}
734
+ type="button"
735
+ >
736
+ <span>{`${QUERY_MODE_LABEL[queryMode.mode]}: ${queryMode.text}`}</span>
737
+ <XIcon className="size-3 opacity-60 transition-opacity group-hover:opacity-100" />
738
+ </button>
739
+ ))}
740
+ </div>
741
+ )}
742
+ </div>
743
+ </CardContent>
744
+ </Card>
745
+ </CollapsibleContent>
746
+ </Collapsible>
357
747
  </form>
358
748
 
359
- {/* Active Tag Filter Chips */}
360
- {activeTags.length > 0 && (
749
+ <div className="mb-6 lg:hidden">
750
+ <Collapsible
751
+ onOpenChange={setShowMobileTags}
752
+ open={showMobileTags}
753
+ >
754
+ <CollapsibleTrigger asChild>
755
+ <Button
756
+ className="gap-2"
757
+ size="sm"
758
+ type="button"
759
+ variant="outline"
760
+ >
761
+ <SlidersHorizontal className="size-4" />
762
+ Tags
763
+ <ChevronDown
764
+ className={cn(
765
+ "size-4 transition-transform",
766
+ showMobileTags && "rotate-180"
767
+ )}
768
+ />
769
+ </Button>
770
+ </CollapsibleTrigger>
771
+ <CollapsibleContent className="pt-3">
772
+ <TagFacets
773
+ activeTags={activeTags}
774
+ className="rounded-md border border-border/50"
775
+ onTagRemove={handleTagRemove}
776
+ onTagSelect={handleTagSelect}
777
+ />
778
+ </CollapsibleContent>
779
+ </Collapsible>
780
+ </div>
781
+
782
+ {hasActiveFilters && (
361
783
  <div className="mb-6 flex flex-wrap items-center gap-2">
362
784
  <span className="font-mono text-muted-foreground text-xs">
363
- Filtering by:
785
+ Filters:
364
786
  </span>
787
+ {activeFilterPills.map((pill) => (
788
+ <Badge
789
+ className="font-mono text-[10px]"
790
+ key={pill}
791
+ variant="outline"
792
+ >
793
+ {pill}
794
+ </Badge>
795
+ ))}
365
796
  {activeTags.map((tag) => (
366
797
  <button
367
798
  className={cn(
368
- "group inline-flex items-center gap-1",
369
- "rounded-full border border-primary/30 bg-primary/10",
370
- "px-2.5 py-1 font-mono text-xs text-primary",
371
- "transition-all duration-150",
799
+ "group inline-flex items-center gap-1 rounded-full border border-primary/30 bg-primary/10",
800
+ "px-2.5 py-1 font-mono text-xs text-primary transition-all duration-150",
372
801
  "hover:border-primary/50 hover:bg-primary/20"
373
802
  )}
374
803
  key={tag}
375
804
  onClick={() => handleTagRemove(tag)}
376
805
  type="button"
377
806
  >
378
- <span>{tag}</span>
807
+ <span>{`${tagMode}:${tag}`}</span>
379
808
  <XIcon className="size-3 opacity-60 transition-opacity group-hover:opacity-100" />
380
809
  </button>
381
810
  ))}
382
811
  <button
383
812
  className="font-mono text-muted-foreground text-xs underline-offset-2 hover:text-foreground hover:underline"
384
- onClick={() => setActiveTags([])}
813
+ onClick={() => {
814
+ setActiveTags([]);
815
+ clearAdvancedFilters();
816
+ }}
385
817
  type="button"
386
818
  >
387
819
  Clear all
@@ -389,7 +821,6 @@ export default function Search({ navigate }: PageProps) {
389
821
  </div>
390
822
  )}
391
823
 
392
- {/* Error */}
393
824
  {error && (
394
825
  <Card className="mb-6 border-destructive bg-destructive/10">
395
826
  <CardContent className="py-4 text-destructive">
@@ -398,7 +829,6 @@ export default function Search({ navigate }: PageProps) {
398
829
  </Card>
399
830
  )}
400
831
 
401
- {/* Loading */}
402
832
  {loading && (
403
833
  <div className="flex flex-col items-center justify-center gap-4 py-20">
404
834
  <Loader className="text-primary" size={32} />
@@ -406,7 +836,6 @@ export default function Search({ navigate }: PageProps) {
406
836
  </div>
407
837
  )}
408
838
 
409
- {/* Empty state */}
410
839
  {!loading && searched && results.length === 0 && !error && (
411
840
  <div className="py-20 text-center">
412
841
  <div className="relative mx-auto mb-6 size-20">
@@ -416,10 +845,8 @@ export default function Search({ navigate }: PageProps) {
416
845
  </div>
417
846
  <h3 className="mb-2 font-semibold text-xl">No matches found</h3>
418
847
  <p className="mx-auto mb-6 max-w-sm text-muted-foreground">
419
- We couldn't find any documents matching "{query}"
420
- {activeTags.length > 0 &&
421
- ` with tags: ${activeTags.join(", ")}`}
422
- . Try different keywords or remove some filters.
848
+ We couldn't find any documents matching "{query}". Try fewer
849
+ filters, different keywords, or another depth mode.
423
850
  </p>
424
851
  <div className="flex justify-center gap-3">
425
852
  <Button
@@ -429,9 +856,12 @@ export default function Search({ navigate }: PageProps) {
429
856
  >
430
857
  Clear search
431
858
  </Button>
432
- {activeTags.length > 0 && (
859
+ {hasActiveFilters && (
433
860
  <Button
434
- onClick={() => setActiveTags([])}
861
+ onClick={() => {
862
+ setActiveTags([]);
863
+ clearAdvancedFilters();
864
+ }}
435
865
  size="sm"
436
866
  variant="outline"
437
867
  >
@@ -445,7 +875,6 @@ export default function Search({ navigate }: PageProps) {
445
875
  </div>
446
876
  )}
447
877
 
448
- {/* Results */}
449
878
  {!loading && results.length > 0 && (
450
879
  <div className="space-y-4">
451
880
  <div className="mb-6 flex items-center justify-between">
@@ -478,40 +907,52 @@ export default function Search({ navigate }: PageProps) {
478
907
  reranked
479
908
  </Badge>
480
909
  )}
910
+ {meta.queryModes &&
911
+ (meta.queryModes.term > 0 ||
912
+ meta.queryModes.intent > 0 ||
913
+ meta.queryModes.hyde) && (
914
+ <Badge
915
+ className="font-mono text-[10px]"
916
+ variant="secondary"
917
+ >
918
+ modes
919
+ </Badge>
920
+ )}
481
921
  </div>
482
922
  )}
483
923
  </div>
484
- {results.map((r, i) => (
924
+ {results.map((result, i) => (
485
925
  <Card
486
926
  className="group animate-fade-in cursor-pointer opacity-0 transition-all hover:border-primary/50 hover:bg-card/80"
487
- key={`${r.docid}-${i}`}
927
+ key={`${result.docid}-${i}`}
488
928
  onClick={() =>
489
- navigate(`/doc?uri=${encodeURIComponent(r.uri)}`)
929
+ navigate(`/doc?uri=${encodeURIComponent(result.uri)}`)
490
930
  }
491
931
  style={{ animationDelay: `${i * 0.05}s` }}
492
932
  >
493
933
  <CardContent className="py-4">
494
934
  <div className="mb-2 flex items-start justify-between gap-4">
495
935
  <h3 className="font-medium text-primary underline-offset-2 group-hover:underline">
496
- {r.title || r.uri.split("/").pop()}
936
+ {result.title || result.uri.split("/").pop()}
497
937
  </h3>
498
938
  <Badge
499
939
  className="shrink-0 font-mono text-xs"
500
940
  variant="secondary"
501
941
  >
502
- {(r.score * 100).toFixed(0)}%
942
+ {(result.score * 100).toFixed(0)}%
503
943
  </Badge>
504
944
  </div>
505
945
  <p className="line-clamp-3 text-muted-foreground text-sm leading-relaxed">
506
- {renderSnippet(r.snippet)}
946
+ {renderSnippet(result.snippet)}
507
947
  </p>
508
948
  <div className="mt-2 flex items-center gap-2">
509
949
  <p className="truncate font-mono text-muted-foreground/60 text-xs">
510
- {r.uri}
950
+ {result.uri}
511
951
  </p>
512
- {r.snippetRange && (
952
+ {result.snippetRange && (
513
953
  <span className="shrink-0 font-mono text-[10px] text-muted-foreground/40">
514
- L{r.snippetRange.startLine}-{r.snippetRange.endLine}
954
+ L{result.snippetRange.startLine}-
955
+ {result.snippetRange.endLine}
515
956
  </span>
516
957
  )}
517
958
  </div>