@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/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
+ }