@et0and/ovid 0.0.2
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/README.md +1 -0
- package/bin/semantic-navigator.js +2 -0
- package/package.json +33 -0
- package/src/auth.ts +137 -0
- package/src/cluster.ts +487 -0
- package/src/embed.ts +143 -0
- package/src/fs.ts +187 -0
- package/src/labels.ts +205 -0
- package/src/main.ts +239 -0
- package/src/tokenize.ts +76 -0
- package/src/tree.ts +176 -0
- package/src/ui.ts +460 -0
package/src/ui.ts
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenTUI core UI for semantic-navigator.
|
|
3
|
+
*
|
|
4
|
+
* Renders an interactive, keyboard-navigable tree view using the OpenTUI
|
|
5
|
+
* imperative core API.
|
|
6
|
+
*
|
|
7
|
+
* Controls:
|
|
8
|
+
* ↑ / k Move cursor up
|
|
9
|
+
* ↓ / j Move cursor down
|
|
10
|
+
* ← / h Collapse node
|
|
11
|
+
* → / l Expand node
|
|
12
|
+
* Enter Toggle expand/collapse
|
|
13
|
+
* q / Esc Quit
|
|
14
|
+
* ? Toggle help overlay
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
createCliRenderer,
|
|
19
|
+
BoxRenderable,
|
|
20
|
+
TextRenderable,
|
|
21
|
+
ScrollBoxRenderable,
|
|
22
|
+
type CliRenderer,
|
|
23
|
+
} from "@opentui/core"
|
|
24
|
+
|
|
25
|
+
import type { Tree } from "./tree.ts"
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Theme
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
const THEME = {
|
|
31
|
+
bg: "#1a1b26",
|
|
32
|
+
headerBg: "#24283b",
|
|
33
|
+
footerBg: "#24283b",
|
|
34
|
+
border: "#414868",
|
|
35
|
+
text: "#c0caf5",
|
|
36
|
+
dimText: "#565f89",
|
|
37
|
+
cursorBg: "#2d3f76",
|
|
38
|
+
cursorText: "#7dcfff",
|
|
39
|
+
countColor: "#e0af68",
|
|
40
|
+
expandColor: "#9ece6a",
|
|
41
|
+
patternColor: "#7aa2f7",
|
|
42
|
+
statusColor: "#bb9af7",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Flat node model for scroll-based rendering
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
interface FlatNode {
|
|
50
|
+
tree: Tree
|
|
51
|
+
depth: number
|
|
52
|
+
isExpanded: boolean
|
|
53
|
+
isLeaf: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function buildFlatList(
|
|
57
|
+
tree: Tree,
|
|
58
|
+
depth: number,
|
|
59
|
+
expanded: Set<string>,
|
|
60
|
+
out: FlatNode[] = []
|
|
61
|
+
): FlatNode[] {
|
|
62
|
+
const isLeaf = tree.children.length === 0
|
|
63
|
+
const isExpanded = !isLeaf && expanded.has(nodeKey(tree, depth))
|
|
64
|
+
out.push({ tree, depth, isExpanded, isLeaf })
|
|
65
|
+
if (isExpanded) {
|
|
66
|
+
for (const child of tree.children) {
|
|
67
|
+
buildFlatList(child, depth + 1, expanded, out)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function nodeKey(tree: Tree, depth: number): string {
|
|
74
|
+
return `${depth}::${tree.label}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Progress / status UI shown before tree is ready
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
export interface ProgressState {
|
|
82
|
+
phase: "reading" | "embedding" | "clustering" | "labelling" | "done"
|
|
83
|
+
done: number
|
|
84
|
+
total: number
|
|
85
|
+
message?: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Main UI class
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
export class SemanticNavigatorUI {
|
|
93
|
+
private renderer!: CliRenderer
|
|
94
|
+
private rootBox!: BoxRenderable
|
|
95
|
+
private headerText!: TextRenderable
|
|
96
|
+
private scrollBox!: ScrollBoxRenderable
|
|
97
|
+
private footerText!: TextRenderable
|
|
98
|
+
|
|
99
|
+
// Tree state
|
|
100
|
+
private tree: Tree | null = null
|
|
101
|
+
private flatNodes: FlatNode[] = []
|
|
102
|
+
private expanded: Set<string> = new Set()
|
|
103
|
+
private cursorIndex = 0
|
|
104
|
+
|
|
105
|
+
// Progress state (shown before tree is ready)
|
|
106
|
+
private progress: ProgressState = { phase: "reading", done: 0, total: 0 }
|
|
107
|
+
|
|
108
|
+
// Row renderables (reused by rebuildRows)
|
|
109
|
+
private rowRenderables: TextRenderable[] = []
|
|
110
|
+
private rowIds: string[] = []
|
|
111
|
+
|
|
112
|
+
// Help overlay
|
|
113
|
+
private helpVisible = false
|
|
114
|
+
private helpBox!: BoxRenderable
|
|
115
|
+
|
|
116
|
+
async init(): Promise<void> {
|
|
117
|
+
this.renderer = await createCliRenderer({
|
|
118
|
+
targetFps: 30,
|
|
119
|
+
exitOnCtrlC: true,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const W = this.renderer.width
|
|
123
|
+
const H = this.renderer.height
|
|
124
|
+
|
|
125
|
+
// Root container
|
|
126
|
+
this.rootBox = new BoxRenderable(this.renderer, {
|
|
127
|
+
id: "root",
|
|
128
|
+
width: W,
|
|
129
|
+
height: H,
|
|
130
|
+
flexDirection: "column",
|
|
131
|
+
backgroundColor: THEME.bg,
|
|
132
|
+
})
|
|
133
|
+
this.renderer.root.add(this.rootBox)
|
|
134
|
+
|
|
135
|
+
// Header
|
|
136
|
+
const headerBox = new BoxRenderable(this.renderer, {
|
|
137
|
+
id: "header",
|
|
138
|
+
width: "100%",
|
|
139
|
+
height: 1,
|
|
140
|
+
backgroundColor: THEME.headerBg,
|
|
141
|
+
flexDirection: "row",
|
|
142
|
+
paddingLeft: 1,
|
|
143
|
+
paddingRight: 1,
|
|
144
|
+
})
|
|
145
|
+
this.headerText = new TextRenderable(this.renderer, {
|
|
146
|
+
id: "header-text",
|
|
147
|
+
content: " semantic-navigator ",
|
|
148
|
+
fg: THEME.statusColor,
|
|
149
|
+
})
|
|
150
|
+
headerBox.add(this.headerText)
|
|
151
|
+
this.rootBox.add(headerBox)
|
|
152
|
+
|
|
153
|
+
// Scroll box for tree rows
|
|
154
|
+
this.scrollBox = new ScrollBoxRenderable(this.renderer, {
|
|
155
|
+
id: "scrollbox",
|
|
156
|
+
width: "100%",
|
|
157
|
+
height: H - 2,
|
|
158
|
+
})
|
|
159
|
+
this.rootBox.add(this.scrollBox)
|
|
160
|
+
|
|
161
|
+
// Footer
|
|
162
|
+
const footerBox = new BoxRenderable(this.renderer, {
|
|
163
|
+
id: "footer",
|
|
164
|
+
width: "100%",
|
|
165
|
+
height: 1,
|
|
166
|
+
backgroundColor: THEME.footerBg,
|
|
167
|
+
paddingLeft: 1,
|
|
168
|
+
paddingRight: 1,
|
|
169
|
+
})
|
|
170
|
+
this.footerText = new TextRenderable(this.renderer, {
|
|
171
|
+
id: "footer-text",
|
|
172
|
+
content: "↑↓ navigate ←→/Enter toggle q quit ? help",
|
|
173
|
+
fg: THEME.dimText,
|
|
174
|
+
})
|
|
175
|
+
footerBox.add(this.footerText)
|
|
176
|
+
this.rootBox.add(footerBox)
|
|
177
|
+
|
|
178
|
+
// Help overlay (hidden by default)
|
|
179
|
+
this.helpBox = new BoxRenderable(this.renderer, {
|
|
180
|
+
id: "help",
|
|
181
|
+
position: "absolute",
|
|
182
|
+
left: Math.floor(W / 2) - 20,
|
|
183
|
+
top: Math.floor(H / 2) - 7,
|
|
184
|
+
width: 40,
|
|
185
|
+
height: 14,
|
|
186
|
+
border: true,
|
|
187
|
+
borderStyle: "rounded",
|
|
188
|
+
borderColor: THEME.border,
|
|
189
|
+
backgroundColor: THEME.headerBg,
|
|
190
|
+
title: " Help ",
|
|
191
|
+
titleAlignment: "center",
|
|
192
|
+
padding: 1,
|
|
193
|
+
zIndex: 10,
|
|
194
|
+
})
|
|
195
|
+
this.helpBox.visible = false
|
|
196
|
+
|
|
197
|
+
const helpLines = [
|
|
198
|
+
["↑ / k", "Move cursor up"],
|
|
199
|
+
["↓ / j", "Move cursor down"],
|
|
200
|
+
["→ / l / Enter", "Expand node"],
|
|
201
|
+
["← / h / Enter", "Collapse node"],
|
|
202
|
+
["q / Esc", "Quit"],
|
|
203
|
+
["?", "Toggle this help"],
|
|
204
|
+
]
|
|
205
|
+
for (const [keys, desc] of helpLines) {
|
|
206
|
+
const row = new BoxRenderable(this.renderer, {
|
|
207
|
+
flexDirection: "row",
|
|
208
|
+
marginBottom: 0,
|
|
209
|
+
})
|
|
210
|
+
const keyText = new TextRenderable(this.renderer, {
|
|
211
|
+
content: (keys ?? "").padEnd(16),
|
|
212
|
+
fg: THEME.cursorText,
|
|
213
|
+
width: 16,
|
|
214
|
+
})
|
|
215
|
+
const descText = new TextRenderable(this.renderer, {
|
|
216
|
+
content: desc ?? "",
|
|
217
|
+
fg: THEME.text,
|
|
218
|
+
})
|
|
219
|
+
row.add(keyText)
|
|
220
|
+
row.add(descText)
|
|
221
|
+
this.helpBox.add(row)
|
|
222
|
+
}
|
|
223
|
+
this.rootBox.add(this.helpBox)
|
|
224
|
+
|
|
225
|
+
// Keyboard handler
|
|
226
|
+
this.renderer.keyInput.on("keypress", (key) => {
|
|
227
|
+
if (this.helpVisible) {
|
|
228
|
+
if (key.name === "escape" || key.name === "?" || key.name === "q") {
|
|
229
|
+
this.toggleHelp()
|
|
230
|
+
}
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
switch (key.name) {
|
|
235
|
+
case "up":
|
|
236
|
+
case "k":
|
|
237
|
+
this.moveCursor(-1)
|
|
238
|
+
break
|
|
239
|
+
case "down":
|
|
240
|
+
case "j":
|
|
241
|
+
this.moveCursor(1)
|
|
242
|
+
break
|
|
243
|
+
case "right":
|
|
244
|
+
case "l":
|
|
245
|
+
this.expandCurrent()
|
|
246
|
+
break
|
|
247
|
+
case "left":
|
|
248
|
+
case "h":
|
|
249
|
+
this.collapseCurrent()
|
|
250
|
+
break
|
|
251
|
+
case "return":
|
|
252
|
+
case "enter":
|
|
253
|
+
this.toggleCurrent()
|
|
254
|
+
break
|
|
255
|
+
case "q":
|
|
256
|
+
case "escape":
|
|
257
|
+
this.renderer.destroy()
|
|
258
|
+
process.exit(0)
|
|
259
|
+
break
|
|
260
|
+
case "?":
|
|
261
|
+
this.toggleHelp()
|
|
262
|
+
break
|
|
263
|
+
}
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
// Show loading state
|
|
267
|
+
this.renderProgress()
|
|
268
|
+
|
|
269
|
+
// Start the render loop — without this, no setTimeout is ever scheduled
|
|
270
|
+
// and the process exits as soon as the async pipeline completes.
|
|
271
|
+
this.renderer.start()
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// Progress display (before tree is ready)
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
updateProgress(state: ProgressState): void {
|
|
279
|
+
this.progress = state
|
|
280
|
+
this.renderProgress()
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private renderProgress(): void {
|
|
284
|
+
const { phase, done, total, message } = this.progress
|
|
285
|
+
const pct = total > 0 ? Math.round((done / total) * 100) : 0
|
|
286
|
+
|
|
287
|
+
let phaseLabel: string
|
|
288
|
+
switch (phase) {
|
|
289
|
+
case "reading": phaseLabel = "Reading files"; break
|
|
290
|
+
case "embedding": phaseLabel = "Embedding"; break
|
|
291
|
+
case "clustering": phaseLabel = "Clustering"; break
|
|
292
|
+
case "labelling": phaseLabel = "Labelling"; break
|
|
293
|
+
default: phaseLabel = "Done"; break
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const statusLine = total > 0
|
|
297
|
+
? `${phaseLabel}: ${done}/${total} (${pct}%)`
|
|
298
|
+
: message ?? phaseLabel
|
|
299
|
+
|
|
300
|
+
this.headerText.content = ` semantic-navigator ${statusLine}`
|
|
301
|
+
|
|
302
|
+
// Clear rows and show a single status line
|
|
303
|
+
this.clearRows()
|
|
304
|
+
const id = "status-line"
|
|
305
|
+
const statusText = new TextRenderable(this.renderer, {
|
|
306
|
+
id,
|
|
307
|
+
content: ` ${statusLine}…`,
|
|
308
|
+
fg: THEME.statusColor,
|
|
309
|
+
})
|
|
310
|
+
this.scrollBox.add(statusText)
|
|
311
|
+
this.rowRenderables = [statusText]
|
|
312
|
+
this.rowIds = [id]
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
// Tree display (after tree is ready)
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
setTree(tree: Tree): void {
|
|
320
|
+
this.tree = tree
|
|
321
|
+
|
|
322
|
+
// Auto-expand root and its direct children
|
|
323
|
+
this.expanded.add(nodeKey(tree, 0))
|
|
324
|
+
|
|
325
|
+
this.rebuildFlatList()
|
|
326
|
+
this.renderRows()
|
|
327
|
+
|
|
328
|
+
this.headerText.content =
|
|
329
|
+
` semantic-navigator ${tree.label} (${tree.files.length} files)`
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private rebuildFlatList(): void {
|
|
333
|
+
if (this.tree === null) return
|
|
334
|
+
this.flatNodes = buildFlatList(this.tree, 0, this.expanded)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private clearRows(): void {
|
|
338
|
+
for (const id of this.rowIds) {
|
|
339
|
+
this.scrollBox.remove(id)
|
|
340
|
+
}
|
|
341
|
+
for (const r of this.rowRenderables) {
|
|
342
|
+
r.destroy()
|
|
343
|
+
}
|
|
344
|
+
this.rowRenderables = []
|
|
345
|
+
this.rowIds = []
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private renderRows(): void {
|
|
349
|
+
this.clearRows()
|
|
350
|
+
|
|
351
|
+
const W = this.renderer.width - 3 // scrollbar takes ~3 cols
|
|
352
|
+
|
|
353
|
+
for (let i = 0; i < this.flatNodes.length; i++) {
|
|
354
|
+
const node = this.flatNodes[i]!
|
|
355
|
+
const isCursor = i === this.cursorIndex
|
|
356
|
+
const indent = " ".repeat(node.depth)
|
|
357
|
+
|
|
358
|
+
let chevron: string
|
|
359
|
+
if (node.isLeaf) {
|
|
360
|
+
chevron = " "
|
|
361
|
+
} else if (node.isExpanded) {
|
|
362
|
+
chevron = "▾ "
|
|
363
|
+
} else {
|
|
364
|
+
chevron = "▸ "
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const rawLabel = node.tree.label
|
|
368
|
+
const colonIdx = rawLabel.lastIndexOf(": ")
|
|
369
|
+
let displayLabel: string
|
|
370
|
+
if (!node.isLeaf && colonIdx > 0) {
|
|
371
|
+
const count = ` (${node.tree.files.length})`
|
|
372
|
+
displayLabel = `${indent}${chevron}${rawLabel}${count}`
|
|
373
|
+
} else {
|
|
374
|
+
displayLabel = `${indent}${chevron}${rawLabel}`
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Truncate to terminal width
|
|
378
|
+
if (displayLabel.length > W) {
|
|
379
|
+
displayLabel = displayLabel.slice(0, W - 1) + "…"
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const id = `row-${i}`
|
|
383
|
+
const row = new TextRenderable(this.renderer, {
|
|
384
|
+
id,
|
|
385
|
+
content: displayLabel,
|
|
386
|
+
fg: isCursor ? THEME.cursorText : (node.isLeaf ? THEME.text : THEME.expandColor),
|
|
387
|
+
bg: isCursor ? THEME.cursorBg : THEME.bg,
|
|
388
|
+
width: W,
|
|
389
|
+
})
|
|
390
|
+
this.scrollBox.add(row)
|
|
391
|
+
this.rowRenderables.push(row)
|
|
392
|
+
this.rowIds.push(id)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
// Navigation
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
private moveCursor(delta: number): void {
|
|
401
|
+
const newIndex = Math.max(
|
|
402
|
+
0,
|
|
403
|
+
Math.min(this.flatNodes.length - 1, this.cursorIndex + delta)
|
|
404
|
+
)
|
|
405
|
+
if (newIndex === this.cursorIndex) return
|
|
406
|
+
this.cursorIndex = newIndex
|
|
407
|
+
this.renderRows()
|
|
408
|
+
this.scrollToCursor()
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private scrollToCursor(): void {
|
|
412
|
+
this.scrollBox.scrollTo(this.cursorIndex)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private expandCurrent(): void {
|
|
416
|
+
const node = this.flatNodes[this.cursorIndex]
|
|
417
|
+
if (node === undefined || node.isLeaf) return
|
|
418
|
+
const key = nodeKey(node.tree, node.depth)
|
|
419
|
+
if (this.expanded.has(key)) return
|
|
420
|
+
this.expanded.add(key)
|
|
421
|
+
this.rebuildFlatList()
|
|
422
|
+
this.renderRows()
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private collapseCurrent(): void {
|
|
426
|
+
const node = this.flatNodes[this.cursorIndex]
|
|
427
|
+
if (node === undefined || node.isLeaf) return
|
|
428
|
+
const key = nodeKey(node.tree, node.depth)
|
|
429
|
+
if (!this.expanded.has(key)) return
|
|
430
|
+
this.expanded.delete(key)
|
|
431
|
+
this.rebuildFlatList()
|
|
432
|
+
this.renderRows()
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private toggleCurrent(): void {
|
|
436
|
+
const node = this.flatNodes[this.cursorIndex]
|
|
437
|
+
if (node === undefined || node.isLeaf) return
|
|
438
|
+
const key = nodeKey(node.tree, node.depth)
|
|
439
|
+
if (this.expanded.has(key)) {
|
|
440
|
+
this.expanded.delete(key)
|
|
441
|
+
} else {
|
|
442
|
+
this.expanded.add(key)
|
|
443
|
+
}
|
|
444
|
+
this.rebuildFlatList()
|
|
445
|
+
this.renderRows()
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private toggleHelp(): void {
|
|
449
|
+
this.helpVisible = !this.helpVisible
|
|
450
|
+
this.helpBox.visible = this.helpVisible
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ---------------------------------------------------------------------------
|
|
454
|
+
// Lifecycle
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
destroy(): void {
|
|
458
|
+
this.renderer.destroy()
|
|
459
|
+
}
|
|
460
|
+
}
|