@schlessera/brain-ui-react 0.5.1 → 0.6.1

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 (36) hide show
  1. package/dist/components/chat/chat-page.d.ts.map +1 -1
  2. package/dist/components/chat/chat-page.js +17 -10
  3. package/dist/components/chat/chat-page.js.map +1 -1
  4. package/dist/components/layout/kbd.d.ts +6 -0
  5. package/dist/components/layout/kbd.d.ts.map +1 -0
  6. package/dist/components/layout/kbd.js +6 -0
  7. package/dist/components/layout/kbd.js.map +1 -0
  8. package/dist/components/quick-actions/add-modal.d.ts +5 -0
  9. package/dist/components/quick-actions/add-modal.d.ts.map +1 -0
  10. package/dist/components/quick-actions/add-modal.js +91 -0
  11. package/dist/components/quick-actions/add-modal.js.map +1 -0
  12. package/dist/components/quick-actions/search-modal.d.ts +5 -0
  13. package/dist/components/quick-actions/search-modal.d.ts.map +1 -0
  14. package/dist/components/quick-actions/search-modal.js +134 -0
  15. package/dist/components/quick-actions/search-modal.js.map +1 -0
  16. package/dist/lib/api-client.d.ts +22 -9
  17. package/dist/lib/api-client.d.ts.map +1 -1
  18. package/dist/lib/api-client.js +9 -1
  19. package/dist/lib/api-client.js.map +1 -1
  20. package/dist/lib/search-snippet.d.ts +15 -0
  21. package/dist/lib/search-snippet.d.ts.map +1 -0
  22. package/dist/lib/search-snippet.js +45 -0
  23. package/dist/lib/search-snippet.js.map +1 -0
  24. package/dist/stores/ui-store.d.ts +6 -0
  25. package/dist/stores/ui-store.d.ts.map +1 -1
  26. package/dist/stores/ui-store.js +6 -0
  27. package/dist/stores/ui-store.js.map +1 -1
  28. package/dist/styles.css +1 -1
  29. package/package.json +2 -2
  30. package/src/components/chat/chat-page.tsx +25 -9
  31. package/src/components/layout/kbd.tsx +10 -0
  32. package/src/components/quick-actions/add-modal.tsx +231 -0
  33. package/src/components/quick-actions/search-modal.tsx +285 -0
  34. package/src/lib/api-client.ts +40 -6
  35. package/src/lib/search-snippet.ts +64 -0
  36. package/src/stores/ui-store.ts +14 -0
@@ -0,0 +1,231 @@
1
+ import { useState, useEffect, useRef } from "react";
2
+ import { Loader2, CheckCircle2, XCircle } from "lucide-react";
3
+ import { SlidePanel } from "../layout/slide-panel.js";
4
+ import { Kbd } from "../layout/kbd.js";
5
+ import { api } from "../../lib/api-client.js";
6
+
7
+ type State = "editing" | "saving" | "saved" | "error";
8
+
9
+ /** Split the tags field on commas; empties dropped so "a,,b," is clean. */
10
+ function parseTags(raw: string): string[] {
11
+ return raw
12
+ .split(",")
13
+ .map((t) => t.trim())
14
+ .filter(Boolean);
15
+ }
16
+
17
+ export function AddPanel({
18
+ open,
19
+ onClose,
20
+ }: {
21
+ open: boolean;
22
+ onClose: () => void;
23
+ }) {
24
+ const [content, setContent] = useState("");
25
+ const [title, setTitle] = useState("");
26
+ const [type, setType] = useState("");
27
+ const [tags, setTags] = useState("");
28
+ const [state, setState] = useState<State>("editing");
29
+ const [error, setError] = useState("");
30
+ // Types already in the brain, offered as completions — `brain add` accepts
31
+ // any string, so this is a hint list, not a closed set.
32
+ const [knownTypes, setKnownTypes] = useState<string[]>([]);
33
+
34
+ const contentRef = useRef<HTMLTextAreaElement>(null);
35
+
36
+ useEffect(() => {
37
+ if (!open) return;
38
+ setContent("");
39
+ setTitle("");
40
+ setType("");
41
+ setTags("");
42
+ setError("");
43
+ setState("editing");
44
+
45
+ const t = setTimeout(() => contentRef.current?.focus(), 120);
46
+
47
+ let cancelled = false;
48
+ api
49
+ .brainStats()
50
+ .then((s) => {
51
+ if (!cancelled) setKnownTypes(Object.keys(s.byType).sort());
52
+ })
53
+ .catch(() => {
54
+ // Completions are a nicety; a failed stats call must not block adding.
55
+ });
56
+
57
+ return () => {
58
+ cancelled = true;
59
+ clearTimeout(t);
60
+ };
61
+ }, [open]);
62
+
63
+ async function handleSave() {
64
+ const body = content.trim();
65
+ if (!body || state === "saving") return;
66
+
67
+ setState("saving");
68
+ setError("");
69
+ try {
70
+ await api.brainAdd(body, {
71
+ type: type.trim() || undefined,
72
+ title: title.trim() || undefined,
73
+ tags: parseTags(tags).length ? parseTags(tags) : undefined,
74
+ });
75
+ setState("saved");
76
+ } catch (err: any) {
77
+ setError(err?.message || "Add failed");
78
+ setState("error");
79
+ }
80
+ }
81
+
82
+ function handleAddAnother() {
83
+ setContent("");
84
+ setTitle("");
85
+ setType("");
86
+ setTags("");
87
+ setState("editing");
88
+ setTimeout(() => contentRef.current?.focus(), 0);
89
+ }
90
+
91
+ function handleKeyDown(e: React.KeyboardEvent) {
92
+ // Cmd/Ctrl+Enter saves from anywhere in the form.
93
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
94
+ e.preventDefault();
95
+ void handleSave();
96
+ }
97
+ }
98
+
99
+ const saving = state === "saving";
100
+ const canSave = content.trim().length > 0 && !saving;
101
+
102
+ return (
103
+ <SlidePanel open={open} onClose={onClose} title="Add to brain" wide>
104
+ <div className="flex h-full flex-col" onKeyDown={handleKeyDown}>
105
+ {state === "saved" ? (
106
+ <div className="flex flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
107
+ <CheckCircle2 className="h-8 w-8 text-primary" />
108
+ <p className="text-sm text-foreground">Added to your brain.</p>
109
+ <p className="text-xs text-muted-foreground">
110
+ Run a sync to index and push it.
111
+ </p>
112
+ <div className="flex gap-2">
113
+ <button
114
+ onClick={handleAddAnother}
115
+ className="rounded-lg bg-surface-raised px-4 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-surface-overlay"
116
+ >
117
+ Add another
118
+ </button>
119
+ <button
120
+ onClick={onClose}
121
+ className="rounded-lg bg-primary px-4 py-1.5 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90"
122
+ >
123
+ Done
124
+ </button>
125
+ </div>
126
+ </div>
127
+ ) : (
128
+ <>
129
+ <div className="flex-1 space-y-4 overflow-y-auto p-5">
130
+ <Field label="Note">
131
+ <textarea
132
+ ref={contentRef}
133
+ value={content}
134
+ onChange={(e) => setContent(e.target.value)}
135
+ rows={8}
136
+ placeholder="What do you want to remember?"
137
+ className="w-full resize-y rounded-lg border border-border bg-surface-raised px-3 py-2 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary/40"
138
+ />
139
+ </Field>
140
+
141
+ <Field label="Title" hint="optional, derived from the note if empty">
142
+ <input
143
+ value={title}
144
+ onChange={(e) => setTitle(e.target.value)}
145
+ className="w-full rounded-lg border border-border bg-surface-raised px-3 py-2 text-sm text-foreground outline-none transition-colors focus:border-primary/40"
146
+ />
147
+ </Field>
148
+
149
+ <div className="grid grid-cols-2 gap-3">
150
+ <Field label="Type" hint="optional">
151
+ <input
152
+ value={type}
153
+ onChange={(e) => setType(e.target.value)}
154
+ list="brain-add-types"
155
+ placeholder="note"
156
+ className="w-full rounded-lg border border-border bg-surface-raised px-3 py-2 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary/40"
157
+ />
158
+ <datalist id="brain-add-types">
159
+ {knownTypes.map((t) => (
160
+ <option key={t} value={t} />
161
+ ))}
162
+ </datalist>
163
+ </Field>
164
+
165
+ <Field label="Tags" hint="comma-separated">
166
+ <input
167
+ value={tags}
168
+ onChange={(e) => setTags(e.target.value)}
169
+ placeholder="idea, reading"
170
+ className="w-full rounded-lg border border-border bg-surface-raised px-3 py-2 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary/40"
171
+ />
172
+ </Field>
173
+ </div>
174
+
175
+ {state === "error" && (
176
+ <div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2">
177
+ <XCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-destructive" />
178
+ <span className="text-xs text-destructive">{error}</span>
179
+ </div>
180
+ )}
181
+ </div>
182
+
183
+ <div className="flex items-center justify-between border-t border-border px-5 py-3">
184
+ <span className="text-[11px] text-muted-foreground">
185
+ <Kbd>Ctrl</Kbd>/<Kbd>Cmd</Kbd> + <Kbd>↵</Kbd> to save
186
+ </span>
187
+ <div className="flex gap-2">
188
+ <button
189
+ onClick={onClose}
190
+ className="rounded-lg bg-surface-raised px-4 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-surface-overlay"
191
+ >
192
+ Cancel
193
+ </button>
194
+ <button
195
+ onClick={handleSave}
196
+ disabled={!canSave}
197
+ className="flex items-center gap-1.5 rounded-lg bg-primary px-4 py-1.5 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40"
198
+ >
199
+ {saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
200
+ {saving ? "Saving..." : "Add"}
201
+ </button>
202
+ </div>
203
+ </div>
204
+ </>
205
+ )}
206
+ </div>
207
+ </SlidePanel>
208
+ );
209
+ }
210
+
211
+ function Field({
212
+ label,
213
+ hint,
214
+ children,
215
+ }: {
216
+ label: string;
217
+ hint?: string;
218
+ children: React.ReactNode;
219
+ }) {
220
+ return (
221
+ <label className="block space-y-1.5">
222
+ <span className="text-xs font-medium text-foreground">
223
+ {label}
224
+ {hint && (
225
+ <span className="font-normal text-muted-foreground"> — {hint}</span>
226
+ )}
227
+ </span>
228
+ {children}
229
+ </label>
230
+ );
231
+ }
@@ -0,0 +1,285 @@
1
+ import { useState, useEffect, useRef, useCallback } from "react";
2
+ import { Loader2, Search, X, AlertTriangle } from "lucide-react";
3
+ import { SlidePanel } from "../layout/slide-panel.js";
4
+ import { Kbd } from "../layout/kbd.js";
5
+ import { useFileStore } from "../../stores/file-store.js";
6
+ import { useUIStore } from "../../stores/ui-store.js";
7
+ import { api, type BrainSearchHit } from "../../lib/api-client.js";
8
+ import { parseSnippet } from "../../lib/search-snippet.js";
9
+ import { cn } from "../../lib/utils.js";
10
+
11
+ /** Wait this long after the last keystroke before hitting the CLI. */
12
+ const DEBOUNCE_MS = 250;
13
+ const MIN_QUERY = 2;
14
+ const LIMIT = 25;
15
+
16
+ type State = "idle" | "loading" | "done" | "error";
17
+
18
+ export function SearchPanel({
19
+ open,
20
+ onClose,
21
+ }: {
22
+ open: boolean;
23
+ onClose: () => void;
24
+ }) {
25
+ const [query, setQuery] = useState("");
26
+ const [state, setState] = useState<State>("idle");
27
+ const [results, setResults] = useState<BrainSearchHit[]>([]);
28
+ const [warnings, setWarnings] = useState<string[]>([]);
29
+ const [error, setError] = useState("");
30
+ const [selected, setSelected] = useState(0);
31
+
32
+ const inputRef = useRef<HTMLInputElement>(null);
33
+ const listRef = useRef<HTMLDivElement>(null);
34
+ const controllerRef = useRef<AbortController | null>(null);
35
+
36
+ const openFile = useFileStore((s) => s.openFile);
37
+ const setFilePanelOpen = useUIStore((s) => s.setFilePanelOpen);
38
+
39
+ const trimmed = query.trim();
40
+
41
+ const runSearch = useCallback(async (q: string) => {
42
+ controllerRef.current?.abort();
43
+ const controller = new AbortController();
44
+ controllerRef.current = controller;
45
+
46
+ setState("loading");
47
+ try {
48
+ const res = await api.brainSearch(q, {
49
+ limit: LIMIT,
50
+ signal: controller.signal,
51
+ });
52
+ if (controller.signal.aborted) return;
53
+ setResults(res.results);
54
+ setWarnings(res.warnings ?? []);
55
+ setSelected(0);
56
+ setState("done");
57
+ } catch (err: any) {
58
+ // A superseded request is not a failure — the newer one owns the state.
59
+ if (err?.name === "AbortError" || controller.signal.aborted) return;
60
+ setError(err?.message || "Search failed");
61
+ setResults([]);
62
+ setState("error");
63
+ } finally {
64
+ if (controllerRef.current === controller) controllerRef.current = null;
65
+ }
66
+ }, []);
67
+
68
+ // Fresh panel every time it opens: stale results from a previous query would
69
+ // otherwise flash before the first search lands.
70
+ useEffect(() => {
71
+ if (!open) {
72
+ controllerRef.current?.abort();
73
+ controllerRef.current = null;
74
+ return;
75
+ }
76
+ setQuery("");
77
+ setResults([]);
78
+ setWarnings([]);
79
+ setError("");
80
+ setState("idle");
81
+ setSelected(0);
82
+ // Focus after the slide-in starts; focusing mid-transform fights the
83
+ // mobile keyboard on some browsers.
84
+ const t = setTimeout(() => inputRef.current?.focus(), 120);
85
+ return () => clearTimeout(t);
86
+ }, [open]);
87
+
88
+ // Debounced search on typing.
89
+ useEffect(() => {
90
+ if (!open) return;
91
+ if (trimmed.length < MIN_QUERY) {
92
+ controllerRef.current?.abort();
93
+ controllerRef.current = null;
94
+ setResults([]);
95
+ setWarnings([]);
96
+ setState("idle");
97
+ return;
98
+ }
99
+ const t = setTimeout(() => void runSearch(trimmed), DEBOUNCE_MS);
100
+ return () => clearTimeout(t);
101
+ }, [open, trimmed, runSearch]);
102
+
103
+ function handleOpenResult(hit: BrainSearchHit) {
104
+ // The file panel takes the same right-hand slot, so hand the screen over
105
+ // rather than stacking two sheets.
106
+ onClose();
107
+ setFilePanelOpen(true);
108
+ void openFile(hit.path);
109
+ }
110
+
111
+ function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
112
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
113
+ if (results.length === 0) return;
114
+ e.preventDefault();
115
+ const delta = e.key === "ArrowDown" ? 1 : -1;
116
+ const next = (selected + delta + results.length) % results.length;
117
+ setSelected(next);
118
+ listRef.current
119
+ ?.querySelectorAll("[data-result]")
120
+ [next]?.scrollIntoView({ block: "nearest" });
121
+ return;
122
+ }
123
+ if (e.key === "Enter") {
124
+ e.preventDefault();
125
+ const hit = results[selected];
126
+ if (hit) handleOpenResult(hit);
127
+ // Enter with nothing selected jumps the debounce.
128
+ else if (trimmed.length >= MIN_QUERY) void runSearch(trimmed);
129
+ }
130
+ }
131
+
132
+ return (
133
+ <SlidePanel open={open} onClose={onClose} title="Search" wide>
134
+ <div className="flex h-full flex-col">
135
+ {/* Query field */}
136
+ <div className="border-b border-border px-5 py-3">
137
+ <div className="flex items-center gap-2 rounded-lg border border-border bg-surface-raised px-3 py-2 focus-within:border-primary/40">
138
+ <Search className="h-4 w-4 shrink-0 text-muted-foreground" />
139
+ <input
140
+ ref={inputRef}
141
+ value={query}
142
+ onChange={(e) => setQuery(e.target.value)}
143
+ onKeyDown={handleKeyDown}
144
+ placeholder="Search your brain..."
145
+ className="min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
146
+ autoComplete="off"
147
+ autoCorrect="off"
148
+ spellCheck={false}
149
+ />
150
+ {state === "loading" && (
151
+ <Loader2 className="h-4 w-4 shrink-0 animate-spin text-primary" />
152
+ )}
153
+ {query && state !== "loading" && (
154
+ <button
155
+ onClick={() => {
156
+ setQuery("");
157
+ inputRef.current?.focus();
158
+ }}
159
+ className="shrink-0 rounded p-0.5 text-muted-foreground transition-colors hover:text-foreground"
160
+ title="Clear"
161
+ >
162
+ <X className="h-3.5 w-3.5" />
163
+ </button>
164
+ )}
165
+ </div>
166
+ </div>
167
+
168
+ {/* Degraded-mode notices from the CLI (e.g. FTS-only, no vectors) */}
169
+ {warnings.length > 0 && (
170
+ <div className="flex items-start gap-2 border-b border-border bg-surface-raised/40 px-5 py-2">
171
+ <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
172
+ <div className="text-[11px] leading-relaxed text-muted-foreground">
173
+ {warnings.map((w, i) => (
174
+ <div key={i}>{w}</div>
175
+ ))}
176
+ </div>
177
+ </div>
178
+ )}
179
+
180
+ {/* Results */}
181
+ <div ref={listRef} className="flex-1 overflow-y-auto">
182
+ {state === "error" ? (
183
+ <Placeholder>{error}</Placeholder>
184
+ ) : trimmed.length < MIN_QUERY ? (
185
+ <Placeholder>
186
+ <span>
187
+ Type at least {MIN_QUERY} characters. <Kbd>↑</Kbd> <Kbd>↓</Kbd> to
188
+ pick, <Kbd>↵</Kbd> to open.
189
+ </span>
190
+ </Placeholder>
191
+ ) : state === "done" && results.length === 0 ? (
192
+ <Placeholder>No results for “{trimmed}”.</Placeholder>
193
+ ) : (
194
+ <div className="divide-y divide-border/40">
195
+ {results.map((hit, i) => (
196
+ <ResultRow
197
+ key={hit.path}
198
+ hit={hit}
199
+ active={i === selected}
200
+ onMouseEnter={() => setSelected(i)}
201
+ onClick={() => handleOpenResult(hit)}
202
+ />
203
+ ))}
204
+ </div>
205
+ )}
206
+ </div>
207
+
208
+ {/* Footer */}
209
+ <div className="flex items-center justify-between border-t border-border px-5 py-3">
210
+ <span className="text-[11px] text-muted-foreground">
211
+ {state === "done" && results.length > 0
212
+ ? `${results.length}${results.length === LIMIT ? "+" : ""} result${results.length === 1 ? "" : "s"}`
213
+ : ""}
214
+ </span>
215
+ <button
216
+ onClick={onClose}
217
+ className="rounded-lg bg-surface-raised px-4 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-surface-overlay"
218
+ >
219
+ Close
220
+ </button>
221
+ </div>
222
+ </div>
223
+ </SlidePanel>
224
+ );
225
+ }
226
+
227
+ function ResultRow({
228
+ hit,
229
+ active,
230
+ onClick,
231
+ onMouseEnter,
232
+ }: {
233
+ hit: BrainSearchHit;
234
+ active: boolean;
235
+ onClick: () => void;
236
+ onMouseEnter: () => void;
237
+ }) {
238
+ return (
239
+ <button
240
+ data-result
241
+ onClick={onClick}
242
+ onMouseEnter={onMouseEnter}
243
+ title={hit.path}
244
+ className={cn(
245
+ "flex w-full flex-col gap-1 px-5 py-3 text-left transition-colors",
246
+ active ? "bg-surface-raised" : "hover:bg-surface-raised/60"
247
+ )}
248
+ >
249
+ <div className="flex items-baseline gap-2">
250
+ {hit.type && (
251
+ <span className="shrink-0 rounded bg-surface-overlay px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
252
+ {hit.type}
253
+ </span>
254
+ )}
255
+ <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
256
+ {hit.title || hit.path}
257
+ </span>
258
+ </div>
259
+ {hit.snippet && (
260
+ <p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground">
261
+ {parseSnippet(hit.snippet).map((seg, i) =>
262
+ seg.hit ? (
263
+ <span key={i} className="text-foreground">
264
+ {seg.text}
265
+ </span>
266
+ ) : (
267
+ <span key={i}>{seg.text}</span>
268
+ )
269
+ )}
270
+ </p>
271
+ )}
272
+ <span className="truncate font-mono text-[10px] text-muted-foreground/60">
273
+ {hit.path}
274
+ </span>
275
+ </button>
276
+ );
277
+ }
278
+
279
+ function Placeholder({ children }: { children: React.ReactNode }) {
280
+ return (
281
+ <div className="flex h-32 items-center justify-center px-8 text-center text-xs text-muted-foreground">
282
+ {children}
283
+ </div>
284
+ );
285
+ }
@@ -15,6 +15,26 @@ import type {
15
15
  PublicKeyCredentialRequestOptionsJSON,
16
16
  } from "@simplewebauthn/browser";
17
17
 
18
+ /**
19
+ * One hit from `brain search`. `snippet` carries the CLI's FTS highlight
20
+ * markers (`>>>term<<<`) — render it through `renderSnippet()` in the search
21
+ * panel rather than printing it raw.
22
+ */
23
+ export interface BrainSearchHit {
24
+ path: string;
25
+ title: string;
26
+ type: string;
27
+ relevance: string;
28
+ score: number;
29
+ snippet: string;
30
+ }
31
+
32
+ export interface BrainSearchResponse {
33
+ results: BrainSearchHit[];
34
+ /** Degraded-mode notices, e.g. vector search unavailable so results are FTS-only. */
35
+ warnings: string[];
36
+ }
37
+
18
38
  /** Backend id + capability flags the client renders behavior from. */
19
39
  export interface BackendInfo {
20
40
  id: string;
@@ -58,12 +78,26 @@ export const api = {
58
78
  activeSession: boolean;
59
79
  }>("/status"),
60
80
 
61
- brainSearch: (q: string, opts?: { type?: string; limit?: number }) =>
62
- fetchJson<{
63
- results: Array<{ path: string; title: string; snippet: string; score: number }>;
64
- warnings: string[];
65
- }>(
66
- `/brain/search?q=${encodeURIComponent(q)}${opts?.type ? `&type=${opts.type}` : ""}${opts?.limit ? `&limit=${opts.limit}` : ""}`
81
+ brainSearch: (
82
+ q: string,
83
+ opts?: {
84
+ type?: string;
85
+ tag?: string;
86
+ limit?: number;
87
+ mode?: "fts" | "vector" | "hybrid";
88
+ signal?: AbortSignal;
89
+ }
90
+ ) =>
91
+ // Built with encodeURIComponent rather than URLSearchParams: the latter
92
+ // form-encodes spaces as "+", which not every query parser turns back into
93
+ // a space. %20 is unambiguous.
94
+ fetchJson<BrainSearchResponse>(
95
+ `/brain/search?q=${encodeURIComponent(q)}` +
96
+ (opts?.type ? `&type=${encodeURIComponent(opts.type)}` : "") +
97
+ (opts?.tag ? `&tag=${encodeURIComponent(opts.tag)}` : "") +
98
+ (opts?.limit ? `&limit=${opts.limit}` : "") +
99
+ (opts?.mode ? `&mode=${opts.mode}` : ""),
100
+ { signal: opts?.signal }
67
101
  ),
68
102
 
69
103
  brainBriefing: () =>
@@ -0,0 +1,64 @@
1
+ import { stripMarkdown } from "./strip-markdown.js";
2
+
3
+ /**
4
+ * `brain search` returns FTS snippets with the matched terms wrapped in
5
+ * `>>>` / `<<<` markers (sqlite's snippet() delimiters, chosen by the CLI so
6
+ * they survive JSON without colliding with markdown). Vector hits come back
7
+ * unmarked, as a raw slice of chunk text — headings, emphasis, blank lines and
8
+ * all. Split a snippet into plain and matched segments, with the markdown
9
+ * syntax stripped and the whitespace collapsed, so a result row is one legible
10
+ * line with its hits highlighted.
11
+ */
12
+ export interface SnippetSegment {
13
+ text: string;
14
+ hit: boolean;
15
+ }
16
+
17
+ const OPEN = ">>>";
18
+ const CLOSE = "<<<";
19
+ // Control characters stand in for the markers while stripMarkdown runs: a
20
+ // snippet starting with ">>>" would otherwise lose a ">" to the blockquote
21
+ // rule, and emphasis spanning a highlight (`**a >>>b<<<**`) has to be
22
+ // unwrapped as one string, not per segment.
23
+ const OPEN_MARK = "\u0001";
24
+ const CLOSE_MARK = "\u0002";
25
+
26
+ export function parseSnippet(snippet: string): SnippetSegment[] {
27
+ const cleaned = stripMarkdown(
28
+ snippet.split(OPEN).join(OPEN_MARK).split(CLOSE).join(CLOSE_MARK)
29
+ )
30
+ .replace(/\s+/g, " ")
31
+ .trim();
32
+
33
+ const segments: SnippetSegment[] = [];
34
+ let rest = cleaned;
35
+
36
+ while (rest.length > 0) {
37
+ const start = rest.indexOf(OPEN_MARK);
38
+ if (start === -1) break;
39
+
40
+ const end = rest.indexOf(CLOSE_MARK, start + 1);
41
+ // An unclosed marker means the snippet was clipped mid-highlight; keep the
42
+ // remainder as plain text rather than dropping it.
43
+ if (end === -1) break;
44
+
45
+ if (start > 0) push(segments, rest.slice(0, start), false);
46
+ push(segments, rest.slice(start + 1, end), true);
47
+ rest = rest.slice(end + 1);
48
+ }
49
+
50
+ push(segments, rest, false);
51
+ return segments;
52
+ }
53
+
54
+ function push(segments: SnippetSegment[], text: string, hit: boolean): void {
55
+ // Any stray marker left by a clipped snippet is noise, not content —
56
+ // squeeze the gap it leaves behind so the row reads as one sentence.
57
+ const clean = text
58
+ .split(OPEN_MARK)
59
+ .join("")
60
+ .split(CLOSE_MARK)
61
+ .join("")
62
+ .replace(/ {2,}/g, " ");
63
+ if (clean) segments.push({ text: clean, hit });
64
+ }
@@ -7,17 +7,23 @@ interface UIState {
7
7
  sessionPanelOpen: boolean;
8
8
  syncPanelOpen: boolean;
9
9
  whatsupPanelOpen: boolean;
10
+ searchPanelOpen: boolean;
11
+ addPanelOpen: boolean;
10
12
  filePanelOpen: boolean;
11
13
  settingsPanelOpen: boolean;
12
14
  settingsTab: SettingsTab;
13
15
  toggleSessionPanel: () => void;
14
16
  toggleSyncPanel: () => void;
15
17
  toggleWhatsupPanel: () => void;
18
+ toggleSearchPanel: () => void;
19
+ toggleAddPanel: () => void;
16
20
  toggleFilePanel: () => void;
17
21
  toggleSettingsPanel: () => void;
18
22
  closeAllPanels: () => void;
19
23
  setSyncPanelOpen: (open: boolean) => void;
20
24
  setWhatsupPanelOpen: (open: boolean) => void;
25
+ setSearchPanelOpen: (open: boolean) => void;
26
+ setAddPanelOpen: (open: boolean) => void;
21
27
  setSessionPanelOpen: (open: boolean) => void;
22
28
  setFilePanelOpen: (open: boolean) => void;
23
29
  setSettingsPanelOpen: (open: boolean) => void;
@@ -30,6 +36,8 @@ const CLOSED = {
30
36
  sessionPanelOpen: false,
31
37
  syncPanelOpen: false,
32
38
  whatsupPanelOpen: false,
39
+ searchPanelOpen: false,
40
+ addPanelOpen: false,
33
41
  filePanelOpen: false,
34
42
  settingsPanelOpen: false,
35
43
  };
@@ -43,6 +51,10 @@ export const useUIStore = create<UIState>((set) => ({
43
51
  set((s) => ({ ...CLOSED, syncPanelOpen: !s.syncPanelOpen })),
44
52
  toggleWhatsupPanel: () =>
45
53
  set((s) => ({ ...CLOSED, whatsupPanelOpen: !s.whatsupPanelOpen })),
54
+ toggleSearchPanel: () =>
55
+ set((s) => ({ ...CLOSED, searchPanelOpen: !s.searchPanelOpen })),
56
+ toggleAddPanel: () =>
57
+ set((s) => ({ ...CLOSED, addPanelOpen: !s.addPanelOpen })),
46
58
  toggleFilePanel: () =>
47
59
  set((s) => ({ ...CLOSED, filePanelOpen: !s.filePanelOpen })),
48
60
  toggleSettingsPanel: () =>
@@ -50,6 +62,8 @@ export const useUIStore = create<UIState>((set) => ({
50
62
  closeAllPanels: () => set({ ...CLOSED }),
51
63
  setSyncPanelOpen: (open) => set({ syncPanelOpen: open }),
52
64
  setWhatsupPanelOpen: (open) => set({ whatsupPanelOpen: open }),
65
+ setSearchPanelOpen: (open) => set({ searchPanelOpen: open }),
66
+ setAddPanelOpen: (open) => set({ addPanelOpen: open }),
53
67
  setSessionPanelOpen: (open) => set({ sessionPanelOpen: open }),
54
68
  setFilePanelOpen: (open) => set({ filePanelOpen: open }),
55
69
  setSettingsPanelOpen: (open) => set({ settingsPanelOpen: open }),