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.
- package/LICENSE +21 -0
- package/README.md +155 -0
- package/assets/architecture.png +0 -0
- package/bin/brainbank +18 -0
- package/bin/brainbank-mcp +19 -0
- package/dist/chunk-3YBCD6DI.js +117 -0
- package/dist/chunk-3YBCD6DI.js.map +1 -0
- package/dist/chunk-63GBCDS5.js +3249 -0
- package/dist/chunk-63GBCDS5.js.map +1 -0
- package/dist/chunk-DMFMTOHF.js +123 -0
- package/dist/chunk-DMFMTOHF.js.map +1 -0
- package/dist/chunk-FQYKWB2Q.js +136 -0
- package/dist/chunk-FQYKWB2Q.js.map +1 -0
- package/dist/chunk-IMJJ2VEM.js +74 -0
- package/dist/chunk-IMJJ2VEM.js.map +1 -0
- package/dist/chunk-M744PCJQ.js +43 -0
- package/dist/chunk-M744PCJQ.js.map +1 -0
- package/dist/chunk-O3J6ZIXK.js +82 -0
- package/dist/chunk-O3J6ZIXK.js.map +1 -0
- package/dist/chunk-OPH7GZ7U.js +124 -0
- package/dist/chunk-OPH7GZ7U.js.map +1 -0
- package/dist/chunk-PXEWQMN7.js +89 -0
- package/dist/chunk-PXEWQMN7.js.map +1 -0
- package/dist/chunk-RDQYDLYZ.js +69 -0
- package/dist/chunk-RDQYDLYZ.js.map +1 -0
- package/dist/chunk-VIIHPCC4.js +254 -0
- package/dist/chunk-VIIHPCC4.js.map +1 -0
- package/dist/chunk-WCQVDF3K.js +14 -0
- package/dist/chunk-WCQVDF3K.js.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +3076 -0
- package/dist/cli.js.map +1 -0
- package/dist/haiku-expander-YRSIPGKP.js +8 -0
- package/dist/haiku-expander-YRSIPGKP.js.map +1 -0
- package/dist/haiku-pruner-SHAXUPY6.js +8 -0
- package/dist/haiku-pruner-SHAXUPY6.js.map +1 -0
- package/dist/http-server-QUXHLWUM.js +9 -0
- package/dist/http-server-QUXHLWUM.js.map +1 -0
- package/dist/index.d.ts +2161 -0
- package/dist/index.js +357 -0
- package/dist/index.js.map +1 -0
- package/dist/local-embedding-NZQTILGV.js +8 -0
- package/dist/local-embedding-NZQTILGV.js.map +1 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +334 -0
- package/dist/mcp.js.map +1 -0
- package/dist/openai-embedding-ZP5TSUJG.js +8 -0
- package/dist/openai-embedding-ZP5TSUJG.js.map +1 -0
- package/dist/perplexity-context-embedding-GI5PHE6X.js +9 -0
- package/dist/perplexity-context-embedding-GI5PHE6X.js.map +1 -0
- package/dist/perplexity-embedding-KZRYGJRC.js +10 -0
- package/dist/perplexity-embedding-KZRYGJRC.js.map +1 -0
- package/dist/plugin-IKQ6IRSJ.js +32 -0
- package/dist/plugin-IKQ6IRSJ.js.map +1 -0
- package/dist/resolve-ASGLBNUC.js +10 -0
- package/dist/resolve-ASGLBNUC.js.map +1 -0
- package/dist/stats-tui-ZY2NQSEA.js +1904 -0
- package/dist/stats-tui-ZY2NQSEA.js.map +1 -0
- package/package.json +96 -0
- package/src/brainbank.ts +617 -0
- package/src/cli/commands/collection.ts +77 -0
- package/src/cli/commands/context.ts +179 -0
- package/src/cli/commands/daemon.ts +100 -0
- package/src/cli/commands/docs.ts +71 -0
- package/src/cli/commands/files.ts +69 -0
- package/src/cli/commands/help.ts +77 -0
- package/src/cli/commands/index.ts +482 -0
- package/src/cli/commands/kv.ts +140 -0
- package/src/cli/commands/mcp-export.ts +273 -0
- package/src/cli/commands/mcp.ts +6 -0
- package/src/cli/commands/reembed.ts +30 -0
- package/src/cli/commands/scan.ts +336 -0
- package/src/cli/commands/search.ts +203 -0
- package/src/cli/commands/stats.ts +68 -0
- package/src/cli/commands/status.ts +47 -0
- package/src/cli/commands/watch.ts +47 -0
- package/src/cli/factory/brain-context.ts +43 -0
- package/src/cli/factory/builtin-registration.ts +87 -0
- package/src/cli/factory/config-loader.ts +77 -0
- package/src/cli/factory/index.ts +69 -0
- package/src/cli/factory/plugin-loader.ts +325 -0
- package/src/cli/index.ts +71 -0
- package/src/cli/server-client.ts +178 -0
- package/src/cli/tui/index-tui.tsx +667 -0
- package/src/cli/tui/stats-data.ts +523 -0
- package/src/cli/tui/stats-search.ts +262 -0
- package/src/cli/tui/stats-tui.tsx +1465 -0
- package/src/cli/tui/tree-scanner.ts +650 -0
- package/src/cli/utils.ts +137 -0
- package/src/config.ts +49 -0
- package/src/constants.ts +21 -0
- package/src/db/adapter.ts +112 -0
- package/src/db/metadata.ts +130 -0
- package/src/db/migrations.ts +66 -0
- package/src/db/sqlite-adapter.ts +218 -0
- package/src/db/tracker.ts +91 -0
- package/src/engine/index-api.ts +81 -0
- package/src/engine/reembed.ts +206 -0
- package/src/engine/search-api.ts +218 -0
- package/src/index.ts +154 -0
- package/src/lib/fts.ts +57 -0
- package/src/lib/languages.ts +180 -0
- package/src/lib/logger.ts +126 -0
- package/src/lib/math.ts +87 -0
- package/src/lib/provider-key.ts +20 -0
- package/src/lib/prune.ts +71 -0
- package/src/lib/rrf.ts +133 -0
- package/src/lib/write-lock.ts +108 -0
- package/src/mcp/mcp-server.ts +195 -0
- package/src/mcp/workspace-factory.ts +68 -0
- package/src/mcp/workspace-pool.ts +224 -0
- package/src/plugin.ts +381 -0
- package/src/providers/embeddings/embedding-worker-thread.ts +95 -0
- package/src/providers/embeddings/embedding-worker.ts +141 -0
- package/src/providers/embeddings/local-embedding.ts +115 -0
- package/src/providers/embeddings/openai-embedding.ts +167 -0
- package/src/providers/embeddings/perplexity-context-embedding.ts +195 -0
- package/src/providers/embeddings/perplexity-embedding.ts +165 -0
- package/src/providers/embeddings/resolve.ts +34 -0
- package/src/providers/pruners/haiku-expander.ts +166 -0
- package/src/providers/pruners/haiku-pruner.ts +112 -0
- package/src/providers/vector/hnsw-index.ts +174 -0
- package/src/providers/vector/hnsw-loader.ts +129 -0
- package/src/search/bm25-boost.ts +69 -0
- package/src/search/context-builder.ts +251 -0
- package/src/search/keyword/composite-bm25-search.ts +47 -0
- package/src/search/types.ts +37 -0
- package/src/search/vector/composite-vector-search.ts +61 -0
- package/src/search/vector/mmr.ts +64 -0
- package/src/services/collection.ts +384 -0
- package/src/services/daemon.ts +87 -0
- package/src/services/http-server.ts +336 -0
- package/src/services/kv-service.ts +64 -0
- package/src/services/plugin-registry.ts +77 -0
- package/src/services/watch.ts +340 -0
- package/src/services/webhook-server.ts +100 -0
- package/src/types.ts +493 -0
|
@@ -0,0 +1,1465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stats-tui.tsx — Interactive Ink TUI for `brainbank stats`.
|
|
3
|
+
*
|
|
4
|
+
* Split-panel layout with 5 views:
|
|
5
|
+
* 1. Dashboard — overview, language bars, directory list
|
|
6
|
+
* 2. File Explorer — drill into directory, file list + detail
|
|
7
|
+
* 3. Chunk Viewer — browse chunks, preview content
|
|
8
|
+
* 4. Call Graph — interactive call tree
|
|
9
|
+
* 5. Semantic Search — full pipeline (vector → prune → expand)
|
|
10
|
+
*
|
|
11
|
+
* Reuses patterns & colors from index-tui.tsx.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
|
|
15
|
+
import { render, Box, Text, useApp, useInput, useStdout } from 'ink';
|
|
16
|
+
import {
|
|
17
|
+
fetchOverview, fetchLanguageBreakdown, fetchDirectories,
|
|
18
|
+
fetchFilesInDir, fetchFileDetail, fetchChunksForFile, fetchCallTree,
|
|
19
|
+
searchSymbols,
|
|
20
|
+
} from './stats-data.ts';
|
|
21
|
+
import type {
|
|
22
|
+
StatsOverview, LanguageStat, DirectoryStat,
|
|
23
|
+
FileStat, FileDetailInfo, ChunkInfo, CallTreeNode,
|
|
24
|
+
} from './stats-data.ts';
|
|
25
|
+
import { BrainSearchSession } from './stats-search.ts';
|
|
26
|
+
import type { SearchPipelineResult, SourceOption } from './stats-search.ts';
|
|
27
|
+
import type { SearchResult } from '@/types.ts';
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
// ── Colors (Aurora palette — same as index-tui) ───────
|
|
31
|
+
|
|
32
|
+
const C = {
|
|
33
|
+
aurora: '#7AA2F7',
|
|
34
|
+
success: '#9ECE6A',
|
|
35
|
+
error: '#F7768E',
|
|
36
|
+
warning: '#E0AF68',
|
|
37
|
+
dim: '#565F89',
|
|
38
|
+
text: '#C0CAF5',
|
|
39
|
+
border: '#3B4261',
|
|
40
|
+
cyan: '#7DCFFF',
|
|
41
|
+
purple: '#BB9AF7',
|
|
42
|
+
orange: '#FF9E64',
|
|
43
|
+
dir: '#E0AF68',
|
|
44
|
+
} as const;
|
|
45
|
+
|
|
46
|
+
// Use 90% of terminal — cleared on launch for full-screen feel.
|
|
47
|
+
|
|
48
|
+
// ── Language colors / badges ──────────────────────
|
|
49
|
+
|
|
50
|
+
const LANG_COLORS: Record<string, string> = {
|
|
51
|
+
python: '#4B8BBE',
|
|
52
|
+
typescript: '#519ABA',
|
|
53
|
+
javascript: '#CBCB41',
|
|
54
|
+
css: '#42A5F5',
|
|
55
|
+
go: '#7FD5EA',
|
|
56
|
+
rust: '#DEA584',
|
|
57
|
+
ruby: '#CC3E44',
|
|
58
|
+
java: '#CC3E44',
|
|
59
|
+
c: '#599EFF',
|
|
60
|
+
cpp: '#599EFF',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const LANG_BADGES: Record<string, string> = {
|
|
64
|
+
python: 'PY', typescript: 'TS', javascript: 'JS', css: 'CS',
|
|
65
|
+
go: 'GO', rust: 'RS', ruby: 'RB', java: 'JV', c: 'C ', cpp: 'C+',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function langColor(lang: string): string { return LANG_COLORS[lang] ?? C.text; }
|
|
69
|
+
function langBadge(lang: string): string { return LANG_BADGES[lang] ?? lang.slice(0, 2).toUpperCase(); }
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
// ── Utilities ───────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
function centerScroll(cursor: number, total: number, viewH: number): number {
|
|
75
|
+
if (total <= viewH) return 0;
|
|
76
|
+
const half = Math.floor(viewH / 2);
|
|
77
|
+
const offset = Math.max(0, cursor - half);
|
|
78
|
+
return Math.min(offset, total - viewH);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function bar(percent: number, width: number): string {
|
|
82
|
+
const filled = Math.round(percent / 100 * width);
|
|
83
|
+
return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function truncate(str: string, max: number): string {
|
|
87
|
+
return str.length > max ? str.slice(0, max - 1) + '…' : str;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Syntax Highlighting ───────────────────────────
|
|
91
|
+
|
|
92
|
+
/** A colored segment of a syntax-highlighted line. */
|
|
93
|
+
interface SyntaxSegment {
|
|
94
|
+
text: string;
|
|
95
|
+
color: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Keyword sets for common languages
|
|
99
|
+
const KEYWORDS = new Set([
|
|
100
|
+
// JS/TS
|
|
101
|
+
'async', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue',
|
|
102
|
+
'default', 'delete', 'do', 'else', 'enum', 'export', 'extends', 'false',
|
|
103
|
+
'finally', 'for', 'from', 'function', 'if', 'implements', 'import', 'in',
|
|
104
|
+
'instanceof', 'interface', 'let', 'new', 'null', 'of', 'return', 'static',
|
|
105
|
+
'super', 'switch', 'this', 'throw', 'true', 'try', 'type', 'typeof',
|
|
106
|
+
'undefined', 'var', 'void', 'while', 'yield',
|
|
107
|
+
// Python
|
|
108
|
+
'def', 'class', 'self', 'None', 'True', 'False', 'and', 'or', 'not',
|
|
109
|
+
'is', 'lambda', 'with', 'as', 'pass', 'raise', 'global', 'nonlocal',
|
|
110
|
+
'elif', 'except', 'assert',
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
const TYPE_WORDS = new Set([
|
|
114
|
+
'string', 'number', 'boolean', 'any', 'void', 'never', 'unknown',
|
|
115
|
+
'object', 'Promise', 'Array', 'Map', 'Set', 'Record', 'Partial',
|
|
116
|
+
'Readonly', 'Required', 'Pick', 'Omit', 'int', 'float', 'str',
|
|
117
|
+
'list', 'dict', 'tuple', 'bool', 'Optional',
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
/** Tokenize a line of code into colored segments. Simple regex-based. */
|
|
121
|
+
function highlightLine(line: string, maxW: number): SyntaxSegment[] {
|
|
122
|
+
const trimmed = line.length > maxW ? line.slice(0, maxW - 1) + '…' : line;
|
|
123
|
+
if (trimmed.length === 0) return [{ text: '', color: C.text }];
|
|
124
|
+
|
|
125
|
+
const segments: SyntaxSegment[] = [];
|
|
126
|
+
// Single-line comment check
|
|
127
|
+
const commentIdx = findCommentStart(trimmed);
|
|
128
|
+
const codePart = commentIdx >= 0 ? trimmed.slice(0, commentIdx) : trimmed;
|
|
129
|
+
const commentPart = commentIdx >= 0 ? trimmed.slice(commentIdx) : '';
|
|
130
|
+
|
|
131
|
+
// Tokenize code part
|
|
132
|
+
if (codePart.length > 0) {
|
|
133
|
+
// Match patterns: strings, numbers, keywords, decorators, rest
|
|
134
|
+
const pattern = /(@\w+)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\b\d+(?:\.\d+)?\b)|(\b[A-Za-z_]\w*\b)|([^A-Za-z_@'"\d`]+)/g;
|
|
135
|
+
let m: RegExpExecArray | null;
|
|
136
|
+
while ((m = pattern.exec(codePart)) !== null) {
|
|
137
|
+
const [full, decorator, str, num, word, other] = m;
|
|
138
|
+
if (decorator) {
|
|
139
|
+
segments.push({ text: decorator, color: C.warning });
|
|
140
|
+
} else if (str) {
|
|
141
|
+
segments.push({ text: str, color: C.success });
|
|
142
|
+
} else if (num) {
|
|
143
|
+
segments.push({ text: num, color: C.orange });
|
|
144
|
+
} else if (word) {
|
|
145
|
+
if (KEYWORDS.has(word)) {
|
|
146
|
+
segments.push({ text: word, color: C.purple });
|
|
147
|
+
} else if (TYPE_WORDS.has(word)) {
|
|
148
|
+
segments.push({ text: word, color: C.cyan });
|
|
149
|
+
} else if (word[0] === word[0].toUpperCase() && word[0] !== word[0].toLowerCase()) {
|
|
150
|
+
// PascalCase → likely a class/type
|
|
151
|
+
segments.push({ text: word, color: C.cyan });
|
|
152
|
+
} else {
|
|
153
|
+
segments.push({ text: word, color: C.text });
|
|
154
|
+
}
|
|
155
|
+
} else if (other) {
|
|
156
|
+
segments.push({ text: other, color: C.dim });
|
|
157
|
+
} else {
|
|
158
|
+
segments.push({ text: full, color: C.text });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Comment part
|
|
164
|
+
if (commentPart) {
|
|
165
|
+
segments.push({ text: commentPart, color: C.dim });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return segments.length > 0 ? segments : [{ text: trimmed, color: C.text }];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Find the start of a single-line comment, avoiding matches inside strings. */
|
|
172
|
+
function findCommentStart(line: string): number {
|
|
173
|
+
let inString: string | null = null;
|
|
174
|
+
for (let i = 0; i < line.length; i++) {
|
|
175
|
+
const ch = line[i];
|
|
176
|
+
if (inString) {
|
|
177
|
+
if (ch === '\\') { i++; continue; }
|
|
178
|
+
if (ch === inString) inString = null;
|
|
179
|
+
} else {
|
|
180
|
+
if (ch === '"' || ch === "'" || ch === '`') { inString = ch; continue; }
|
|
181
|
+
if (ch === '/' && line[i + 1] === '/') return i;
|
|
182
|
+
if (ch === '#' && (i === 0 || /\s/.test(line[i - 1]))) return i;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return -1;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Render a syntax-highlighted code line as Ink <Text> elements. */
|
|
189
|
+
function HighlightedLine({ segments }: { segments: SyntaxSegment[] }): React.ReactNode {
|
|
190
|
+
return (
|
|
191
|
+
<>
|
|
192
|
+
{segments.map((seg, i) => (
|
|
193
|
+
<Text key={i} color={seg.color}>{seg.text}</Text>
|
|
194
|
+
))}
|
|
195
|
+
</>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
type View = 'dashboard' | 'files' | 'chunks' | 'callgraph' | 'search';
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
// ── Dashboard View ────────────────────────────────
|
|
203
|
+
|
|
204
|
+
function DashboardView({ overview, languages, dirs, width, height, onDrillDir, onCallGraph, onSearch }: {
|
|
205
|
+
overview: StatsOverview;
|
|
206
|
+
languages: LanguageStat[];
|
|
207
|
+
dirs: DirectoryStat[];
|
|
208
|
+
width: number;
|
|
209
|
+
height: number;
|
|
210
|
+
onDrillDir: (dir: string) => void;
|
|
211
|
+
onCallGraph: () => void;
|
|
212
|
+
onSearch: () => void;
|
|
213
|
+
}): React.ReactNode {
|
|
214
|
+
const [cursor, setCursor] = useState(0);
|
|
215
|
+
|
|
216
|
+
useInput((input, key) => {
|
|
217
|
+
if (key.downArrow) setCursor(c => Math.min(c + 1, dirs.length - 1));
|
|
218
|
+
if (key.upArrow) setCursor(c => Math.max(c - 1, 0));
|
|
219
|
+
if (key.return && dirs[cursor]) onDrillDir(dirs[cursor].dir);
|
|
220
|
+
if (input === 'g') onCallGraph();
|
|
221
|
+
if (input === '/') onSearch();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const leftW = 30;
|
|
225
|
+
const rightW = Math.max(40, width - leftW - 5);
|
|
226
|
+
const barW = Math.max(10, rightW - 35);
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<Box flexDirection="row" width={width} height={height - 4}>
|
|
230
|
+
{/* Left: Overview */}
|
|
231
|
+
<Box flexDirection="column" width={leftW} paddingX={1}>
|
|
232
|
+
<Box marginBottom={1}>
|
|
233
|
+
<Text color={C.aurora} bold>Overview</Text>
|
|
234
|
+
</Box>
|
|
235
|
+
<Text color={C.text}> 📁 <Text bold>{overview.files}</Text> files</Text>
|
|
236
|
+
<Text color={C.text}> 🧩 <Text bold>{overview.chunks}</Text> chunks</Text>
|
|
237
|
+
<Text color={C.text}> 🔗 <Text bold>{overview.callEdges}</Text> call edges</Text>
|
|
238
|
+
<Text color={C.text}> 📥 <Text bold>{overview.importEdges}</Text> imports</Text>
|
|
239
|
+
<Text color={C.text}> 🏷 <Text bold>{overview.symbols}</Text> symbols</Text>
|
|
240
|
+
<Text color={C.text}> 📊 <Text bold>{overview.dbSizeMB}</Text> MB db</Text>
|
|
241
|
+
<Text color={C.text}> 🔍 <Text bold>{overview.hnswSize}</Text> vectors</Text>
|
|
242
|
+
<Box marginTop={1}>
|
|
243
|
+
<Text color={C.dim}>Embedding:</Text>
|
|
244
|
+
</Box>
|
|
245
|
+
<Text color={C.cyan}> {overview.embeddingModel}</Text>
|
|
246
|
+
<Text color={C.dim}> Pruner: <Text color={C.text}>{overview.pruner}</Text></Text>
|
|
247
|
+
<Text color={C.dim}> Expander: <Text color={C.text}>{overview.expander}</Text></Text>
|
|
248
|
+
</Box>
|
|
249
|
+
|
|
250
|
+
{/* Right: Languages + Directories */}
|
|
251
|
+
<Box flexDirection="column" width={rightW} paddingX={1}>
|
|
252
|
+
<Box marginBottom={1}>
|
|
253
|
+
<Text color={C.aurora} bold>Language Breakdown</Text>
|
|
254
|
+
</Box>
|
|
255
|
+
{languages.map(lang => (
|
|
256
|
+
<Box key={lang.language} height={1}>
|
|
257
|
+
<Text wrap="truncate">
|
|
258
|
+
<Text color={langColor(lang.language)} bold>{langBadge(lang.language)}</Text>
|
|
259
|
+
<Text> </Text>
|
|
260
|
+
<Text color={langColor(lang.language)}>{bar(lang.percent, barW)}</Text>
|
|
261
|
+
<Text color={C.dim}> {String(lang.chunks).padStart(4)} </Text>
|
|
262
|
+
<Text color={C.dim}>{lang.percent.toFixed(1).padStart(5)}%</Text>
|
|
263
|
+
</Text>
|
|
264
|
+
</Box>
|
|
265
|
+
))}
|
|
266
|
+
|
|
267
|
+
<Box marginTop={1} marginBottom={1}>
|
|
268
|
+
<Text color={C.aurora} bold>Directories</Text>
|
|
269
|
+
<Text color={C.dim}> ─── files ── chunks</Text>
|
|
270
|
+
</Box>
|
|
271
|
+
{dirs.map((d, i) => {
|
|
272
|
+
const isCursor = i === cursor;
|
|
273
|
+
const ptr = isCursor ? '▸ ' : ' ';
|
|
274
|
+
const dirBarW = Math.max(5, Math.min(15, Math.round(d.percent / 100 * 15)));
|
|
275
|
+
return (
|
|
276
|
+
<Box key={d.dir} height={1}>
|
|
277
|
+
<Text wrap="truncate">
|
|
278
|
+
<Text color={isCursor ? C.aurora : C.dim}>{ptr}</Text>
|
|
279
|
+
<Text color={isCursor ? C.aurora : C.dir} bold={isCursor}>
|
|
280
|
+
{truncate(d.dir + '/', 20).padEnd(20)}
|
|
281
|
+
</Text>
|
|
282
|
+
<Text color={C.dim}>{String(d.files).padStart(5)} {String(d.chunks).padStart(5)} </Text>
|
|
283
|
+
<Text color={isCursor ? C.aurora : C.success}>{bar(d.percent, dirBarW)}</Text>
|
|
284
|
+
</Text>
|
|
285
|
+
</Box>
|
|
286
|
+
);
|
|
287
|
+
})}
|
|
288
|
+
</Box>
|
|
289
|
+
</Box>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
// ── File Explorer View ────────────────────────────
|
|
295
|
+
|
|
296
|
+
function FileExplorerView({ dbPath, dir, width, height, onDrillFile, onBack }: {
|
|
297
|
+
dbPath: string;
|
|
298
|
+
dir: string;
|
|
299
|
+
width: number;
|
|
300
|
+
height: number;
|
|
301
|
+
onDrillFile: (filePath: string) => void;
|
|
302
|
+
onBack: () => void;
|
|
303
|
+
}): React.ReactNode {
|
|
304
|
+
const files = useMemo(() => fetchFilesInDir(dbPath, dir), [dbPath, dir]);
|
|
305
|
+
const [cursor, setCursor] = useState(0);
|
|
306
|
+
const [sortMode, setSortMode] = useState<'chunks' | 'name' | 'symbols'>('chunks');
|
|
307
|
+
|
|
308
|
+
// Filter mode
|
|
309
|
+
const [filterText, setFilterText] = useState('');
|
|
310
|
+
const isFilteringRef = useRef(false);
|
|
311
|
+
const [isFiltering, setIsFiltering] = useState(false);
|
|
312
|
+
|
|
313
|
+
const sorted = useMemo(() => {
|
|
314
|
+
const s = [...files];
|
|
315
|
+
if (sortMode === 'name') s.sort((a, b) => a.fileName.localeCompare(b.fileName));
|
|
316
|
+
else if (sortMode === 'symbols') s.sort((a, b) => b.symbols - a.symbols);
|
|
317
|
+
return s;
|
|
318
|
+
}, [files, sortMode]);
|
|
319
|
+
|
|
320
|
+
const filtered = useMemo(() => {
|
|
321
|
+
if (!filterText) return sorted;
|
|
322
|
+
const lower = filterText.toLowerCase();
|
|
323
|
+
return sorted.filter(f => f.fileName.toLowerCase().includes(lower));
|
|
324
|
+
}, [sorted, filterText]);
|
|
325
|
+
|
|
326
|
+
const detail: FileDetailInfo | null = useMemo(() => {
|
|
327
|
+
if (!filtered[cursor]) return null;
|
|
328
|
+
return fetchFileDetail(dbPath, filtered[cursor].filePath);
|
|
329
|
+
}, [dbPath, filtered, cursor]);
|
|
330
|
+
|
|
331
|
+
const listH = Math.max(5, height - 6);
|
|
332
|
+
const scrollOff = centerScroll(cursor, filtered.length, listH);
|
|
333
|
+
|
|
334
|
+
useInput((input, key) => {
|
|
335
|
+
const filtering = isFilteringRef.current;
|
|
336
|
+
|
|
337
|
+
if (key.escape) {
|
|
338
|
+
if (filtering || filterText) {
|
|
339
|
+
isFilteringRef.current = false;
|
|
340
|
+
setIsFiltering(false);
|
|
341
|
+
setFilterText('');
|
|
342
|
+
setCursor(0);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
onBack();
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Filter mode typing
|
|
350
|
+
if (filtering) {
|
|
351
|
+
if (key.return) {
|
|
352
|
+
isFilteringRef.current = false;
|
|
353
|
+
setIsFiltering(false);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (key.backspace || key.delete) {
|
|
357
|
+
setFilterText(prev => prev.slice(0, -1));
|
|
358
|
+
setCursor(0);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (input && !key.upArrow && !key.downArrow) {
|
|
362
|
+
setFilterText(prev => prev + input);
|
|
363
|
+
setCursor(0);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// '/' to start filtering
|
|
369
|
+
if (input === '/' && !filtering) {
|
|
370
|
+
isFilteringRef.current = true;
|
|
371
|
+
setIsFiltering(true);
|
|
372
|
+
setFilterText('');
|
|
373
|
+
setCursor(0);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (key.downArrow) setCursor(c => Math.min(c + 1, filtered.length - 1));
|
|
378
|
+
if (key.upArrow) setCursor(c => Math.max(c - 1, 0));
|
|
379
|
+
if (key.return && filtered[cursor]) onDrillFile(filtered[cursor].filePath);
|
|
380
|
+
if (!filtering && input === 's') setSortMode(m => m === 'chunks' ? 'name' : m === 'name' ? 'symbols' : 'chunks');
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const leftW = Math.min(40, Math.floor(width * 0.4));
|
|
384
|
+
const rightW = width - leftW - 3;
|
|
385
|
+
const visible = filtered.slice(scrollOff, scrollOff + listH);
|
|
386
|
+
|
|
387
|
+
return (
|
|
388
|
+
<Box flexDirection="row" width={width} height={height - 4}>
|
|
389
|
+
{/* Left: File list */}
|
|
390
|
+
<Box flexDirection="column" width={leftW} paddingX={1}>
|
|
391
|
+
<Box marginBottom={1}>
|
|
392
|
+
<Text color={C.aurora} bold>Files</Text>
|
|
393
|
+
<Text color={C.dim}> ({filtered.length}{filterText ? `/${files.length}` : ''})</Text>
|
|
394
|
+
<Text color={C.dim}> sort: </Text>
|
|
395
|
+
<Text color={C.cyan}>{sortMode}</Text>
|
|
396
|
+
</Box>
|
|
397
|
+
{/* Filter bar */}
|
|
398
|
+
{(isFiltering || filterText) && (
|
|
399
|
+
<Box height={1} marginBottom={0}>
|
|
400
|
+
<Text color={C.aurora} bold>/ </Text>
|
|
401
|
+
<Text color={C.text}>{filterText}</Text>
|
|
402
|
+
<Text color={C.aurora}>▎</Text>
|
|
403
|
+
</Box>
|
|
404
|
+
)}
|
|
405
|
+
{visible.map((f, vi) => {
|
|
406
|
+
const idx = scrollOff + vi;
|
|
407
|
+
const isCursor = idx === cursor;
|
|
408
|
+
const ptr = isCursor ? '▸ ' : ' ';
|
|
409
|
+
return (
|
|
410
|
+
<Box key={f.filePath} height={1}>
|
|
411
|
+
<Text wrap="truncate">
|
|
412
|
+
<Text color={isCursor ? C.aurora : C.dim}>{ptr}</Text>
|
|
413
|
+
<Text color={langColor(f.language)} bold>{langBadge(f.language)}</Text>
|
|
414
|
+
<Text> </Text>
|
|
415
|
+
<Text color={isCursor ? C.text : C.dim}>
|
|
416
|
+
{truncate(f.fileName, leftW - 12)}
|
|
417
|
+
</Text>
|
|
418
|
+
<Text color={C.dim}> {String(f.chunks).padStart(3)}ch</Text>
|
|
419
|
+
</Text>
|
|
420
|
+
</Box>
|
|
421
|
+
);
|
|
422
|
+
})}
|
|
423
|
+
</Box>
|
|
424
|
+
|
|
425
|
+
{/* Right: File detail */}
|
|
426
|
+
<Box flexDirection="column" width={rightW} paddingX={1}>
|
|
427
|
+
{detail && (
|
|
428
|
+
<>
|
|
429
|
+
<Box marginBottom={1}>
|
|
430
|
+
<Text color={C.aurora} bold>File Detail</Text>
|
|
431
|
+
</Box>
|
|
432
|
+
<Text color={C.dim}>📄 <Text color={C.text}>{detail.filePath}</Text></Text>
|
|
433
|
+
<Text color={C.dim}> Language: <Text color={langColor(detail.language)}>{detail.language}</Text></Text>
|
|
434
|
+
<Text color={C.dim}> Chunks: <Text color={C.text}>{detail.chunks}</Text></Text>
|
|
435
|
+
<Text color={C.dim}> Symbols: <Text color={C.text}>{detail.symbols.length}</Text></Text>
|
|
436
|
+
<Text color={C.dim}> Imports: <Text color={C.success}>{detail.importsIn.length} in</Text>, <Text color={C.orange}>{detail.importsOut.length} out</Text></Text>
|
|
437
|
+
<Text color={C.dim}> Call edges: <Text color={C.success}>{detail.callEdgesIn} in</Text>, <Text color={C.orange}>{detail.callEdgesOut} out</Text></Text>
|
|
438
|
+
|
|
439
|
+
{detail.symbols.length > 0 && (
|
|
440
|
+
<Box flexDirection="column" marginTop={1}>
|
|
441
|
+
<Text color={C.purple} bold>Symbols</Text>
|
|
442
|
+
{detail.symbols.slice(0, Math.max(5, height - 15)).map((sym, i) => (
|
|
443
|
+
<Box key={`${sym.name}-${i}`} height={1}>
|
|
444
|
+
<Text wrap="truncate">
|
|
445
|
+
<Text color={C.dim}> {sym.kind === 'class' || sym.kind === 'Class' ? 'C' : 'ƒ'} </Text>
|
|
446
|
+
<Text color={C.text}>{truncate(sym.name, rightW - 10)}</Text>
|
|
447
|
+
<Text color={C.dim}> L{sym.line}</Text>
|
|
448
|
+
</Text>
|
|
449
|
+
</Box>
|
|
450
|
+
))}
|
|
451
|
+
{detail.symbols.length > height - 15 && (
|
|
452
|
+
<Text color={C.dim}> … {detail.symbols.length - (height - 15)} more</Text>
|
|
453
|
+
)}
|
|
454
|
+
</Box>
|
|
455
|
+
)}
|
|
456
|
+
|
|
457
|
+
{detail.importsIn.length > 0 && (
|
|
458
|
+
<Box flexDirection="column" marginTop={1}>
|
|
459
|
+
<Text color={C.cyan} bold>Imported by</Text>
|
|
460
|
+
{detail.importsIn.slice(0, 5).map((imp, i) => (
|
|
461
|
+
<Text key={i} color={C.dim}> ← {truncate(imp, rightW - 6)}</Text>
|
|
462
|
+
))}
|
|
463
|
+
</Box>
|
|
464
|
+
)}
|
|
465
|
+
</>
|
|
466
|
+
)}
|
|
467
|
+
</Box>
|
|
468
|
+
</Box>
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
// ── Chunk Viewer ──────────────────────────────────
|
|
474
|
+
|
|
475
|
+
function ChunkViewerView({ dbPath, filePath, width, height, onBack }: {
|
|
476
|
+
dbPath: string;
|
|
477
|
+
filePath: string;
|
|
478
|
+
width: number;
|
|
479
|
+
height: number;
|
|
480
|
+
onBack: () => void;
|
|
481
|
+
}): React.ReactNode {
|
|
482
|
+
const chunks = useMemo(() => fetchChunksForFile(dbPath, filePath), [dbPath, filePath]);
|
|
483
|
+
const [cursor, setCursor] = useState(0);
|
|
484
|
+
const [contentScroll, setContentScroll] = useState(0);
|
|
485
|
+
const [focusPanel, setFocusPanel] = useState<'list' | 'content'>('list');
|
|
486
|
+
|
|
487
|
+
const listH = Math.max(5, height - 6);
|
|
488
|
+
const scrollOff = centerScroll(cursor, chunks.length, listH);
|
|
489
|
+
|
|
490
|
+
const leftW = Math.min(26, Math.floor(width * 0.28));
|
|
491
|
+
const rightW = width - leftW - 3;
|
|
492
|
+
const activeChunk = chunks[cursor] ?? null;
|
|
493
|
+
const visible = chunks.slice(scrollOff, scrollOff + listH);
|
|
494
|
+
// Preview fills to footer: height - 4 (outer margin) - 2 (header) - 3 (calls+meta) = usable
|
|
495
|
+
const previewH = Math.max(3, height - 9);
|
|
496
|
+
const contentLines = useMemo(() => activeChunk?.content.split('\n') ?? [], [activeChunk]);
|
|
497
|
+
const maxContentScroll = Math.max(0, contentLines.length - previewH);
|
|
498
|
+
|
|
499
|
+
// Reset content scroll when switching chunks
|
|
500
|
+
useEffect(() => { setContentScroll(0); }, [cursor]);
|
|
501
|
+
|
|
502
|
+
useInput((input, key) => {
|
|
503
|
+
if (key.escape) {
|
|
504
|
+
if (focusPanel === 'content') { setFocusPanel('list'); return; }
|
|
505
|
+
onBack();
|
|
506
|
+
}
|
|
507
|
+
if (key.tab || (input === 'l' && focusPanel === 'list') || (input === 'h' && focusPanel === 'content')
|
|
508
|
+
|| (key.rightArrow && focusPanel === 'list') || (key.leftArrow && focusPanel === 'content')) {
|
|
509
|
+
setFocusPanel(p => p === 'list' ? 'content' : 'list');
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (focusPanel === 'list') {
|
|
513
|
+
if (key.downArrow) setCursor(c => Math.min(c + 1, chunks.length - 1));
|
|
514
|
+
if (key.upArrow) setCursor(c => Math.max(c - 1, 0));
|
|
515
|
+
if (input === '}') setCursor(c => Math.min(c + 10, chunks.length - 1));
|
|
516
|
+
if (input === '{') setCursor(c => Math.max(c - 10, 0));
|
|
517
|
+
if (key.return) setFocusPanel('content');
|
|
518
|
+
} else {
|
|
519
|
+
// Content panel scrolling
|
|
520
|
+
if (key.downArrow) setContentScroll(s => Math.min(s + 1, maxContentScroll));
|
|
521
|
+
if (key.upArrow) setContentScroll(s => Math.max(s - 1, 0));
|
|
522
|
+
if (input === '}') setContentScroll(s => Math.min(s + 10, maxContentScroll));
|
|
523
|
+
if (input === '{') setContentScroll(s => Math.max(s - 10, 0));
|
|
524
|
+
if (input === 'd') setContentScroll(s => Math.min(s + 15, maxContentScroll));
|
|
525
|
+
if (input === 'u') setContentScroll(s => Math.max(s - 15, 0));
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
const visibleLines = contentLines.slice(contentScroll, contentScroll + previewH);
|
|
530
|
+
const scrollPct = maxContentScroll > 0 ? Math.round(contentScroll / maxContentScroll * 100) : 100;
|
|
531
|
+
|
|
532
|
+
return (
|
|
533
|
+
<Box flexDirection="row" width={width} height={height - 4}>
|
|
534
|
+
{/* Left: Chunk list */}
|
|
535
|
+
<Box flexDirection="column" width={leftW} paddingX={1}>
|
|
536
|
+
<Box marginBottom={1}>
|
|
537
|
+
<Text color={focusPanel === 'list' ? C.aurora : C.dim} bold>Chunks</Text>
|
|
538
|
+
<Text color={C.dim}> ({chunks.length})</Text>
|
|
539
|
+
</Box>
|
|
540
|
+
{visible.map((ch, vi) => {
|
|
541
|
+
const idx = scrollOff + vi;
|
|
542
|
+
const isCursor = idx === cursor;
|
|
543
|
+
const ptr = isCursor ? '▸ ' : ' ';
|
|
544
|
+
const hasSym = ch.name !== null && ch.name !== '';
|
|
545
|
+
const active = focusPanel === 'list' && isCursor;
|
|
546
|
+
return (
|
|
547
|
+
<Box key={ch.id} height={1}>
|
|
548
|
+
<Text wrap="truncate">
|
|
549
|
+
<Text color={active ? C.aurora : isCursor ? C.cyan : C.dim}>{ptr}</Text>
|
|
550
|
+
<Text color={active ? C.text : isCursor ? C.cyan : C.dim}>
|
|
551
|
+
#{String(idx + 1).padStart(2)} L{ch.startLine}-{ch.endLine}
|
|
552
|
+
</Text>
|
|
553
|
+
{hasSym && <Text color={C.warning}> ★</Text>}
|
|
554
|
+
</Text>
|
|
555
|
+
</Box>
|
|
556
|
+
);
|
|
557
|
+
})}
|
|
558
|
+
<Box marginTop={1}>
|
|
559
|
+
<Text color={C.dim}>★ = named symbol</Text>
|
|
560
|
+
</Box>
|
|
561
|
+
</Box>
|
|
562
|
+
|
|
563
|
+
{/* Right: Chunk preview (scrollable with syntax highlighting) */}
|
|
564
|
+
<Box flexDirection="column" width={rightW} paddingX={1}>
|
|
565
|
+
{activeChunk && (
|
|
566
|
+
<>
|
|
567
|
+
<Box marginBottom={0} justifyContent="space-between">
|
|
568
|
+
<Text>
|
|
569
|
+
<Text color={focusPanel === 'content' ? C.aurora : C.dim} bold>Preview</Text>
|
|
570
|
+
<Text color={C.dim}> #{cursor + 1} L{activeChunk.startLine}-{activeChunk.endLine}</Text>
|
|
571
|
+
{activeChunk.name ? <Text color={C.purple}> {activeChunk.name}</Text> : null}
|
|
572
|
+
</Text>
|
|
573
|
+
<Text>
|
|
574
|
+
{focusPanel === 'list' && <Text color={C.dim} italic>Enter to scroll </Text>}
|
|
575
|
+
{contentLines.length > previewH && (
|
|
576
|
+
<Text color={focusPanel === 'content' ? C.cyan : C.dim}>{scrollPct}%</Text>
|
|
577
|
+
)}
|
|
578
|
+
</Text>
|
|
579
|
+
</Box>
|
|
580
|
+
<Box flexDirection="column">
|
|
581
|
+
{visibleLines.map((line, i) => {
|
|
582
|
+
const segs = highlightLine(line, rightW - 6);
|
|
583
|
+
return (
|
|
584
|
+
<Box key={contentScroll + i} height={1}>
|
|
585
|
+
<Text wrap="truncate">
|
|
586
|
+
<Text color={C.dim}>{String(activeChunk.startLine + contentScroll + i).padStart(4)}│</Text>
|
|
587
|
+
<HighlightedLine segments={segs} />
|
|
588
|
+
</Text>
|
|
589
|
+
</Box>
|
|
590
|
+
);
|
|
591
|
+
})}
|
|
592
|
+
</Box>
|
|
593
|
+
|
|
594
|
+
<Box marginTop={1} flexDirection="column">
|
|
595
|
+
{activeChunk.callsOut.length > 0 && (
|
|
596
|
+
<Text color={C.dim}>→ <Text color={C.orange}>{activeChunk.callsOut.slice(0, 8).join(', ')}</Text></Text>
|
|
597
|
+
)}
|
|
598
|
+
{activeChunk.calledBy.length > 0 && (
|
|
599
|
+
<Text color={C.dim}>← <Text color={C.success}>{activeChunk.calledBy.slice(0, 8).join(', ')}</Text></Text>
|
|
600
|
+
)}
|
|
601
|
+
</Box>
|
|
602
|
+
</>
|
|
603
|
+
)}
|
|
604
|
+
</Box>
|
|
605
|
+
</Box>
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
// ── Semantic Search View ──────────────────────────
|
|
611
|
+
|
|
612
|
+
type SearchState = 'idle' | 'initializing' | 'searching' | 'done' | 'error';
|
|
613
|
+
type SearchFocus = 'input' | 'sources' | 'raw' | 'final' | 'preview' | 'fullpreview';
|
|
614
|
+
|
|
615
|
+
/** Extract display info from a SearchResult. */
|
|
616
|
+
function resultLabel(r: SearchResult): { name: string; path: string; score: number; line: number } {
|
|
617
|
+
const meta = r.metadata as Record<string, unknown> | undefined;
|
|
618
|
+
return {
|
|
619
|
+
name: (meta?.name as string) ?? r.type,
|
|
620
|
+
path: r.filePath ?? 'unknown',
|
|
621
|
+
score: r.score,
|
|
622
|
+
line: (meta?.startLine as number) ?? 0,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function SemanticSearchView({ repoPath, width, height, onBack, session }: {
|
|
627
|
+
repoPath: string;
|
|
628
|
+
width: number;
|
|
629
|
+
height: number;
|
|
630
|
+
onBack: () => void;
|
|
631
|
+
session: BrainSearchSession;
|
|
632
|
+
}): React.ReactNode {
|
|
633
|
+
// Session — owned by StatsApp, shared across view transitions
|
|
634
|
+
const sessionRef = useRef<BrainSearchSession>(session);
|
|
635
|
+
|
|
636
|
+
// State
|
|
637
|
+
const [query, setQuery] = useState('');
|
|
638
|
+
const [state, setState] = useState<SearchState>(() =>
|
|
639
|
+
session.initialized ? 'idle' : 'initializing',
|
|
640
|
+
);
|
|
641
|
+
const [stateMsg, setStateMsg] = useState(() =>
|
|
642
|
+
session.initialized ? '' : 'Loading search index...',
|
|
643
|
+
);
|
|
644
|
+
const [focus, setFocus] = useState<SearchFocus>('input');
|
|
645
|
+
const [pipeline, setPipeline] = useState<SearchPipelineResult | null>(null);
|
|
646
|
+
const [sourceOpts, setSourceOpts] = useState<SourceOption[]>(() =>
|
|
647
|
+
session.initialized ? [...session.sources] : [],
|
|
648
|
+
);
|
|
649
|
+
const [rawCursor, setRawCursor] = useState(0);
|
|
650
|
+
const [finalCursor, setFinalCursor] = useState(0);
|
|
651
|
+
const [previewScroll, setPreviewScroll] = useState(0);
|
|
652
|
+
const [errorMsg, setErrorMsg] = useState('');
|
|
653
|
+
const [sourceCursor, setSourceCursor] = useState(0);
|
|
654
|
+
const [usePruner, setUsePruner] = useState(true);
|
|
655
|
+
const [useExpander, setUseExpander] = useState(true);
|
|
656
|
+
|
|
657
|
+
// Init session on mount — skip if already initialized (re-entry)
|
|
658
|
+
useEffect(() => {
|
|
659
|
+
sessionRef.current = session;
|
|
660
|
+
|
|
661
|
+
if (session.initialized) {
|
|
662
|
+
setSourceOpts([...session.sources]);
|
|
663
|
+
setState('idle');
|
|
664
|
+
setStateMsg('');
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
setState('initializing');
|
|
669
|
+
setStateMsg('Loading search index...');
|
|
670
|
+
|
|
671
|
+
// Defer to next tick so React paints the loading modal before heavy sync work
|
|
672
|
+
const timer = setTimeout(() => {
|
|
673
|
+
session.init()
|
|
674
|
+
.then(() => {
|
|
675
|
+
setSourceOpts([...session.sources]);
|
|
676
|
+
setState('idle');
|
|
677
|
+
setStateMsg('');
|
|
678
|
+
})
|
|
679
|
+
.catch((err: unknown) => {
|
|
680
|
+
setState('error');
|
|
681
|
+
setErrorMsg(err instanceof Error ? err.message : String(err));
|
|
682
|
+
});
|
|
683
|
+
}, 50);
|
|
684
|
+
|
|
685
|
+
return () => { clearTimeout(timer); };
|
|
686
|
+
}, [session]);
|
|
687
|
+
|
|
688
|
+
// Column sizing — preview gets most space for code readability
|
|
689
|
+
const rawW = Math.max(20, Math.floor(width * 0.22));
|
|
690
|
+
const prunedW = Math.max(20, Math.floor(width * 0.22));
|
|
691
|
+
const previewW = width - rawW - prunedW - 4;
|
|
692
|
+
const listH = Math.max(3, height - 12);
|
|
693
|
+
const previewH = Math.max(3, height - 12);
|
|
694
|
+
|
|
695
|
+
// Active result for preview — each column independently controls what's shown
|
|
696
|
+
const activeResult = useMemo(() => {
|
|
697
|
+
if (!pipeline) return null;
|
|
698
|
+
if (focus === 'final') {
|
|
699
|
+
const combined = [...pipeline.pruned, ...pipeline.expanded];
|
|
700
|
+
return combined[finalCursor] ?? null;
|
|
701
|
+
}
|
|
702
|
+
// raw, input, sources, preview all show the raw cursor's item
|
|
703
|
+
return pipeline.raw[rawCursor] ?? null;
|
|
704
|
+
}, [focus, rawCursor, finalCursor, pipeline]);
|
|
705
|
+
|
|
706
|
+
const previewLines = useMemo(() => activeResult?.content.split('\n') ?? [], [activeResult]);
|
|
707
|
+
const maxPreviewScroll = Math.max(0, previewLines.length - previewH);
|
|
708
|
+
|
|
709
|
+
// Reset preview scroll when result changes
|
|
710
|
+
useEffect(() => { setPreviewScroll(0); }, [activeResult]);
|
|
711
|
+
|
|
712
|
+
// Toggle source
|
|
713
|
+
const toggleSource = useCallback((key: string) => {
|
|
714
|
+
setSourceOpts(prev => {
|
|
715
|
+
const next = prev.map(s => ({ ...s }));
|
|
716
|
+
const target = next.find(s => s.key === key);
|
|
717
|
+
if (target) target.enabled = !target.enabled;
|
|
718
|
+
return next;
|
|
719
|
+
});
|
|
720
|
+
}, []);
|
|
721
|
+
|
|
722
|
+
// Run search
|
|
723
|
+
const doSearch = useCallback(async () => {
|
|
724
|
+
const session = sessionRef.current;
|
|
725
|
+
if (!session?.initialized || !query.trim()) return;
|
|
726
|
+
setState('searching');
|
|
727
|
+
setStateMsg('Searching...');
|
|
728
|
+
setRawCursor(0);
|
|
729
|
+
setFinalCursor(0);
|
|
730
|
+
try {
|
|
731
|
+
const activeKeys = new Set(
|
|
732
|
+
sourceOpts.filter(s => s.enabled).map(s => s.key),
|
|
733
|
+
);
|
|
734
|
+
const allEnabled = sourceOpts.every(s => s.enabled);
|
|
735
|
+
const result = await session.search(query, allEnabled ? undefined : activeKeys, usePruner, useExpander);
|
|
736
|
+
setPipeline(result);
|
|
737
|
+
setState('done');
|
|
738
|
+
setStateMsg('');
|
|
739
|
+
setFocus('raw');
|
|
740
|
+
} catch (err: unknown) {
|
|
741
|
+
setState('error');
|
|
742
|
+
setErrorMsg(err instanceof Error ? err.message : String(err));
|
|
743
|
+
}
|
|
744
|
+
}, [query, sourceOpts, usePruner, useExpander]);
|
|
745
|
+
|
|
746
|
+
// Input handling
|
|
747
|
+
useInput((input, key) => {
|
|
748
|
+
// Esc — back out
|
|
749
|
+
if (key.escape) {
|
|
750
|
+
if (focus === 'fullpreview') { setFocus('final'); return; }
|
|
751
|
+
if (focus !== 'input' && pipeline) { setFocus('input'); return; }
|
|
752
|
+
onBack();
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Source toggles removed from input mode — now handled in 'sources' focus
|
|
757
|
+
|
|
758
|
+
// Input mode — typing query
|
|
759
|
+
if (focus === 'input') {
|
|
760
|
+
if (key.return && query.trim()) {
|
|
761
|
+
doSearch();
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
if (key.backspace || key.delete) {
|
|
765
|
+
setQuery(q => q.slice(0, -1));
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
if (key.tab) {
|
|
769
|
+
setFocus('sources');
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (input && input.length === 1 && !key.ctrl && !key.meta) {
|
|
773
|
+
setQuery(q => q + input);
|
|
774
|
+
}
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Sources mode — ←→ to move cursor, space to toggle
|
|
779
|
+
// Combined list: [sources...] | [Pruner] [Expander]
|
|
780
|
+
if (focus === 'sources') {
|
|
781
|
+
const totalOpts = sourceOpts.length + 2; // +2 for pruner, expander
|
|
782
|
+
if (key.rightArrow) setSourceCursor(c => Math.min(c + 1, totalOpts - 1));
|
|
783
|
+
if (key.leftArrow) setSourceCursor(c => Math.max(c - 1, 0));
|
|
784
|
+
if (input === ' ') {
|
|
785
|
+
if (sourceCursor < sourceOpts.length) {
|
|
786
|
+
toggleSource(sourceOpts[sourceCursor].key);
|
|
787
|
+
} else if (sourceCursor === sourceOpts.length) {
|
|
788
|
+
setUsePruner(p => !p);
|
|
789
|
+
} else {
|
|
790
|
+
setUseExpander(e => !e);
|
|
791
|
+
}
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
// ↑↓ adjust K for the focused source
|
|
795
|
+
if (key.upArrow && sourceCursor < sourceOpts.length) {
|
|
796
|
+
setSourceOpts(prev => {
|
|
797
|
+
const next = prev.map(s => ({ ...s }));
|
|
798
|
+
const target = next[sourceCursor];
|
|
799
|
+
if (target) target.k = Math.min(target.k + 5, 50);
|
|
800
|
+
return next;
|
|
801
|
+
});
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
if (key.downArrow && sourceCursor < sourceOpts.length) {
|
|
805
|
+
setSourceOpts(prev => {
|
|
806
|
+
const next = prev.map(s => ({ ...s }));
|
|
807
|
+
const target = next[sourceCursor];
|
|
808
|
+
if (target) target.k = Math.max(target.k - 5, 5);
|
|
809
|
+
return next;
|
|
810
|
+
});
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
if (key.tab) {
|
|
814
|
+
setFocus(pipeline ? 'raw' : 'input');
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
if (key.return && query.trim()) {
|
|
818
|
+
doSearch();
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// 'P' — toggle full context preview (what the agent sees)
|
|
825
|
+
if (input === 'p' && focus !== 'input') {
|
|
826
|
+
if (focus === 'fullpreview') { setFocus('final'); }
|
|
827
|
+
else { setFocus('fullpreview'); setPreviewScroll(0); }
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Tab — cycle focus
|
|
832
|
+
if (key.tab) {
|
|
833
|
+
const order: SearchFocus[] = ['raw', 'final', 'preview', 'input'];
|
|
834
|
+
const idx = order.indexOf(focus);
|
|
835
|
+
setFocus(order[(idx + 1) % order.length]);
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Column navigation
|
|
840
|
+
if (focus === 'raw') {
|
|
841
|
+
if (key.downArrow) setRawCursor(c => Math.min(c + 1, (pipeline?.raw.length ?? 1) - 1));
|
|
842
|
+
if (key.upArrow) setRawCursor(c => Math.max(c - 1, 0));
|
|
843
|
+
if (input === '}') setRawCursor(c => Math.min(c + 10, (pipeline?.raw.length ?? 1) - 1));
|
|
844
|
+
if (input === '{') setRawCursor(c => Math.max(c - 10, 0));
|
|
845
|
+
if (input === 'h' || key.leftArrow) setFocus('sources');
|
|
846
|
+
if (input === 'l' || key.rightArrow) setFocus('final');
|
|
847
|
+
if (key.return) { setFocus('preview'); setPreviewScroll(0); }
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
if (focus === 'final') {
|
|
852
|
+
const combined = pipeline ? [...pipeline.pruned, ...pipeline.expanded] : [];
|
|
853
|
+
if (key.downArrow) setFinalCursor(c => Math.min(c + 1, combined.length - 1));
|
|
854
|
+
if (key.upArrow) setFinalCursor(c => Math.max(c - 1, 0));
|
|
855
|
+
if (input === '}') setFinalCursor(c => Math.min(c + 10, combined.length - 1));
|
|
856
|
+
if (input === '{') setFinalCursor(c => Math.max(c - 10, 0));
|
|
857
|
+
if (input === 'h' || key.leftArrow) setFocus('raw');
|
|
858
|
+
if (input === 'l' || key.rightArrow) setFocus('preview');
|
|
859
|
+
if (key.return) { setFocus('preview'); setPreviewScroll(0); }
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
if (focus === 'preview') {
|
|
864
|
+
if (key.downArrow) setPreviewScroll(s => Math.min(s + 1, maxPreviewScroll));
|
|
865
|
+
if (key.upArrow) setPreviewScroll(s => Math.max(s - 1, 0));
|
|
866
|
+
if (input === '}') setPreviewScroll(s => Math.min(s + 10, maxPreviewScroll));
|
|
867
|
+
if (input === '{') setPreviewScroll(s => Math.max(s - 10, 0));
|
|
868
|
+
if (input === 'd') setPreviewScroll(s => Math.min(s + 15, maxPreviewScroll));
|
|
869
|
+
if (input === 'u') setPreviewScroll(s => Math.max(s - 15, 0));
|
|
870
|
+
if (input === 'h' || key.leftArrow) setFocus('final');
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (focus === 'fullpreview') {
|
|
875
|
+
if (key.downArrow) setPreviewScroll(s => Math.min(s + 1, maxPreviewScroll));
|
|
876
|
+
if (key.upArrow) setPreviewScroll(s => Math.max(s - 1, 0));
|
|
877
|
+
if (input === '}') setPreviewScroll(s => Math.min(s + 10, maxPreviewScroll));
|
|
878
|
+
if (input === '{') setPreviewScroll(s => Math.max(s - 10, 0));
|
|
879
|
+
if (input === 'd') setPreviewScroll(s => Math.min(s + 15, maxPreviewScroll));
|
|
880
|
+
if (input === 'u') setPreviewScroll(s => Math.max(s - 15, 0));
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
// Combined pruned+expanded list
|
|
886
|
+
const combinedResults = useMemo(() => {
|
|
887
|
+
if (!pipeline) return [];
|
|
888
|
+
return [
|
|
889
|
+
...pipeline.pruned.map(r => ({ r, type: 'kept' as const })),
|
|
890
|
+
...pipeline.expanded.map(r => ({ r, type: 'expanded' as const })),
|
|
891
|
+
];
|
|
892
|
+
}, [pipeline]);
|
|
893
|
+
|
|
894
|
+
// Set of dropped filePaths for dimming in raw column
|
|
895
|
+
const droppedPaths = useMemo(() => {
|
|
896
|
+
if (!pipeline) return new Set<string>();
|
|
897
|
+
return new Set(pipeline.dropped.map(r => r.filePath ?? ''));
|
|
898
|
+
}, [pipeline]);
|
|
899
|
+
|
|
900
|
+
const rawScrollOff = centerScroll(rawCursor, pipeline?.raw.length ?? 0, listH);
|
|
901
|
+
const finalScrollOff = centerScroll(finalCursor, combinedResults.length, listH);
|
|
902
|
+
|
|
903
|
+
// Full context preview: all final results concatenated
|
|
904
|
+
const fullContextLines = useMemo(() => {
|
|
905
|
+
if (!pipeline) return [];
|
|
906
|
+
const final = [...pipeline.pruned, ...pipeline.expanded];
|
|
907
|
+
const lines: string[] = [];
|
|
908
|
+
for (const r of final) {
|
|
909
|
+
const info = resultLabel(r);
|
|
910
|
+
lines.push(`━━━ ${info.path} L${info.line} ━━━`);
|
|
911
|
+
lines.push(...r.content.split('\n'));
|
|
912
|
+
lines.push('');
|
|
913
|
+
}
|
|
914
|
+
return lines;
|
|
915
|
+
}, [pipeline]);
|
|
916
|
+
|
|
917
|
+
const currentPreviewLines = focus === 'fullpreview' ? fullContextLines : previewLines;
|
|
918
|
+
const currentMaxScroll = Math.max(0, currentPreviewLines.length - previewH);
|
|
919
|
+
const visiblePreview = currentPreviewLines.slice(previewScroll, previewScroll + previewH);
|
|
920
|
+
const scrollPct = currentMaxScroll > 0 ? Math.round(previewScroll / currentMaxScroll * 100) : 100;
|
|
921
|
+
// Clamp preview scroll to current max
|
|
922
|
+
useEffect(() => {
|
|
923
|
+
setPreviewScroll(s => Math.min(s, currentMaxScroll));
|
|
924
|
+
}, [currentMaxScroll]);
|
|
925
|
+
|
|
926
|
+
// Animated spinner during init
|
|
927
|
+
const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
928
|
+
const [spinIdx, setSpinIdx] = useState(0);
|
|
929
|
+
useEffect(() => {
|
|
930
|
+
if (state !== 'initializing') return;
|
|
931
|
+
const timer = setInterval(() => setSpinIdx(i => (i + 1) % spinnerFrames.length), 80);
|
|
932
|
+
return () => clearInterval(timer);
|
|
933
|
+
}, [state]);
|
|
934
|
+
|
|
935
|
+
// Show loading modal if still initializing
|
|
936
|
+
if (state === 'initializing') {
|
|
937
|
+
return (
|
|
938
|
+
<Box flexDirection="column" width={width} height={height - 4}
|
|
939
|
+
justifyContent="center" alignItems="center">
|
|
940
|
+
<Box flexDirection="column" alignItems="center" borderStyle="round"
|
|
941
|
+
borderColor={C.border} paddingX={6} paddingY={2}>
|
|
942
|
+
<Text color={C.aurora} bold>
|
|
943
|
+
{spinnerFrames[spinIdx]} Loading Search Engine
|
|
944
|
+
</Text>
|
|
945
|
+
<Text color={C.dim}> </Text>
|
|
946
|
+
<Text color={C.dim}>Initializing HNSW indices and plugins...</Text>
|
|
947
|
+
<Text color={C.dim}>This only happens once per session</Text>
|
|
948
|
+
</Box>
|
|
949
|
+
<Box marginTop={1}>
|
|
950
|
+
<Text color={C.dim}>Esc to go back</Text>
|
|
951
|
+
</Box>
|
|
952
|
+
</Box>
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
return (
|
|
957
|
+
<Box flexDirection="column" width={width} height={height - 4}>
|
|
958
|
+
{/* Search bar */}
|
|
959
|
+
<Box paddingX={1} flexDirection="column" marginBottom={1}>
|
|
960
|
+
<Box>
|
|
961
|
+
<Text color={C.aurora} bold>🔍 </Text>
|
|
962
|
+
<Text color={focus === 'input' ? C.text : C.dim}>
|
|
963
|
+
{query}<Text color={focus === 'input' ? C.aurora : C.dim}>▎</Text>
|
|
964
|
+
</Text>
|
|
965
|
+
<Text color={C.dim}>
|
|
966
|
+
{' '}{state === 'initializing' ? '⟳ ' + stateMsg
|
|
967
|
+
: state === 'searching' ? '⟳ Searching...'
|
|
968
|
+
: state === 'error' ? '✗ ' + errorMsg
|
|
969
|
+
: state === 'done' ? `${pipeline?.raw.length ?? 0} results`
|
|
970
|
+
: 'Enter to search'}
|
|
971
|
+
</Text>
|
|
972
|
+
</Box>
|
|
973
|
+
{/* Source filter tabs + pipeline toggles */}
|
|
974
|
+
<Box marginTop={1}>
|
|
975
|
+
<Text color={focus === 'sources' ? C.aurora : C.dim}>Sources: </Text>
|
|
976
|
+
{sourceOpts.map((s, i) => {
|
|
977
|
+
const isFocused = focus === 'sources' && i === sourceCursor;
|
|
978
|
+
return (
|
|
979
|
+
<Text key={s.key}>
|
|
980
|
+
<Text color={isFocused ? C.text : (s.enabled ? C.aurora : C.dim)}
|
|
981
|
+
bold={isFocused}
|
|
982
|
+
underline={isFocused}>
|
|
983
|
+
[{s.enabled ? '■' : '□'}] {s.label}
|
|
984
|
+
</Text>
|
|
985
|
+
<Text color={isFocused ? C.cyan : C.dim}>:{s.k}</Text>
|
|
986
|
+
<Text color={C.dim}> </Text>
|
|
987
|
+
</Text>
|
|
988
|
+
);
|
|
989
|
+
})}
|
|
990
|
+
<Text color={C.dim}>│ </Text>
|
|
991
|
+
{/* Pruner toggle */}
|
|
992
|
+
<Text color={focus === 'sources' && sourceCursor === sourceOpts.length
|
|
993
|
+
? C.text
|
|
994
|
+
: (usePruner ? C.purple : C.dim)}
|
|
995
|
+
bold={focus === 'sources' && sourceCursor === sourceOpts.length}
|
|
996
|
+
underline={focus === 'sources' && sourceCursor === sourceOpts.length}>
|
|
997
|
+
[{usePruner ? '■' : '□'}] Pruner
|
|
998
|
+
</Text>
|
|
999
|
+
<Text color={C.dim}> </Text>
|
|
1000
|
+
{/* Expander toggle */}
|
|
1001
|
+
<Text color={focus === 'sources' && sourceCursor === sourceOpts.length + 1
|
|
1002
|
+
? C.text
|
|
1003
|
+
: (useExpander ? C.purple : C.dim)}
|
|
1004
|
+
bold={focus === 'sources' && sourceCursor === sourceOpts.length + 1}
|
|
1005
|
+
underline={focus === 'sources' && sourceCursor === sourceOpts.length + 1}>
|
|
1006
|
+
[{useExpander ? '■' : '□'}] Expander
|
|
1007
|
+
</Text>
|
|
1008
|
+
<Text color={C.dim}> (←→ move, Space toggle, ↑↓ adjust K)</Text>
|
|
1009
|
+
</Box>
|
|
1010
|
+
</Box>
|
|
1011
|
+
|
|
1012
|
+
{/* Full context preview mode */}
|
|
1013
|
+
{focus === 'fullpreview' ? (
|
|
1014
|
+
<Box flexDirection="column" width={width} height={listH + 2} marginTop={1} paddingX={1}>
|
|
1015
|
+
<Box marginBottom={0} justifyContent="space-between">
|
|
1016
|
+
<Text color={C.aurora} bold>Full Context Preview</Text>
|
|
1017
|
+
<Text>
|
|
1018
|
+
<Text color={C.dim} italic>What the agent sees </Text>
|
|
1019
|
+
<Text color={C.cyan}>{scrollPct}%</Text>
|
|
1020
|
+
<Text color={C.dim}> P to close</Text>
|
|
1021
|
+
</Text>
|
|
1022
|
+
</Box>
|
|
1023
|
+
<Box flexDirection="column">
|
|
1024
|
+
{visiblePreview.map((line, i) => {
|
|
1025
|
+
const isHeader = line.startsWith('━━━');
|
|
1026
|
+
if (isHeader) {
|
|
1027
|
+
return (
|
|
1028
|
+
<Box key={previewScroll + i} height={1}>
|
|
1029
|
+
<Text color={C.purple} bold wrap="truncate">{truncate(line, width - 4)}</Text>
|
|
1030
|
+
</Box>
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
const segs = highlightLine(line, width - 8);
|
|
1034
|
+
return (
|
|
1035
|
+
<Box key={previewScroll + i} height={1}>
|
|
1036
|
+
<Text wrap="truncate">
|
|
1037
|
+
<Text color={C.dim}>{String(previewScroll + i + 1).padStart(4)}│</Text>
|
|
1038
|
+
<HighlightedLine segments={segs} />
|
|
1039
|
+
</Text>
|
|
1040
|
+
</Box>
|
|
1041
|
+
);
|
|
1042
|
+
})}
|
|
1043
|
+
</Box>
|
|
1044
|
+
</Box>
|
|
1045
|
+
) : (
|
|
1046
|
+
/* Two result columns + wide preview */
|
|
1047
|
+
<Box flexDirection="row" width={width} height={listH} marginTop={1}>
|
|
1048
|
+
{/* Column 1: Raw results */}
|
|
1049
|
+
<Box flexDirection="column" width={rawW} paddingX={1}>
|
|
1050
|
+
<Box marginBottom={0}>
|
|
1051
|
+
<Text color={focus === 'raw' ? C.aurora : C.dim} bold>
|
|
1052
|
+
Raw ({pipeline?.raw.length ?? 0})
|
|
1053
|
+
</Text>
|
|
1054
|
+
</Box>
|
|
1055
|
+
{pipeline && pipeline.raw.slice(rawScrollOff, rawScrollOff + listH - 1).map((r, vi) => {
|
|
1056
|
+
const idx = rawScrollOff + vi;
|
|
1057
|
+
const isCursor = focus === 'raw' && idx === rawCursor;
|
|
1058
|
+
const info = resultLabel(r);
|
|
1059
|
+
const isDimmed = droppedPaths.has(r.filePath ?? '');
|
|
1060
|
+
const ptr = isCursor ? '▸' : ' ';
|
|
1061
|
+
const scorePct = Math.round(info.score * 100);
|
|
1062
|
+
return (
|
|
1063
|
+
<Box key={idx} height={1}>
|
|
1064
|
+
<Text wrap="truncate">
|
|
1065
|
+
<Text color={isCursor ? C.aurora : C.dim}>{ptr} </Text>
|
|
1066
|
+
<Text color={isDimmed ? C.error : (isCursor ? C.text : C.dim)}
|
|
1067
|
+
strikethrough={isDimmed}>
|
|
1068
|
+
{truncate(info.name, rawW - 10)}
|
|
1069
|
+
</Text>
|
|
1070
|
+
<Text color={isDimmed ? C.error : C.dim}> {scorePct}%</Text>
|
|
1071
|
+
</Text>
|
|
1072
|
+
</Box>
|
|
1073
|
+
);
|
|
1074
|
+
})}
|
|
1075
|
+
</Box>
|
|
1076
|
+
|
|
1077
|
+
{/* Column 2: Final = pruned survivors + expanded discoveries */}
|
|
1078
|
+
<Box flexDirection="column" width={prunedW} paddingX={1}>
|
|
1079
|
+
<Box marginBottom={0}>
|
|
1080
|
+
<Text color={focus === 'final' ? C.aurora : C.dim} bold>
|
|
1081
|
+
Final ({combinedResults.length})
|
|
1082
|
+
</Text>
|
|
1083
|
+
{(pipeline?.expanded.length ?? 0) > 0 && (
|
|
1084
|
+
<Text color={C.success}> +{pipeline?.expanded.length}◆</Text>
|
|
1085
|
+
)}
|
|
1086
|
+
{pipeline && pipeline.dropped.length > 0 && (
|
|
1087
|
+
<Text color={C.error}> -{pipeline.dropped.length}</Text>
|
|
1088
|
+
)}
|
|
1089
|
+
</Box>
|
|
1090
|
+
{combinedResults.slice(finalScrollOff, finalScrollOff + listH - 1).map((item, vi) => {
|
|
1091
|
+
const idx = finalScrollOff + vi;
|
|
1092
|
+
const isCursor = focus === 'final' && idx === finalCursor;
|
|
1093
|
+
const info = resultLabel(item.r);
|
|
1094
|
+
const isExpanded = item.type === 'expanded';
|
|
1095
|
+
const ptr = isCursor ? '▸' : (isExpanded ? '◆' : ' ');
|
|
1096
|
+
// Show rerank position# + shortened file path (visually distinct from raw column)
|
|
1097
|
+
const shortFile = info.path.split('/').pop() ?? info.path;
|
|
1098
|
+
return (
|
|
1099
|
+
<Box key={idx} height={1}>
|
|
1100
|
+
<Text wrap="truncate">
|
|
1101
|
+
<Text color={isCursor ? C.aurora : (isExpanded ? C.success : C.dim)}>
|
|
1102
|
+
{ptr}
|
|
1103
|
+
</Text>
|
|
1104
|
+
<Text color={C.dim}>{String(idx + 1).padStart(2)} </Text>
|
|
1105
|
+
<Text color={isCursor ? C.text : (isExpanded ? C.success : C.dim)}>
|
|
1106
|
+
{truncate(shortFile, prunedW - 8)}
|
|
1107
|
+
</Text>
|
|
1108
|
+
</Text>
|
|
1109
|
+
</Box>
|
|
1110
|
+
);
|
|
1111
|
+
})}
|
|
1112
|
+
</Box>
|
|
1113
|
+
|
|
1114
|
+
{/* Column 3: Preview */}
|
|
1115
|
+
<Box flexDirection="column" width={previewW} paddingX={1}>
|
|
1116
|
+
<Box marginBottom={0} justifyContent="space-between">
|
|
1117
|
+
<Text color={focus === 'preview' ? C.aurora : C.dim} bold>Preview</Text>
|
|
1118
|
+
<Text>
|
|
1119
|
+
{focus !== 'preview' && <Text color={C.dim} italic>Enter/→ to scroll </Text>}
|
|
1120
|
+
{currentPreviewLines.length > previewH && (
|
|
1121
|
+
<Text color={focus === 'preview' ? C.cyan : C.dim}>{scrollPct}%</Text>
|
|
1122
|
+
)}
|
|
1123
|
+
</Text>
|
|
1124
|
+
</Box>
|
|
1125
|
+
{activeResult && (
|
|
1126
|
+
<Box flexDirection="column">
|
|
1127
|
+
<Text color={C.dim} wrap="truncate">
|
|
1128
|
+
{truncate(resultLabel(activeResult).path, previewW - 4)} L{resultLabel(activeResult).line}
|
|
1129
|
+
</Text>
|
|
1130
|
+
{visiblePreview.map((line, i) => {
|
|
1131
|
+
const segs = highlightLine(line, previewW - 5);
|
|
1132
|
+
return (
|
|
1133
|
+
<Box key={previewScroll + i} height={1}>
|
|
1134
|
+
<Text wrap="truncate">
|
|
1135
|
+
<Text color={C.dim}>{String(previewScroll + i + 1).padStart(3)}│</Text>
|
|
1136
|
+
<HighlightedLine segments={segs} />
|
|
1137
|
+
</Text>
|
|
1138
|
+
</Box>
|
|
1139
|
+
);
|
|
1140
|
+
})}
|
|
1141
|
+
</Box>
|
|
1142
|
+
)}
|
|
1143
|
+
</Box>
|
|
1144
|
+
</Box>
|
|
1145
|
+
)}
|
|
1146
|
+
|
|
1147
|
+
{/* Timings bar */}
|
|
1148
|
+
{pipeline && (
|
|
1149
|
+
<Box paddingX={1} marginTop={0}>
|
|
1150
|
+
<Text color={C.dim}>
|
|
1151
|
+
⏱ search: {pipeline.timings.search}ms
|
|
1152
|
+
{pipeline.prunerName && (
|
|
1153
|
+
<Text>{' '}prune: {pipeline.timings.prune}ms ({pipeline.prunerName}, -{pipeline.dropped.length})</Text>
|
|
1154
|
+
)}
|
|
1155
|
+
{pipeline.expanderName && pipeline.expanded.length > 0 && (
|
|
1156
|
+
<Text>{' '}expand: {pipeline.timings.expand}ms ({pipeline.expanderName}, +{pipeline.expanded.length})</Text>
|
|
1157
|
+
)}
|
|
1158
|
+
{' '}total: {pipeline.timings.total}ms
|
|
1159
|
+
</Text>
|
|
1160
|
+
</Box>
|
|
1161
|
+
)}
|
|
1162
|
+
</Box>
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
// ── Call Graph View ───────────────────────────────
|
|
1168
|
+
|
|
1169
|
+
function CallGraphView({ dbPath, width, height, onBack }: {
|
|
1170
|
+
dbPath: string;
|
|
1171
|
+
width: number;
|
|
1172
|
+
height: number;
|
|
1173
|
+
onBack: () => void;
|
|
1174
|
+
}): React.ReactNode {
|
|
1175
|
+
// Start with a top-level symbol search
|
|
1176
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
1177
|
+
const [rootNodes, setRootNodes] = useState<CallTreeNode[]>([]);
|
|
1178
|
+
const [cursor, setCursor] = useState(0);
|
|
1179
|
+
|
|
1180
|
+
// Flatten tree for display
|
|
1181
|
+
const flatNodes = useMemo(() => {
|
|
1182
|
+
interface FlatNode { node: CallTreeNode; depth: number }
|
|
1183
|
+
const flat: FlatNode[] = [];
|
|
1184
|
+
function walk(nodes: CallTreeNode[], d: number): void {
|
|
1185
|
+
for (const n of nodes) {
|
|
1186
|
+
flat.push({ node: n, depth: d });
|
|
1187
|
+
walk(n.children, d + 1);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
walk(rootNodes, 0);
|
|
1191
|
+
return flat;
|
|
1192
|
+
}, [rootNodes]);
|
|
1193
|
+
|
|
1194
|
+
useInput((input, key) => {
|
|
1195
|
+
if (key.escape) {
|
|
1196
|
+
if (rootNodes.length > 0) { setRootNodes([]); setSearchQuery(''); }
|
|
1197
|
+
else onBack();
|
|
1198
|
+
}
|
|
1199
|
+
if (key.downArrow) setCursor(c => Math.min(c + 1, flatNodes.length - 1));
|
|
1200
|
+
if (key.upArrow) setCursor(c => Math.max(c - 1, 0));
|
|
1201
|
+
if (key.return && flatNodes[cursor]) {
|
|
1202
|
+
// Drill into this node
|
|
1203
|
+
const tree = fetchCallTree(dbPath, flatNodes[cursor].node.chunkId, 3);
|
|
1204
|
+
if (tree.children.length > 0) {
|
|
1205
|
+
setRootNodes([tree]);
|
|
1206
|
+
setCursor(0);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
if (key.backspace || key.delete) {
|
|
1210
|
+
setSearchQuery(q => q.slice(0, -1));
|
|
1211
|
+
}
|
|
1212
|
+
if (input && input.length === 1 && !key.ctrl && !key.meta) {
|
|
1213
|
+
setSearchQuery(q => q + input);
|
|
1214
|
+
}
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
// Search for matching chunks when query changes
|
|
1218
|
+
useEffect(() => {
|
|
1219
|
+
if (searchQuery.length < 2) { setRootNodes([]); return; }
|
|
1220
|
+
try {
|
|
1221
|
+
const rows = searchSymbols(dbPath, searchQuery, 10);
|
|
1222
|
+
const trees = rows.map(r => fetchCallTree(dbPath, r.id, 2));
|
|
1223
|
+
setRootNodes(trees);
|
|
1224
|
+
setCursor(0);
|
|
1225
|
+
} catch { /* ignore */ }
|
|
1226
|
+
}, [searchQuery, dbPath]);
|
|
1227
|
+
|
|
1228
|
+
const listH = Math.max(5, height - 8);
|
|
1229
|
+
const scrollOff = centerScroll(cursor, flatNodes.length, listH);
|
|
1230
|
+
const visible = flatNodes.slice(scrollOff, scrollOff + listH);
|
|
1231
|
+
|
|
1232
|
+
return (
|
|
1233
|
+
<Box flexDirection="column" width={width} height={height - 4} paddingX={1}>
|
|
1234
|
+
<Box marginBottom={1}>
|
|
1235
|
+
<Text color={C.aurora} bold>Call Graph</Text>
|
|
1236
|
+
</Box>
|
|
1237
|
+
<Box marginBottom={1}>
|
|
1238
|
+
<Text color={C.dim}>🔍 Search: </Text>
|
|
1239
|
+
<Text color={C.text}>{searchQuery || '(type to search symbols…)'}</Text>
|
|
1240
|
+
</Box>
|
|
1241
|
+
|
|
1242
|
+
{flatNodes.length === 0 && searchQuery.length >= 2 && (
|
|
1243
|
+
<Text color={C.dim}>No matching symbols found.</Text>
|
|
1244
|
+
)}
|
|
1245
|
+
|
|
1246
|
+
{visible.map((item, vi) => {
|
|
1247
|
+
const idx = scrollOff + vi;
|
|
1248
|
+
const isCursor = idx === cursor;
|
|
1249
|
+
const indent = ' '.repeat(item.depth);
|
|
1250
|
+
const prefix = item.depth > 0 ? '├── ' : '';
|
|
1251
|
+
return (
|
|
1252
|
+
<Box key={`${item.node.chunkId}-${vi}`} height={1}>
|
|
1253
|
+
<Text wrap="truncate">
|
|
1254
|
+
<Text color={isCursor ? C.aurora : C.dim}>{isCursor ? '▸ ' : ' '}</Text>
|
|
1255
|
+
<Text color={C.dim}>{indent}{prefix}</Text>
|
|
1256
|
+
<Text color={isCursor ? C.aurora : C.purple} bold={isCursor}>{item.node.symbol}</Text>
|
|
1257
|
+
<Text color={C.dim}> </Text>
|
|
1258
|
+
<Text color={C.dim}>{truncate(item.node.filePath, width - indent.length * 2 - item.node.symbol.length - 12)}</Text>
|
|
1259
|
+
{item.node.children.length > 0 && (
|
|
1260
|
+
<Text color={C.dim}> ({item.node.children.length})</Text>
|
|
1261
|
+
)}
|
|
1262
|
+
</Text>
|
|
1263
|
+
</Box>
|
|
1264
|
+
);
|
|
1265
|
+
})}
|
|
1266
|
+
</Box>
|
|
1267
|
+
);
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
// ── Header / Footer ───────────────────────────────
|
|
1272
|
+
|
|
1273
|
+
function Header({ repoPath, dbSizeMB, view, breadcrumb, width }: {
|
|
1274
|
+
repoPath: string; dbSizeMB: number; view: View; breadcrumb: string[];
|
|
1275
|
+
width: number;
|
|
1276
|
+
}): React.ReactNode {
|
|
1277
|
+
const crumb = breadcrumb.length > 0 ? ' ▸ ' + breadcrumb.join(' ▸ ') : '';
|
|
1278
|
+
const right = `${repoPath} ${dbSizeMB}MB`;
|
|
1279
|
+
return (
|
|
1280
|
+
<Box width={width} height={2} paddingX={1} flexDirection="column">
|
|
1281
|
+
<Box justifyContent="space-between">
|
|
1282
|
+
<Text>
|
|
1283
|
+
<Text color={C.aurora} bold>⚡ BrainBank Explorer</Text>
|
|
1284
|
+
<Text color={C.dim}>{crumb}</Text>
|
|
1285
|
+
</Text>
|
|
1286
|
+
<Text color={C.dim}>{truncate(right, Math.max(10, width - 40))}</Text>
|
|
1287
|
+
</Box>
|
|
1288
|
+
<Text color={C.border}>{'─'.repeat(width - 2)}</Text>
|
|
1289
|
+
</Box>
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
function Footer({ view, width }: { view: View; width: number }): React.ReactNode {
|
|
1294
|
+
const hints: Record<View, string> = {
|
|
1295
|
+
dashboard: '↑↓ navigate Enter drill in / search g call graph q quit',
|
|
1296
|
+
files: '↑↓ navigate Enter view chunks s sort / filter Esc back q quit',
|
|
1297
|
+
chunks: 'Tab focus ↑↓ scroll {/} jump 10 u/d page Enter preview Esc back',
|
|
1298
|
+
callgraph: 'type to search ↑↓ navigate Enter expand Esc back q quit',
|
|
1299
|
+
search: 'type query → Enter ←→ panels {/} jump 10 P full context Tab sources Space toggle Esc back',
|
|
1300
|
+
};
|
|
1301
|
+
|
|
1302
|
+
return (
|
|
1303
|
+
<Box width={width} height={2} paddingX={1} flexDirection="column">
|
|
1304
|
+
<Text color={C.border}>{'─'.repeat(width - 2)}</Text>
|
|
1305
|
+
<Text color={C.dim}>
|
|
1306
|
+
{hints[view].split(/(\S+:)/).map((part, i) =>
|
|
1307
|
+
part.endsWith(':') ? (
|
|
1308
|
+
<Text key={i} color={C.aurora}>{part} </Text>
|
|
1309
|
+
) : (
|
|
1310
|
+
<Text key={i}>{part}</Text>
|
|
1311
|
+
)
|
|
1312
|
+
)}
|
|
1313
|
+
</Text>
|
|
1314
|
+
</Box>
|
|
1315
|
+
);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
// ── App Root ──────────────────────────────────────
|
|
1320
|
+
|
|
1321
|
+
function StatsApp({ dbPath, repoPath, configPath }: {
|
|
1322
|
+
dbPath: string;
|
|
1323
|
+
repoPath: string;
|
|
1324
|
+
configPath: string;
|
|
1325
|
+
}): React.ReactNode {
|
|
1326
|
+
const { exit } = useApp();
|
|
1327
|
+
const { stdout } = useStdout();
|
|
1328
|
+
const [rawW, setRawW] = useState(stdout?.columns || 100);
|
|
1329
|
+
const [rawH, setRawH] = useState(stdout?.rows || 30);
|
|
1330
|
+
const [view, setView] = useState<View>('dashboard');
|
|
1331
|
+
const [breadcrumb, setBreadcrumb] = useState<string[]>([]);
|
|
1332
|
+
const [currentDir, setCurrentDir] = useState('');
|
|
1333
|
+
const [currentFile, setCurrentFile] = useState('');
|
|
1334
|
+
|
|
1335
|
+
// Persistent search session — survives view transitions
|
|
1336
|
+
const searchSessionRef = useRef<BrainSearchSession | null>(null);
|
|
1337
|
+
const getSearchSession = useCallback(() => {
|
|
1338
|
+
if (!searchSessionRef.current) {
|
|
1339
|
+
searchSessionRef.current = new BrainSearchSession(repoPath);
|
|
1340
|
+
}
|
|
1341
|
+
return searchSessionRef.current;
|
|
1342
|
+
}, [repoPath]);
|
|
1343
|
+
// Clean up on unmount
|
|
1344
|
+
useEffect(() => {
|
|
1345
|
+
return () => { searchSessionRef.current?.close(); };
|
|
1346
|
+
}, []);
|
|
1347
|
+
|
|
1348
|
+
const width = Math.floor(rawW * 0.9);
|
|
1349
|
+
const height = Math.floor(rawH * 0.9);
|
|
1350
|
+
|
|
1351
|
+
useEffect(() => {
|
|
1352
|
+
if (!stdout) return;
|
|
1353
|
+
const onResize = () => { setRawW(stdout.columns); setRawH(stdout.rows); };
|
|
1354
|
+
stdout.on('resize', onResize);
|
|
1355
|
+
return () => { stdout.off('resize', onResize); };
|
|
1356
|
+
}, [stdout]);
|
|
1357
|
+
|
|
1358
|
+
// Global quit — skip in text-input views (search, callgraph)
|
|
1359
|
+
useInput((input) => {
|
|
1360
|
+
if (input === 'q' && view !== 'search' && view !== 'callgraph') { exit(); }
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
// Fetch data
|
|
1364
|
+
const overview = useMemo(() => fetchOverview(dbPath, repoPath, configPath), [dbPath, repoPath, configPath]);
|
|
1365
|
+
const languages = useMemo(() => fetchLanguageBreakdown(dbPath), [dbPath]);
|
|
1366
|
+
const dirs = useMemo(() => fetchDirectories(dbPath), [dbPath]);
|
|
1367
|
+
|
|
1368
|
+
// Navigation handlers
|
|
1369
|
+
const drillDir = (dir: string) => {
|
|
1370
|
+
setCurrentDir(dir);
|
|
1371
|
+
setBreadcrumb([dir + '/']);
|
|
1372
|
+
setView('files');
|
|
1373
|
+
};
|
|
1374
|
+
|
|
1375
|
+
const drillFile = (filePath: string) => {
|
|
1376
|
+
setCurrentFile(filePath);
|
|
1377
|
+
const fileName = filePath.split('/').pop() || filePath;
|
|
1378
|
+
setBreadcrumb([currentDir + '/', fileName]);
|
|
1379
|
+
setView('chunks');
|
|
1380
|
+
};
|
|
1381
|
+
|
|
1382
|
+
const goBack = () => {
|
|
1383
|
+
if (view === 'chunks') {
|
|
1384
|
+
setBreadcrumb([currentDir + '/']);
|
|
1385
|
+
setView('files');
|
|
1386
|
+
} else if (view === 'files') {
|
|
1387
|
+
setBreadcrumb([]);
|
|
1388
|
+
setView('dashboard');
|
|
1389
|
+
} else if (view === 'callgraph' || view === 'search') {
|
|
1390
|
+
setBreadcrumb([]);
|
|
1391
|
+
setView('dashboard');
|
|
1392
|
+
}
|
|
1393
|
+
};
|
|
1394
|
+
|
|
1395
|
+
const goCallGraph = () => {
|
|
1396
|
+
setBreadcrumb(['Call Graph']);
|
|
1397
|
+
setView('callgraph');
|
|
1398
|
+
};
|
|
1399
|
+
|
|
1400
|
+
const goSearch = () => {
|
|
1401
|
+
setBreadcrumb(['Search']);
|
|
1402
|
+
setView('search');
|
|
1403
|
+
};
|
|
1404
|
+
|
|
1405
|
+
return (
|
|
1406
|
+
<Box flexDirection="column" width={width} height={height}>
|
|
1407
|
+
<Header repoPath={overview.repoPath} dbSizeMB={overview.dbSizeMB} view={view} breadcrumb={breadcrumb} width={width} />
|
|
1408
|
+
|
|
1409
|
+
{view === 'dashboard' && (
|
|
1410
|
+
<DashboardView
|
|
1411
|
+
overview={overview} languages={languages} dirs={dirs}
|
|
1412
|
+
width={width} height={height}
|
|
1413
|
+
onDrillDir={drillDir} onCallGraph={goCallGraph} onSearch={goSearch}
|
|
1414
|
+
/>
|
|
1415
|
+
)}
|
|
1416
|
+
{view === 'files' && (
|
|
1417
|
+
<FileExplorerView
|
|
1418
|
+
dbPath={dbPath} dir={currentDir}
|
|
1419
|
+
width={width} height={height}
|
|
1420
|
+
onDrillFile={drillFile} onBack={goBack}
|
|
1421
|
+
/>
|
|
1422
|
+
)}
|
|
1423
|
+
{view === 'chunks' && (
|
|
1424
|
+
<ChunkViewerView
|
|
1425
|
+
dbPath={dbPath} filePath={currentFile}
|
|
1426
|
+
width={width} height={height}
|
|
1427
|
+
onBack={goBack}
|
|
1428
|
+
/>
|
|
1429
|
+
)}
|
|
1430
|
+
{view === 'callgraph' && (
|
|
1431
|
+
<CallGraphView
|
|
1432
|
+
dbPath={dbPath}
|
|
1433
|
+
width={width} height={height}
|
|
1434
|
+
onBack={goBack}
|
|
1435
|
+
/>
|
|
1436
|
+
)}
|
|
1437
|
+
{view === 'search' && (
|
|
1438
|
+
<SemanticSearchView
|
|
1439
|
+
repoPath={repoPath}
|
|
1440
|
+
width={width} height={height}
|
|
1441
|
+
onBack={goBack}
|
|
1442
|
+
session={getSearchSession()}
|
|
1443
|
+
/>
|
|
1444
|
+
)}
|
|
1445
|
+
|
|
1446
|
+
<Footer view={view} width={width} />
|
|
1447
|
+
</Box>
|
|
1448
|
+
);
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
|
|
1452
|
+
// ── Public API ────────────────────────────────────
|
|
1453
|
+
|
|
1454
|
+
export async function runStatsTui(dbPath: string, repoPath: string, configPath: string): Promise<void> {
|
|
1455
|
+
// Clear screen + hide cursor for full-screen feel
|
|
1456
|
+
process.stdout.write('\x1b[2J\x1b[H\x1b[?25l');
|
|
1457
|
+
|
|
1458
|
+
const instance = render(
|
|
1459
|
+
<StatsApp dbPath={dbPath} repoPath={repoPath} configPath={configPath} />
|
|
1460
|
+
);
|
|
1461
|
+
await instance.waitUntilExit();
|
|
1462
|
+
|
|
1463
|
+
// Restore: show cursor + clear screen
|
|
1464
|
+
process.stdout.write('\x1b[?25h\x1b[2J\x1b[H');
|
|
1465
|
+
}
|