brainbank 0.1.0-beta.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 (137) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +155 -0
  3. package/assets/architecture.png +0 -0
  4. package/bin/brainbank +18 -0
  5. package/bin/brainbank-mcp +19 -0
  6. package/dist/chunk-3YBCD6DI.js +117 -0
  7. package/dist/chunk-3YBCD6DI.js.map +1 -0
  8. package/dist/chunk-63GBCDS5.js +3249 -0
  9. package/dist/chunk-63GBCDS5.js.map +1 -0
  10. package/dist/chunk-DMFMTOHF.js +123 -0
  11. package/dist/chunk-DMFMTOHF.js.map +1 -0
  12. package/dist/chunk-FQYKWB2Q.js +136 -0
  13. package/dist/chunk-FQYKWB2Q.js.map +1 -0
  14. package/dist/chunk-IMJJ2VEM.js +74 -0
  15. package/dist/chunk-IMJJ2VEM.js.map +1 -0
  16. package/dist/chunk-M744PCJQ.js +43 -0
  17. package/dist/chunk-M744PCJQ.js.map +1 -0
  18. package/dist/chunk-O3J6ZIXK.js +82 -0
  19. package/dist/chunk-O3J6ZIXK.js.map +1 -0
  20. package/dist/chunk-OPH7GZ7U.js +124 -0
  21. package/dist/chunk-OPH7GZ7U.js.map +1 -0
  22. package/dist/chunk-PXEWQMN7.js +89 -0
  23. package/dist/chunk-PXEWQMN7.js.map +1 -0
  24. package/dist/chunk-RDQYDLYZ.js +69 -0
  25. package/dist/chunk-RDQYDLYZ.js.map +1 -0
  26. package/dist/chunk-VIIHPCC4.js +254 -0
  27. package/dist/chunk-VIIHPCC4.js.map +1 -0
  28. package/dist/chunk-WCQVDF3K.js +14 -0
  29. package/dist/chunk-WCQVDF3K.js.map +1 -0
  30. package/dist/cli.d.ts +1 -0
  31. package/dist/cli.js +3076 -0
  32. package/dist/cli.js.map +1 -0
  33. package/dist/haiku-expander-YRSIPGKP.js +8 -0
  34. package/dist/haiku-expander-YRSIPGKP.js.map +1 -0
  35. package/dist/haiku-pruner-SHAXUPY6.js +8 -0
  36. package/dist/haiku-pruner-SHAXUPY6.js.map +1 -0
  37. package/dist/http-server-QUXHLWUM.js +9 -0
  38. package/dist/http-server-QUXHLWUM.js.map +1 -0
  39. package/dist/index.d.ts +2161 -0
  40. package/dist/index.js +357 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/local-embedding-NZQTILGV.js +8 -0
  43. package/dist/local-embedding-NZQTILGV.js.map +1 -0
  44. package/dist/mcp.d.ts +2 -0
  45. package/dist/mcp.js +334 -0
  46. package/dist/mcp.js.map +1 -0
  47. package/dist/openai-embedding-ZP5TSUJG.js +8 -0
  48. package/dist/openai-embedding-ZP5TSUJG.js.map +1 -0
  49. package/dist/perplexity-context-embedding-GI5PHE6X.js +9 -0
  50. package/dist/perplexity-context-embedding-GI5PHE6X.js.map +1 -0
  51. package/dist/perplexity-embedding-KZRYGJRC.js +10 -0
  52. package/dist/perplexity-embedding-KZRYGJRC.js.map +1 -0
  53. package/dist/plugin-IKQ6IRSJ.js +32 -0
  54. package/dist/plugin-IKQ6IRSJ.js.map +1 -0
  55. package/dist/resolve-ASGLBNUC.js +10 -0
  56. package/dist/resolve-ASGLBNUC.js.map +1 -0
  57. package/dist/stats-tui-ZY2NQSEA.js +1904 -0
  58. package/dist/stats-tui-ZY2NQSEA.js.map +1 -0
  59. package/package.json +96 -0
  60. package/src/brainbank.ts +617 -0
  61. package/src/cli/commands/collection.ts +77 -0
  62. package/src/cli/commands/context.ts +179 -0
  63. package/src/cli/commands/daemon.ts +100 -0
  64. package/src/cli/commands/docs.ts +71 -0
  65. package/src/cli/commands/files.ts +69 -0
  66. package/src/cli/commands/help.ts +77 -0
  67. package/src/cli/commands/index.ts +482 -0
  68. package/src/cli/commands/kv.ts +140 -0
  69. package/src/cli/commands/mcp-export.ts +273 -0
  70. package/src/cli/commands/mcp.ts +6 -0
  71. package/src/cli/commands/reembed.ts +30 -0
  72. package/src/cli/commands/scan.ts +336 -0
  73. package/src/cli/commands/search.ts +203 -0
  74. package/src/cli/commands/stats.ts +68 -0
  75. package/src/cli/commands/status.ts +47 -0
  76. package/src/cli/commands/watch.ts +47 -0
  77. package/src/cli/factory/brain-context.ts +43 -0
  78. package/src/cli/factory/builtin-registration.ts +87 -0
  79. package/src/cli/factory/config-loader.ts +77 -0
  80. package/src/cli/factory/index.ts +69 -0
  81. package/src/cli/factory/plugin-loader.ts +325 -0
  82. package/src/cli/index.ts +71 -0
  83. package/src/cli/server-client.ts +178 -0
  84. package/src/cli/tui/index-tui.tsx +667 -0
  85. package/src/cli/tui/stats-data.ts +523 -0
  86. package/src/cli/tui/stats-search.ts +262 -0
  87. package/src/cli/tui/stats-tui.tsx +1465 -0
  88. package/src/cli/tui/tree-scanner.ts +650 -0
  89. package/src/cli/utils.ts +137 -0
  90. package/src/config.ts +49 -0
  91. package/src/constants.ts +21 -0
  92. package/src/db/adapter.ts +112 -0
  93. package/src/db/metadata.ts +130 -0
  94. package/src/db/migrations.ts +66 -0
  95. package/src/db/sqlite-adapter.ts +218 -0
  96. package/src/db/tracker.ts +91 -0
  97. package/src/engine/index-api.ts +81 -0
  98. package/src/engine/reembed.ts +206 -0
  99. package/src/engine/search-api.ts +218 -0
  100. package/src/index.ts +154 -0
  101. package/src/lib/fts.ts +57 -0
  102. package/src/lib/languages.ts +180 -0
  103. package/src/lib/logger.ts +126 -0
  104. package/src/lib/math.ts +87 -0
  105. package/src/lib/provider-key.ts +20 -0
  106. package/src/lib/prune.ts +71 -0
  107. package/src/lib/rrf.ts +133 -0
  108. package/src/lib/write-lock.ts +108 -0
  109. package/src/mcp/mcp-server.ts +195 -0
  110. package/src/mcp/workspace-factory.ts +68 -0
  111. package/src/mcp/workspace-pool.ts +224 -0
  112. package/src/plugin.ts +381 -0
  113. package/src/providers/embeddings/embedding-worker-thread.ts +95 -0
  114. package/src/providers/embeddings/embedding-worker.ts +141 -0
  115. package/src/providers/embeddings/local-embedding.ts +115 -0
  116. package/src/providers/embeddings/openai-embedding.ts +167 -0
  117. package/src/providers/embeddings/perplexity-context-embedding.ts +195 -0
  118. package/src/providers/embeddings/perplexity-embedding.ts +165 -0
  119. package/src/providers/embeddings/resolve.ts +34 -0
  120. package/src/providers/pruners/haiku-expander.ts +166 -0
  121. package/src/providers/pruners/haiku-pruner.ts +112 -0
  122. package/src/providers/vector/hnsw-index.ts +174 -0
  123. package/src/providers/vector/hnsw-loader.ts +129 -0
  124. package/src/search/bm25-boost.ts +69 -0
  125. package/src/search/context-builder.ts +251 -0
  126. package/src/search/keyword/composite-bm25-search.ts +47 -0
  127. package/src/search/types.ts +37 -0
  128. package/src/search/vector/composite-vector-search.ts +61 -0
  129. package/src/search/vector/mmr.ts +64 -0
  130. package/src/services/collection.ts +384 -0
  131. package/src/services/daemon.ts +87 -0
  132. package/src/services/http-server.ts +336 -0
  133. package/src/services/kv-service.ts +64 -0
  134. package/src/services/plugin-registry.ts +77 -0
  135. package/src/services/watch.ts +340 -0
  136. package/src/services/webhook-server.ts +100 -0
  137. package/src/types.ts +493 -0
@@ -0,0 +1,667 @@
1
+ /**
2
+ * index-tui.tsx — Interactive Ink TUI for `brainbank index`.
3
+ *
4
+ * Split-panel layout:
5
+ * Left: Module sidebar (Tab to focus, Space toggle)
6
+ * Right: Interactive file explorer (↑↓ navigate, Space toggle dirs)
7
+ *
8
+ * Phase 2: Config setup (embedding + pruner) — only if no config.json.
9
+ */
10
+
11
+ import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react';
12
+ import { render, Box, Text, useApp, useInput, useStdout } from 'ink';
13
+ import type { ScanResult } from '@/cli/commands/scan.ts';
14
+ import {
15
+ buildFileTree, expandDir, collapseDir, toggleDir, toggleFile, setAllDirs,
16
+ generatePatternsFromTree, countTotalFiles, getExtColor,
17
+ scanDocsPreview, scanGitPreview,
18
+ } from './tree-scanner.ts';
19
+ import type { FileTreeItem, PreviewLine } from './tree-scanner.ts';
20
+
21
+
22
+ // ── Types ──────────────────────────────────────────────
23
+
24
+ export interface TuiSelection {
25
+ modules: string[];
26
+ include: string[];
27
+ ignore: string[];
28
+ config?: { embedding: string; pruner: string; expander: string };
29
+ }
30
+
31
+ type Phase = 'main' | 'config';
32
+ type Pane = 'modules' | 'tree';
33
+
34
+ const MAX_W = 90;
35
+ const MAX_H = 36;
36
+
37
+
38
+ // ── Colors ─────────────────────────────────────────────
39
+
40
+ const C = {
41
+ aurora: '#7AA2F7',
42
+ success: '#9ECE6A',
43
+ error: '#F7768E',
44
+ warning: '#E0AF68',
45
+ dim: '#565F89',
46
+ text: '#C0CAF5',
47
+ border: '#3B4261',
48
+ cyan: '#7DCFFF',
49
+ dir: '#E0AF68',
50
+ } as const;
51
+
52
+
53
+ // ── Extension badges ──────────────────────────────────
54
+
55
+ const EXT_BADGES: Record<string, string> = {
56
+ '.ts': 'TS', '.tsx': 'TX', '.js': 'JS', '.jsx': 'JX', '.mjs': 'JS',
57
+ '.py': 'PY', '.go': 'GO', '.rs': 'RS', '.rb': 'RB', '.java': 'JV',
58
+ '.c': 'C ', '.cpp': 'C+', '.h': 'H ', '.cs': 'C#', '.php': 'PH',
59
+ '.swift': 'SW', '.kt': 'KT', '.lua': 'LU', '.zig': 'ZG',
60
+ '.css': 'CS', '.scss': 'SC', '.html': 'HT', '.vue': 'VU', '.svelte': 'SV',
61
+ '.json': '{}', '.yaml': 'YM', '.yml': 'YM', '.toml': 'TM',
62
+ '.md': 'MD', '.sql': 'SQ', '.sh': 'SH', '.bash': 'SH', '.zsh': 'SH',
63
+ };
64
+ function extBadge(ext: string): string { return EXT_BADGES[ext] ?? '··'; }
65
+
66
+
67
+ // ── Embedding / Pruner ─────────────────────────────────
68
+
69
+ interface OptionItem { value: string; label: string; desc: string; badge?: string }
70
+
71
+ const EMBEDDINGS: OptionItem[] = [
72
+ { value: 'perplexity-context', label: 'perplexity-context', desc: 'best accuracy', badge: '★' },
73
+ { value: 'perplexity', label: 'perplexity', desc: 'fast, high quality' },
74
+ { value: 'openai', label: 'openai', desc: 'text-embedding-3-small' },
75
+ { value: 'local', label: 'local', desc: 'offline, no API key' },
76
+ ];
77
+
78
+ const PRUNERS: OptionItem[] = [
79
+ { value: 'haiku', label: 'haiku', desc: 'AI-powered noise filter', badge: '★' },
80
+ { value: 'none', label: 'none', desc: 'no pruning' },
81
+ ];
82
+
83
+ const EXPANDERS: OptionItem[] = [
84
+ { value: 'haiku', label: 'haiku', desc: 'discovers related context', badge: '★' },
85
+ { value: 'none', label: 'none', desc: 'no expansion' },
86
+ ];
87
+
88
+
89
+ // ── Center-cursor scroll (Aurora pattern) ─────────────
90
+
91
+ function centerScroll(cursor: number, total: number, viewH: number): number {
92
+ if (total <= viewH) return 0;
93
+ const half = Math.floor(viewH / 2);
94
+ const offset = Math.max(0, cursor - half);
95
+ return Math.min(offset, total - viewH);
96
+ }
97
+
98
+
99
+ // ── Tree Row ──────────────────────────────────────────
100
+
101
+ function TreeItemRow({ item, isCursor }: {
102
+ item: FileTreeItem; isCursor: boolean;
103
+ }): React.ReactNode {
104
+ const indent = ' '.repeat(item.depth);
105
+ const excluded = !item.checked;
106
+ const ptr = isCursor ? '▸ ' : ' ';
107
+
108
+ if (item.isDir) {
109
+ const arrow = item.expanded ? '▾' : '▸';
110
+ const check = item.checked ? '✓' : '✗';
111
+ const checkColor = item.checked ? C.success : C.error;
112
+ const nameColor = excluded ? C.dim : isCursor ? C.aurora : C.dir;
113
+ const count = String(item.fileCount);
114
+
115
+ return (
116
+ <Box height={1} justifyContent="space-between">
117
+ <Text wrap="truncate">
118
+ <Text color={isCursor ? C.aurora : C.dim}>{ptr}</Text>
119
+ <Text>{indent}</Text>
120
+ <Text color={C.dim}>{arrow} </Text>
121
+ <Text color={checkColor} bold>{check} </Text>
122
+ <Text color={nameColor} bold={isCursor}>{item.name}/</Text>
123
+ </Text>
124
+ <Text color={C.dim}>{count}</Text>
125
+ </Box>
126
+ );
127
+ }
128
+
129
+ const check = item.checked ? '✓' : '✗';
130
+ const checkColor = item.checked ? C.success : C.error;
131
+
132
+ return (
133
+ <Box height={1}>
134
+ <Text wrap="truncate">
135
+ <Text color={isCursor ? C.aurora : C.dim}>{ptr}</Text>
136
+ <Text>{indent}</Text>
137
+ <Text color={checkColor}>{check} </Text>
138
+ <Text color={excluded ? C.dim : getExtColor(item.ext)}>{item.name}</Text>
139
+ </Text>
140
+ </Box>
141
+ );
142
+ }
143
+
144
+
145
+ // ── Main Screen (sidebar + tree) ──────────────────────
146
+
147
+ function MainScreen({ scan, width, height, onConfirm, externalPreviews }: {
148
+ scan: ScanResult; width: number; height: number;
149
+ onConfirm: (modules: string[], include: string[], ignore: string[]) => void;
150
+ externalPreviews?: Map<string, PreviewLine[]>;
151
+ }): React.ReactNode {
152
+ const { exit } = useApp();
153
+ const allMods = scan.modules;
154
+
155
+ // Pane focus
156
+ const [pane, setPane] = useState<Pane>('modules');
157
+
158
+ // Module state — pre-populate from config if plugins exist
159
+ const [checked, setChecked] = useState<Set<string>>(() => {
160
+ const configPlugins = scan.config.plugins;
161
+ if (configPlugins && configPlugins.length > 0) {
162
+ // --setup: match existing config selection
163
+ return new Set(configPlugins.filter(p => allMods.some(m => m.name === p)));
164
+ }
165
+ // Fresh: check all available modules
166
+ return new Set(allMods.filter(m => m.available && m.checked).map(m => m.name));
167
+ });
168
+ const firstAvail = allMods.findIndex(m => m.available);
169
+ const [modCursor, setModCursor] = useState(Math.max(0, firstAvail));
170
+
171
+ // Tree state — pre-populate checked from config include patterns
172
+ const [treeItems, setTreeItems] = useState<FileTreeItem[]>(() => buildFileTree(scan.repoPath, scan.config.include));
173
+ const [treeCursor, setTreeCursor] = useState(0);
174
+
175
+ // Filter mode — '/' to activate, Esc to clear
176
+ // Use ref so useInput always reads the latest value (avoids React batching lag)
177
+ const [filterText, setFilterText] = useState('');
178
+ const [isFiltering, setIsFiltering] = useState(false);
179
+ const isFilteringRef = useRef(false);
180
+ const startFiltering = useCallback(() => {
181
+ isFilteringRef.current = true;
182
+ setIsFiltering(true);
183
+ setFilterText('');
184
+ setTreeCursor(0);
185
+ }, []);
186
+ const stopFiltering = useCallback(() => {
187
+ isFilteringRef.current = false;
188
+ setIsFiltering(false);
189
+ setFilterText('');
190
+ setTreeCursor(0);
191
+ }, []);
192
+
193
+ // Docs & Git preview (lazy, memoized)
194
+ const docsPreview = useMemo(() => scanDocsPreview(scan.repoPath), [scan.repoPath]);
195
+ const gitPreview = useMemo(() => scanGitPreview(scan.repoPath), [scan.repoPath]);
196
+
197
+ // Merged preview data: external previews override built-in for same name
198
+ const allPreviews = useMemo(() => {
199
+ const map = new Map<string, PreviewLine[]>();
200
+ map.set('docs', docsPreview);
201
+ map.set('git', gitPreview);
202
+ if (externalPreviews) {
203
+ for (const [name, lines] of externalPreviews) {
204
+ map.set(name, lines);
205
+ }
206
+ }
207
+ return map;
208
+ }, [docsPreview, gitPreview, externalPreviews]);
209
+
210
+ // Which module is focused determines Explorer content
211
+ const focusedModName = allMods[modCursor]?.name ?? 'code';
212
+
213
+ // Panel height = total height - header(3) - footer(2) - borders
214
+ const panelH = Math.max(6, height - 7);
215
+ const treeViewH = Math.max(3, panelH - 4); // border(2) + title(1) + title margin(1)
216
+
217
+ // Module nav (skip disabled)
218
+ const modUp = () => setModCursor(p => {
219
+ for (let i = p - 1; i >= 0; i--) if (allMods[i]!.available) return i;
220
+ return p;
221
+ });
222
+ const modDown = () => setModCursor(p => {
223
+ for (let i = p + 1; i < allMods.length; i++) if (allMods[i]!.available) return i;
224
+ return p;
225
+ });
226
+
227
+ useInput((input, key) => {
228
+ const filtering = isFilteringRef.current;
229
+
230
+ if (key.escape) {
231
+ if (filtering || filterText) {
232
+ stopFiltering();
233
+ return;
234
+ }
235
+ exit(); return;
236
+ }
237
+ if (input === 'q' && !filtering) { exit(); return; }
238
+ if (key.tab && !filtering) { setPane(p => p === 'modules' ? 'tree' : 'modules'); return; }
239
+
240
+ if (key.return) {
241
+ if (filtering) {
242
+ // Confirm filter, stop typing mode but keep filter text
243
+ isFilteringRef.current = false;
244
+ setIsFiltering(false);
245
+ return;
246
+ }
247
+ const selected = [...checked];
248
+ if (selected.length === 0) return;
249
+ const patterns = generatePatternsFromTree(treeItems, scan.config.include);
250
+ onConfirm(selected, patterns.include, patterns.ignore);
251
+ return;
252
+ }
253
+
254
+ // ── Modules pane ──
255
+ if (pane === 'modules') {
256
+ if (key.upArrow || input === 'k') { modUp(); return; }
257
+ if (key.downArrow || input === 'j') { modDown(); return; }
258
+ if (input === ' ') {
259
+ const mod = allMods[modCursor];
260
+ if (!mod?.available) return;
261
+ setChecked(prev => {
262
+ const next = new Set(prev);
263
+ if (next.has(mod.name)) next.delete(mod.name); else next.add(mod.name);
264
+ return next;
265
+ });
266
+ return;
267
+ }
268
+ }
269
+
270
+ // ── Tree pane ──
271
+ if (pane === 'tree') {
272
+ // Filter mode input handling
273
+ if (filtering) {
274
+ if (key.backspace || key.delete) {
275
+ setFilterText(prev => prev.slice(0, -1));
276
+ setTreeCursor(0);
277
+ return;
278
+ }
279
+ if (input && !key.upArrow && !key.downArrow && !key.leftArrow && !key.rightArrow) {
280
+ setFilterText(prev => prev + input);
281
+ setTreeCursor(0);
282
+ return;
283
+ }
284
+ }
285
+
286
+ // '/' to enter filter mode (instant via ref)
287
+ if (input === '/' && !filtering) {
288
+ startFiltering();
289
+ return;
290
+ }
291
+
292
+ if (key.upArrow || (!filtering && input === 'k')) {
293
+ setTreeCursor(p => Math.max(0, p - 1));
294
+ return;
295
+ }
296
+ if (key.downArrow || (!filtering && input === 'j')) {
297
+ setTreeCursor(p => Math.min(filteredItems.length - 1, p + 1));
298
+ return;
299
+ }
300
+ if (key.rightArrow || (!filtering && input === 'l')) {
301
+ const item = filteredItems[treeCursor];
302
+ if (item?.isDir && !item.expanded) {
303
+ setTreeItems(prev => expandDir(prev, prev.indexOf(item), scan.repoPath));
304
+ }
305
+ return;
306
+ }
307
+ if (key.leftArrow || (!filtering && input === 'h')) {
308
+ const item = filteredItems[treeCursor];
309
+ if (item?.isDir && item.expanded) {
310
+ setTreeItems(prev => collapseDir(prev, prev.indexOf(item)));
311
+ }
312
+ return;
313
+ }
314
+ if (input === ' ') {
315
+ const item = filteredItems[treeCursor];
316
+ if (!item) return;
317
+ const realIdx = treeItems.indexOf(item);
318
+ if (realIdx < 0) return;
319
+ if (item.isDir) setTreeItems(prev => toggleDir(prev, realIdx));
320
+ else setTreeItems(prev => toggleFile(prev, realIdx));
321
+ return;
322
+ }
323
+ if (!filtering && input === 'a') { setTreeItems(prev => setAllDirs(prev, true)); return; }
324
+ if (!filtering && input === 'n') { setTreeItems(prev => setAllDirs(prev, false)); return; }
325
+ if (!filtering && input === 'i') {
326
+ setTreeItems(prev => prev.map(it => ({ ...it, checked: !it.checked })));
327
+ return;
328
+ }
329
+ }
330
+ });
331
+
332
+ // Filtered tree items — show matching files + their parent dirs
333
+ const filteredItems = useMemo(() => {
334
+ if (!filterText) return treeItems;
335
+ const lower = filterText.toLowerCase();
336
+ const matchedPaths = new Set<string>();
337
+ // Find direct matches
338
+ for (const item of treeItems) {
339
+ if (item.name.toLowerCase().includes(lower)) {
340
+ matchedPaths.add(item.path);
341
+ // Add all ancestor dirs
342
+ const parts = item.path.split('/');
343
+ for (let i = 1; i < parts.length; i++) {
344
+ matchedPaths.add(parts.slice(0, i).join('/'));
345
+ }
346
+ }
347
+ }
348
+ return treeItems.filter(item => matchedPaths.has(item.path));
349
+ }, [treeItems, filterText]);
350
+
351
+ const totalFiles = countTotalFiles(treeItems);
352
+ const selectedDirs = treeItems.filter(i => i.depth === 0 && i.isDir && i.checked).length;
353
+ const totalDirs = treeItems.filter(i => i.depth === 0 && i.isDir).length;
354
+ const visible = filteredItems.slice(
355
+ centerScroll(treeCursor, filteredItems.length, treeViewH),
356
+ centerScroll(treeCursor, filteredItems.length, treeViewH) + treeViewH,
357
+ );
358
+ const scrollOffset = centerScroll(treeCursor, filteredItems.length, treeViewH);
359
+ const dbInfo = scan.db?.exists ? `${scan.db.sizeMB} MB` : 'new';
360
+ const sidebarW = 30;
361
+
362
+ return (
363
+ <Box flexDirection="column" width={width}>
364
+ {/* Repo info */}
365
+ <Box paddingX={2} gap={2} marginTop={1} marginBottom={1}>
366
+ <Text color={C.aurora} bold>BrainBank</Text>
367
+ <Text color={C.dim}>·</Text>
368
+ <Text color={C.text}>{scan.repoPath}</Text>
369
+ <Text color={C.dim}>· 💾 {dbInfo}</Text>
370
+ </Box>
371
+
372
+ {/* Split panels — same height */}
373
+ <Box flexDirection="row" gap={1} height={panelH}>
374
+ {/* Left: Module sidebar */}
375
+ <Box flexDirection="column" width={sidebarW}
376
+ borderStyle="round" borderColor={pane === 'modules' ? C.aurora : C.border}
377
+ >
378
+ <Box paddingX={1}>
379
+ <Text color={pane === 'modules' ? C.aurora : C.dim} bold>Modules</Text>
380
+ </Box>
381
+ <Box flexDirection="column" paddingX={1} paddingY={1}>
382
+ {allMods.map((m, i) => {
383
+ const avail = m.available;
384
+ const isCur = i === modCursor;
385
+ const isChk = checked.has(m.name);
386
+ const box = !avail ? '─' : isChk ? '✓' : ' ';
387
+ const boxCol = !avail ? C.dim : isChk ? C.success : C.dim;
388
+ const curCol = isCur ? (pane === 'modules' ? C.aurora : C.dim) : 'transparent';
389
+
390
+ return (
391
+ <Box key={m.name} height={1}>
392
+ <Text color={curCol}>{isCur ? '▸' : ' '} </Text>
393
+ <Text color={boxCol}>[{box}] </Text>
394
+ <Text color={avail ? C.text : C.dim}>
395
+ {m.name.charAt(0).toUpperCase() + m.name.slice(1)}
396
+ </Text>
397
+ </Box>
398
+ );
399
+ })}
400
+ </Box>
401
+ {/* Fill to match tree height */}
402
+ <Box flexGrow={1} />
403
+ <Box paddingX={1} paddingBottom={1} flexDirection="column">
404
+ {allMods.filter(m => m.available && checked.has(m.name)).map(m => (
405
+ <Box key={`s${m.name}`} height={1}>
406
+ <Text color={C.dim} wrap="truncate">{m.summary}</Text>
407
+ </Box>
408
+ ))}
409
+ </Box>
410
+ </Box>
411
+
412
+ {/* Right: Explorer — contextual based on sidebar cursor */}
413
+ <Box flexDirection="column" flexGrow={1}
414
+ borderStyle="round" borderColor={pane === 'tree' ? C.aurora : C.border}
415
+ marginBottom={1}
416
+ >
417
+ <Box paddingX={1} justifyContent="space-between" marginBottom={1}>
418
+ <Text>
419
+ <Text color={pane === 'tree' ? C.aurora : C.dim} bold>Explorer</Text>
420
+ <Text color={C.dim}> · </Text>
421
+ <Text color={C.text}>{focusedModName.charAt(0).toUpperCase() + focusedModName.slice(1)}</Text>
422
+ </Text>
423
+ {focusedModName === 'code' && (
424
+ <Text color={C.dim}>{selectedDirs}/{totalDirs} dirs · {totalFiles} files</Text>
425
+ )}
426
+ </Box>
427
+
428
+ {/* Code: interactive tree */}
429
+ {focusedModName === 'code' && (
430
+ <Box flexDirection="column" paddingLeft={1} height={treeViewH} overflow="hidden">
431
+ {/* Filter bar */}
432
+ {(isFiltering || filterText) && (
433
+ <Box height={1} marginBottom={0}>
434
+ <Text color={C.aurora} bold>/ </Text>
435
+ <Text color={C.text}>{filterText}</Text>
436
+ <Text color={C.aurora}>▎</Text>
437
+ {filterText && <Text color={C.dim}> ({filteredItems.length} matches)</Text>}
438
+ </Box>
439
+ )}
440
+ {visible.map((item, i) => {
441
+ const globalIdx = centerScroll(treeCursor, filteredItems.length, treeViewH) + i;
442
+ return (
443
+ <TreeItemRow key={item.path} item={item}
444
+ isCursor={pane === 'tree' && globalIdx === treeCursor}
445
+ />
446
+ );
447
+ })}
448
+ {visible.length < treeViewH && Array.from(
449
+ { length: treeViewH - visible.length - (isFiltering || filterText ? 1 : 0) },
450
+ (_, i) => <Box key={`e${i}`} height={1}><Text> </Text></Box>,
451
+ )}
452
+ </Box>
453
+ )}
454
+
455
+ {/* Docs / Git / External: static preview */}
456
+ {focusedModName !== 'code' && allPreviews.has(focusedModName) && (
457
+ <Box flexDirection="column" paddingLeft={1} height={treeViewH} overflow="hidden">
458
+ {allPreviews.get(focusedModName)!.slice(0, treeViewH).map((line, i) => (
459
+ <Text key={`p${i}`} color={line.dim ? C.dim : line.color ?? C.text}
460
+ bold={line.bold} wrap="truncate"
461
+ >{line.text}</Text>
462
+ ))}
463
+ </Box>
464
+ )}
465
+
466
+ {/* No preview available for this module */}
467
+ {focusedModName !== 'code' && !allPreviews.has(focusedModName) && (
468
+ <Box flexDirection="column" paddingLeft={1} height={treeViewH} justifyContent="center" alignItems="center">
469
+ <Text color={C.dim}>No preview available</Text>
470
+ <Text color={C.dim}>This plugin will be indexed with default settings</Text>
471
+ </Box>
472
+ )}
473
+
474
+ {focusedModName === 'code' && filteredItems.length > treeViewH && (
475
+ <Box paddingX={2} justifyContent="flex-end">
476
+ <Text color={C.dim}>
477
+ {scrollOffset + 1}–{Math.min(scrollOffset + treeViewH, filteredItems.length)}/{filteredItems.length}
478
+ </Text>
479
+ </Box>
480
+ )}
481
+ </Box>
482
+ </Box>
483
+
484
+ {/* Footer */}
485
+ <Box paddingX={2} justifyContent="space-between" marginTop={1}>
486
+ <Text color={C.dim}>
487
+ <Text color={C.aurora}>Tab</Text> pane
488
+ <Text color={C.dim}> · </Text>
489
+ <Text color={C.aurora}>↑↓</Text> move
490
+ <Text color={C.dim}> · </Text>
491
+ <Text color={C.aurora}>Space</Text> toggle
492
+ <Text color={C.dim}> · </Text>
493
+ <Text color={C.aurora}>→←</Text> expand
494
+ <Text color={C.dim}> · </Text>
495
+ <Text color={C.aurora}>a</Text> all
496
+ <Text color={C.dim}> · </Text>
497
+ <Text color={C.aurora}>n</Text> none
498
+ <Text color={C.dim}> · </Text>
499
+ <Text color={C.aurora}>i</Text> invert
500
+ <Text color={C.dim}> · </Text>
501
+ <Text color={C.aurora}>/</Text> filter
502
+ </Text>
503
+ <Text color={C.aurora} bold>
504
+ Enter: {scan.config.exists ? 'Index ⚡' : 'Next →'}
505
+ </Text>
506
+ </Box>
507
+ </Box>
508
+ );
509
+ }
510
+
511
+
512
+ // ── Config Panel ───────────────────────────────────────
513
+
514
+ function ConfigPanel({ onDone }: {
515
+ onDone: (embedding: string, pruner: string, expander: string) => void;
516
+ }): React.ReactNode {
517
+ const { exit } = useApp();
518
+ type Section = 'embedding' | 'pruner' | 'expander';
519
+ const SECTIONS: Section[] = ['embedding', 'pruner', 'expander'];
520
+ const [section, setSection] = useState<Section>('embedding');
521
+ const [embIdx, setEmbIdx] = useState(0);
522
+ const [prunerIdx, setPrunerIdx] = useState(0);
523
+ const [expanderIdx, setExpanderIdx] = useState(0);
524
+
525
+ useInput((input, key) => {
526
+ if (key.escape || input === 'q') { exit(); return; }
527
+ if (key.upArrow || input === 'k') {
528
+ if (section === 'embedding') setEmbIdx(p => Math.max(0, p - 1));
529
+ else if (section === 'pruner') setPrunerIdx(p => Math.max(0, p - 1));
530
+ else setExpanderIdx(p => Math.max(0, p - 1));
531
+ return;
532
+ }
533
+ if (key.downArrow || input === 'j') {
534
+ if (section === 'embedding') setEmbIdx(p => Math.min(EMBEDDINGS.length - 1, p + 1));
535
+ else if (section === 'pruner') setPrunerIdx(p => Math.min(PRUNERS.length - 1, p + 1));
536
+ else setExpanderIdx(p => Math.min(EXPANDERS.length - 1, p + 1));
537
+ return;
538
+ }
539
+ if (key.tab) {
540
+ setSection(p => {
541
+ const idx = SECTIONS.indexOf(p);
542
+ return SECTIONS[(idx + 1) % SECTIONS.length]!;
543
+ });
544
+ return;
545
+ }
546
+ if (key.return) {
547
+ onDone(EMBEDDINGS[embIdx]!.value, PRUNERS[prunerIdx]!.value, EXPANDERS[expanderIdx]!.value);
548
+ return;
549
+ }
550
+ });
551
+
552
+ const renderOpt = (item: OptionItem, i: number, cur: boolean, sel: boolean) => (
553
+ <Box key={item.value} height={1}>
554
+ <Text color={cur ? C.aurora : C.dim}>{cur ? '▸' : ' '} </Text>
555
+ <Text color={sel ? C.success : C.dim}>{sel ? '●' : '○'} </Text>
556
+ <Text color={cur ? C.text : C.dim} bold={cur}>{item.label.padEnd(22)}</Text>
557
+ <Text color={C.dim}>{item.desc}</Text>
558
+ {item.badge ? <Text color={C.warning}> {item.badge}</Text> : null}
559
+ </Box>
560
+ );
561
+
562
+ return (
563
+ <Box flexDirection="column" paddingX={1}>
564
+ <Box justifyContent="center" marginBottom={1}>
565
+ <Text color={C.cyan} bold>⚙ First-Time Setup</Text>
566
+ </Box>
567
+ <Box flexDirection="column" borderStyle="round"
568
+ borderColor={section === 'embedding' ? C.aurora : C.border} paddingX={2} paddingY={1}
569
+ >
570
+ <Box marginBottom={1}>
571
+ <Text color={section === 'embedding' ? C.aurora : C.dim} bold>Embedding Provider</Text>
572
+ </Box>
573
+ {EMBEDDINGS.map((it, i) => renderOpt(it, i, section === 'embedding' && i === embIdx, i === embIdx))}
574
+ </Box>
575
+ <Box flexDirection="column" borderStyle="round"
576
+ borderColor={section === 'pruner' ? C.aurora : C.border} paddingX={2} paddingY={1}
577
+ >
578
+ <Box marginBottom={1}>
579
+ <Text color={section === 'pruner' ? C.aurora : C.dim} bold>Noise Pruner</Text>
580
+ </Box>
581
+ {PRUNERS.map((it, i) => renderOpt(it, i, section === 'pruner' && i === prunerIdx, i === prunerIdx))}
582
+ </Box>
583
+ <Box flexDirection="column" borderStyle="round"
584
+ borderColor={section === 'expander' ? C.aurora : C.border} paddingX={2} paddingY={1}
585
+ >
586
+ <Box marginBottom={1}>
587
+ <Text color={section === 'expander' ? C.aurora : C.dim} bold>Context Expander</Text>
588
+ </Box>
589
+ {EXPANDERS.map((it, i) => renderOpt(it, i, section === 'expander' && i === expanderIdx, i === expanderIdx))}
590
+ </Box>
591
+ <Box paddingX={1} marginTop={1}>
592
+ <Text color={C.dim}>
593
+ <Text color={C.aurora}>↑↓</Text> select · <Text color={C.aurora}>Tab</Text> section · <Text color={C.aurora}>Enter</Text> start indexing
594
+ </Text>
595
+ </Box>
596
+ </Box>
597
+ );
598
+ }
599
+
600
+
601
+ // ── App Root ────────────────────────────────────────────
602
+
603
+ function IndexApp({ scan, externalPreviews }: {
604
+ scan: ScanResult;
605
+ externalPreviews?: Map<string, PreviewLine[]>;
606
+ }): React.ReactNode {
607
+ const { exit } = useApp();
608
+ const { stdout } = useStdout();
609
+ const [rawW, setRawW] = useState(stdout?.columns || 100);
610
+ const [rawH, setRawH] = useState(stdout?.rows || 30);
611
+ const [phase, setPhase] = useState<Phase>('main');
612
+ const [selectedModules, setSelectedModules] = useState<string[]>([]);
613
+ const [patterns, setPatterns] = useState({ include: [] as string[], ignore: [] as string[] });
614
+
615
+ const width = Math.min(rawW, MAX_W);
616
+ const height = Math.min(rawH, MAX_H);
617
+
618
+ useEffect(() => {
619
+ if (!stdout) return;
620
+ const onResize = () => { setRawW(stdout.columns); setRawH(stdout.rows); };
621
+ stdout.on('resize', onResize);
622
+ return () => { stdout.off('resize', onResize); };
623
+ }, [stdout]);
624
+
625
+ const handleMainConfirm = (modules: string[], include: string[], ignore: string[]) => {
626
+ setSelectedModules(modules);
627
+ setPatterns({ include, ignore });
628
+ if (scan.config.exists) {
629
+ _lastSelection = { modules, include, ignore };
630
+ setTimeout(() => exit(), 50);
631
+ } else {
632
+ setPhase('config');
633
+ }
634
+ };
635
+
636
+ const handleConfigDone = (embedding: string, pruner: string, expander: string) => {
637
+ _lastSelection = { modules: selectedModules, ...patterns, config: { embedding, pruner, expander } };
638
+ setTimeout(() => exit(), 50);
639
+ };
640
+
641
+ return (
642
+ <Box flexDirection="column" width={width} height={height}>
643
+ {phase === 'main' && (
644
+ <MainScreen scan={scan} width={width} height={height}
645
+ onConfirm={handleMainConfirm}
646
+ externalPreviews={externalPreviews}
647
+ />
648
+ )}
649
+ {phase === 'config' && <ConfigPanel onDone={handleConfigDone} />}
650
+ </Box>
651
+ );
652
+ }
653
+
654
+
655
+ // ── Public API ─────────────────────────────────────────
656
+
657
+ let _lastSelection: TuiSelection | null = null;
658
+
659
+ export async function runIndexTui(
660
+ scan: ScanResult,
661
+ externalPreviews?: Map<string, PreviewLine[]>,
662
+ ): Promise<TuiSelection | null> {
663
+ _lastSelection = null;
664
+ const instance = render(<IndexApp scan={scan} externalPreviews={externalPreviews} />);
665
+ await instance.waitUntilExit();
666
+ return _lastSelection;
667
+ }